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

2

3 For each skills/*/SKILL.md that does NOT have disable-model-invocation: true,

4 extract trigger keywords and body excerpt, prompt Claude (haiku) to produce a

5 description ≤100 characters, and optionally write it back to the frontmatter.

6

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

8"""

9

10 from __future__ import annotations

11

12 import argparse

13 import re

14 import sys

15 from pathlib import Path

16

17 from 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

25 def _find_plugin_root() -> Path:

26 from little_loops.skill_expander import _find_plugin_root as _fpr

27

28 return _fpr()

29

30

31 def _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

49 def _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

57 def _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

71 def _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

92 def _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

157 def 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="""

168 Examples:

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