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

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

2 

3from __future__ import annotations 

4 

5import json 

6import sys 

7from pathlib import Path 

8from typing import Any 

9 

10import questionary 

11from rich.console import Console 

12from rich.panel import Panel 

13from rich.table import Table 

14 

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] 

31 

32_DEFAULT_FEATURES: frozenset[str] = frozenset( 

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

34) 

35 

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} 

51 

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] 

58 

59_HOST_LABELS: dict[str, str] = { 

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

61 "codex": "Codex CLI", 

62 "pi": "Pi", 

63} 

64 

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

66_CUSTOM_SENTINEL = "Custom…" 

67 

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] 

75 

76 

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) 

102 

103 

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. 

106 

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

121 

122 

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. 

131 

132 Args: 

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

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

135 

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 

145 

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 ) 

155 

156 console = Console() 

157 ll_dir = project_root / ".ll" 

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

159 

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 = {} 

168 

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

170 

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 

178 

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 

183 

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 ) 

193 

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 ) 

221 

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 ) 

237 

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

241 

242 console.print( 

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

244 ) 

245 

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

247 

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

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

250 

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

252 

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 

259 

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 

266 

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 

274 

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 

282 

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 

289 

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 

297 

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

299 console.print() 

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

301 

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 

311 

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 

317 

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 

326 

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

328 console.print() 

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

330 

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 

343 

344 selected_set = set(selected_features) 

345 

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 

365 

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 

382 

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 

390 

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 

409 

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 

419 

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 

427 

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 

435 

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 

455 

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

457 console.print() 

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

459 

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 

469 

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

471 console.print() 

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

473 

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 

493 

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

495 console.print() 

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

497 

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" 

502 

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 

511 

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" 

527 

528 # --- Compute documents categories --- 

529 from little_loops.init.detect import detect_documents 

530 

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

532 if "documents" in selected_set: 

533 documents_categories = detect_documents(project_root) 

534 

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

538 

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 ) 

562 

563 if install_source: 

564 config["install_source"] = install_source 

565 

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

579 

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 

586 

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 

602 

603 

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 

627 

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 ) 

646 

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 

655 

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 

662 

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 

674 

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 

680 

681 if "design_tokens" in selected_set: 

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

683 

684 # GitHub sync 

685 if "github_sync" in selected_set: 

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

687 

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 

696 

697 return config 

698 

699 

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

712 

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

716 

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

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

719 

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

729 

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

733 

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

736 

737 # New: sync / commands 

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

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

740 

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

749 

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

754 

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

759 

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

764 

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

768 

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

776 

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

780 

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

789 

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) 

797 

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

804 

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

806 

807 

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 ) 

836 

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

838 

839 write_config(config, ll_dir) 

840 make_issue_dirs(issues_base) 

841 

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

843 deploy_goals(ll_dir, templates_dir) 

844 

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) 

848 

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

850 deploy_issue_templates(ll_dir, templates_dir) 

851 

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

853 make_learning_tests_dir(ll_dir) 

854 

855 update_gitignore(project_root) 

856 

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 ) 

867 

868 if claude_md_opt_in: 

869 write_claude_md(project_root) 

870 

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

872 

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

878 

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

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