Coverage for little_loops / init / cli.py: 11%

160 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-08 15:34 -0500

1"""ll-init: Headless project initialization CLI.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import json 

7import shutil 

8import sys 

9from pathlib import Path 

10from typing import Any 

11 

12from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context 

13 

14 

15def _plugin_version() -> str: 

16 from little_loops import __version__ 

17 

18 return __version__ 

19 

20 

21def _plugin_root() -> Path: 

22 """Return the little-loops project root (four parents above this file).""" 

23 return Path(__file__).parent.parent.parent.parent 

24 

25 

26def _detect_hosts(project_root: Path) -> list[str]: 

27 """Return list of detected host harnesses based on installed binaries and project dirs.""" 

28 detected: list[str] = [] 

29 if shutil.which("claude"): 

30 detected.append("claude-code") 

31 if shutil.which("codex") or (project_root / ".codex").exists(): 

32 detected.append("codex") 

33 if shutil.which("pi"): 

34 detected.append("pi") 

35 return detected or ["claude-code"] 

36 

37 

38def _dispatch_host_adapters( 

39 hosts: list[str], 

40 project_root: Path, 

41 plugin_root: Path, 

42 force: bool = False, 

43 dry_run: bool = False, 

44) -> None: 

45 """Install adapters for each selected host; print per-host post-install notes.""" 

46 from little_loops.init.writers import install_codex_adapter 

47 

48 for host in hosts: 

49 if host == "codex": 

50 installed = install_codex_adapter( 

51 project_root, plugin_root, force=force, dry_run=dry_run 

52 ) 

53 if installed and not dry_run: 

54 print("[Codex] Hook adapter installed to .codex/hooks.json") 

55 print( 

56 "[Codex] Note: Codex will show a hook-trust dialog on next session start. " 

57 "Hooks are silently skipped (HookRunStatus::Untrusted) until trusted." 

58 ) 

59 elif host == "pi": 

60 print("[Pi] Adapter not yet available — tracked in EPIC-1622.") 

61 # claude-code: no adapter file needed; plugin hooks fire when globally enabled 

62 

63 

64def _run_yes( 

65 project_root: Path, 

66 templates_dir: Path, 

67 plugin_root: Path, 

68 force: bool, 

69 dry_run: bool, 

70 hosts: list[str], 

71) -> int: 

72 """Execute the non-interactive --yes init flow.""" 

73 from little_loops.init.core import build_config 

74 from little_loops.init.detect import detect_project_type 

75 from little_loops.init.validate import validate_deps 

76 from little_loops.init.writers import ( 

77 deploy_goals, 

78 make_issue_dirs, 

79 make_learning_tests_dir, 

80 merge_settings, 

81 update_gitignore, 

82 write_config, 

83 ) 

84 

85 ll_dir = project_root / ".ll" 

86 config_path = ll_dir / "ll-config.json" 

87 

88 if config_path.exists() and not force: 

89 print( 

90 f"Configuration already exists at {config_path}\n" 

91 "Use --force to overwrite, or edit the existing file directly.", 

92 file=sys.stderr, 

93 ) 

94 return 1 

95 

96 if config_path.exists() and force and not dry_run: 

97 print("Overwriting existing configuration.") 

98 

99 template = detect_project_type(project_root, templates_dir) 

100 print(f"Detected project type: {template.name}") 

101 

102 config = build_config(template, {"project_name": project_root.name}) 

103 

104 if dry_run: 

105 _print_dry_run(config, project_root, ll_dir, hosts=hosts) 

106 return 0 

107 

108 issues_base_rel = config.get("issues", {}).get("base_dir", ".issues") 

109 issues_base = project_root / issues_base_rel 

110 

111 write_config(config, ll_dir) 

112 make_issue_dirs(issues_base) 

113 

114 if config.get("product", {}).get("enabled"): 

115 deploy_goals(ll_dir, templates_dir) 

116 

117 if config.get("learning_tests", {}).get("enabled"): 

118 make_learning_tests_dir(ll_dir) 

119 

120 update_gitignore(project_root) 

121 merge_settings(project_root) 

122 

123 _dispatch_host_adapters(hosts, project_root, plugin_root, force=force) 

124 

125 print("\nValidating dependencies...") 

126 warnings = validate_deps(config, _plugin_version(), project_root) 

127 for w in warnings: 

128 msg = f"Warning: {w.message}" 

129 if w.install_hint: 

130 msg += f"\n Install/fix: {w.install_hint}" 

131 print(msg, file=sys.stderr) 

132 

133 print(f"\n✓ little-loops initialized in {project_root}") 

134 print(f" Config: {config_path}") 

135 return 0 

136 

137 

138def _print_dry_run( 

139 config: dict[str, Any], 

140 project_root: Path, 

141 ll_dir: Path, 

142 hosts: list[str], 

143) -> None: 

144 issues_base_rel = config.get("issues", {}).get("base_dir", ".issues") 

145 issues_base = project_root / issues_base_rel 

146 

147 print("\n=== DRY RUN: ll-init ===\n") 

148 print("--- Configuration Preview (.ll/ll-config.json) ---") 

149 print(json.dumps(config, indent=2)) 

150 print("\n--- Actions that would be taken ---") 

151 print(f" [write] {ll_dir / 'll-config.json'}") 

152 if config.get("product", {}).get("enabled"): 

153 print(f" [write] {ll_dir / 'll-goals.md'} (from ll-goals-template.md)") 

154 for sd in ("bugs", "features", "enhancements", "completed", "deferred"): 

155 print(f" [mkdir] {issues_base / sd}") 

156 print(" [update] .gitignore (add state file exclusions)") 

157 print(" [update] .claude/settings.local.json (add ll- CLI tool permissions)") 

158 if "codex" in hosts: 

159 print(" [write] .codex/hooks.json (Codex CLI hook adapter)") 

160 if "pi" in hosts: 

161 print(" [Pi] Adapter not yet available — tracked in EPIC-1622.") 

162 print("\n=== END DRY RUN (no changes made) ===") 

163 

164 

165def _run_plan(project_root: Path, templates_dir: Path) -> int: 

166 """Emit a machine-readable JSON plan without writing anything.""" 

167 from little_loops.init.core import build_config 

168 from little_loops.init.detect import detect_project_type 

169 from little_loops.init.validate import validate_deps 

170 

171 template = detect_project_type(project_root, templates_dir) 

172 config = build_config(template, {"project_name": project_root.name}) 

173 warnings = validate_deps(config, _plugin_version(), project_root) 

174 

175 plan: dict[str, Any] = { 

176 "detected": { 

177 "template_name": template.filename, 

178 "project_type": template.name, 

179 "project_name": project_root.name, 

180 }, 

181 "proposed_config": config, 

182 "host_options": { 

183 "has_claude_code": bool(shutil.which("claude")), 

184 "has_codex": bool(shutil.which("codex")), 

185 "has_pi": bool(shutil.which("pi")), 

186 "suggested_settings_file": ".claude/settings.local.json", 

187 }, 

188 "warnings": [ 

189 {"message": w.message, "install_hint": w.install_hint} for w in warnings 

190 ], 

191 } 

192 print(json.dumps(plan, indent=2)) 

193 return 0 

194 

195 

196def _run_apply( 

197 plan_config: str, 

198 project_root: Path, 

199 templates_dir: Path, 

200 force: bool, 

201) -> int: 

202 """Apply writes from a --plan JSON (file path or raw JSON string).""" 

203 from little_loops.init.writers import ( 

204 deploy_goals, 

205 make_issue_dirs, 

206 merge_settings, 

207 update_gitignore, 

208 write_config, 

209 ) 

210 

211 # Accept a file path or a raw JSON string 

212 plan_path = Path(plan_config) 

213 if plan_path.exists(): 

214 raw = plan_path.read_text(encoding="utf-8") 

215 else: 

216 raw = plan_config 

217 

218 try: 

219 plan = json.loads(raw) 

220 except json.JSONDecodeError as exc: 

221 print(f"Error: invalid JSON: {exc}", file=sys.stderr) 

222 return 2 

223 

224 config: dict[str, Any] = plan.get("proposed_config") or plan 

225 ll_dir = project_root / ".ll" 

226 config_path = ll_dir / "ll-config.json" 

227 

228 if config_path.exists() and not force: 

229 print( 

230 f"Configuration already exists at {config_path}\nUse --force to overwrite.", 

231 file=sys.stderr, 

232 ) 

233 return 1 

234 

235 issues_base_rel = config.get("issues", {}).get("base_dir", ".issues") 

236 issues_base = project_root / issues_base_rel 

237 

238 write_config(config, ll_dir) 

239 make_issue_dirs(issues_base) 

240 

241 if config.get("product", {}).get("enabled"): 

242 deploy_goals(ll_dir, templates_dir) 

243 

244 update_gitignore(project_root) 

245 merge_settings(project_root) 

246 

247 print(f"✓ Applied init plan to {project_root}") 

248 return 0 

249 

250 

251def main_init(argv: list[str] | None = None) -> int: 

252 """Entry point for ll-init command. 

