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

168 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-16 13:11 -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_design_tokens, 

78 deploy_goals, 

79 make_issue_dirs, 

80 make_learning_tests_dir, 

81 merge_settings, 

82 update_gitignore, 

83 write_claude_md, 

84 write_config, 

85 ) 

86 

87 ll_dir = project_root / ".ll" 

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

89 

90 if config_path.exists() and not force: 

91 print( 

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

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

94 file=sys.stderr, 

95 ) 

96 return 1 

97 

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

99 print("Overwriting existing configuration.") 

100 

101 template = detect_project_type(project_root, templates_dir) 

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

103 

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

105 

106 if dry_run: 

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

108 return 0 

109 

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

111 issues_base = project_root / issues_base_rel 

112 

113 write_config(config, ll_dir) 

114 make_issue_dirs(issues_base) 

115 

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

117 deploy_goals(ll_dir, templates_dir) 

118 

119 if config.get("design_tokens", {}).get("enabled"): 

120 deploy_design_tokens(ll_dir, templates_dir) 

121 

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

123 make_learning_tests_dir(ll_dir) 

124 

125 update_gitignore(project_root) 

126 

127 extra_permissions: list[str] | None = None 

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

129 extra_permissions = ["Skill(ll:explore-api)"] 

130 merge_settings(project_root, extra_permissions=extra_permissions) 

131 

132 write_claude_md(project_root) 

133 

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

135 

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

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

138 for w in warnings: 

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

140 if w.install_hint: 

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

142 print(msg, file=sys.stderr) 

143 

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

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

146 return 0 

147 

148 

149def _print_dry_run( 

150 config: dict[str, Any], 

151 project_root: Path, 

152 ll_dir: Path, 

153 hosts: list[str], 

154) -> None: 

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

156 issues_base = project_root / issues_base_rel 

157 

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

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

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

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

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

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

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

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

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

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

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

169 from little_loops.init.writers import write_claude_md 

170 

171 write_claude_md(project_root, dry_run=True) 

172 if "codex" in hosts: 

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

174 if "pi" in hosts: 

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

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

177 

178 

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

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

181 from little_loops.init.core import build_config 

182 from little_loops.init.detect import detect_project_type 

183 from little_loops.init.validate import validate_deps 

184 

185 template = detect_project_type(project_root, templates_dir) 

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

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

188 

189 plan: dict[str, Any] = { 

190 "detected": { 

191 "template_name": template.filename, 

192 "project_type": template.name, 

193 "project_name": project_root.name, 

194 }, 

195 "proposed_config": config, 

196 "host_options": { 

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

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

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

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

201 }, 

202 "warnings": [{"message": w.message, "install_hint": w.install_hint} for w in warnings], 

203 } 

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

205 return 0 

206 

207 

208def _run_apply( 

209 plan_config: str, 

210 project_root: Path, 

211 templates_dir: Path, 

212 force: bool, 

213) -> int: 

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

215 from little_loops.init.writers import ( 

216 deploy_goals, 

217 make_issue_dirs, 

218 merge_settings, 

219 update_gitignore, 

220 write_config, 

221 ) 

222 

223 # Accept a file path or a raw JSON string 

224 plan_path = Path(plan_config) 

225 if plan_path.exists(): 

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

227 else: 

228 raw = plan_config 

229 

230 try: 

231 plan = json.loads(raw) 

232 except json.JSONDecodeError as exc: 

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

234 return 2 

235 

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

237 ll_dir = project_root / ".ll" 

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

239 

240 if config_path.exists() and not force: 

241 print( 

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

243 file=sys.stderr, 

244 ) 

245 return 1 

246 

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

248 issues_base = project_root / issues_base_rel 

249 

250 write_config(config, ll_dir) 

251 make_issue_dirs(issues_base) 

252 

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

254 deploy_goals(ll_dir, templates_dir) 

255 

256 update_gitignore(project_root) 

257 merge_settings(project_root) 

258 

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

260 return 0 

261 

262 

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

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

265 

266 Returns: 

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

268 """ 

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

270 parser = argparse.ArgumentParser( 

271 prog="ll-init", 

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

273 formatter_class=argparse.RawDescriptionHelpFormatter, 

274 epilog=""" 

275Examples: 

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

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

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

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

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

281 

282Exit codes: 

283 0 - Success 

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

285 2 - Usage error 

286""", 

287 ) 

288 parser.add_argument( 

289 "--yes", 

290 "-y", 

291 action="store_true", 

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

293 ) 

294 parser.add_argument( 

295 "--force", 

296 "-f", 

297 action="store_true", 

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

299 ) 

300 parser.add_argument( 

301 "--dry-run", 

302 "-n", 

303 action="store_true", 

304 help="Preview actions without writing files", 

305 ) 

306 parser.add_argument( 

307 "--plan", 

308 action="store_true", 

309 help=( 

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

311 "without writing anything" 

312 ), 

313 ) 

314 parser.add_argument( 

315 "--hosts", 

316 nargs="+", 

317 metavar="HOST", 

318 default=None, 

319 help=( 

320 "Host harnesses to install adapters for " 

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

322 ), 

323 ) 

324 parser.add_argument( 

325 "--codex", 

326 action="store_true", 

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

328 ) 

329 parser.add_argument( 

330 "--root", 

331 "-C", 

332 type=Path, 

333 default=None, 

334 dest="root", 

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

336 ) 

337 

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

339 apply_parser = subparsers.add_parser( 

340 "apply", 

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

342 ) 

343 apply_parser.add_argument( 

344 "--config", 

345 "-c", 

346 required=True, 

347 dest="plan_config", 

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

349 ) 

350 apply_parser.add_argument( 

351 "--force", 

352 "-f", 

353 action="store_true", 

354 help="Overwrite existing configuration", 

355 ) 

356 

357 args = parser.parse_args(argv) 

358 

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

360 plug_root = _plugin_root() 

361 templates_dir = plug_root / "templates" 

362 

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

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

365 if args.hosts: 

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

367 hosts: list[str] = [] 

368 for h in args.hosts: 

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

370 elif args.codex: 

371 hosts = ["codex"] 

372 else: 

373 hosts = _detect_hosts(project_root) 

374 

375 if args.command == "apply": 

376 return _run_apply( 

377 plan_config=args.plan_config, 

378 project_root=project_root, 

379 templates_dir=templates_dir, 

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

381 ) 

382 

383 if args.plan: 

384 return _run_plan(project_root, templates_dir) 

385 

386 if args.yes or args.dry_run: 

387 return _run_yes( 

388 project_root=project_root, 

389 templates_dir=templates_dir, 

390 plugin_root=plug_root, 

391 force=args.force, 

392 dry_run=args.dry_run, 

393 hosts=hosts, 

394 ) 

395 

396 from little_loops.init.tui import run_tui 

397 

398 return run_tui( 

399 project_root=project_root, 

400 templates_dir=templates_dir, 

401 plugin_root=plug_root, 

402 force=args.force, 

403 hosts=hosts, 

404 )