Coverage for little_loops / cli / adapt_agents_for_codex.py: 13%
127 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-04 12:21 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-04 12:21 -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
19from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context
21__all__ = ["main_adapt_agents_for_codex"]
23_MARKER = "# generated by ll-adapt-agents-for-codex"
24_MAX_SHORT_DESC = 80
27def _find_plugin_root() -> Path:
28 from little_loops.skill_expander import _find_plugin_root as _fpr
30 return _fpr()
33def _extract_agent_frontmatter(text: str) -> dict | None:
34 """Parse agents/*.md and return frontmatter dict, or None on failure."""
35 if not text.startswith("---"):
36 return None
37 end = text.find("---", 3)
38 if end == -1:
39 return None
40 try:
41 fm = yaml.safe_load(text[3:end]) or {}
42 except yaml.YAMLError:
43 return None
44 return fm if isinstance(fm, dict) else None
47def _extract_short_desc(text: str) -> str:
48 """Return first non-empty description line from agent frontmatter, ≤80 chars."""
49 fm = _extract_agent_frontmatter(text)
50 if not fm:
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 _extract_body(text: str) -> str:
63 """Return body text after the closing --- of frontmatter."""
64 if not text.startswith("---"):
65 return ""
66 end = text.find("---", 3)
67 if end == -1:
68 return ""
69 after_fm = text[end + 3 :]
70 if after_fm.startswith("\n"):
71 after_fm = after_fm[1:]
72 return after_fm
75def _emit_agent_toml(name: str, description: str, model: str, body: str) -> str:
76 """Return TOML content for .codex/agents/<name>.toml."""
77 escaped_desc = description.replace("\\", "\\\\").replace('"', '\\"')
78 escaped_model = model.replace("\\", "\\\\").replace('"', '\\"')
79 # TOML multiline basic strings end at """; escape any occurrence in body
80 safe_body = body.replace('"""', '\\"\\"\\"')
81 if not safe_body.endswith("\n"):
82 safe_body += "\n"
83 return (
84 f"{_MARKER}\n"
85 f'name = "{name}"\n'
86 f'description = "{escaped_desc}"\n'
87 f'model = "{escaped_model}"\n'
88 f"\n"
89 f'developer_instructions = """\n'
90 f"{safe_body}"
91 f'"""\n'
92 )
95def _process_agents(
96 agents_dir: Path,
97 codex_dir: Path,
98 apply: bool,
99 quiet: bool,
100 only: str | None,
101) -> tuple[int, int, int]:
102 """Process all agents/*.md files; return (adapted, skipped, errors)."""
103 adapted = skipped = errors = 0
105 for agent_md in sorted(agents_dir.glob("*.md")):
106 agent_name = agent_md.stem
108 if only is not None and agent_name != only:
109 continue
111 try:
112 text = agent_md.read_text()
113 except OSError as exc:
114 if not quiet:
115 print(f" ERROR {agent_name}: cannot read: {exc}", file=sys.stderr)
116 errors += 1
117 continue
119 fm = _extract_agent_frontmatter(text)
120 if not fm:
121 if not quiet:
122 print(f" SKIP {agent_name}: no parseable frontmatter")
123 skipped += 1
124 continue
126 name = str(fm.get("name") or agent_name)
127 model = str(fm.get("model") or "")
128 short_desc = _extract_short_desc(text)
129 if not short_desc:
130 if not quiet:
131 print(f" SKIP {agent_name}: no description found")
132 skipped += 1
133 continue
135 body = _extract_body(text)
136 out_toml = codex_dir / f"{agent_name}.toml"
137 new_content = _emit_agent_toml(name, short_desc, model, body)
139 if out_toml.exists():
140 existing = out_toml.read_text()
141 if not existing.startswith(_MARKER):
142 if not quiet:
143 print(f" SKIP {agent_name}: user-authored file (no marker)")
144 skipped += 1
145 continue
146 if existing == new_content:
147 if not quiet:
148 print(f" SKIP {agent_name}: already up to date")
149 skipped += 1
150 continue
152 if apply:
153 codex_dir.mkdir(parents=True, exist_ok=True)
154 out_toml.write_text(new_content)
155 if not quiet:
156 print(f" APPLY {agent_name}: {short_desc[:50]}")
157 else:
158 if not quiet:
159 print(f" DRY {agent_name}: {short_desc[:50]}")
161 adapted += 1
163 return adapted, skipped, errors
166def main_adapt_agents_for_codex() -> int:
167 """Entry point for ll-adapt-agents-for-codex CLI."""
168 with cli_event_context(DEFAULT_DB_PATH, "ll-adapt-agents-for-codex", sys.argv[1:]):
169 parser = argparse.ArgumentParser(
170 prog="ll-adapt-agents-for-codex",
171 description=(
172 "Emit .codex/agents/<name>.toml files from agents/*.md for Codex subagent "
173 "discovery. Dry-run by default; use --apply to write changes."
174 ),
175 formatter_class=argparse.RawDescriptionHelpFormatter,
176 epilog="""
177Examples:
178 ll-adapt-agents-for-codex # Dry-run: preview proposed changes
179 ll-adapt-agents-for-codex --apply # Write .codex/agents/*.toml files
180 ll-adapt-agents-for-codex --only codebase-analyzer --apply # Single agent
181 ll-adapt-agents-for-codex --quiet # Suppress per-entry output
182""",
183 )
184 parser.add_argument(
185 "--apply",
186 action="store_true",
187 default=False,
188 help="Write .codex/agents/*.toml files (default: dry-run)",
189 )
190 parser.add_argument(
191 "--only",
192 metavar="NAME",
193 default=None,
194 help="Restrict to a single agent by stem name (e.g. codebase-analyzer)",
195 )
196 parser.add_argument(
197 "--quiet",
198 action="store_true",
199 default=False,
200 help="Suppress per-agent output; only print final summary",
201 )
203 args = parser.parse_args()
205 plugin_root = _find_plugin_root()
206 agents_dir = plugin_root / "agents"
207 codex_dir = plugin_root / ".codex" / "agents"
209 if not agents_dir.exists():
210 print(f"ERROR: agents directory not found: {agents_dir}", file=sys.stderr)
211 return 1
213 mode = "APPLY" if args.apply else "DRY-RUN"
214 if not args.quiet:
215 print(f"ll-adapt-agents-for-codex [{mode}]")
216 print(f"Agents dir: {agents_dir}")
217 print(f"Output dir: {codex_dir}")
218 if args.only:
219 print(f"Filter: {args.only}")
220 print()
222 adapted, skipped, errors = _process_agents(
223 agents_dir, codex_dir, args.apply, args.quiet, args.only
224 )
226 print(f"\nDone: {adapted} adapted, {skipped} skipped, {errors} errors")
227 return 0 if errors == 0 else 1