253 

254 Returns: 

255 Exit code: 0 success, 1 error, 2 usage error. 

256 """ 

257 with cli_event_context(DEFAULT_DB_PATH, "ll-init", sys.argv[1:]): 

258 parser = argparse.ArgumentParser( 

259 prog="ll-init", 

260 description="Initialize little-loops for a project", 

261 formatter_class=argparse.RawDescriptionHelpFormatter, 

262 epilog=""" 

263Examples: 

264 %(prog)s --yes # Non-interactive full init with defaults 

265 %(prog)s --yes --dry-run # Preview without writing files 

266 %(prog)s --yes --force # Overwrite existing configuration 

267 %(prog)s --plan # Emit JSON plan without writing 

268 %(prog)s apply --config plan.json # Apply writes from a --plan output 

269 

270Exit codes: 

271 0 - Success 

272 1 - Error (config exists, template missing, etc.) 

273 2 - Usage error 

274""", 

275 ) 

276 parser.add_argument( 

277 "--yes", 

278 "-y", 

279 action="store_true", 

280 help="Accept all defaults; run non-interactively", 

281 ) 

282 parser.add_argument( 

283 "--force", 

284 "-f", 

285 action="store_true", 

286 help="Overwrite existing .ll/ll-config.json", 

287 ) 

288 parser.add_argument( 

289 "--dry-run", 

290 "-n", 

291 action="store_true", 

292 help="Preview actions without writing files", 

293 ) 

294 parser.add_argument( 

295 "--plan", 

296 action="store_true", 

297 help=( 

298 "Emit JSON plan {detected, proposed_config, host_options, warnings} " 

299 "without writing anything" 

300 ), 

301 ) 

302 parser.add_argument( 

303 "--hosts", 

304 nargs="+", 

305 metavar="HOST", 

306 default=None, 

307 help=( 

308 "Host harnesses to install adapters for " 

309 "(claude-code, codex, pi). Defaults to auto-detected hosts." 

310 ), 

311 ) 

312 parser.add_argument( 

313 "--codex", 

314 action="store_true", 

315 help=argparse.SUPPRESS, # deprecated alias for --hosts codex 

316 ) 

317 parser.add_argument( 

318 "--root", 

319 "-C", 

320 type=Path, 

321 default=None, 

322 dest="root", 

323 help="Project root directory (default: current directory)", 

324 ) 

325 

326 subparsers = parser.add_subparsers(dest="command") 

327 apply_parser = subparsers.add_parser( 

328 "apply", 

329 help="Apply writes from a --plan JSON output", 

330 ) 

331 apply_parser.add_argument( 

332 "--config", 

333 "-c", 

334 required=True, 

335 dest="plan_config", 

336 help="Path to plan JSON file, or raw JSON string", 

337 ) 

338 apply_parser.add_argument( 

339 "--force", 

340 "-f", 

341 action="store_true", 

342 help="Overwrite existing configuration", 

343 ) 

344 

345 args = parser.parse_args(argv) 

346 

347 project_root = (args.root or Path.cwd()).resolve() 

348 plug_root = _plugin_root() 

349 templates_dir = plug_root / "templates" 

350 

351 # Resolve hosts: --hosts takes precedence; --codex is a deprecated alias. 

352 # When neither is given, auto-detect from installed binaries / project dirs. 

353 if args.hosts: 

354 # Expand any comma-separated values (e.g. --hosts claude-code,codex) 

355 hosts: list[str] = [] 

356 for h in args.hosts: 

357 hosts.extend(h.split(",")) 

358 elif args.codex: 

359 hosts = ["codex"] 

360 else: 

361 hosts = _detect_hosts(project_root) 

362 

363 if args.command == "apply": 

364 return _run_apply( 

365 plan_config=args.plan_config, 

366 project_root=project_root, 

367 templates_dir=templates_dir, 

368 force=getattr(args, "force", False), 

369 ) 

370 

371 if args.plan: 

372 return _run_plan(project_root, templates_dir) 

373 

374 if args.yes or args.dry_run: 

375 return _run_yes( 

376 project_root=project_root, 

377 templates_dir=templates_dir, 

378 plugin_root=plug_root, 

379 force=args.force, 

380 dry_run=args.dry_run, 

381 hosts=hosts, 

382 ) 

383 

384 from little_loops.init.tui import run_tui 

385 

386 return run_tui( 

387 project_root=project_root, 

388 templates_dir=templates_dir, 

389 plugin_root=plug_root, 

390 force=args.force, 

391 hosts=hosts, 

392 )