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

254 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-26 17:38 -0500

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

2 

3from __future__ import annotations 

4 

5import argparse 

6import json 

7import shutil 

8import subprocess as _subprocess 

9import sys 

10from pathlib import Path 

11from typing import Any 

12 

13from little_loops.issue_template import get_bundled_templates_dir 

14from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context 

15 

16# Feature keys toggleable via --enable/--disable in the headless path. These map 

17# to the ``*_enabled`` choice keys honored by build_config(). Richer features 

18# (parallel, sync, documents, design_tokens, confidence_gate, tdd) carry 

19# sub-config and remain interactive-only. 

20_TOGGLEABLE_FEATURES: frozenset[str] = frozenset( 

21 { 

22 "product", 

23 "analytics", 

24 "context_monitor", 

25 "learning_tests", 

26 "decisions", 

27 "scratch_pad", 

28 "session_capture", 

29 "session_digest", 

30 "prompt_optimization", 

31 } 

32) 

33 

34 

35def _plugin_version() -> str: 

36 from little_loops import __version__ 

37 

38 return __version__ 

39 

40 

41def _plugin_root() -> Path: 

42 """Return the little-loops project root (env-var-first resolver). 

43 

44 Checks CLAUDE_PLUGIN_ROOT first so non-editable installs resolve correctly. 

45 Falls back to __file__-relative path for editable dev installs. 

46 """ 

47 import os 

48 

49 env_root = os.environ.get("CLAUDE_PLUGIN_ROOT") 

50 if env_root: 

51 return Path(env_root) 

52 return Path(__file__).resolve().parent.parent.parent.parent 

53 

54 

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

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

57 detected: list[str] = [] 

58 if shutil.which("claude"): 

59 detected.append("claude-code") 

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

61 detected.append("codex") 

62 if shutil.which("pi"): 

63 detected.append("pi") 

64 return detected or ["claude-code"] 

65 

66 

67def _dispatch_host_adapters( 

68 hosts: list[str], 

69 project_root: Path, 

70 plugin_root: Path, 

71 force: bool = False, 

72 dry_run: bool = False, 

73) -> None: 

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

75 from little_loops.init.writers import install_codex_adapter 

76 

77 for host in hosts: 

78 if host == "codex": 

79 installed = install_codex_adapter( 

80 project_root, plugin_root, force=force, dry_run=dry_run 

81 ) 

82 if installed is None: 

83 import sys as _sys 

84 

85 print( 

86 "[Codex] Warning: adapter template not found in package install; " 

87 ".codex/hooks.json was not written.", 

88 file=_sys.stderr, 

89 ) 

90 elif installed and not dry_run: 

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

92 print( 

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

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

95 ) 

96 elif host == "pi": 

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

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

99 

100 

101def _feature_choices_from_args(enable: list[str], disable: list[str]) -> dict[str, Any]: 

102 """Translate --enable/--disable feature names into build_config choice keys. 

103 

104 Args: 

105 enable: Feature names to turn on. 

106 disable: Feature names to turn off. 

107 

108 Returns: 

109 Mapping of ``{name}_enabled`` -> bool for recognized features. 

110 

111 Raises: 

112 ValueError: If any name is not a known toggleable feature. 

113 """ 

114 choices: dict[str, Any] = {} 

115 unknown = sorted({f for f in (*enable, *disable) if f not in _TOGGLEABLE_FEATURES}) 

116 if unknown: 

117 raise ValueError( 

118 f"Unknown feature(s): {', '.join(unknown)}. " 

119 f"Valid features: {', '.join(sorted(_TOGGLEABLE_FEATURES))}" 

120 ) 

121 for f in enable: 

122 choices[f"{f}_enabled"] = True 

123 for f in disable: 

124 choices[f"{f}_enabled"] = False 

125 return choices 

126 

127 

128def _run_yes( 

129 project_root: Path, 

130 templates_dir: Path, 

131 plugin_root: Path, 

132 force: bool, 

133 dry_run: bool, 

134 hosts: list[str], 

135 feature_choices: dict[str, Any] | None = None, 

136 upgrade: bool = False, 

137) -> int: 

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

139 from little_loops.config.core import resolve_config_path 

140 from little_loops.init.core import build_config 

141 from little_loops.init.detect import detect_project_type 

142 from little_loops.init.install_check import ( 

143 InstallStatus, 

144 check_version, 

145 detect_installation, 

146 fetch_latest_pypi, 

147 ) 

148 from little_loops.init.validate import validate_deps 

149 from little_loops.init.writers import ( 

150 deploy_design_tokens, 

151 deploy_goals, 

152 deploy_issue_templates, 

153 make_issue_dirs, 

154 make_learning_tests_dir, 

155 merge_settings, 

156 update_gitignore, 

157 write_claude_md, 

158 write_config, 

159 ) 

