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-15 17:30 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:30 -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
24from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context
26__all__ = ["main_adapt_skills_for_codex"]
28_MAX_SHORT_DESC = 80
29_FM_CLOSE_RE = re.compile(r"\n---\s*\n")
32def _find_plugin_root() -> Path:
33 from little_loops.skill_expander import _find_plugin_root as _fpr
35 return _fpr()
38def _extract_short_desc(text: str) -> str:
39 """Parse SKILL.md and return first non-empty description line, ≤80 chars.
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 ""
64def _insert_fields(content: str, name: str, short_desc: str) -> tuple[str, bool]:
65 """Insert name: and metadata.short-description: into SKILL.md frontmatter.
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
74 m = _FM_CLOSE_RE.search(content[3:])
75 if not m:
76 return content, False
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..."
82 changed = False
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
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
103 return f"---\n{fm_text}{after}", changed
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())
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'
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
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
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
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()
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
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]}")
160 adapted += 1
162 return adapted, skipped, errors
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
181def _synthesized_skill_md(stem: str, description: str) -> str:
182 """Build a minimal synthesized SKILL.md for a bridged command.
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
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()}"
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 )
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.
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)
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
232 if not commands_dir.exists():
233 return adapted, skipped, errors
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
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
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
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
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
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"
281 skill_md_exists = out_skill_md.exists()
282 yaml_exists = out_openai_yaml.exists()
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
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]}")
304 adapted += 1
306 return adapted, skipped, errors
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 )
340 args = parser.parse_args()
342 plugin_root = _find_plugin_root()
343 skills_dir = plugin_root / "skills"
344 commands_dir = plugin_root / "commands"
346 if not skills_dir.exists():
347 print(f"ERROR: skills directory not found: {skills_dir}", file=sys.stderr)
348 return 1
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()
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 )
362 adapted = s_adapted + c_adapted
363 skipped = s_skipped + c_skipped
364 errors = s_errors + c_errors
366 print(f"\nDone: {adapted} adapted, {skipped} skipped, {errors} errors")
367 return 0 if errors == 0 else 1