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

250 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:55 -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# Recognized host names for --hosts validation; mirrors _HOST_RUNNER_REGISTRY keys. 

35_KNOWN_HOSTS: frozenset[str] = frozenset({"claude-code", "codex", "opencode", "pi"}) 

36 

37 

38def _plugin_version() -> str: 

39 from little_loops import __version__ 

40 

41 return __version__ 

42 

43 

44def _plugin_root() -> Path: 

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

46 

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

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

49 """ 

50 import os 

51 

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

53 if env_root: 

54 return Path(env_root) 

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

56 

57 

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

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

60 detected: list[str] = [] 

61 if shutil.which("claude"): 

62 detected.append("claude-code") 

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

64 detected.append("codex") 

65 if shutil.which("opencode"): 

66 detected.append("opencode") 

67 if shutil.which("pi"): 

68 detected.append("pi") 

69 return detected or ["claude-code"] 

70 

71 

72def _dispatch_host_adapters( 

73 hosts: list[str], 

74 project_root: Path, 

75 plugin_root: Path, 

76 force: bool = False, 

77 dry_run: bool = False, 

78) -> None: 

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

80 from little_loops.init.writers import install_codex_adapter 

81 

82 for host in hosts: 

83 if host not in _KNOWN_HOSTS: 

84 print( 

85 f"[Warning] Unknown host {host!r}; skipping. Known hosts: {sorted(_KNOWN_HOSTS)}", 

86 file=sys.stderr, 

87 ) 

88 continue 

89 if host == "codex": 

90 installed = install_codex_adapter( 

91 project_root, plugin_root, force=force, dry_run=dry_run 

92 ) 

93 if installed is None: 

94 print( 

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

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

97 file=sys.stderr, 

98 ) 

99 elif installed and not dry_run: 

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

101 print( 

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

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

104 ) 

105 elif host == "opencode": 

106 print("[OpenCode] Adapter not yet available — opencode orchestration not yet wired.") 

107 elif host == "pi": 

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

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

110 

111 

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

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

114 

115 Args: 

116 enable: Feature names to turn on. 

117 disable: Feature names to turn off. 

118 

119 Returns: 

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

121 

122 Raises: 

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

124 """ 

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

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

127 if unknown: 

128 raise ValueError( 

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

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

131 ) 

132 for f in enable: 

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

134 for f in disable: 

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

136 return choices 

137 

138 

139def _run_yes( 

140 project_root: Path, 

141 templates_dir: Path, 

142 plugin_root: Path, 

143 force: bool, 

144 dry_run: bool, 

145 hosts: list[str], 

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

147 upgrade: bool = False, 

148) -> int: 

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

150 from little_loops.init.core import build_config 

151 from little_loops.init.detect import detect_project_type 

152 from little_loops.init.install_check import ( 

153 InstallStatus, 

154 check_version, 

155 detect_installation, 

156 fetch_latest_pypi, 

157 ) 

158 from little_loops.init.validate import validate_deps 

159 from little_loops.init.writers import ( 

160 deploy_design_tokens, 

161 deploy_goals, 

162 deploy_issue_templates, 

163 load_existing_config, 

164 make_issue_dirs, 

165 make_learning_tests_dir, 

166 merge_settings, 

167 merge_with_existing, 

168 update_gitignore, 

169 write_claude_md, 

170 write_config, 

171 ) 

172 

173 ll_dir = project_root / ".ll" 

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

175 

176 # Load existing config as baseline for pre-population (and the merge below). 

177 existing_config = load_existing_config(project_root) 

178 

179 if existing_config and not dry_run: 

180 # --force resets to template defaults; a plain re-init merges (BUG-2310). 

181 print( 

182 "Overwriting existing configuration." 

183 if force 

184 else "Merging with existing configuration." 

185 ) 

186 

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

188 install_source, installed_version, _install_path = detect_installation(project_root) 

189 if install_source is None: 

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

191 if upgrade: 

192 print(" Installing...") 

193 try: 

194 _subprocess.run( 

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

196 check=True, 

197 ) 

198 install_source = "pypi" 

199 except _subprocess.CalledProcessError as exc: 

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

201 else: 

202 print( 

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

204 file=sys.stderr, 

205 ) 

206 elif installed_version is not None: 

207 _latest = fetch_latest_pypi() 

208 if _latest is not None: 

209 _status = check_version(installed_version, _latest) 

210 if _status == InstallStatus.OutOfDate: 

211 print( 

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

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

214 file=sys.stderr, 

215 ) 

216 if upgrade: 

217 print(" Upgrading...") 

218 if install_source == "local-editable": 

219 # Resolve true editable path via pip show. 

220 _pip_show = _subprocess.run( 

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

222 capture_output=True, 

223 text=True, 

224 timeout=10, 

225 ) 

226 _editable_line = next( 

227 ( 

228 line 

229 for line in _pip_show.stdout.splitlines() 

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

231 ), 

232 None, 

233 ) 

234 if _editable_line: 

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

236 try: 