160 

161 ll_dir = project_root / ".ll" 

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

163 

164 # Load existing config as baseline for pre-population. 

165 existing_config: dict[str, Any] = {} 

166 _existing_path = resolve_config_path(project_root) 

167 if _existing_path is not None: 

168 try: 

169 existing_config = json.loads(_existing_path.read_text(encoding="utf-8")) 

170 except json.JSONDecodeError: 

171 existing_config = {} 

172 

173 if existing_config and not dry_run: 

174 print("Merging with existing configuration.") 

175 elif config_path.exists() and force and not dry_run: 

176 print("Overwriting existing configuration.") 

177 

178 # Detect installation; notify-and-act (only with --upgrade) or warn-only. 

179 install_source, installed_version, _install_path = detect_installation(project_root) 

180 if install_source is None: 

181 print("little-loops package not detected.", file=sys.stderr) 

182 if upgrade: 

183 print(" Installing...") 

184 try: 

185 _subprocess.run( 

186 [sys.executable, "-m", "pip", "install", "little-loops"], 

187 check=True, 

188 ) 

189 install_source = "pypi" 

190 except _subprocess.CalledProcessError as exc: 

191 print(f" Warning: auto-install failed: {exc}", file=sys.stderr) 

192 else: 

193 print( 

194 " Hint: pip install little-loops (pass --upgrade to act automatically)", 

195 file=sys.stderr, 

196 ) 

197 elif installed_version is not None: 

198 _latest = fetch_latest_pypi() 

199 if _latest is not None: 

200 _status = check_version(installed_version, _latest) 

201 if _status == InstallStatus.OutOfDate: 

202 print( 

203 f"little-loops version mismatch (installed: {installed_version!r}, " 

204 f"latest: {_latest!r})", 

205 file=sys.stderr, 

206 ) 

207 if upgrade: 

208 print(" Upgrading...") 

209 if install_source == "local-editable": 

210 # Resolve true editable path via pip show. 

211 _pip_show = _subprocess.run( 

212 [sys.executable, "-m", "pip", "show", "little-loops"], 

213 capture_output=True, 

214 text=True, 

215 timeout=10, 

216 ) 

217 _editable_line = next( 

218 ( 

219 line 

220 for line in _pip_show.stdout.splitlines() 

221 if line.startswith("Editable project location:") 

222 ), 

223 None, 

224 ) 

225 if _editable_line: 

226 _editable_path = _editable_line.split(": ", 1)[1].strip() 

227 try: 

228 _subprocess.run( 

229 [ 

230 sys.executable, 

231 "-m", 

232 "pip", 

233 "install", 

234 "-e", 

235 f"{_editable_path}[dev]", 

236 ], 

237 check=True, 

238 ) 

239 except _subprocess.CalledProcessError as exc: 

240 print(f" Warning: auto-upgrade failed: {exc}", file=sys.stderr) 

241 else: 

242 print( 

243 " Warning: could not determine editable install path.", 

244 file=sys.stderr, 

245 ) 

246 else: 

247 try: 

248 _subprocess.run( 

249 [ 

250 sys.executable, 

251 "-m", 

252 "pip", 

253 "install", 

254 "--upgrade", 

255 "little-loops", 

256 ], 

257 check=True, 

258 ) 

259 except _subprocess.CalledProcessError as exc: 

260 print(f" Warning: auto-upgrade failed: {exc}", file=sys.stderr) 

261 else: 

262 print( 

263 " Hint: pip install --upgrade little-loops (pass --upgrade to act automatically)", 

264 file=sys.stderr, 

265 ) 

266 

267 template = detect_project_type(project_root, templates_dir) 

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

269 

270 # Build choices: start from existing config values, then apply CLI overrides. 

271 choices: dict[str, Any] = {"project_name": project_root.name} 

272 if existing_config: 

273 _ex_proj = existing_config.get("project", {}) 

274 if _ex_proj.get("name"): 

275 choices["project_name"] = _ex_proj["name"] 

276 if _ex_proj.get("src_dir"): 

277 choices["src_dir"] = _ex_proj["src_dir"] 

