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

2

3 from __future__ import annotations

4

5 import argparse

6 import json

7 import shutil

8 import subprocess as _subprocess

9 import sys

10 from pathlib import Path

11 from typing import Any

12

13 from little_loops.issue_template import get_bundled_templates_dir

14 from 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

38 def _plugin_version() -> str:

39 from little_loops import __version__

40

41 return __version__

42

43

44 def _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

58 def _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

72 def _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

112 def _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

139 def _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

365 def _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

401 def _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

484 def 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="""

496 Examples:

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

506 Feature 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

513 Exit 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 )