Coverage for little_loops / cli / generate_skill_descriptions.py: 16%

101 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:55 -0500

1"""ll-generate-skill-descriptions: Auto-generate concise skill descriptions via Claude CLI. 

2 

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. 

6 

7Dry-run by default. Use --apply to write back. 

8""" 

9 

10from __future__ import annotations 

11 

12import argparse 

13import re 

14import sys 

15from pathlib import Path 

16 

17from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context 

18 

19__all__ = ["main_generate_skill_descriptions"] 

20 

21_MAX_DESC_LEN = 100 

22_BODY_EXCERPT_CHARS = 500 

23 

24 

25def _find_plugin_root() -> Path: 

26 from little_loops.skill_expander import _find_plugin_root as _fpr 

27 

28 return _fpr() 

29 

30 

31def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]: 

32 """Return (frontmatter_keys, body_text) from a SKILL.md string. 

33 

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 

38 

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 

47 

48 

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 "" 

55 

56 

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 ) 

69 

70 

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:] 

81 

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) 

90 

91 

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 

95 

96 processed = skipped = errors = 0 

97 

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 

107 

108 fm, body = _parse_frontmatter(text) 

109 

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 

115 

116 trigger_keywords = _extract_trigger_keywords(fm.get("description", "")) 

117 prompt = _build_prompt(skill_name, trigger_keywords, body) 

118 

119 result = run_claude_command( 

120 command=prompt, 

121 timeout=60, 

122 ) 

123 

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 

132 

133 new_desc = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else "" 

134 

135 if len(new_desc) > _MAX_DESC_LEN: 

136 new_desc = new_desc[:_MAX_DESC_LEN] 

137 

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 

143 

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}") 

151 

152 processed += 1 

153 

154 return processed, skipped, errors 

155 

156 

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 ) 

186 

187 args = parser.parse_args() 

188 

189 plugin_root = _find_plugin_root() 

190 skills_dir = plugin_root / "skills" 

191 

192 if not skills_dir.exists(): 

193 print(f"ERROR: skills directory not found: {skills_dir}", file=sys.stderr) 

194 return 1 

195 

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() 

201 

202 processed, skipped, errors = _process_skills(skills_dir, args.apply, args.quiet) 

203 

204 print(f"\nDone: {processed} generated, {skipped} skipped, {errors} errors") 

205 return 0 if errors == 0 else 1