278 choices.update( 

279 { 

280 "product_enabled": existing_config.get("product", {}).get("enabled", True), 

281 "analytics_enabled": existing_config.get("analytics", {}).get("enabled", True), 

282 "context_monitor_enabled": existing_config.get("context_monitor", {}).get( 

283 "enabled", True 

284 ), 

285 "learning_tests_enabled": existing_config.get("learning_tests", {}).get( 

286 "enabled", True 

287 ), 

288 "decisions_enabled": existing_config.get("decisions", {}).get("enabled", False), 

289 "scratch_pad_enabled": existing_config.get("scratch_pad", {}).get("enabled", False), 

290 "session_capture_enabled": existing_config.get("session_capture", {}).get( 

291 "enabled", False 

292 ), 

293 "session_digest_enabled": existing_config.get("history", {}) 

294 .get("session_digest", {}) 

295 .get("enabled", True), 

296 "prompt_optimization_enabled": existing_config.get("prompt_optimization", {}).get( 

297 "enabled", True 

298 ), 

299 } 

300 ) 

301 if feature_choices: 

302 choices.update(feature_choices) 

303 config = build_config(template, choices) 

304 

305 if install_source: 

306 config["install_source"] = install_source 

307 

308 if dry_run: 

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

310 return 0 

311 

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

313 issues_base = project_root / issues_base_rel 

314 

315 write_config(config, ll_dir) 

316 make_issue_dirs(issues_base) 

317 

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

319 deploy_goals(ll_dir, templates_dir) 

320 

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

322 deploy_design_tokens(ll_dir, templates_dir) 

323 

324 if config.get("issues", {}).get("deploy_templates"): 

325 deploy_issue_templates(ll_dir, templates_dir) 

326 

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

328 make_learning_tests_dir(ll_dir) 

329 

330 update_gitignore(project_root) 

331 

332 extra_permissions: list[str] | None = None 

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

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

335 merge_settings(project_root, extra_permissions=extra_permissions) 

336 

337 write_claude_md(project_root) 

338 

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

340 

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

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

343 for w in warnings: 

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

345 if w.install_hint: 

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

347 print(msg, file=sys.stderr) 

348 

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

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

351 return 0 

352 

353 

354def _print_dry_run( 

355 config: dict[str, Any], 

356 project_root: Path, 

357 ll_dir: Path, 

358 hosts: list[str], 

359) -> None: 

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

361 issues_base = project_root / issues_base_rel 

362 

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

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

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

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

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

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

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

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

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

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

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

374 from little_loops.init.writers import write_claude_md 

375 

376 write_claude_md(project_root, dry_run=True) 

377 if "codex" in hosts: 

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

379 if "pi" in hosts: 

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

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

382 

383 

384def _run_plan( 

385 project_root: Path, 

386 templates_dir: Path, 

387 feature_choices: dict[str, Any] | None = None, 

388) -> int: 

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

390 from little_loops.init.core import build_config 

391 from little_loops.init.detect import detect_project_type 

392 from little_loops.init.validate import validate_deps 

393 

394 template = detect_project_type(project_root, templates_dir) 

395 choices: dict[str, Any] = {"project_name": project_root.name} 

396 if feature_choices: 

397 choices.update(feature_choices) 

398 config = build_config(template, choices) 

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

400 

401 plan: dict[str, Any] = { 

402 "detected": { 

403 "template_name": template.filename, 

404 "project_type": template.name, 

405 "project_name": project_root.name, 

406 }, 

407 "proposed_config": config, 

408 "host_options": { 

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

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

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

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

413 }, 

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

415 } 

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

417 return 0 

418 

419 

420def _run_apply( 

421 plan_config: str, 

422 project_root: Path, 

423 templates_dir: Path, 

424 force: bool, 

425) -> int: 

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

427 from little_loops.init.writers import ( 

428 deploy_goals, 

429 make_issue_dirs, 

430 merge_settings, 

431 update_gitignore, 

432 write_config, 

433 ) 

434 

435 # Accept a file path or a raw JSON string 

436 plan_path = Path(plan_config) 

437 if plan_path.exists(): 

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

439 else: 

440 raw = plan_config 

441 

442 try: 

443 plan = json.loads(raw) 

444 except json.JSONDecodeError as exc: 

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

446 return 2 

447 

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

449 ll_dir = project_root / ".ll" 

450 

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

452 issues_base = project_root / issues_base_rel 

453 

454 write_config(config, ll_dir) 

455 make_issue_dirs(issues_base) 

456 

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

458 deploy_goals(ll_dir, templates_dir) 

459 

460 update_gitignore(project_root) 

461 merge_settings(project_root) 

462 

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

464 return 0 

465 

466 

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

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

469 

470 Returns: 

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

472 """ 

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

474 parser = argparse.ArgumentParser( 

475 prog="ll-init", 

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

477 formatter_class=argparse.RawDescriptionHelpFormatter, 

478 epilog=""" 

479Examples: 

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

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

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

483 %(prog)s --yes --upgrade # Upgrade stale package/plugin automatically 

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

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

486 %(prog)s --yes --enable decisions --enable session_capture 

487 %(prog)s --yes --disable prompt_optimization 

488 

