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
« 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.
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.
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).
12Dry-run by default. Use --apply to write changes.
13"""
15from __future__ import annotations
17import argparse
18import re
19import sys
20from pathlib import Path
22import yaml
24__all__ = ["main_adapt_skills_for_codex"]
26_MAX_SHORT_DESC = 80
27_FM_CLOSE_RE = re.compile(r"\n---\s*\n")
30def _find_plugin_root() -> Path:
31 from little_loops.skill_expander import _find_plugin_root as _fpr
33 return _fpr()
36def _extract_short_desc(text: str) -> str:
37 """Parse SKILL.md and return first non-empty description line, ≤80 chars.
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 ""
62def _insert_fields(content: str, name: str, short_desc: str) -> tuple[str, bool]:
63 """Insert name: and metadata.short-description: into SKILL.md frontmatter.
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
72 m = _FM_CLOSE_RE.search(content[3:])
73 if not m:
74 return content, False
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..."
80 changed = False
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
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
101 return f"---\n{fm_text}{after}", changed
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())
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'
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
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
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
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()
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
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]}")
158 adapted += 1
160 return adapted, skipped, errors
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
179def _synthesized_skill_md(stem: str, description: str) -> str:
180 """Build a minimal synthesized SKILL.md for a bridged command.
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
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()}"
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 )
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.
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)
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
230 if not commands_dir.exists():
231 return adapted, skipped, errors
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
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
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
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
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
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"
279 skill_md_exists = out_skill_md.exists()
280 yaml_exists = out_openai_yaml.exists()
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
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]}")
302 adapted += 1
304 return adapted, skipped, errors
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 )
337 args = parser.parse_args()
339 plugin_root = _find_plugin_root()
340 skills_dir = plugin_root / "skills"
341 commands_dir = plugin_root / "commands"
343 if not skills_dir.exists():
344 print(f"ERROR: skills directory not found: {skills_dir}", file=sys.stderr)
345 return 1
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()
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 )
359 adapted = s_adapted + c_adapted
360 skipped = s_skipped + c_skipped
361 errors = s_errors + c_errors
363 print(f"\nDone: {adapted} adapted, {skipped} skipped, {errors} errors")
364 return 0 if errors == 0 else 1