1"""Interactive TUI for ll-init using questionary and rich."""

2

3 from __future__ import annotations

4

5 import sys

6 from pathlib import Path

7 from typing import Any

8

9 import questionary

10 from rich.console import Console

11 from rich.panel import Panel

12 from rich.table import Table

13

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]

30

31_DEFAULT_FEATURES: frozenset[str] = frozenset(

32 {"parallel", "product", "learning_tests", "analytics", "context_monitor"}

33)

34

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}

50

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]

57

58_HOST_LABELS: dict[str, str] = {

59 "claude-code": "Claude Code",

60 "codex": "Codex CLI",

61 "pi": "Pi",

62}

63

64# Sentinel used in curated command menus for the free-text fallthrough

65_CUSTOM_SENTINEL = "Custom…"

66

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]

74

75

76 def _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)

101

102

103 def _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.

105

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()

120

121

122 def 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.

130

131 Args:

132 hosts: Detection-seeded default host list shown pre-checked.

133 When None, defaults to ["claude-code"].

134

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

144

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

154

155 console = Console()

156 ll_dir = project_root / ".ll"

157 config_path = ll_dir / "ll-config.json"

158

159 # Load existing config for pre-population and the merge in _apply_config.

160 existing_config = load_existing_config(project_root)

161

162 _selected_hosts: frozenset[str] = frozenset(hosts or ["claude-code"])

163

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

171

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

176

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 )

186

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 )

214

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 )

230

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", {})

234

235 console.print(

236 f"\n[bold blue]little-loops setup[/bold blue] — detected [cyan]{template.name}[/cyan]\n"

237 )

238

239 default_hosts: frozenset[str] = frozenset(hosts or ["claude-code"])

240

241 # --- Screen 2 / 7: Project Basics ---

242 console.rule("[bold]2 / 7 Project Basics[/bold]")

243

244 _ex_proj = existing_config.get("project", {})

245

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

252

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

259

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

267

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

275

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

282

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

290

291 # --- Screen 3 / 7: Scan ---

292 console.print()

293 console.rule("[bold]3 / 7 Scan[/bold]")

294

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

304

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

310

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

319

320 # --- Screen 4 / 7: Features ---

321 console.print()

322 console.rule("[bold]4 / 7 Features[/bold]")

323

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

336

337 selected_set = set(selected_features)

338

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

358

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

375

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

383

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

402

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

412

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

420

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

428

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

448

449 # --- Screen 5 / 7: Hosts ---

450 console.print()

451 console.rule("[bold]5 / 7 Hosts[/bold]")

452

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

462

463 # --- Screen 6 / 7: Settings target ---

464 console.print()

465 console.rule("[bold]6 / 7 Settings[/bold]")

466

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

486

487 # --- Screen 7 / 7: CLAUDE.md ---

488 console.print()

489 console.rule("[bold]7 / 7 CLAUDE.md[/bold]")

490

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"

495

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

504

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"

520

521 # --- Compute documents categories ---

522 from little_loops.init.detect import detect_documents

523

524 documents_categories: dict[str, Any] = {}

525 if "documents" in selected_set:

526 documents_categories = detect_documents(project_root)

527

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()]

531

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 )

555

556 if install_source:

557 config["install_source"] = install_source

558

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()

572

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

579

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

596

597

598 def _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

621

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 )

640

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

649

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

656

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

668

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

674

675 if "design_tokens" in selected_set:

676 config["design_tokens"] = {"enabled": True, "active": design_token_profile}

677

678 # GitHub sync

679 if "github_sync" in selected_set:

680 config["sync"] = {"enabled": True}

681

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

690

691 return config

692

693

694 def _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", {})

706

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")

710

711 table.add_row("Project", proj.get("name", project_root.name))

712 table.add_row("Source dir", proj.get("src_dir", ""))

713

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))

723

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))

727

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")

730

731 # New: sync / commands

732 if config.get("sync", {}).get("enabled"):

733 table.add_row("Sync", "GitHub sync enabled")

734

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))

743

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"))

748

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")

753

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))

758

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")

762

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")

770

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")

774

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")

783

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)

791

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")

798

799 console.print(Panel(table, title="[bold]Configuration Summary[/bold]", border_style="blue"))

800

801

802 def _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 )

832

833 # Preserve any config keys the wizard does not model (BUG-2310); --force resets.

834 config = merge_with_existing(config, existing_config or {}, force)

835

836 issues_base = project_root / config.get("issues", {}).get("base_dir", ".issues")

837

838 write_config(config, ll_dir)

839 make_issue_dirs(issues_base)

840

841 if config.get("product", {}).get("enabled"):

842 deploy_goals(ll_dir, templates_dir)

843

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)

847

848 if config.get("issues", {}).get("deploy_templates"):

849 deploy_issue_templates(ll_dir, templates_dir)

850

851 if config.get("learning_tests", {}).get("enabled"):

852 make_learning_tests_dir(ll_dir)

853

854 update_gitignore(project_root)

855

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 )

866

867 if claude_md_opt_in:

868 write_claude_md(project_root)

869

870 _dispatch_host_adapters(hosts, project_root, plugin_root, force=force)

871

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}")

877

878 console.print(f"\n[bold green]✓ little-loops initialized in {project_root}[/bold green]")

879 console.print(f" Config: [cyan]{config_path}[/cyan]")