"""Standards linter for Agent Skills — extends check_skill.py with consolidation-plan rules.
Usage: standards_linter.py <skill-dir>
- Frontmatter valid and complete (name, description, license optional).
- name matches directory name exactly.
- description under ~1,200 characters.
- SKILL.md under 500 lines.
- Routing table present (if skill has 3+ references).
- All referenced paths (references/, scripts/, templates/) exist on disk.
- scripts/ entrypoint exists and is executable.
print("Usage: standards_linter.py <skill-dir>", file=sys.stderr)
skill_dir = Path(sys.argv[1])
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
print(f"❌ no SKILL.md found in {skill_dir}")
text = skill_md.read_text(encoding="utf-8")
# Check 1: Frontmatter present
fm = re.match(r"^---\n(.*?)\n---\n", text, re.DOTALL)
print("❌ no YAML frontmatter found")
frontmatter = fm.group(1)
# Check 2: name field present and matches directory
name_match = re.search(r"^name:\s*(.+)$", frontmatter, re.MULTILINE)
print("❌ frontmatter missing 'name'")
name_val = name_match.group(1).strip()
if name_val != skill_dir.name:
print(f"❌ frontmatter name '{name_val}' does not match directory name '{skill_dir.name}'")
print(f"✅ name matches directory: {skill_dir.name}")
# Check 3: description field present and under cap.
# description is often a `>-` folded block scalar spanning multiple
# indented lines; it ends at the next top-level key (a line starting at
# column 0) or the end of frontmatter — NOT at the next literal newline,
# and NOT by greedily consuming the rest of frontmatter (a following
# `triggers:` block is a separate field, not part of the description).
desc_match = re.search(r"^description:[ \t]*(>-|>|\|)?[ \t]*\n?((?:[ \t]+.*\n?)*)|^description:[ \t]*(.+)$",
frontmatter, re.MULTILINE)
print("❌ frontmatter missing 'description'")
desc_text = (desc_match.group(2) or desc_match.group(3) or "").strip()
desc_len = len(desc_text)
print(f"❌ description is {desc_len} chars — too long to trigger reliably, tighten it")
print(f"✅ description present ({desc_len} chars)")
# Check 4: SKILL.md line count under 500
body_lines = text[body_start:].split("\n")
# Count only non-empty lines for a fair line count
skill_md_lines = len([ln for ln in body_lines if ln.strip()])
print(f"❌ SKILL.md is {skill_md_lines} lines (over 500)")
print(f"✅ SKILL.md is {skill_md_lines} lines (under 500)")
# Check 5: All referenced paths exist
referenced = re.findall(r"`(references/[\w./-]+|scripts/[\w./-]+|templates/[\w./-]+)`", text)
missing = [r for r in referenced if not (skill_dir / r).exists()]
print(f"❌ SKILL.md references path(s) that don't exist on disk: {missing}")
print(f"✅ all {len(referenced) if referenced else 0} referenced references/scripts/templates paths exist")
# Check 6: Routing table present (warning if 3+ references but no routing table)
num_references = len(referenced)
has_routing_table = "| You're…" in text or "| **You're" in text or "| *You" in text
if num_references >= 3 and not has_routing_table:
print(f"⚠️ skill has {num_references} references but no 'Route by task' table — add one for clarity")
# Check 7: scripts/ entrypoint exists and is executable
scripts_dir = skill_dir / "scripts"
# Look for common entrypoint names
for name in ["check.sh", "lint.sh", "check.py", "lint.py"]:
cand = scripts_dir / name
# If no standard name, use the first executable script
for f in scripts_dir.iterdir():
if f.is_file() and f.stat().st_mode & 0o111:
if entrypoint.stat().st_mode & 0o111:
print(f"✅ scripts/ entrypoint ({entrypoint.name}) is executable")
print(f"❌ scripts/{entrypoint.name} exists but is not executable")
print(f"⚠️ scripts/ directory exists but no executable entrypoint found")
if __name__ == "__main__":