Coverage for little_loops / cli / adapt_agents_for_codex.py: 82%
125 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-agents-for-codex: Emit .codex/agents/<name>.toml for each agents/*.md.
3For each agents/<name>.md, reads name/description/model frontmatter and the
4body text, then emits <repo-root>/.codex/agents/<name>.toml for Codex subagent
5discovery. Existing user-authored files (no marker comment) are never
6overwritten.
8Dry-run by default. Use --apply to write changes.
9"""
11from __future__ import annotations
13import argparse
14import sys
15from pathlib import Path
17import yaml
19__all__ = ["main_adapt_agents_for_codex"]
21_MARKER = "# generated by ll-adapt-agents-for-codex"
22_MAX_SHORT_DESC = 80
25def _find_plugin_root() -> Path:
26 from little_loops.skill_expander import _find_plugin_root as _fpr
28 return _fpr()
31def _extract_agent_frontmatter(text: str) -> dict | None:
32 """Parse agents/*.md and return frontmatter dict, or None on failure."""
33 if not text.startswith("---"):
34 return None
35 end = text.find("---", 3)
36 if end == -1:
37 return None
38 try:
39 fm = yaml.safe_load(text[3:end]) or {}
40 except yaml.YAMLError:
41 return None
42 return fm if isinstance(fm, dict) else None
45def _extract_short_desc(text: str) -> str:
46 """Return first non-empty description line from agent frontmatter, ≤80 chars."""
47 fm = _extract_agent_frontmatter(text)
48 if not fm:
49 return ""
50 desc = fm.get("description", "") or ""
51 if not isinstance(desc, str):
52 return ""
53 for line in desc.splitlines():
54 stripped = line.strip()
55 if stripped:
56 return stripped[:_MAX_SHORT_DESC]
57 return ""
60def _extract_body(text: str) -> str:
61 """Return body text after the closing --- of frontmatter."""
62 if not text.startswith("---"):
63 return ""
64 end = text.find("---", 3)
65 if end == -1:
66 return ""
67 after_fm = text[end + 3 :]
68 if after_fm.startswith("\n"):
69 after_fm = after_fm[1:]
70 return after_fm
73def _emit_agent_toml(name: str, description: str, model: str, body: str) -> str:
74 """Return TOML content for .codex/agents/<name>.toml."""
75 escaped_desc = description.replace("\\", "\\\\").replace('"', '\\"')
76 escaped_model = model.replace("\\", "\\\\").replace('"', '\\"')
77 # TOML multiline basic strings end at """; escape any occurrence in body
78 safe_body = body.replace('"""', '\\"\\"\\"')
79 if not safe_body.endswith("\n"):
80 safe_body += "\n"
81 return (
82 f"{_MARKER}\n"
83 f'name = "{name}"\n'
84 f'description = "{escaped_desc}"\n'
85 f'model = "{escaped_model}"\n'
86 f"\n"
87 f'developer_instructions = """\n'
88 f"{safe_body}"
89 f'"""\n'
90 )
93def _process_agents(
94 agents_dir: Path,
95 codex_dir: Path,
96 apply: bool,
97 quiet: bool,
98 only: str | None,
99) -> tuple[int, int, int]:
100 """Process all agents/*.md files; return (adapted, skipped, errors)."""
101 adapted = skipped = errors = 0
103 for agent_md in sorted(agents_dir.glob("*.md")):
104 agent_name = agent_md.stem
106 if only is not None and agent_name != only:
107 continue
109 try:
110 text = agent_md.read_text()
111 except OSError as exc:
112 if not quiet:
113 print(f" ERROR {agent_name}: cannot read: {exc}", file=sys.stderr)
114 errors += 1
115 continue
117 fm = _extract_agent_frontmatter(text)
118 if not fm:
119 if not quiet:
120 print(f" SKIP {agent_name}: no parseable frontmatter")
121 skipped += 1
122 continue
124 name = str(fm.get("name") or agent_name)
125 model = str(fm.get("model") or "")
126 short_desc = _extract_short_desc(text)
127 if not short_desc:
128 if not quiet:
129 print(f" SKIP {agent_name}: no description found")
130 skipped += 1
131 continue
133 body = _extract_body(text)
134 out_toml = codex_dir / f"{agent_name}.toml"
135 new_content = _emit_agent_toml(name, short_desc, model, body)
137 if out_toml.exists():
138 existing = out_toml.read_text()
139 if not existing.startswith(_MARKER):
140 if not quiet:
141 print(f" SKIP {agent_name}: user-authored file (no marker)")
142 skipped += 1
143 continue
144 if existing == new_content:
145 if not quiet:
146 print(f" SKIP {agent_name}: already up to date")
147 skipped += 1
148 continue
150 if apply:
151 codex_dir.mkdir(parents=True, exist_ok=True)
152 out_toml.write_text(new_content)
153 if not quiet:
154 print(f" APPLY {agent_name}: {short_desc[:50]}")
155 else:
156 if not quiet:
157 print(f" DRY {agent_name}: {short_desc[:50]}")
159 adapted += 1
161 return adapted, skipped, errors
164def main_adapt_agents_for_codex() -> int:
165 """Entry point for ll-adapt-agents-for-codex CLI."""
166 parser = argparse.ArgumentParser(
167 prog="ll-adapt-agents-for-codex",
168 description=(
169 "Emit .codex/agents/<name>.toml files from agents/*.md for Codex subagent "
170 "discovery. Dry-run by default; use --apply to write changes."
171 ),
172 formatter_class=argparse.RawDescriptionHelpFormatter,
173 epilog="""
174Examples:
175 ll-adapt-agents-for-codex # Dry-run: preview proposed changes
176 ll-adapt-agents-for-codex --apply # Write .codex/agents/*.toml files
177 ll-adapt-agents-for-codex --only codebase-analyzer --apply # Single agent
178 ll-adapt-agents-for-codex --quiet # Suppress per-entry output
179""",
180 )
181 parser.add_argument(
182 "--apply",
183 action="store_true",
184 default=False,
185 help="Write .codex/agents/*.toml files (default: dry-run)",
186 )
187 parser.add_argument(
188 "--only",
189 metavar="NAME",
190 default=None,
191 help="Restrict to a single agent by stem name (e.g. codebase-analyzer)",
192 )
193 parser.add_argument(
194 "--quiet",
195 action="store_true",
196 default=False,
197 help="Suppress per-agent output; only print final summary",
198 )
200 args = parser.parse_args()
202 plugin_root = _find_plugin_root()
203 agents_dir = plugin_root / "agents"
204 codex_dir = plugin_root / ".codex" / "agents"
206 if not agents_dir.exists():
207 print(f"ERROR: agents directory not found: {agents_dir}", file=sys.stderr)
208 return 1
210 mode = "APPLY" if args.apply else "DRY-RUN"
211 if not args.quiet:
212 print(f"ll-adapt-agents-for-codex [{mode}]")
213 print(f"Agents dir: {agents_dir}")
214 print(f"Output dir: {codex_dir}")
215 if args.only:
216 print(f"Filter: {args.only}")
217 print()
219 adapted, skipped, errors = _process_agents(
220 agents_dir, codex_dir, args.apply, args.quiet, args.only
221 )
223 print(f"\nDone: {adapted} adapted, {skipped} skipped, {errors} errors")
224 return 0 if errors == 0 else 1