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-16 13:11 -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 

19from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context 

20 

21__all__ = ["main_adapt_agents_for_codex"] 

22 

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

24_MAX_SHORT_DESC = 80 

25 

26 

27def _find_plugin_root() -> Path: 

28 from little_loops.skill_expander import _find_plugin_root as _fpr 

29 

30 return _fpr() 

31 

32 

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 

45 

46 

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

60 

61 

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 

73 

74 

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 ) 

93 

94 

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 

104 

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

106 agent_name = agent_md.stem 

107 

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

109 continue 

110 

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 

118 

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 

125 

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 

134 

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) 

138 

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 

151 

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

160 

161 adapted += 1 

162 

163 return adapted, skipped, errors 

164 

165 

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 ) 

202 

203 args = parser.parse_args() 

204 

205 plugin_root = _find_plugin_root() 

206 agents_dir = plugin_root / "agents" 

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

208 

209 if not agents_dir.exists(): 

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

211 return 1 

212 

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

221 

222 adapted, skipped, errors = _process_agents( 

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

224 ) 

225 

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

227 return 0 if errors == 0 else 1