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

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

2 

3from __future__ import annotations 

4 

5import sys 

6from pathlib import Path 

7from typing import Any 

8 

9import questionary 

10from rich.console import Console 

11from rich.panel import Panel 

12from 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 

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) 

101 

102 

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. 

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 

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. 

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 

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 

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 

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

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 

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 ) 

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