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
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -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)
35def _plugin_version() -> str:
36 from little_loops import __version__
38 return __version__
41def _plugin_root() -> Path:
42 """Return the little-loops project root (env-var-first resolver).
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
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
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"]
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
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
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
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.
104 Args:
105 enable: Feature names to turn on.
106 disable: Feature names to turn off.
108 Returns:
109 Mapping of ``{name}_enabled`` -> bool for recognized features.
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
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 )
161 ll_dir = project_root / ".ll"
162 config_path = ll_dir / "ll-config.json"
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 = {}
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.")
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 )
267 template = detect_project_type(project_root, templates_dir)
268 print(f"Detected project type: {template.name}")
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)
305 if install_source:
306 config["install_source"] = install_source
308 if dry_run:
309 _print_dry_run(config, project_root, ll_dir, hosts=hosts)
310 return 0
312 issues_base_rel = config.get("issues", {}).get("base_dir", ".issues")
313 issues_base = project_root / issues_base_rel
315 write_config(config, ll_dir)
316 make_issue_dirs(issues_base)
318 if config.get("product", {}).get("enabled"):
319 deploy_goals(ll_dir, templates_dir)
321 if config.get("design_tokens", {}).get("enabled"):
322 deploy_design_tokens(ll_dir, templates_dir)
324 if config.get("issues", {}).get("deploy_templates"):
325 deploy_issue_templates(ll_dir, templates_dir)
327 if config.get("learning_tests", {}).get("enabled"):
328 make_learning_tests_dir(ll_dir)
330 update_gitignore(project_root)
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)
337 write_claude_md(project_root)
339 _dispatch_host_adapters(hosts, project_root, plugin_root, force=force)
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)
349 print(f"\n✓ little-loops initialized in {project_root}")
350 print(f" Config: {config_path}")
351 return 0
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
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
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) ===")
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
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)
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
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 )
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
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
448 config: dict[str, Any] = plan.get("proposed_config") or plan
449 ll_dir = project_root / ".ll"
451 issues_base_rel = config.get("issues", {}).get("base_dir", ".issues")
452 issues_base = project_root / issues_base_rel
454 write_config(config, ll_dir)
455 make_issue_dirs(issues_base)
457 if config.get("product", {}).get("enabled"):
458 deploy_goals(ll_dir, templates_dir)
460 update_gitignore(project_root)
461 merge_settings(project_root)
463 print(f"✓ Applied init plan to {project_root}")
464 return 0
467def main_init(argv: list[str] | None = None) -> int:
468 """Entry point for ll-init command.
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
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.
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 )
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 )
601 args = parser.parse_args(argv)
603 project_root = (args.root or Path.cwd()).resolve()
604 plug_root = _plugin_root()
605 templates_dir = get_bundled_templates_dir()
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)
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 )
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
641 if args.plan:
642 return _run_plan(project_root, templates_dir, feature_choices=feature_choices)
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 )
656 from little_loops.init.tui import run_tui
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 )