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