237 _subprocess.run( 

238 [ 

239 sys.executable, 

240 "-m", 

241 "pip", 

242 "install", 

243 "-e", 

244 f"{_editable_path}[dev]", 

245 ], 

246 check=True, 

247 ) 

248 except _subprocess.CalledProcessError as exc: 

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

250 else: 

251 print( 

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

253 file=sys.stderr, 

254 ) 

255 else: 

256 try: 

257 _subprocess.run( 

258 [ 

259 sys.executable, 

260 "-m", 

261 "pip", 

262 "install", 

263 "--upgrade", 

264 "little-loops", 

265 ], 

266 check=True, 

267 ) 

268 except _subprocess.CalledProcessError as exc: 

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

270 else: 

271 print( 

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

273 file=sys.stderr, 

274 ) 

275 

276 template = detect_project_type(project_root, templates_dir) 

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

278 

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

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

281 if existing_config: 

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

283 if _ex_proj.get("name"): 

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

285 if _ex_proj.get("src_dir"): 

286 choices["src_dir"] = _ex_proj["src_dir"] 

287 choices.update( 

288 { 

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

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

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

292 "enabled", True 

293 ), 

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

295 "enabled", True 

296 ), 

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

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

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

300 "enabled", False 

301 ), 

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

303 .get("session_digest", {}) 

304 .get("enabled", True), 

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

306 "enabled", True 

307 ), 

308 } 

309 ) 

310 if feature_choices: 

311 choices.update(feature_choices) 

312 config = build_config(template, choices) 

313 

314 # Preserve any config keys build_config does not model (BUG-2310); --force 

315 # bypasses the merge to reset to template defaults. 

316 config = merge_with_existing(config, existing_config, force) 

317 

318 if install_source: 

319 config["install_source"] = install_source 

320 

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

322 issues_base = project_root / issues_base_rel 

323 

324 write_config(config, ll_dir, dry_run=dry_run) 

325 make_issue_dirs(issues_base, dry_run=dry_run) 

326 

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

328 deploy_goals(ll_dir, templates_dir, dry_run=dry_run) 

329 

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

331 deploy_design_tokens(ll_dir, templates_dir, dry_run=dry_run) 

332 

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

334 deploy_issue_templates(ll_dir, templates_dir, dry_run=dry_run) 

335 

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

337 make_learning_tests_dir(ll_dir, dry_run=dry_run) 

338 

339 update_gitignore(project_root, dry_run=dry_run) 

340 

341 extra_permissions: list[str] | None = None 

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

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

344 merge_settings(project_root, extra_permissions=extra_permissions, dry_run=dry_run) 

345 

346 write_claude_md(project_root, dry_run=dry_run) 

347 

348 _dispatch_host_adapters(hosts, project_root, plugin_root, force=force, dry_run=dry_run) 

349 

350 if not dry_run: 

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

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

353 for w in warnings: 

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

355 if w.install_hint: 

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

357 print(msg, file=sys.stderr) 

358 

359 if not dry_run: 

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

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

362 return 0 

363 

364 

365def _run_plan( 

366 project_root: Path, 

367 templates_dir: Path, 

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

369) -> int: 

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

371 from little_loops.init.core import build_config 

372 from little_loops.init.detect import detect_project_type 

373 from little_loops.init.validate import validate_deps 

374 

375 template = detect_project_type(project_root, templates_dir) 

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

377 if feature_choices: 

378 choices.update(feature_choices) 

379 config = build_config(template, choices) 

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

381 

382 plan: dict[str, Any] = { 

383 "detected": { 

384 "template_name": template.filename, 

385 "project_type": template.name, 

386 "project_name": project_root.name, 

387 }, 

388 "proposed_config": config, 

389 "host_options": { 

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

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

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

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

394 }, 

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

396 } 

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

398 return 0 

399 

400 

401def _run_apply( 

402 plan_config: str, 

403 project_root: Path, 

404 templates_dir: Path, 

405 plugin_root: Path, 

406 hosts: list[str], 

407 force: bool, 

408) -> int: 

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

410 from little_loops.init.validate import validate_deps 

411 from little_loops.init.writers import ( 

412 deploy_design_tokens, 

413 deploy_goals, 

414 deploy_issue_templates, 

415 load_existing_config, 

416 make_issue_dirs, 

417 make_learning_tests_dir, 

418 merge_settings, 

419 merge_with_existing, 

420 update_gitignore, 

421 write_claude_md, 

422 write_config, 

423 ) 

424 

425 # Accept a file path or a raw JSON string 

426 plan_path = Path(plan_config) 

427 if plan_path.exists(): 

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

429 else: 

430 raw = plan_config 

431 

432 try: 

433 plan = json.loads(raw) 

434 except json.JSONDecodeError as exc: 

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

436 return 2 

437 

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

439 ll_dir = project_root / ".ll" 

440 

441 # Preserve any config keys the plan does not model (BUG-2310); --force resets. 

442 config = merge_with_existing(config, load_existing_config(project_root), force) 

443 

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

445 issues_base = project_root / issues_base_rel 

446 

447 write_config(config, ll_dir) 

