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
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:55 -0500
1"""ll-init: Headless project initialization CLI."""
3from __future__ import annotations
5import argparse
6import json
7import shutil
8import subprocess as _subprocess
9import sys
10from pathlib import Path
11from typing import Any
13from little_loops.issue_template import get_bundled_templates_dir
14from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context
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)
34# Recognized host names for --hosts validation; mirrors _HOST_RUNNER_REGISTRY keys.
35_KNOWN_HOSTS: frozenset[str] = frozenset({"claude-code", "codex", "opencode", "pi"})
38def _plugin_version() -> str:
39 from little_loops import __version__
41 return __version__
44def _plugin_root() -> Path:
45 """Return the little-loops project root (env-var-first resolver).
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
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
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"]
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
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
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.
115 Args:
116 enable: Feature names to turn on.
117 disable: Feature names to turn off.
119 Returns:
120 Mapping of ``{name}_enabled`` -> bool for recognized features.
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
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 )
173 ll_dir = project_root / ".ll"
174 config_path = ll_dir / "ll-config.json"
176 # Load existing config as baseline for pre-population (and the merge below).
177 existing_config = load_existing_config(project_root)
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 )
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 )
276 template = detect_project_type(project_root, templates_dir)
277 print(f"Detected project type: {template.name}")
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)
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)
318 if install_source:
319 config["install_source"] = install_source
321 issues_base_rel = config.get("issues", {}).get("base_dir", ".issues")
322 issues_base = project_root / issues_base_rel
324 write_config(config, ll_dir, dry_run=dry_run)
325 make_issue_dirs(issues_base, dry_run=dry_run)
327 if config.get("product", {}).get("enabled"):
328 deploy_goals(ll_dir, templates_dir, dry_run=dry_run)
330 if config.get("design_tokens", {}).get("enabled"):
331 deploy_design_tokens(ll_dir, templates_dir, dry_run=dry_run)
333 if config.get("issues", {}).get("deploy_templates"):
334 deploy_issue_templates(ll_dir, templates_dir, dry_run=dry_run)
336 if config.get("learning_tests", {}).get("enabled"):
337 make_learning_tests_dir(ll_dir, dry_run=dry_run)
339 update_gitignore(project_root, dry_run=dry_run)
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)
346 write_claude_md(project_root, dry_run=dry_run)
348 _dispatch_host_adapters(hosts, project_root, plugin_root, force=force, dry_run=dry_run)
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)
359 if not dry_run:
360 print(f"\n✓ little-loops initialized in {project_root}")
361 print(f" Config: {config_path}")
362 return 0
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
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)
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
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 )
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
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
438 config: dict[str, Any] = plan.get("proposed_config") or plan
439 ll_dir = project_root / ".ll"
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)
444 issues_base_rel = config.get("issues", {}).get("base_dir", ".issues")
445 issues_base = project_root / issues_base_rel
447 write_config(config, ll_dir)
448 make_issue_dirs(issues_base)
450 if config.get("product", {}).get("enabled"):
451 deploy_goals(ll_dir, templates_dir)
453 if config.get("design_tokens", {}).get("enabled"):
454 deploy_design_tokens(ll_dir, templates_dir)
456 if config.get("issues", {}).get("deploy_templates"):
457 deploy_issue_templates(ll_dir, templates_dir)
459 if config.get("learning_tests", {}).get("enabled"):
460 make_learning_tests_dir(ll_dir)
462 update_gitignore(project_root)
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)
469 write_claude_md(project_root)
471 _dispatch_host_adapters(hosts, project_root, plugin_root, force=force)
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)
480 print(f"✓ Applied init plan to {project_root}")
481 return 0
484def main_init(argv: list[str] | None = None) -> int:
485 """Entry point for ll-init command.
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
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.
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 )
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 )
618 args = parser.parse_args(argv)
620 project_root = (args.root or Path.cwd()).resolve()
621 plug_root = _plugin_root()
622 templates_dir = get_bundled_templates_dir()
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)
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 )
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
660 if args.plan:
661 return _run_plan(project_root, templates_dir, feature_choices=feature_choices)
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 )
675 from little_loops.init.tui import run_tui
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 )