Coverage for little_loops / cli / adapt_skills_for_codex.py: 79%

203 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16:19 -0500

1"""ll-adapt-skills-for-codex: Add Codex Skills API frontmatter to all ll skills. 

2 

3For each skills/*/SKILL.md, inserts `name:` (directory slug) and 

4`metadata.short-description:` (first line of existing description, ≤80 chars). 

5Also creates `agents/openai.yaml` per the Codex Skills API spec. 

6 

7Additionally bridges `commands/*.md` to the Skills API by synthesizing a 

8`skills/ll-<stem>/SKILL.md` wrapper + `agents/openai.yaml` for each command, 

9so Codex CLI users can invoke `/ll:*` slash commands via the same discovery 

10path as adapted skills (FEAT-1493). 

11 

12Dry-run by default. Use --apply to write changes. 

13""" 

14 

15from __future__ import annotations 

16 

17import argparse 

18import re 

19import sys 

20from pathlib import Path 

21 

22import yaml 

23 

24__all__ = ["main_adapt_skills_for_codex"] 

25 

26_MAX_SHORT_DESC = 80 

27_FM_CLOSE_RE = re.compile(r"\n---\s*\n") 

28 

29 

30def _find_plugin_root() -> Path: 

31 from little_loops.skill_expander import _find_plugin_root as _fpr 

32 

33 return _fpr() 

34 

35 

36def _extract_short_desc(text: str) -> str: 

37 """Parse SKILL.md and return first non-empty description line, ≤80 chars. 

38 

39 Uses yaml.safe_load for reading only — never writes back. 

40 Handles both single-line and block-scalar description fields. 

41 """ 

42 if not text.startswith("---"): 

43 return "" 

44 end = text.find("---", 3) 

45 if end == -1: 

46 return "" 

47 fm_raw = text[3:end] 

48 try: 

49 fm = yaml.safe_load(fm_raw) or {} 

50 except yaml.YAMLError: 

51 return "" 

52 desc = fm.get("description", "") or "" 

53 if not isinstance(desc, str): 

54 return "" 

55 for line in desc.splitlines(): 

56 stripped = line.strip() 

57 if stripped: 

58 return stripped[:_MAX_SHORT_DESC] 

59 return "" 

60 

61 

62def _insert_fields(content: str, name: str, short_desc: str) -> tuple[str, bool]: 

63 """Insert name: and metadata.short-description: into SKILL.md frontmatter. 

64 

65 Uses targeted string manipulation — no yaml roundtrip — to preserve 

66 existing frontmatter formatting (block scalars, special chars, etc.). 

67 Returns (new_content, changed). 

68 """ 

69 if not content.startswith("---\n"): 

70 return content, False 

71 

72 m = _FM_CLOSE_RE.search(content[3:]) 

73 if not m: 

74 return content, False 

75 

76 # fm_text: lines between opening --- and closing ---\n (no trailing newline) 

77 fm_text = content[4 : 3 + m.start()] 

78 after = content[3 + m.start() :] # starts with "\n---\n..." 

79 

80 changed = False 

81 

82 # Insert name: if absent 

83 if not re.search(r"^name\s*:", fm_text, re.MULTILINE): 

84 fm_text = f"name: {name}\n" + fm_text 

85 changed = True 

86 

87 # Insert metadata.short-description: if absent 

88 if "short-description:" not in fm_text: 

89 if re.search(r"^metadata\s*:", fm_text, re.MULTILINE): 

90 fm_text = re.sub( 

91 r"^(metadata\s*:.*)$", 

92 lambda mtch: mtch.group(0) + f"\n short-description: {short_desc}", 

93 fm_text, 

94 flags=re.MULTILINE, 

95 count=1, 

96 ) 

97 else: 

98 fm_text += f"\nmetadata:\n short-description: {short_desc}" 

99 changed = True 

100 

101 return f"---\n{fm_text}{after}", changed 

102 

103 

104def _title_case(slug: str) -> str: 

105 """Convert skill slug to display name (e.g. 'capture-issue' → 'Capture Issue').""" 

106 return " ".join(word.capitalize() for word in slug.replace("-", " ").split()) 

107 

108 

109def _make_openai_yaml(display_name: str, short_desc: str) -> str: 

110 """Generate agents/openai.yaml content for the Codex Skills API.""" 

111 return f'interface:\n display_name: "{display_name}"\n short_description: "{short_desc}"\n' 

112 

113 

114def _process_skills(skills_dir: Path, apply: bool, quiet: bool) -> tuple[int, int, int]: 

115 """Process all skills; return (adapted, skipped, errors).""" 

116 adapted = skipped = errors = 0 

117 

118 for skill_md in sorted(skills_dir.glob("*/SKILL.md")): 