448 make_issue_dirs(issues_base) 

449 

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

451 deploy_goals(ll_dir, templates_dir) 

452 

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

454 deploy_design_tokens(ll_dir, templates_dir) 

455 

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

457 deploy_issue_templates(ll_dir, templates_dir) 

458 

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

460 make_learning_tests_dir(ll_dir) 

461 

462 update_gitignore(project_root) 

463 

464 extra_permissions: list[str] | None = None 

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

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

467 merge_settings(project_root, extra_permissions=extra_permissions) 

468 

469 write_claude_md(project_root) 

470 

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

472 

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

474 for w in warnings: 

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

476 if w.install_hint: 

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

478 print(msg, file=sys.stderr) 

479 

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

481 return 0 

482 

483 

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

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

486 

487 Returns: 

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

489 """ 

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

491 parser = argparse.ArgumentParser( 

492 prog="ll-init", 

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

494 formatter_class=argparse.RawDescriptionHelpFormatter, 

495 epilog=""" 

496Examples: 

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

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

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

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

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

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

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

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

505 

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

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

508 learning_tests, decisions, scratch_pad, session_capture, session_digest, 

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

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

511 interactive-only. 

512 

513Exit codes: 

514 0 - Success 

515 1 - Error (template missing, stdin not a TTY, etc.) 

516 2 - Usage error 

517""", 

518 ) 

519 parser.add_argument( 

520 "--yes", 

521 "-y", 

522 action="store_true", 

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

524 ) 

525 parser.add_argument( 

526 "--force", 

527 "-f", 

528 action="store_true", 

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

530 ) 

531 parser.add_argument( 

532 "--dry-run", 

533 "-n", 

534 action="store_true", 

535 help="Preview actions without writing files", 

536 ) 

537 parser.add_argument( 

538 "--plan", 

539 action="store_true", 

540 help=( 

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

542 "without writing anything" 

543 ), 

544 ) 

545 parser.add_argument( 

546 "--hosts", 

547 nargs="+", 

548 metavar="HOST", 

549 default=None, 

550 help=( 

551 "Host harnesses to install adapters for " 

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

553 ), 

554 ) 

555 parser.add_argument( 

556 "--enable", 

557 action="append", 

558 default=[], 

559 metavar="FEATURE", 

560 help=( 

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

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

563 "analytics, context_monitor, learning_tests, session_digest, " 

564 "prompt_optimization." 

565 ), 

566 ) 

567 parser.add_argument( 

568 "--disable", 

569 action="append", 

570 default=[], 

571 metavar="FEATURE", 

572 help=( 

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

574 "Same valid names as --enable." 

575 ), 

576 ) 

577 parser.add_argument( 

578 "--upgrade", 

579 action="store_true", 

580 help=( 

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

582 "Default headless behaviour is warn-only." 

583 ), 

584 ) 

585 parser.add_argument( 

586 "--codex", 

587 action="store_true", 

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

589 ) 

590 parser.add_argument( 

591 "--root", 

592 "-C", 

593 type=Path, 

594 default=None, 

595 dest="root", 

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

597 ) 

598 

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

600 apply_parser = subparsers.add_parser( 

601 "apply", 

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

603 ) 

604 apply_parser.add_argument( 

605 "--config", 

606 "-c", 

607 required=True, 

608 dest="plan_config", 

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

610 ) 

611 apply_parser.add_argument( 

612 "--force", 

613 "-f", 

614 action="store_true", 

615 help="Overwrite existing configuration", 

616 ) 

617 

618 args = parser.parse_args(argv) 

619 

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

621 plug_root = _plugin_root() 

622 templates_dir = get_bundled_templates_dir() 

623 

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

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

626 if args.hosts: 

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

628 hosts: list[str] = [] 

629 for h in args.hosts: 

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

631 elif args.codex: 

632 hosts = ["codex"] 

633 else: 

634 hosts = _detect_hosts(project_root) 

635 

636 if args.command == "apply": 

637 return _run_apply( 

638 plan_config=args.plan_config, 

639 project_root=project_root, 

640 templates_dir=templates_dir, 

641 plugin_root=plug_root, 

642 hosts=hosts, 

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

644 ) 

645 

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

647 try: 

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

649 except ValueError as exc: 

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

651 return 2 

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

653 print( 

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

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

656 file=sys.stderr, 

657 ) 

658 return 2 

659 

660 if args.plan: 

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

662 

663 if args.yes or args.dry_run: 

664 return _run_yes( 

665 project_root=project_root, 

666 templates_dir=templates_dir, 

667 plugin_root=plug_root, 

668 force=args.force, 

669 dry_run=args.dry_run, 

670 hosts=hosts, 

671 feature_choices=feature_choices, 

672 upgrade=args.upgrade, 

673 ) 

674 

675 from little_loops.init.tui import run_tui 

676 

677 return run_tui( 

678 project_root=project_root, 

679 templates_dir=templates_dir, 

680 plugin_root=plug_root, 

681 force=args.force, 

682 hosts=hosts, 

683 )