489Feature flags (headless --yes / --plan only): 

490 --enable / --disable accept: product, analytics, context_monitor, 

491 learning_tests, decisions, scratch_pad, session_capture, session_digest, 

492 prompt_optimization. Richer features (parallel, sync, documents, 

493 design_tokens, confidence_gate, tdd) carry sub-config and are 

494 interactive-only. 

495 

496Exit codes: 

497 0 - Success 

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

499 2 - Usage error 

500""", 

501 ) 

502 parser.add_argument( 

503 "--yes", 

504 "-y", 

505 action="store_true", 

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

507 ) 

508 parser.add_argument( 

509 "--force", 

510 "-f", 

511 action="store_true", 

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

513 ) 

514 parser.add_argument( 

515 "--dry-run", 

516 "-n", 

517 action="store_true", 

518 help="Preview actions without writing files", 

519 ) 

520 parser.add_argument( 

521 "--plan", 

522 action="store_true", 

523 help=( 

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

525 "without writing anything" 

526 ), 

527 ) 

528 parser.add_argument( 

529 "--hosts", 

530 nargs="+", 

531 metavar="HOST", 

532 default=None, 

533 help=( 

534 "Host harnesses to install adapters for " 

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

536 ), 

537 ) 

538 parser.add_argument( 

539 "--enable", 

540 action="append", 

541 default=[], 

542 metavar="FEATURE", 

543 help=( 

544 "Enable a feature in the headless config (repeatable). " 

545 "Valid: decisions, scratch_pad, session_capture, product, " 

546 "analytics, context_monitor, learning_tests, session_digest, " 

547 "prompt_optimization." 

548 ), 

549 ) 

550 parser.add_argument( 

551 "--disable", 

552 action="append", 

553 default=[], 

554 metavar="FEATURE", 

555 help=( 

556 "Disable a feature in the headless config (repeatable). " 

557 "Same valid names as --enable." 

558 ), 

559 ) 

560 parser.add_argument( 

561 "--upgrade", 

562 action="store_true", 

563 help=( 

564 "Act on version drift automatically (install or upgrade). " 

565 "Default headless behaviour is warn-only." 

566 ), 

567 ) 

568 parser.add_argument( 

569 "--codex", 

570 action="store_true", 

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

572 ) 

573 parser.add_argument( 

574 "--root", 

575 "-C", 

576 type=Path, 

577 default=None, 

578 dest="root", 

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

580 ) 

581 

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

583 apply_parser = subparsers.add_parser( 

584 "apply", 

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

586 ) 

587 apply_parser.add_argument( 

588 "--config", 

589 "-c", 

590 required=True, 

591 dest="plan_config", 

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

593 ) 

594 apply_parser.add_argument( 

595 "--force", 

596 "-f", 

597 action="store_true", 

598 help="Overwrite existing configuration", 

599 ) 

600 

601 args = parser.parse_args(argv) 

602 

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

604 plug_root = _plugin_root() 

605 templates_dir = get_bundled_templates_dir() 

606 

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

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

609 if args.hosts: 

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

611 hosts: list[str] = [] 

612 for h in args.hosts: 

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

614 elif args.codex: 

615 hosts = ["codex"] 

616 else: 

617 hosts = _detect_hosts(project_root) 

618 

619 if args.command == "apply": 

620 return _run_apply( 

621 plan_config=args.plan_config, 

622 project_root=project_root, 

623 templates_dir=templates_dir, 

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

625 ) 

626 

627 # Resolve --enable/--disable feature flags (headless / plan paths only). 

628 try: 

629 feature_choices = _feature_choices_from_args(args.enable, args.disable) 

630 except ValueError as exc: 

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

632 return 2 

633 if feature_choices and not (args.plan or args.yes or args.dry_run): 

634 print( 

635 "Error: --enable/--disable require --yes, --dry-run, or --plan " 

636 "(the interactive wizard uses its own feature checkboxes).", 

637 file=sys.stderr, 

638 ) 

639 return 2 

640 

641 if args.plan: 

642 return _run_plan(project_root, templates_dir, feature_choices=feature_choices) 

643 

644 if args.yes or args.dry_run: 

645 return _run_yes( 

646 project_root=project_root, 

647 templates_dir=templates_dir, 

648 plugin_root=plug_root, 

649 force=args.force, 

650 dry_run=args.dry_run, 

651 hosts=hosts, 

652 feature_choices=feature_choices, 

653 upgrade=args.upgrade, 

654 ) 

655 

656 from little_loops.init.tui import run_tui 

657 

658 return run_tui( 

659 project_root=project_root, 

660 templates_dir=templates_dir, 

661 plugin_root=plug_root, 

662 force=args.force, 

663 hosts=hosts, 

664 )