Coverage for little_loops / init / tui.py: 0%
377 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:54 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:54 -0500
1"""Interactive TUI for ll-init using questionary and rich."""
3from __future__ import annotations
5import sys
6from pathlib import Path
7from typing import Any
9import questionary
10from rich.console import Console
11from rich.panel import Panel
12from rich.table import Table
14# Feature choices in display order for the multi-select screen
15_FEATURE_CHOICES: list[tuple[str, str]] = [
16 ("Parallel processing (ll-parallel)", "parallel"),
17 ("Product analysis (ll-scan-product)", "product"),
18 ("Document tracking", "documents"),
19 ("Design tokens", "design_tokens"),
20 ("Learning tests registry", "learning_tests"),
21 ("Analytics capture", "analytics"),
22 ("Context monitor (auto-handoff)", "context_monitor"),
23 ("GitHub sync (ll-sync)", "github_sync"),
24 ("Confidence gate", "confidence_gate"),
25 ("TDD mode", "tdd"),
26 ("Decisions & rules log (ll-issues decisions)", "decisions"),
27 ("Scratch pad (automation context masking)", "scratch_pad"),
28 ("Session event capture (PreCompact handoff)", "session_capture"),
29]
31_DEFAULT_FEATURES: frozenset[str] = frozenset(
32 {"parallel", "product", "learning_tests", "analytics", "context_monitor"}
33)
35_FEATURE_LABELS: dict[str, str] = {
36 "parallel": "Parallel processing",
37 "product": "Product analysis",
38 "documents": "Document tracking",
39 "design_tokens": "Design tokens",
40 "learning_tests": "Learning tests",
41 "analytics": "Analytics",
42 "context_monitor": "Context monitor",
43 "github_sync": "GitHub sync",
44 "confidence_gate": "Confidence gate",
45 "tdd": "TDD mode",
46 "decisions": "Decisions & rules log",
47 "scratch_pad": "Scratch pad",
48 "session_capture": "Session event capture",
49}
51# Host choices for the multi-select screen
52_HOST_CHOICES: list[tuple[str, str]] = [
53 ("Claude Code (global plugin — no adapter file needed)", "claude-code"),
54 ("Codex CLI (writes .codex/hooks.json)", "codex"),
55 ("Pi (not yet available — EPIC-1622)", "pi"),
56]
58_HOST_LABELS: dict[str, str] = {
59 "claude-code": "Claude Code",
60 "codex": "Codex CLI",
61 "pi": "Pi",
62}
64# Sentinel used in curated command menus for the free-text fallthrough
65_CUSTOM_SENTINEL = "Custom…"
67# Named design-token profiles (discovered from templates/design-tokens/profiles/)
68_DESIGN_TOKEN_PROFILES: list[tuple[str, str]] = [
69 ("Default", "default"),
70 ("Editorial Mono", "editorial-mono"),
71 ("Warm Paper", "warm-paper"),
72 ("Custom path…", "_custom"),
73]
76def _features_from_existing_config(cfg: dict[str, Any]) -> frozenset[str]:
77 """Extract which TUI feature keys are enabled from an existing ll-config.json."""
78 enabled: set[str] = set()
79 if cfg.get("parallel"):
80 enabled.add("parallel")
81 for key in (
82 "product",
83 "documents",
84 "design_tokens",
85 "learning_tests",
86 "analytics",
87 "context_monitor",
88 "decisions",
89 "scratch_pad",
90 "session_capture",
91 ):
92 if cfg.get(key, {}).get("enabled"):
93 enabled.add(key)
94 if cfg.get("sync", {}).get("enabled"):
95 enabled.add("github_sync")
96 if cfg.get("commands", {}).get("confidence_gate", {}).get("enabled"):
97 enabled.add("confidence_gate")
98 if cfg.get("commands", {}).get("tdd_mode"):
99 enabled.add("tdd")
100 return frozenset(enabled)
103def _ask_command(label: str, default: str, options: list[str] | None) -> str | None:
104 """Ask for a command field using a curated menu when options are provided.
106 Falls through to free-text if the user selects "Custom…" or if no options are given.
107 Returns None on Ctrl-C.
108 """
109 if options:
110 sel_default = default if default in options else options[0]
111 choices = [questionary.Choice(o, value=o) for o in options]
112 choices.append(questionary.Choice(_CUSTOM_SENTINEL, value=_CUSTOM_SENTINEL))
113 chosen = questionary.select(label, choices=choices, default=sel_default).ask()
114 if chosen is None:
115 return None
116 if chosen == _CUSTOM_SENTINEL:
117 return questionary.text(f"{label} (enter custom value):", default=default).ask()
118 return chosen
119 return questionary.text(label, default=default).ask()
122def run_tui(
123 project_root: Path,
124 templates_dir: Path,
125 plugin_root: Path,
126 force: bool = False,
127 hosts: list[str] | None = None,
128) -> int:
129 """Run the interactive TUI for ll-init.
131 Args:
132 hosts: Detection-seeded default host list shown pre-checked.
133 When None, defaults to ["claude-code"].
135 Returns:
136 0 on success, 1 on user-abort/config-exists/error, 130 on Ctrl-C.
137 """
138 if not sys.stdin.isatty():
139 print(
140 "stdin is not a TTY. Run 'll-init --yes' for non-interactive setup.",
141 file=sys.stderr,
142 )
143 return 1
145 from little_loops.init.detect import detect_project_type
146 from little_loops.init.install_check import (
147 InstallStatus,
148 check_version,
149 detect_installation,
150 fetch_latest_plugin,
151 fetch_latest_pypi,
152 )
153 from little_loops.init.writers import load_existing_config
155 console = Console()
156 ll_dir = project_root / ".ll"
157 config_path = ll_dir / "ll-config.json"
159 # Load existing config for pre-population and the merge in _apply_config.
160 existing_config = load_existing_config(project_root)
162 _selected_hosts: frozenset[str] = frozenset(hosts or ["claude-code"])
164 # --- Screen 1 / 7: Plugin Install ---
165 install_source, installed_version, _install_path = detect_installation(project_root)
166 _needs_install = install_source is None
167 _pkg_outdated = False
168 _plugin_outdated = False
169 _pkg_latest: str | None = None
170 _plugin_latest: str | None = None
172 if install_source in ("local-editable", "pypi") and installed_version is not None:
173 _pkg_latest = fetch_latest_pypi()
174 if _pkg_latest is not None:
175 _pkg_outdated = check_version(installed_version, _pkg_latest) == InstallStatus.OutOfDate
177 if "claude-code" in _selected_hosts and install_source in (
178 "global-claude-code",
179 "project-claude-code",
180 ):
181 _plugin_latest = fetch_latest_plugin()
182 if installed_version is not None and _plugin_latest is not None:
183 _plugin_outdated = (
184 check_version(installed_version, _plugin_latest) == InstallStatus.OutOfDate
185 )
187 if _needs_install or _pkg_outdated or _plugin_outdated:
188 console.print()
189 console.rule("[bold]1 / 7 Plugin Install[/bold]")
190 if _needs_install:
191 console.print(
192 "[yellow]little-loops package not detected.[/yellow] "
193 "ll-* CLI tools require the pip package to be installed."
194 )
195 console.print(" Install: [cyan]pip install little-loops[/cyan]")
196 if _pkg_outdated:
197 console.print(
198 f"[yellow]Package outdated:[/yellow] installed [cyan]{installed_version}[/cyan], "
199 f"latest [cyan]{_pkg_latest}[/cyan]."
200 )
201 if install_source == "local-editable":
202 console.print(" Upgrade: [cyan]pip install -e <editable-path>[dev][/cyan]")
203 else:
204 console.print(" Upgrade: [cyan]pip install --upgrade little-loops[/cyan]")
205 if _plugin_outdated:
206 console.print(
207 f"[yellow]Plugin outdated:[/yellow] installed [cyan]{installed_version}[/cyan], "
208 f"latest [cyan]{_plugin_latest}[/cyan]."
209 )
210 console.print(
211 " Upgrade: [cyan]claude plugin marketplace update little-loops "
212 "&& claude plugin update ll@little-loops[/cyan]"
213 )
215 _proceed: bool | None = questionary.confirm(
216 "Proceed with wizard? (install/upgrade separately after)",
217 default=True,
218 ).ask()
219 if _proceed is None:
220 return 130
221 if not _proceed:
222 console.print("[yellow]Aborted — no changes made.[/yellow]")
223 return 1
224 else:
225 if install_source is not None:
226 console.print(
227 f"[dim]Plugin status: {install_source} "
228 f"{'v' + installed_version if installed_version else '(version unknown)'} ✓[/dim]"
229 )
231 template = detect_project_type(project_root, templates_dir)
232 project_data = template.data.get("project", {})
233 cmd_options: dict[str, list[str]] = template.meta.get("command_options", {})
235 console.print(
236 f"\n[bold blue]little-loops setup[/bold blue] — detected [cyan]{template.name}[/cyan]\n"
237 )
239 default_hosts: frozenset[str] = frozenset(hosts or ["claude-code"])
241 # --- Screen 2 / 7: Project Basics ---
242 console.rule("[bold]2 / 7 Project Basics[/bold]")
244 _ex_proj = existing_config.get("project", {})
246 name = questionary.text(
247 "Project name:",
248 default=_ex_proj.get("name") or project_root.name,
249 ).ask()
250 if name is None:
251 return 130
253 src_dir = questionary.text(
254 "Source directory:",
255 default=_ex_proj.get("src_dir") or project_data.get("src_dir", "src/"),
256 ).ask()
257 if src_dir is None:
258 return 130
260 test_cmd = _ask_command(
261 "Test command:",
262 default=_ex_proj.get("test_cmd") or project_data.get("test_cmd") or "",
263 options=cmd_options.get("test_cmd"),
264 )
265 if test_cmd is None:
266 return 130
268 lint_cmd = _ask_command(
269 "Lint command:",
270 default=_ex_proj.get("lint_cmd") or project_data.get("lint_cmd") or "",
271 options=cmd_options.get("lint_cmd"),
272 )
273 if lint_cmd is None:
274 return 130
276 type_cmd = questionary.text(
277 "Type-check command (optional):",
278 default=_ex_proj.get("type_cmd") or project_data.get("type_cmd") or "",
279 ).ask()
280 if type_cmd is None:
281 return 130
283 format_cmd = _ask_command(
284 "Format command (optional):",
285 default=_ex_proj.get("format_cmd") or project_data.get("format_cmd") or "",
286 options=cmd_options.get("format_cmd"),
287 )
288 if format_cmd is None:
289 return 130
291 # --- Screen 3 / 7: Scan ---
292 console.print()
293 console.rule("[bold]3 / 7 Scan[/bold]")
295 _scan_data = template.data.get("scan", {})
296 _ex_focus = existing_config.get("scan", {}).get("focus_dirs")
297 _default_focus = ", ".join(_ex_focus if _ex_focus else _scan_data.get("focus_dirs", ["src/"]))
298 focus_dirs_str = questionary.text(
299 "Focus directories (comma-separated):",
300 default=_default_focus,
301 ).ask()
302 if focus_dirs_str is None:
303 return 130
305 add_excludes: bool | None = questionary.confirm(
306 "Add custom exclude patterns?", default=False
307 ).ask()
308 if add_excludes is None:
309 return 130
311 custom_excludes_str = ""
312 if add_excludes:
313 custom_excludes_str = questionary.text(
314 "Custom exclude patterns (comma-separated glob patterns):",
315 default="",
316 ).ask()
317 if custom_excludes_str is None:
318 return 130
320 # --- Screen 4 / 7: Features ---
321 console.print()
322 console.rule("[bold]4 / 7 Features[/bold]")
324 _pre_checked_features = (
325 _features_from_existing_config(existing_config) if existing_config else _DEFAULT_FEATURES
326 )
327 selected_features: list[str] | None = questionary.checkbox(
328 "Enable features:",
329 choices=[
330 questionary.Choice(label, value=val, checked=(val in _pre_checked_features))
331 for label, val in _FEATURE_CHOICES
332 ],
333 ).ask()
334 if selected_features is None:
335 return 130
337 selected_set = set(selected_features)
339 # Conditional: parallel worker count, worktree copy files, and feature-branch mode
340 _ex_parallel = existing_config.get("parallel", {})
341 parallel_workers: int = 4
342 worktree_copy_files: list[str] = []
343 use_feature_branches: bool = False
344 if "parallel" in selected_set:
345 workers_str = questionary.text(
346 "Max parallel workers:",
347 default=str(_ex_parallel.get("max_workers", 4)),
348 ).ask()
349 if workers_str is None:
350 return 130
351 try:
352 parallel_workers = int(workers_str)
353 if parallel_workers < 1:
354 raise ValueError("must be positive")
355 except ValueError:
356 console.print("[yellow]Invalid worker count; defaulting to 4.[/yellow]")
357 parallel_workers = 4
359 _ex_wt_files = set(_ex_parallel.get("worktree_copy_files", []))
360 wt_files: list[str] | None = questionary.checkbox(
361 "Copy these files into each worktree:",
362 choices=[
363 questionary.Choice(".env", value=".env", checked=(".env" in _ex_wt_files)),
364 questionary.Choice(
365 ".env.local", value=".env.local", checked=(".env.local" in _ex_wt_files)
366 ),
367 questionary.Choice(
368 ".secrets", value=".secrets", checked=(".secrets" in _ex_wt_files)
369 ),
370 ],
371 ).ask()
372 if wt_files is None:
373 return 130
374 worktree_copy_files = wt_files
376 fb_val: bool | None = questionary.confirm(
377 "Enable feature-branch mode (branch-per-issue)?",
378 default=_ex_parallel.get("use_feature_branches", False),
379 ).ask()
380 if fb_val is None:
381 return 130
382 use_feature_branches = fb_val
384 # Conditional: design-token profile picker
385 _ex_dt_profile = existing_config.get("design_tokens", {}).get("active", "default")
386 design_token_profile = "default"
387 if "design_tokens" in selected_set:
388 _profile: str | None = questionary.select(
389 "Design-token profile:",
390 choices=[questionary.Choice(label, value=val) for label, val in _DESIGN_TOKEN_PROFILES],
391 default=_ex_dt_profile,
392 ).ask()
393 if _profile is None:
394 return 130
395 if _profile == "_custom":
396 _custom_path = questionary.text("Custom profile path:").ask()
397 if _custom_path is None:
398 return 130
399 design_token_profile = _custom_path
400 else:
401 design_token_profile = _profile
403 # Session digest toggle (always asked)
404 _ex_session_digest = (
405 existing_config.get("history", {}).get("session_digest", {}).get("enabled", True)
406 )
407 session_digest_enabled: bool | None = questionary.confirm(
408 "Enable ambient session digest?", default=_ex_session_digest
409 ).ask()
410 if session_digest_enabled is None:
411 return 130
413 # Prompt optimization opt-out (default-on feature; always asked)
414 _ex_prompt_opt = existing_config.get("prompt_optimization", {}).get("enabled", True)
415 prompt_optimization_enabled: bool | None = questionary.confirm(
416 "Enable automatic prompt optimization?", default=_ex_prompt_opt
417 ).ask()
418 if prompt_optimization_enabled is None:
419 return 130
421 # Loop run defaults — --clear
422 _ex_loop_clear = existing_config.get("loops", {}).get("run_defaults", {}).get("clear", True)
423 loop_clear_default: bool | None = questionary.confirm(
424 "Enable --clear by default for ll-loop run? (recommended)", default=_ex_loop_clear
425 ).ask()
426 if loop_clear_default is None:
427 return 130
429 # Loop run defaults — --show-diagrams
430 _SHOW_DIAGRAMS_CHOICES = [
431 questionary.Choice("clean (recommended)", value="clean"),
432 questionary.Choice("summary", value="summary"),
433 questionary.Choice("layered", value="layered"),
434 questionary.Choice("inline", value="inline"),
435 questionary.Choice("Disabled", value="__disabled__"),
436 ]
437 _ex_sd = (
438 existing_config.get("loops", {}).get("run_defaults", {}).get("show_diagrams") or "clean"
439 )
440 _raw_sd: str | None = questionary.select(
441 "Default diagram mode for ll-loop run:",
442 choices=_SHOW_DIAGRAMS_CHOICES,
443 default=_ex_sd if _ex_sd in ("clean", "summary", "layered", "inline") else "clean",
444 ).ask()
445 if _raw_sd is None:
446 return 130
447 loop_show_diagrams_default: str | None = None if _raw_sd == "__disabled__" else _raw_sd
449 # --- Screen 5 / 7: Hosts ---
450 console.print()
451 console.rule("[bold]5 / 7 Hosts[/bold]")
453 selected_hosts: list[str] | None = questionary.checkbox(
454 "Which host harnesses should ll-init wire adapters for?",
455 choices=[
456 questionary.Choice(label, value=val, checked=(val in default_hosts))
457 for label, val in _HOST_CHOICES
458 ],
459 ).ask()
460 if selected_hosts is None:
461 return 130
463 # --- Screen 6 / 7: Settings target ---
464 console.print()
465 console.rule("[bold]6 / 7 Settings[/bold]")
467 settings_target: str | None = questionary.select(
468 "Where should ll tool permissions be written?",
469 choices=[
470 questionary.Choice(
471 ".claude/settings.local.json (recommended — gitignored)",
472 value="local",
473 ),
474 questionary.Choice(
475 ".claude/settings.json (shared with team)",
476 value="shared",
477 ),
478 questionary.Choice(
479 "Skip — don't write tool permissions",
480 value="skip",
481 ),
482 ],
483 ).ask()
484 if settings_target is None:
485 return 130
487 # --- Screen 7 / 7: CLAUDE.md ---
488 console.print()
489 console.rule("[bold]7 / 7 CLAUDE.md[/bold]")
491 _dot_claude_md = project_root / ".claude" / "CLAUDE.md"
492 _root_claude_md = project_root / "CLAUDE.md"
493 _claude_md_section_present = False
494 _yes_label = "Yes, create .claude/CLAUDE.md"
496 for _candidate in (_dot_claude_md, _root_claude_md):
497 if _candidate.exists():
498 if "## little-loops" in _candidate.read_text(encoding="utf-8"):
499 _claude_md_section_present = True
500 else:
501 _rel = str(_candidate.relative_to(project_root))
502 _yes_label = f"Yes, append to {_rel}"
503 break
505 claude_md_opt_in = False
506 if _claude_md_section_present:
507 console.print("[dim]CLAUDE.md already contains a ## little-loops section — skipping.[/dim]")
508 else:
509 _claude_md_choice: str | None = questionary.select(
510 "Append ll- CLI commands to CLAUDE.md?",
511 choices=[
512 questionary.Choice(_yes_label, value="yes"),
513 questionary.Choice("Skip", value="skip"),
514 ],
515 default="yes",
516 ).ask()
517 if _claude_md_choice is None:
518 return 130
519 claude_md_opt_in = _claude_md_choice == "yes"
521 # --- Compute documents categories ---
522 from little_loops.init.detect import detect_documents
524 documents_categories: dict[str, Any] = {}
525 if "documents" in selected_set:
526 documents_categories = detect_documents(project_root)
528 # --- Parse scan inputs ---
529 scan_focus_dirs = [d.strip() for d in focus_dirs_str.split(",") if d.strip()]
530 scan_custom_excludes = [p.strip() for p in custom_excludes_str.split(",") if p.strip()]
532 # --- Build config ---
533 # install_source captured from the Round 1 detection above (closure).
534 config = _build_final_config(
535 template=template,
536 name=name,
537 src_dir=src_dir,
538 test_cmd=test_cmd,
539 lint_cmd=lint_cmd,
540 type_cmd=type_cmd,
541 format_cmd=format_cmd,
542 selected_set=selected_set,
543 parallel_workers=parallel_workers,
544 scan_focus_dirs=scan_focus_dirs,
545 scan_custom_excludes=scan_custom_excludes,
546 worktree_copy_files=worktree_copy_files,
547 use_feature_branches=use_feature_branches,
548 design_token_profile=design_token_profile,
549 documents_categories=documents_categories,
550 session_digest_enabled=bool(session_digest_enabled),
551 prompt_optimization_enabled=bool(prompt_optimization_enabled),
552 loop_clear_default=bool(loop_clear_default),
553 loop_show_diagrams_default=loop_show_diagrams_default,
554 )
556 if install_source:
557 config["install_source"] = install_source
559 # --- Summary ---
560 console.print()
561 _render_summary(
562 console,
563 config,
564 project_root,
565 selected_set,
566 selected_hosts,
567 settings_target,
568 claude_md_opt_in=claude_md_opt_in,
569 claude_md_section_present=_claude_md_section_present,
570 )
571 console.print()
573 confirmed: bool | None = questionary.confirm("Apply this configuration?", default=True).ask()
574 if confirmed is None:
575 return 130
576 if not confirmed:
577 console.print("[yellow]Aborted — no changes made.[/yellow]")
578 return 1
580 # --- Apply ---
581 _apply_config(
582 config=config,
583 project_root=project_root,
584 ll_dir=ll_dir,
585 config_path=config_path,
586 templates_dir=templates_dir,
587 plugin_root=plugin_root,
588 hosts=selected_hosts,
589 settings_target=settings_target,
590 force=force,
591 console=console,
592 claude_md_opt_in=claude_md_opt_in,
593 existing_config=existing_config,
594 )
595 return 0
598def _build_final_config(
599 template: Any,
600 name: str,
601 src_dir: str,
602 test_cmd: str,
603 lint_cmd: str,
604 type_cmd: str,
605 format_cmd: str,
606 selected_set: set[str],
607 parallel_workers: int,
608 scan_focus_dirs: list[str] | None = None,
609 scan_custom_excludes: list[str] | None = None,
610 worktree_copy_files: list[str] | None = None,
611 use_feature_branches: bool = False,
612 design_token_profile: str = "default",
613 documents_categories: dict[str, Any] | None = None,
614 session_digest_enabled: bool = True,
615 prompt_optimization_enabled: bool = True,
616 loop_clear_default: bool = True,
617 loop_show_diagrams_default: str | None = "clean",
618) -> dict[str, Any]:
619 """Build the ll-config.json dict from TUI answers."""
620 from little_loops.init.core import build_config
622 config = build_config(
623 template,
624 {
625 "project_name": name,
626 "src_dir": src_dir,
627 "product_enabled": "product" in selected_set,
628 "analytics_enabled": "analytics" in selected_set,
629 "context_monitor_enabled": "context_monitor" in selected_set,
630 "learning_tests_enabled": "learning_tests" in selected_set,
631 "decisions_enabled": "decisions" in selected_set,
632 "scratch_pad_enabled": "scratch_pad" in selected_set,
633 "session_capture_enabled": "session_capture" in selected_set,
634 "session_digest_enabled": session_digest_enabled,
635 "prompt_optimization_enabled": prompt_optimization_enabled,
636 "loop_clear_default": loop_clear_default,
637 "loop_show_diagrams_default": loop_show_diagrams_default,
638 },
639 )
641 # Apply command overrides (None for cleared/empty fields)
642 for key, val in [
643 ("test_cmd", test_cmd),
644 ("lint_cmd", lint_cmd),
645 ("type_cmd", type_cmd),
646 ("format_cmd", format_cmd),
647 ]:
648 config["project"][key] = val or None
650 # Update scan section with TUI-provided values
651 if scan_focus_dirs:
652 config["scan"]["focus_dirs"] = scan_focus_dirs
653 if scan_custom_excludes:
654 existing = list(config["scan"].get("exclude_patterns", []))
655 config["scan"]["exclude_patterns"] = existing + scan_custom_excludes
657 # Optional sections from feature toggles
658 if "parallel" in selected_set:
659 parallel_section: dict[str, Any] = {}
660 if parallel_workers != 4:
661 parallel_section["max_workers"] = parallel_workers
662 if worktree_copy_files:
663 parallel_section["worktree_copy_files"] = list(worktree_copy_files)
664 if use_feature_branches:
665 parallel_section["use_feature_branches"] = True
666 if parallel_section:
667 config["parallel"] = parallel_section
669 if "documents" in selected_set:
670 doc_section: dict[str, Any] = {"enabled": True}
671 if documents_categories:
672 doc_section["categories"] = documents_categories
673 config["documents"] = doc_section
675 if "design_tokens" in selected_set:
676 config["design_tokens"] = {"enabled": True, "active": design_token_profile}
678 # GitHub sync
679 if "github_sync" in selected_set:
680 config["sync"] = {"enabled": True}
682 # commands block (confidence_gate + tdd_mode)
683 commands: dict[str, Any] = {}
684 if "confidence_gate" in selected_set:
685 commands["confidence_gate"] = {"enabled": True, "readiness_threshold": 85}
686 if "tdd" in selected_set:
687 commands["tdd_mode"] = True
688 if commands:
689 config["commands"] = commands
691 return config
694def _render_summary(
695 console: Console,
696 config: dict[str, Any],
697 project_root: Path,
698 selected_set: set[str],
699 selected_hosts: list[str],
700 settings_target: str,
701 claude_md_opt_in: bool = False,
702 claude_md_section_present: bool = False,
703) -> None:
704 """Render a rich bordered summary panel of the proposed configuration."""
705 proj = config.get("project", {})
707 table = Table(show_header=False, box=None, padding=(0, 1))
708 table.add_column("Key", style="bold cyan", min_width=14)
709 table.add_column("Value")
711 table.add_row("Project", proj.get("name", project_root.name))
712 table.add_row("Source dir", proj.get("src_dir", ""))
714 for field, label in [
715 ("test_cmd", "Test"),
716 ("lint_cmd", "Lint"),
717 ("type_cmd", "Type-check"),
718 ("format_cmd", "Format"),
719 ]:
720 val = proj.get(field)
721 if val:
722 table.add_row(label, str(val))
724 enabled = [_FEATURE_LABELS[k] for k in _FEATURE_LABELS if k in selected_set]
725 if enabled:
726 table.add_row("Features", ", ".join(enabled))
728 host_labels = [_HOST_LABELS.get(h, h) for h in selected_hosts]
729 table.add_row("Hosts", ", ".join(host_labels) if host_labels else "none")
731 # New: sync / commands
732 if config.get("sync", {}).get("enabled"):
733 table.add_row("Sync", "GitHub sync enabled")
735 cmds = config.get("commands", {})
736 cmd_parts = []
737 if cmds.get("confidence_gate", {}).get("enabled"):
738 cmd_parts.append("confidence gate")
739 if cmds.get("tdd_mode"):
740 cmd_parts.append("TDD mode")
741 if cmd_parts:
742 table.add_row("Commands", ", ".join(cmd_parts))
744 # Design-token profile
745 dt = config.get("design_tokens", {})
746 if dt.get("enabled"):
747 table.add_row("Design tokens", dt.get("active", "default"))
749 # Documents categories
750 doc_cats = config.get("documents", {}).get("categories", {})
751 if doc_cats:
752 table.add_row("Documents", f"{len(doc_cats)} categories detected")
754 # Worktree copy files
755 wt_files = config.get("parallel", {}).get("worktree_copy_files", [])
756 if wt_files:
757 table.add_row("Worktree files", ", ".join(wt_files))
759 # Session digest
760 sd_enabled = config.get("history", {}).get("session_digest", {}).get("enabled", True)
761 table.add_row("Session digest", "on" if sd_enabled else "off")
763 # Opt-in feature sections written by the new toggles
764 if config.get("decisions", {}).get("enabled"):
765 table.add_row("Decisions", "rules log enabled")
766 if config.get("scratch_pad", {}).get("enabled"):
767 table.add_row("Scratch pad", "enabled")
768 if config.get("session_capture", {}).get("enabled"):
769 table.add_row("Session capture", "enabled")
771 # Prompt optimization (default-on; only written when opted out)
772 if config.get("prompt_optimization", {}).get("enabled") is False:
773 table.add_row("Prompt optim.", "off")
775 # Loop run defaults
776 rd = config.get("loops", {}).get("run_defaults", {})
777 rd_parts = []
778 if rd.get("clear"):
779 rd_parts.append("--clear")
780 if rd.get("show_diagrams"):
781 rd_parts.append(f"--show-diagrams {rd['show_diagrams']}")
782 table.add_row("Loop defaults", " ".join(rd_parts) if rd_parts else "none")
784 if settings_target == "skip":
785 sf = "Skip — no permissions written"
786 elif settings_target == "local":
787 sf = ".claude/settings.local.json"
788 else:
789 sf = ".claude/settings.json"
790 table.add_row("Settings", sf)
792 if claude_md_section_present:
793 table.add_row("CLAUDE.md", "Already present — skipped")
794 elif claude_md_opt_in:
795 table.add_row("CLAUDE.md", "Append ll- CLI commands")
796 else:
797 table.add_row("CLAUDE.md", "Skip")
799 console.print(Panel(table, title="[bold]Configuration Summary[/bold]", border_style="blue"))
802def _apply_config(
803 config: dict[str, Any],
804 project_root: Path,
805 ll_dir: Path,
806 config_path: Path,
807 templates_dir: Path,
808 plugin_root: Path,
809 hosts: list[str],
810 settings_target: str,
811 force: bool,
812 console: Console,
813 claude_md_opt_in: bool = False,
814 existing_config: dict[str, Any] | None = None,
815) -> None:
816 """Write all ll-init artifacts to disk."""
817 from little_loops import __version__
818 from little_loops.init.cli import _dispatch_host_adapters
819 from little_loops.init.validate import validate_deps
820 from little_loops.init.writers import (
821 deploy_design_tokens,
822 deploy_goals,
823 deploy_issue_templates,
824 make_issue_dirs,
825 make_learning_tests_dir,
826 merge_settings,
827 merge_with_existing,
828 update_gitignore,
829 write_claude_md,
830 write_config,
831 )
833 # Preserve any config keys the wizard does not model (BUG-2310); --force resets.
834 config = merge_with_existing(config, existing_config or {}, force)
836 issues_base = project_root / config.get("issues", {}).get("base_dir", ".issues")
838 write_config(config, ll_dir)
839 make_issue_dirs(issues_base)
841 if config.get("product", {}).get("enabled"):
842 deploy_goals(ll_dir, templates_dir)
844 if config.get("design_tokens", {}).get("enabled"):
845 active_profile = config["design_tokens"].get("active", "default")
846 deploy_design_tokens(ll_dir, templates_dir, active_profile=active_profile)
848 if config.get("issues", {}).get("deploy_templates"):
849 deploy_issue_templates(ll_dir, templates_dir)
851 if config.get("learning_tests", {}).get("enabled"):
852 make_learning_tests_dir(ll_dir)
854 update_gitignore(project_root)
856 if settings_target != "skip":
857 settings_file = (
858 ".claude/settings.local.json" if settings_target == "local" else ".claude/settings.json"
859 )
860 extra_permissions: list[str] | None = None
861 if config.get("learning_tests", {}).get("enabled"):
862 extra_permissions = ["Skill(ll:explore-api)"]
863 merge_settings(
864 project_root, settings_file=settings_file, extra_permissions=extra_permissions
865 )
867 if claude_md_opt_in:
868 write_claude_md(project_root)
870 _dispatch_host_adapters(hosts, project_root, plugin_root, force=force)
872 warnings = validate_deps(config, __version__, project_root)
873 for w in warnings:
874 console.print(f"[yellow]Warning: {w.message}[/yellow]")
875 if w.install_hint:
876 console.print(f" Install/fix: {w.install_hint}")
878 console.print(f"\n[bold green]✓ little-loops initialized in {project_root}[/bold green]")
879 console.print(f" Config: [cyan]{config_path}[/cyan]")