Coverage for little_loops / init / cli.py: 11%
160 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -0500
1"""ll-init: Headless project initialization CLI."""
3from __future__ import annotations
5import argparse
6import json
7import shutil
8import sys
9from pathlib import Path
10from typing import Any
12from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context
15def _plugin_version() -> str:
16 from little_loops import __version__
18 return __version__
21def _plugin_root() -> Path:
22 """Return the little-loops project root (four parents above this file)."""
23 return Path(__file__).parent.parent.parent.parent
26def _detect_hosts(project_root: Path) -> list[str]:
27 """Return list of detected host harnesses based on installed binaries and project dirs."""
28 detected: list[str] = []
29 if shutil.which("claude"):
30 detected.append("claude-code")
31 if shutil.which("codex") or (project_root / ".codex").exists():
32 detected.append("codex")
33 if shutil.which("pi"):
34 detected.append("pi")
35 return detected or ["claude-code"]
38def _dispatch_host_adapters(
39 hosts: list[str],
40 project_root: Path,
41 plugin_root: Path,
42 force: bool = False,
43 dry_run: bool = False,
44) -> None:
45 """Install adapters for each selected host; print per-host post-install notes."""
46 from little_loops.init.writers import install_codex_adapter
48 for host in hosts:
49 if host == "codex":
50 installed = install_codex_adapter(
51 project_root, plugin_root, force=force, dry_run=dry_run
52 )
53 if installed and not dry_run:
54 print("[Codex] Hook adapter installed to .codex/hooks.json")
55 print(
56 "[Codex] Note: Codex will show a hook-trust dialog on next session start. "
57 "Hooks are silently skipped (HookRunStatus::Untrusted) until trusted."
58 )
59 elif host == "pi":
60 print("[Pi] Adapter not yet available — tracked in EPIC-1622.")
61 # claude-code: no adapter file needed; plugin hooks fire when globally enabled
64def _run_yes(
65 project_root: Path,
66 templates_dir: Path,
67 plugin_root: Path,
68 force: bool,
69 dry_run: bool,
70 hosts: list[str],
71) -> int:
72 """Execute the non-interactive --yes init flow."""
73 from little_loops.init.core import build_config
74 from little_loops.init.detect import detect_project_type
75 from little_loops.init.validate import validate_deps
76 from little_loops.init.writers import (
77 deploy_goals,
78 make_issue_dirs,
79 make_learning_tests_dir,
80 merge_settings,
81 update_gitignore,
82 write_config,
83 )
85 ll_dir = project_root / ".ll"
86 config_path = ll_dir / "ll-config.json"
88 if config_path.exists() and not force:
89 print(
90 f"Configuration already exists at {config_path}\n"
91 "Use --force to overwrite, or edit the existing file directly.",
92 file=sys.stderr,
93 )
94 return 1
96 if config_path.exists() and force and not dry_run:
97 print("Overwriting existing configuration.")
99 template = detect_project_type(project_root, templates_dir)
100 print(f"Detected project type: {template.name}")
102 config = build_config(template, {"project_name": project_root.name})
104 if dry_run:
105 _print_dry_run(config, project_root, ll_dir, hosts=hosts)
106 return 0
108 issues_base_rel = config.get("issues", {}).get("base_dir", ".issues")
109 issues_base = project_root / issues_base_rel
111 write_config(config, ll_dir)
112 make_issue_dirs(issues_base)
114 if config.get("product", {}).get("enabled"):
115 deploy_goals(ll_dir, templates_dir)
117 if config.get("learning_tests", {}).get("enabled"):
118 make_learning_tests_dir(ll_dir)
120 update_gitignore(project_root)
121 merge_settings(project_root)
123 _dispatch_host_adapters(hosts, project_root, plugin_root, force=force)
125 print("\nValidating dependencies...")
126 warnings = validate_deps(config, _plugin_version(), project_root)
127 for w in warnings:
128 msg = f"Warning: {w.message}"
129 if w.install_hint:
130 msg += f"\n Install/fix: {w.install_hint}"
131 print(msg, file=sys.stderr)
133 print(f"\n✓ little-loops initialized in {project_root}")
134 print(f" Config: {config_path}")
135 return 0
138def _print_dry_run(
139 config: dict[str, Any],
140 project_root: Path,
141 ll_dir: Path,
142 hosts: list[str],
143) -> None:
144 issues_base_rel = config.get("issues", {}).get("base_dir", ".issues")
145 issues_base = project_root / issues_base_rel
147 print("\n=== DRY RUN: ll-init ===\n")
148 print("--- Configuration Preview (.ll/ll-config.json) ---")
149 print(json.dumps(config, indent=2))
150 print("\n--- Actions that would be taken ---")
151 print(f" [write] {ll_dir / 'll-config.json'}")
152 if config.get("product", {}).get("enabled"):
153 print(f" [write] {ll_dir / 'll-goals.md'} (from ll-goals-template.md)")
154 for sd in ("bugs", "features", "enhancements", "completed", "deferred"):
155 print(f" [mkdir] {issues_base / sd}")
156 print(" [update] .gitignore (add state file exclusions)")
157 print(" [update] .claude/settings.local.json (add ll- CLI tool permissions)")
158 if "codex" in hosts:
159 print(" [write] .codex/hooks.json (Codex CLI hook adapter)")
160 if "pi" in hosts:
161 print(" [Pi] Adapter not yet available — tracked in EPIC-1622.")
162 print("\n=== END DRY RUN (no changes made) ===")
165def _run_plan(project_root: Path, templates_dir: Path) -> int:
166 """Emit a machine-readable JSON plan without writing anything."""
167 from little_loops.init.core import build_config
168 from little_loops.init.detect import detect_project_type
169 from little_loops.init.validate import validate_deps
171 template = detect_project_type(project_root, templates_dir)
172 config = build_config(template, {"project_name": project_root.name})
173 warnings = validate_deps(config, _plugin_version(), project_root)
175 plan: dict[str, Any] = {
176 "detected": {
177 "template_name": template.filename,
178 "project_type": template.name,
179 "project_name": project_root.name,
180 },
181 "proposed_config": config,
182 "host_options": {
183 "has_claude_code": bool(shutil.which("claude")),
184 "has_codex": bool(shutil.which("codex")),
185 "has_pi": bool(shutil.which("pi")),
186 "suggested_settings_file": ".claude/settings.local.json",
187 },
188 "warnings": [
189 {"message": w.message, "install_hint": w.install_hint} for w in warnings
190 ],
191 }
192 print(json.dumps(plan, indent=2))
193 return 0
196def _run_apply(
197 plan_config: str,
198 project_root: Path,
199 templates_dir: Path,
200 force: bool,
201) -> int:
202 """Apply writes from a --plan JSON (file path or raw JSON string)."""
203 from little_loops.init.writers import (
204 deploy_goals,
205 make_issue_dirs,
206 merge_settings,
207 update_gitignore,
208 write_config,
209 )
211 # Accept a file path or a raw JSON string
212 plan_path = Path(plan_config)
213 if plan_path.exists():
214 raw = plan_path.read_text(encoding="utf-8")
215 else:
216 raw = plan_config
218 try:
219 plan = json.loads(raw)
220 except json.JSONDecodeError as exc:
221 print(f"Error: invalid JSON: {exc}", file=sys.stderr)
222 return 2
224 config: dict[str, Any] = plan.get("proposed_config") or plan
225 ll_dir = project_root / ".ll"
226 config_path = ll_dir / "ll-config.json"
228 if config_path.exists() and not force:
229 print(
230 f"Configuration already exists at {config_path}\nUse --force to overwrite.",
231 file=sys.stderr,
232 )
233 return 1
235 issues_base_rel = config.get("issues", {}).get("base_dir", ".issues")
236 issues_base = project_root / issues_base_rel
238 write_config(config, ll_dir)
239 make_issue_dirs(issues_base)
241 if config.get("product", {}).get("enabled"):
242 deploy_goals(ll_dir, templates_dir)
244 update_gitignore(project_root)
245 merge_settings(project_root)
247 print(f"✓ Applied init plan to {project_root}")
248 return 0
251def main_init(argv: list[str] | None = None) -> int:
252 """Entry point for ll-init command.
254 Returns:
255 Exit code: 0 success, 1 error, 2 usage error.
256 """
257 with cli_event_context(DEFAULT_DB_PATH, "ll-init", sys.argv[1:]):
258 parser = argparse.ArgumentParser(
259 prog="ll-init",
260 description="Initialize little-loops for a project",
261 formatter_class=argparse.RawDescriptionHelpFormatter,
262 epilog="""
263Examples:
264 %(prog)s --yes # Non-interactive full init with defaults
265 %(prog)s --yes --dry-run # Preview without writing files
266 %(prog)s --yes --force # Overwrite existing configuration
267 %(prog)s --plan # Emit JSON plan without writing
268 %(prog)s apply --config plan.json # Apply writes from a --plan output
270Exit codes:
271 0 - Success
272 1 - Error (config exists, template missing, etc.)
273 2 - Usage error
274""",
275 )
276 parser.add_argument(
277 "--yes",
278 "-y",
279 action="store_true",
280 help="Accept all defaults; run non-interactively",
281 )
282 parser.add_argument(
283 "--force",
284 "-f",
285 action="store_true",
286 help="Overwrite existing .ll/ll-config.json",
287 )
288 parser.add_argument(
289 "--dry-run",
290 "-n",
291 action="store_true",
292 help="Preview actions without writing files",
293 )
294 parser.add_argument(
295 "--plan",
296 action="store_true",
297 help=(
298 "Emit JSON plan {detected, proposed_config, host_options, warnings} "
299 "without writing anything"
300 ),
301 )
302 parser.add_argument(
303 "--hosts",
304 nargs="+",
305 metavar="HOST",
306 default=None,
307 help=(
308 "Host harnesses to install adapters for "
309 "(claude-code, codex, pi). Defaults to auto-detected hosts."
310 ),
311 )
312 parser.add_argument(
313 "--codex",
314 action="store_true",
315 help=argparse.SUPPRESS, # deprecated alias for --hosts codex
316 )
317 parser.add_argument(
318 "--root",
319 "-C",
320 type=Path,
321 default=None,
322 dest="root",
323 help="Project root directory (default: current directory)",
324 )
326 subparsers = parser.add_subparsers(dest="command")
327 apply_parser = subparsers.add_parser(
328 "apply",
329 help="Apply writes from a --plan JSON output",
330 )
331 apply_parser.add_argument(
332 "--config",
333 "-c",
334 required=True,
335 dest="plan_config",
336 help="Path to plan JSON file, or raw JSON string",
337 )
338 apply_parser.add_argument(
339 "--force",
340 "-f",
341 action="store_true",
342 help="Overwrite existing configuration",
343 )
345 args = parser.parse_args(argv)
347 project_root = (args.root or Path.cwd()).resolve()
348 plug_root = _plugin_root()
349 templates_dir = plug_root / "templates"
351 # Resolve hosts: --hosts takes precedence; --codex is a deprecated alias.
352 # When neither is given, auto-detect from installed binaries / project dirs.
353 if args.hosts:
354 # Expand any comma-separated values (e.g. --hosts claude-code,codex)
355 hosts: list[str] = []
356 for h in args.hosts:
357 hosts.extend(h.split(","))
358 elif args.codex:
359 hosts = ["codex"]
360 else:
361 hosts = _detect_hosts(project_root)
363 if args.command == "apply":
364 return _run_apply(
365 plan_config=args.plan_config,
366 project_root=project_root,
367 templates_dir=templates_dir,
368 force=getattr(args, "force", False),
369 )
371 if args.plan:
372 return _run_plan(project_root, templates_dir)
374 if args.yes or args.dry_run:
375 return _run_yes(
376 project_root=project_root,
377 templates_dir=templates_dir,
378 plugin_root=plug_root,
379 force=args.force,
380 dry_run=args.dry_run,
381 hosts=hosts,
382 )
384 from little_loops.init.tui import run_tui
386 return run_tui(
387 project_root=project_root,
388 templates_dir=templates_dir,
389 plugin_root=plug_root,
390 force=args.force,
391 hosts=hosts,
392 )