Coverage for little_loops / cli / generate_skill_descriptions.py: 16%
101 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
1"""ll-generate-skill-descriptions: Auto-generate concise skill descriptions via Claude CLI.
3For each skills/*/SKILL.md that does NOT have disable-model-invocation: true,
4extract trigger keywords and body excerpt, prompt Claude (haiku) to produce a
5description ≤100 characters, and optionally write it back to the frontmatter.
7Dry-run by default. Use --apply to write back.
8"""
10from __future__ import annotations
12import argparse
13import re
14import sys
15from pathlib import Path
17from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context
19__all__ = ["main_generate_skill_descriptions"]
21_MAX_DESC_LEN = 100
22_BODY_EXCERPT_CHARS = 500
25def _find_plugin_root() -> Path:
26 from little_loops.skill_expander import _find_plugin_root as _fpr
28 return _fpr()
31def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
32 """Return (frontmatter_keys, body_text) from a SKILL.md string.
34 Thin wrapper around ``little_loops.frontmatter.parse_skill_frontmatter``
35 that preserves the historical ``(fm, body)`` tuple return shape.
36 """
37 from little_loops.frontmatter import parse_skill_frontmatter
39 if not text.startswith("---"):
40 return {}, text
41 end = text.find("---", 3)
42 if end == -1:
43 return {}, text
44 body = text[end + 3 :].lstrip("\n")
45 fm = parse_skill_frontmatter(text)
46 return fm, body
49def _extract_trigger_keywords(description: str) -> str:
50 """Pull the 'Trigger keywords:' line from a description field value."""
51 for line in description.splitlines():
52 if line.strip().lower().startswith("trigger keywords"):
53 return line.strip()
54 return ""
57def _build_prompt(skill_name: str, trigger_keywords: str, body_excerpt: str) -> str:
58 return (
59 f"Generate a single-line skill description for the '{skill_name}' skill.\n"
60 f"Rules:\n"
61 f"- Maximum {_MAX_DESC_LEN} characters total\n"
62 f"- No bullet points or newlines\n"
63 f"- Start with 'Use when' or a clear trigger phrase\n"
64 f"- Include the most important trigger keywords\n"
65 f"\nTrigger keywords: {trigger_keywords or '(none)'}\n"
66 f"Skill body excerpt:\n{body_excerpt[:_BODY_EXCERPT_CHARS]}\n"
67 f"\nRespond with ONLY the description text, nothing else."
68 )
71def _write_description_to_frontmatter(skill_md: Path, new_desc: str) -> None:
72 """Replace the description: field in SKILL.md frontmatter with new_desc."""
73 text = skill_md.read_text()
74 if not text.startswith("---"):
75 return
76 end = text.find("---", 3)
77 if end == -1:
78 return
79 fm_block = text[3:end]
80 after = text[end:]
82 # Replace existing description line (single-line only)
83 new_fm_block = re.sub(
84 r"^description:.*$",
85 f"description: {new_desc}",
86 fm_block,
87 flags=re.MULTILINE,
88 )
89 skill_md.write_text("---" + new_fm_block + after)
92def _process_skills(skills_dir: Path, apply: bool, quiet: bool) -> tuple[int, int, int]:
93 """Process all skills; return (processed, skipped, errors)."""
94 from little_loops.subprocess_utils import run_claude_command
96 processed = skipped = errors = 0
98 for skill_md in sorted(skills_dir.glob("*/SKILL.md")):
99 skill_name = skill_md.parent.name
100 try:
101 text = skill_md.read_text()
102 except OSError as exc:
103 if not quiet:
104 print(f" ERROR {skill_name}: cannot read file: {exc}", file=sys.stderr)
105 errors += 1
106 continue
108 fm, body = _parse_frontmatter(text)
110 if fm.get("disable-model-invocation", "").lower() in ("true", "yes", "1"):
111 if not quiet:
112 print(f" SKIP {skill_name} (disable-model-invocation: true)")
113 skipped += 1
114 continue
116 trigger_keywords = _extract_trigger_keywords(fm.get("description", ""))
117 prompt = _build_prompt(skill_name, trigger_keywords, body)
119 result = run_claude_command(
120 command=prompt,
121 timeout=60,
122 )
124 if result.returncode != 0:
125 if not quiet:
126 print(
127 f" ERROR {skill_name}: Claude returned exit {result.returncode}",
128 file=sys.stderr,
129 )
130 errors += 1
131 continue
133 new_desc = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else ""
135 if len(new_desc) > _MAX_DESC_LEN:
136 new_desc = new_desc[:_MAX_DESC_LEN]
138 if not new_desc:
139 if not quiet:
140 print(f" ERROR {skill_name}: empty description from Claude", file=sys.stderr)
141 errors += 1
142 continue
144 if apply:
145 _write_description_to_frontmatter(skill_md, new_desc)
146 if not quiet:
147 print(f" APPLY {skill_name}: {new_desc}")
148 else:
149 if not quiet:
150 print(f" DRY {skill_name}: {new_desc}")
152 processed += 1
154 return processed, skipped, errors
157def main_generate_skill_descriptions() -> int:
158 """Entry point for ll-generate-skill-descriptions CLI."""
159 with cli_event_context(DEFAULT_DB_PATH, "ll-generate-skill-descriptions", sys.argv[1:]):
160 parser = argparse.ArgumentParser(
161 prog="ll-generate-skill-descriptions",
162 description=(
163 "Auto-generate ≤100-char skill descriptions via Claude CLI. "
164 "Dry-run by default; use --apply to write back to SKILL.md frontmatter."
165 ),
166 formatter_class=argparse.RawDescriptionHelpFormatter,
167 epilog="""
168Examples:
169 ll-generate-skill-descriptions # Dry-run: preview generated descriptions
170 ll-generate-skill-descriptions --apply # Write descriptions back to SKILL.md files
171 ll-generate-skill-descriptions --quiet # Suppress per-skill output
172""",
173 )
174 parser.add_argument(
175 "--apply",
176 action="store_true",
177 default=False,
178 help="Write generated descriptions back to SKILL.md frontmatter (default: dry-run only)",
179 )
180 parser.add_argument(
181 "--quiet",
182 action="store_true",
183 default=False,
184 help="Suppress per-skill output; only print final summary",
185 )
187 args = parser.parse_args()
189 plugin_root = _find_plugin_root()
190 skills_dir = plugin_root / "skills"
192 if not skills_dir.exists():
193 print(f"ERROR: skills directory not found: {skills_dir}", file=sys.stderr)
194 return 1
196 mode = "APPLY" if args.apply else "DRY-RUN"
197 if not args.quiet:
198 print(f"ll-generate-skill-descriptions [{mode}]")
199 print(f"Skills dir: {skills_dir}")
200 print()
202 processed, skipped, errors = _process_skills(skills_dir, args.apply, args.quiet)
204 print(f"\nDone: {processed} generated, {skipped} skipped, {errors} errors")
205 return 0 if errors == 0 else 1