Coverage for little_loops / init / cli.py: 10%
168 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-16 13:11 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-16 13:11 -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_design_tokens,
78 deploy_goals,
79 make_issue_dirs,
80 make_learning_tests_dir,
81 merge_settings,
82 update_gitignore,
83 write_claude_md,
84 write_config,
85 )
87 ll_dir = project_root / ".ll"
88 config_path = ll_dir / "ll-config.json"
90 if config_path.exists() and not force:
91 print(
92 f"Configuration already exists at {config_path}\n"
93 "Use --force to overwrite, or edit the existing file directly.",
94 file=sys.stderr,
95 )
96 return 1
98 if config_path.exists() and force and not dry_run:
99 print("Overwriting existing configuration.")
101 template = detect_project_type(project_root, templates_dir)
102 print(f"Detected project type: {template.name}")
104 config = build_config(template, {"project_name": project_root.name})
106 if dry_run:
107 _print_dry_run(config, project_root, ll_dir, hosts=hosts)
108 return 0
110 issues_base_rel = config.get("issues", {}).get("base_dir", ".issues")
111 issues_base = project_root / issues_base_rel
113 write_config(config, ll_dir)
114 make_issue_dirs(issues_base)
116 if config.get("product", {}).get("enabled"):
117 deploy_goals(ll_dir, templates_dir)
119 if config.get("design_tokens", {}).get("enabled"):
120 deploy_design_tokens(ll_dir, templates_dir)
122 if config.get("learning_tests", {}).get("enabled"):
123 make_learning_tests_dir(ll_dir)
125 update_gitignore(project_root)
127 extra_permissions: list[str] | None = None
128 if config.get("learning_tests", {}).get("enabled"):
129 extra_permissions = ["Skill(ll:explore-api)"]
130 merge_settings(project_root, extra_permissions=extra_permissions)
132 write_claude_md(project_root)
134 _dispatch_host_adapters(hosts, project_root, plugin_root, force=force)
136 print("\nValidating dependencies...")
137 warnings = validate_deps(config, _plugin_version(), project_root)
138 for w in warnings:
139 msg = f"Warning: {w.message}"
140 if w.install_hint:
141 msg += f"\n Install/fix: {w.install_hint}"
142 print(msg, file=sys.stderr)
144 print(f"\n✓ little-loops initialized in {project_root}")
145 print(f" Config: {config_path}")
146 return 0
149def _print_dry_run(
150 config: dict[str, Any],
151 project_root: Path,
152 ll_dir: Path,
153 hosts: list[str],
154) -> None:
155 issues_base_rel = config.get("issues", {}).get("base_dir", ".issues")
156 issues_base = project_root / issues_base_rel
158 print("\n=== DRY RUN: ll-init ===\n")
159 print("--- Configuration Preview (.ll/ll-config.json) ---")
160 print(json.dumps(config, indent=2))
161 print("\n--- Actions that would be taken ---")
162 print(f" [write] {ll_dir / 'll-config.json'}")
163 if config.get("product", {}).get("enabled"):
164 print(f" [write] {ll_dir / 'll-goals.md'} (from ll-goals-template.md)")
165 for sd in ("bugs", "features", "enhancements", "completed", "deferred"):
166 print(f" [mkdir] {issues_base / sd}")
167 print(" [update] .gitignore (add state file exclusions)")
168 print(" [update] .claude/settings.local.json (add ll- CLI tool permissions)")
169 from little_loops.init.writers import write_claude_md
171 write_claude_md(project_root, dry_run=True)
172 if "codex" in hosts:
173 print(" [write] .codex/hooks.json (Codex CLI hook adapter)")
174 if "pi" in hosts:
175 print(" [Pi] Adapter not yet available — tracked in EPIC-1622.")
176 print("\n=== END DRY RUN (no changes made) ===")
179def _run_plan(project_root: Path, templates_dir: Path) -> int:
180 """Emit a machine-readable JSON plan without writing anything."""
181 from little_loops.init.core import build_config
182 from little_loops.init.detect import detect_project_type
183 from little_loops.init.validate import validate_deps
185 template = detect_project_type(project_root, templates_dir)
186 config = build_config(template, {"project_name": project_root.name})
187 warnings = validate_deps(config, _plugin_version(), project_root)
189 plan: dict[str, Any] = {
190 "detected": {
191 "template_name": template.filename,
192 "project_type": template.name,
193 "project_name": project_root.name,
194 },
195 "proposed_config": config,
196 "host_options": {
197 "has_claude_code": bool(shutil.which("claude")),
198 "has_codex": bool(shutil.which("codex")),
199 "has_pi": bool(shutil.which("pi")),
200 "suggested_settings_file": ".claude/settings.local.json",
201 },
202 "warnings": [{"message": w.message, "install_hint": w.install_hint} for w in warnings],
203 }
204 print(json.dumps(plan, indent=2))
205 return 0
208def _run_apply(
209 plan_config: str,
210 project_root: Path,
211 templates_dir: Path,
212 force: bool,
213) -> int:
214 """Apply writes from a --plan JSON (file path or raw JSON string)."""
215 from little_loops.init.writers import (
216 deploy_goals,
217 make_issue_dirs,
218 merge_settings,
219 update_gitignore,
220 write_config,
221 )
223 # Accept a file path or a raw JSON string
224 plan_path = Path(plan_config)
225 if plan_path.exists():
226 raw = plan_path.read_text(encoding="utf-8")
227 else:
228 raw = plan_config
230 try:
231 plan = json.loads(raw)
232 except json.JSONDecodeError as exc:
233 print(f"Error: invalid JSON: {exc}", file=sys.stderr)
234 return 2
236 config: dict[str, Any] = plan.get("proposed_config") or plan
237 ll_dir = project_root / ".ll"
238 config_path = ll_dir / "ll-config.json"
240 if config_path.exists() and not force:
241 print(
242 f"Configuration already exists at {config_path}\nUse --force to overwrite.",
243 file=sys.stderr,
244 )
245 return 1
247 issues_base_rel = config.get("issues", {}).get("base_dir", ".issues")
248 issues_base = project_root / issues_base_rel
250 write_config(config, ll_dir)
251 make_issue_dirs(issues_base)
253 if config.get("product", {}).get("enabled"):
254 deploy_goals(ll_dir, templates_dir)
256 update_gitignore(project_root)
257 merge_settings(project_root)
259 print(f"✓ Applied init plan to {project_root}")
260 return 0
263def main_init(argv: list[str] | None = None) -> int:
264 """Entry point for ll-init command.
266 Returns:
267 Exit code: 0 success, 1 error, 2 usage error.
268 """
269 with cli_event_context(DEFAULT_DB_PATH, "ll-init", sys.argv[1:]):
270 parser = argparse.ArgumentParser(
271 prog="ll-init",
272 description="Initialize little-loops for a project",
273 formatter_class=argparse.RawDescriptionHelpFormatter,
274 epilog="""
275Examples:
276 %(prog)s --yes # Non-interactive full init with defaults
277 %(prog)s --yes --dry-run # Preview without writing files
278 %(prog)s --yes --force # Overwrite existing configuration
279 %(prog)s --plan # Emit JSON plan without writing
280 %(prog)s apply --config plan.json # Apply writes from a --plan output
282Exit codes:
283 0 - Success
284 1 - Error (config exists, template missing, etc.)
285 2 - Usage error
286""",
287 )
288 parser.add_argument(
289 "--yes",
290 "-y",
291 action="store_true",
292 help="Accept all defaults; run non-interactively",
293 )
294 parser.add_argument(
295 "--force",
296 "-f",
297 action="store_true",
298 help="Overwrite existing .ll/ll-config.json",
299 )
300 parser.add_argument(
301 "--dry-run",
302 "-n",
303 action="store_true",
304 help="Preview actions without writing files",
305 )
306 parser.add_argument(
307 "--plan",
308 action="store_true",
309 help=(
310 "Emit JSON plan {detected, proposed_config, host_options, warnings} "
311 "without writing anything"
312 ),
313 )
314 parser.add_argument(
315 "--hosts",
316 nargs="+",
317 metavar="HOST",
318 default=None,
319 help=(
320 "Host harnesses to install adapters for "
321 "(claude-code, codex, pi). Defaults to auto-detected hosts."
322 ),
323 )
324 parser.add_argument(
325 "--codex",
326 action="store_true",
327 help=argparse.SUPPRESS, # deprecated alias for --hosts codex
328 )
329 parser.add_argument(
330 "--root",
331 "-C",
332 type=Path,
333 default=None,
334 dest="root",
335 help="Project root directory (default: current directory)",
336 )
338 subparsers = parser.add_subparsers(dest="command")
339 apply_parser = subparsers.add_parser(
340 "apply",
341 help="Apply writes from a --plan JSON output",
342 )
343 apply_parser.add_argument(
344 "--config",
345 "-c",
346 required=True,
347 dest="plan_config",
348 help="Path to plan JSON file, or raw JSON string",
349 )
350 apply_parser.add_argument(
351 "--force",
352 "-f",
353 action="store_true",
354 help="Overwrite existing configuration",
355 )
357 args = parser.parse_args(argv)
359 project_root = (args.root or Path.cwd()).resolve()
360 plug_root = _plugin_root()
361 templates_dir = plug_root / "templates"
363 # Resolve hosts: --hosts takes precedence; --codex is a deprecated alias.
364 # When neither is given, auto-detect from installed binaries / project dirs.
365 if args.hosts:
366 # Expand any comma-separated values (e.g. --hosts claude-code,codex)
367 hosts: list[str] = []
368 for h in args.hosts:
369 hosts.extend(h.split(","))
370 elif args.codex:
371 hosts = ["codex"]
372 else:
373 hosts = _detect_hosts(project_root)
375 if args.command == "apply":
376 return _run_apply(
377 plan_config=args.plan_config,
378 project_root=project_root,
379 templates_dir=templates_dir,
380 force=getattr(args, "force", False),
381 )
383 if args.plan:
384 return _run_plan(project_root, templates_dir)
386 if args.yes or args.dry_run:
387 return _run_yes(
388 project_root=project_root,
389 templates_dir=templates_dir,
390 plugin_root=plug_root,
391 force=args.force,
392 dry_run=args.dry_run,
393 hosts=hosts,
394 )
396 from little_loops.init.tui import run_tui
398 return run_tui(
399 project_root=project_root,
400 templates_dir=templates_dir,
401 plugin_root=plug_root,
402 force=args.force,
403 hosts=hosts,
404 )