119 skill_name = skill_md.parent.name 

120 try: 

121 text = skill_md.read_text() 

122 except OSError as exc: 

123 if not quiet: 

124 print(f" ERROR {skill_name}: cannot read: {exc}", file=sys.stderr) 

125 errors += 1 

126 continue 

127 

128 short_desc = _extract_short_desc(text) 

129 if not short_desc: 

130 if not quiet: 

131 print(f" SKIP {skill_name}: no description found") 

132 skipped += 1 

133 continue 

134 

135 new_text, skill_changed = _insert_fields(text, skill_name, short_desc) 

136 openai_yaml = skill_md.parent / "agents" / "openai.yaml" 

137 yaml_exists = openai_yaml.exists() 

138 

139 if not skill_changed and yaml_exists: 

140 if not quiet: 

141 print(f" SKIP {skill_name}: already adapted") 

142 skipped += 1 

143 continue 

144 

145 if apply: 

146 if skill_changed: 

147 skill_md.write_text(new_text) 

148 if not yaml_exists: 

149 openai_yaml.parent.mkdir(exist_ok=True) 

150 display_name = _title_case(skill_name) 

151 openai_yaml.write_text(_make_openai_yaml(display_name, short_desc)) 

152 if not quiet: 

153 print(f" APPLY {skill_name}: {short_desc[:50]}") 

154 else: 

155 if not quiet: 

156 print(f" DRY {skill_name}: {short_desc[:50]}") 

157 

158 adapted += 1 

159 

160 return adapted, skipped, errors 

161 

162 

163def _read_command_frontmatter(text: str) -> dict | None: 

164 """Parse a command markdown file's YAML frontmatter. Returns None on failure.""" 

165 if not text.startswith("---"): 

166 return None 

167 end = text.find("---", 3) 

168 if end == -1: 

169 return None 

170 try: 

171 fm = yaml.safe_load(text[3:end]) or {} 

172 except yaml.YAMLError: 

173 return None 

174 if not isinstance(fm, dict): 

175 return None 

176 return fm 

177 

178 

179def _synthesized_skill_md(stem: str, description: str) -> str: 

180 """Build a minimal synthesized SKILL.md for a bridged command. 

181 

182 Writes the whole frontmatter block as a fresh document — never reuses 

183 `_insert_fields`, which is for editing existing frontmatter. 

184 Multi-line descriptions are emitted as YAML block scalars (`|`) so the 

185 resulting frontmatter parses cleanly with yaml.safe_load. 

186 """ 

187 short_desc = "" 

188 for line in description.splitlines(): 

189 stripped = line.strip() 

190 if stripped: 

191 short_desc = stripped[:_MAX_SHORT_DESC] 

192 break 

193 

194 if "\n" in description.strip(): 

195 # Emit as a literal block scalar to preserve multi-line content 

196 indented = "\n".join(f" {line}" if line else "" for line in description.splitlines()) 

197 desc_block = f"description: |\n{indented}" 

198 else: 

199 desc_block = f"description: {description.strip()}" 

200 

201 return ( 

202 f"---\n" 

203 f"name: ll-{stem}\n" 

204 f"{desc_block}\n" 

205 f"metadata:\n" 

206 f" short-description: {short_desc}\n" 

207 f"---\n" 

208 f"\n" 

209 f"# {_title_case(stem)}\n" 

210 f"\n" 

211 f"Bridged from `commands/{stem}.md` for Codex Skills API discovery.\n" 

212 f"See the source command file for the full prompt body.\n" 

213 ) 

214 

215 

216def _process_commands( 

217 commands_dir: Path, skills_dir: Path, apply: bool, quiet: bool 

218) -> tuple[int, int, int]: 

219 """Bridge commands/*.md into skills/ll-<stem>/ as Codex-discoverable skills. 

220 

221 Returns (adapted, skipped, errors). Each command produces: 

222 skills/ll-<stem>/SKILL.md (synthesized wrapper) 

223 skills/ll-<stem>/agents/openai.yaml (Codex Skills API contract) 

224 

225 Skips commands carrying `disable-model-invocation: true` in their frontmatter, 

226 mirroring the contract of generate_skill_descriptions.py. 

227 """ 

228 adapted = skipped = errors = 0 

229 

230 if not commands_dir.exists(): 

231 return adapted, skipped, errors 

232 

233 for cmd_md in sorted(commands_dir.glob("*.md")): 

234 stem = cmd_md.stem 

235 try: 

236 text = cmd_md.read_text() 

237 except OSError as exc: 

238 if not quiet: 

239 print(f" ERROR ll-{stem}: cannot read: {exc}", file=sys.stderr) 

240 errors += 1 

241 continue 

242 

243 fm = _read_command_frontmatter(text) 

244 if fm is None: 

