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

205 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-26 17:38 -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 

24from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context 

25 

26__all__ = ["main_adapt_skills_for_codex"] 

27 

28_MAX_SHORT_DESC = 80 

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

30 

31 

32def _find_plugin_root() -> Path: 

33 from little_loops.skill_expander import _find_plugin_root as _fpr 

34 

35 return _fpr() 

36 

37 

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

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

40 

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

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

43 """ 

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

45 return "" 

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

47 if end == -1: 

48 return "" 

49 fm_raw = text[3:end] 

50 try: 

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

52 except yaml.YAMLError: 

53 return "" 

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

55 if not isinstance(desc, str): 

56 return "" 

57 for line in desc.splitlines(): 

58 stripped = line.strip() 

59 if stripped: 

60 return stripped[:_MAX_SHORT_DESC] 

61 return "" 

62 

63 

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

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

66 

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

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

69 Returns (new_content, changed). 

70 """ 

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

72 return content, False 

73 

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

75 if not m: 

76 return content, False 

77 

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

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

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

81 

82 changed = False 

83 

84 # Insert name: if absent 

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

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

87 changed = True 

88 

89 # Insert metadata.short-description: if absent 

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

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

92 fm_text = re.sub( 

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

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

95 fm_text, 

96 flags=re.MULTILINE, 

97 count=1, 

98 ) 

99 else: 

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

101 changed = True 

102 

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

104 

105 

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

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

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

109 

110 

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

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

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

114 

115 

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

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

118 adapted = skipped = errors = 0 

119 

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

121 skill_name = skill_md.parent.name 

122 try: 

123 text = skill_md.read_text() 

124 except OSError as exc: 

125 if not quiet: 

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

127 errors += 1 

128 continue 

129 

130 short_desc = _extract_short_desc(text) 

131 if not short_desc: 

132 if not quiet: 

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

134 skipped += 1 

135 continue 

136 

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

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

139 yaml_exists = openai_yaml.exists() 

140 

141 if not skill_changed and yaml_exists: 

142 if not quiet: 

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

144 skipped += 1 

145 continue 

146 

147 if apply: 

148 if skill_changed: 

149 skill_md.write_text(new_text) 

150 if not yaml_exists: 

151 openai_yaml.parent.mkdir(exist_ok=True) 

152 display_name = _title_case(skill_name) 

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

154 if not quiet: 

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

156 else: 

157 if not quiet: 

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

159 

160 adapted += 1 

161 

162 return adapted, skipped, errors 

163 

164 

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

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

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

168 return None 

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

170 if end == -1: 

171 return None 

172 try: 

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

174 except yaml.YAMLError: 

175 return None 

176 if not isinstance(fm, dict): 

177 return None 

178 return fm 

179 

180 

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

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

183 

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

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

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

187 resulting frontmatter parses cleanly with yaml.safe_load. 

188 """ 

189 short_desc = "" 

190 for line in description.splitlines(): 

191 stripped = line.strip() 

192 if stripped: 

193 short_desc = stripped[:_MAX_SHORT_DESC] 

194 break 

195 

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

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

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

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

200 else: 

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

202 

203 return ( 

204 f"---\n" 

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

206 f"{desc_block}\n" 

207 f"metadata:\n" 

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

209 f"---\n" 

210 f"\n" 

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

212 f"\n" 

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

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

215 ) 

216 

217 

218def _process_commands( 

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

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

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

222 

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

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

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

226 

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

228 mirroring the contract of generate_skill_descriptions.py. 

229 """ 

230 adapted = skipped = errors = 0 

231 

232 if not commands_dir.exists(): 

233 return adapted, skipped, errors 

234 

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

236 stem = cmd_md.stem 

237 try: 

238 text = cmd_md.read_text() 

239 except OSError as exc: 

240 if not quiet: 

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

242 errors += 1 

243 continue 

244 

245 fm = _read_command_frontmatter(text) 

246 if fm is None: 

247 if not quiet: 

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

249 skipped += 1 

250 continue 

251 

252 # Skip commands explicitly opting out of model invocation 

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

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

255 if isinstance(dmi, str): 

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

257 if dmi: 

258 if not quiet: 

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

260 skipped += 1 

261 continue 

262 

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

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

265 if not quiet: 

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

267 skipped += 1 

268 continue 

269 

270 short_desc = _extract_short_desc(text) 

271 if not short_desc: 

272 if not quiet: 

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

274 skipped += 1 

275 continue 

276 

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

278 out_skill_md = out_dir / "SKILL.md" 

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

280 

281 skill_md_exists = out_skill_md.exists() 

282 yaml_exists = out_openai_yaml.exists() 

283 

284 if skill_md_exists and yaml_exists: 

285 if not quiet: 

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

287 skipped += 1 

288 continue 

289 

290 if apply: 

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

292 if not skill_md_exists: 

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

294 if not yaml_exists: 

295 out_openai_yaml.parent.mkdir(exist_ok=True) 

296 display_name = _title_case(stem) 

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

298 if not quiet: 

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

300 else: 

301 if not quiet: 

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

303 

304 adapted += 1 

305 

306 return adapted, skipped, errors 

307 

308 

309def main_adapt_skills_for_codex() -> int: 

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

311 with cli_event_context(DEFAULT_DB_PATH, "ll-adapt-skills-for-codex", sys.argv[1:]): 

312 parser = argparse.ArgumentParser( 

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

314 description=( 

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

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

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

318 ), 

319 formatter_class=argparse.RawDescriptionHelpFormatter, 

320 epilog=""" 

321Examples: 

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

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

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

325""", 

326 ) 

327 parser.add_argument( 

328 "--apply", 

329 action="store_true", 

330 default=False, 

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

332 ) 

333 parser.add_argument( 

334 "--quiet", 

335 action="store_true", 

336 default=False, 

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

338 ) 

339 

340 args = parser.parse_args() 

341 

342 plugin_root = _find_plugin_root() 

343 skills_dir = plugin_root / "skills" 

344 commands_dir = plugin_root / "commands" 

345 

346 if not skills_dir.exists(): 

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

348 return 1 

349 

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

351 if not args.quiet: 

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

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

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

355 print() 

356 

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

358 c_adapted, c_skipped, c_errors = _process_commands( 

359 commands_dir, skills_dir, args.apply, args.quiet 

360 ) 

361 

362 adapted = s_adapted + c_adapted 

363 skipped = s_skipped + c_skipped 

364 errors = s_errors + c_errors 

365 

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

367 return 0 if errors == 0 else 1