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

1"""ll-adapt-agents-for-codex: Emit .codex/agents/<name>.toml for each agents/*.md. 

2 

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. 

7 

8Dry-run by default. Use --apply to write changes. 

9""" 

10 

11from __future__ import annotations 

12 

13import argparse 

14import sys 

15from pathlib import Path 

16 

17import yaml 

18 

19__all__ = ["main_adapt_agents_for_codex"] 

20 

21_MARKER = "# generated by ll-adapt-agents-for-codex" 

22_MAX_SHORT_DESC = 80 

23 

24 

25def _find_plugin_root() -> Path: 

26 from little_loops.skill_expander import _find_plugin_root as _fpr 

27 

28 return _fpr() 

29 

30 

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 

43 

44 

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 "" 

58 

59 

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 

71 

72 

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 ) 

91 

92 

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 

102 

103 for agent_md in sorted(agents_dir.glob("*.md")): 

104 agent_name = agent_md.stem 

105 

106 if only is not None and agent_name != only: 

107 continue 

108 

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 

116 

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 

123 

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 

132 

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) 

136 

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 

149 

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]}") 

158 

159 adapted += 1 

160 

161 return adapted, skipped, errors 

162 

163 

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 ) 

199 

200 args = parser.parse_args() 

201 

202 plugin_root = _find_plugin_root() 

203 agents_dir = plugin_root / "agents" 

204 codex_dir = plugin_root / ".codex" / "agents" 

205 

206 if not agents_dir.exists(): 

207 print(f"ERROR: agents directory not found: {agents_dir}", file=sys.stderr) 

208 return 1 

209 

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() 

218 

219 adapted, skipped, errors = _process_agents( 

220 agents_dir, codex_dir, args.apply, args.quiet, args.only 

221 ) 

222 

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

224 return 0 if errors == 0 else 1