245 if not quiet: 

246 print(f" SKIP ll-{stem}: no parseable frontmatter") 

247 skipped += 1 

248 continue 

249 

250 # Skip commands explicitly opting out of model invocation 

251 # (string-lowered check, matches generate_skill_descriptions.py:107) 

252 dmi = fm.get("disable-model-invocation") 

253 if isinstance(dmi, str): 

254 dmi = dmi.strip().lower() in {"true", "yes", "1"} 

255 if dmi: 

256 if not quiet: 

257 print(f" SKIP ll-{stem}: disable-model-invocation: true") 

258 skipped += 1 

259 continue 

260 

261 description = fm.get("description", "") or "" 

262 if not isinstance(description, str) or not description.strip(): 

263 if not quiet: 

264 print(f" SKIP ll-{stem}: no description in frontmatter") 

265 skipped += 1 

266 continue 

267 

268 short_desc = _extract_short_desc(text) 

269 if not short_desc: 

270 if not quiet: 

271 print(f" SKIP ll-{stem}: empty short description") 

272 skipped += 1 

273 continue 

274 

275 out_dir = skills_dir / f"ll-{stem}" 

276 out_skill_md = out_dir / "SKILL.md" 

277 out_openai_yaml = out_dir / "agents" / "openai.yaml" 

278 

279 skill_md_exists = out_skill_md.exists() 

280 yaml_exists = out_openai_yaml.exists() 

281 

282 if skill_md_exists and yaml_exists: 

283 if not quiet: 

284 print(f" SKIP ll-{stem}: already adapted") 

285 skipped += 1 

286 continue 

287 

288 if apply: 

289 out_dir.mkdir(parents=True, exist_ok=True) 

290 if not skill_md_exists: 

291 out_skill_md.write_text(_synthesized_skill_md(stem, description)) 

292 if not yaml_exists: 

293 out_openai_yaml.parent.mkdir(exist_ok=True) 

294 display_name = _title_case(stem) 

295 out_openai_yaml.write_text(_make_openai_yaml(display_name, short_desc)) 

296 if not quiet: 

297 print(f" APPLY ll-{stem}: {short_desc[:50]}") 

298 else: 

299 if not quiet: 

300 print(f" DRY ll-{stem}: {short_desc[:50]}") 

301 

302 adapted += 1 

303 

304 return adapted, skipped, errors 

305 

306 

307def main_adapt_skills_for_codex() -> int: 

308 """Entry point for ll-adapt-skills-for-codex CLI.""" 

309 parser = argparse.ArgumentParser( 

310 prog="ll-adapt-skills-for-codex", 

311 description=( 

312 "Add Codex Skills API frontmatter to ll skill SKILL.md files and " 

313 "bridge commands/*.md into skills/ll-<name>/ entries for Codex CLI. " 

314 "Dry-run by default; use --apply to write changes." 

315 ), 

316 formatter_class=argparse.RawDescriptionHelpFormatter, 

317 epilog=""" 

318Examples: 

319 ll-adapt-skills-for-codex # Dry-run: preview proposed changes 

320 ll-adapt-skills-for-codex --apply # Write skill frontmatter + bridge commands 

321 ll-adapt-skills-for-codex --quiet # Suppress per-entry output 

322""", 

323 ) 

324 parser.add_argument( 

325 "--apply", 

326 action="store_true", 

327 default=False, 

328 help="Write changes to SKILL.md files and create agents/openai.yaml (default: dry-run)", 

329 ) 

330 parser.add_argument( 

331 "--quiet", 

332 action="store_true", 

333 default=False, 

334 help="Suppress per-skill output; only print final summary", 

335 ) 

336 

337 args = parser.parse_args() 

338 

339 plugin_root = _find_plugin_root() 

340 skills_dir = plugin_root / "skills" 

341 commands_dir = plugin_root / "commands" 

342 

343 if not skills_dir.exists(): 

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

345 return 1 

346 

347 mode = "APPLY" if args.apply else "DRY-RUN" 

348 if not args.quiet: 

349 print(f"ll-adapt-skills-for-codex [{mode}]") 

350 print(f"Skills dir: {skills_dir}") 

351 print(f"Commands dir: {commands_dir}") 

352 print() 

353 

354 s_adapted, s_skipped, s_errors = _process_skills(skills_dir, args.apply, args.quiet) 

355 c_adapted, c_skipped, c_errors = _process_commands( 

356 commands_dir, skills_dir, args.apply, args.quiet 

357 ) 

358 

359 adapted = s_adapted + c_adapted 

360 skipped = s_skipped + c_skipped 

361 errors = s_errors + c_errors 

362 

363 print(f"\nDone: {adapted} adapted, {skipped} skipped, {errors} errors") 

364 return 0 if errors == 0 else 1