1"""File mutation helpers for headless ll-init."""

2

3 from __future__ import annotations

4

5 import json

6 import shutil

7 import sys

8 from pathlib import Path

9 from typing import Any

10

11 from little_loops.file_utils import atomic_write, atomic_write_json

12 from little_loops.init.core import strip_none_leaves

13

14# Entries added to .gitignore by ll-init (idempotently)

15_GITIGNORE_COMMENT = "# little-loops state files"

16_GITIGNORE_ENTRIES: tuple[str, ...] = (

17 ".auto-manage-state.json",

18 ".parallel-manage-state.json",

19 ".ll/ll-context-state.json",

20 ".ll/ll-sync-state.json",

21 ".ll/ll-session-events.jsonl",

22)

23

24# Canonical permission entries for .claude/settings*.json (Step 10 of the skill)

25_LL_PERMISSIONS: tuple[str, ...] = (

26 "Bash(ll-action:*)",

27 "Bash(ll-issues:*)",

28 "Bash(ll-auto:*)",

29 "Bash(ll-parallel:*)",

30 "Bash(ll-sprint:*)",

31 "Bash(ll-loop:*)",

32 "Bash(ll-workflows:*)",

33 "Bash(ll-messages:*)",

34 "Bash(ll-history:*)",

35 "Bash(ll-history-context:*)",

36 "Bash(ll-deps:*)",

37 "Bash(ll-sync:*)",

38 "Bash(ll-verify-docs:*)",

39 "Bash(ll-verify-package-data:*)",

40 "Bash(ll-verify-skills:*)",

41 "Bash(ll-check-links:*)",

42 "Bash(ll-gitignore:*)",

43 "Bash(ll-create-extension:*)",

44 "Bash(ll-learning-tests:*)",

45 "Bash(ll-logs:*)",

46 "Bash(ll-session:*)",

47 "Bash(ll-doctor:*)",

48 "Bash(ll-ctx-stats:*)",

49 "Bash(ll-adapt:*)",

50 "Bash(ll-harness:*)",

51 "Write(.ll/ll-continue-prompt.md)",

52)

53

54_ISSUE_SUBDIRS: tuple[str, ...] = (

55 "bugs",

56 "features",

57 "enhancements",

58 "epics",

59)

60

61# Sentinel string used to detect whether the ll section already exists in CLAUDE.md

62_CLAUDE_MD_SECTION_MARKER = "## little-loops"

63

64# Canonical CLI Commands block appended/created by write_claude_md (Step 11 of the skill)

65_CLAUDE_MD_COMMANDS_BLOCK = """\

66

67## little-loops CLI Commands

68

69- `ll-action` - Invoke ll skills as one-shot commands with JSON-structured output

70- `ll-harness` - One-shot runner evaluation (skill, cmd, mcp, prompt, dsl) with exit-code and semantic criteria

71- `ll-auto` - Process all backlog issues sequentially in priority order

72- `ll-parallel` - Process issues concurrently using isolated git worktrees

73- `ll-sprint` - Define and execute curated issue sets with dependency-aware ordering

74- `ll-loop` - Execute FSM-based automation loops

75- `ll-workflows` - Identify multi-step workflow patterns from user message history

76- `ll-messages` - Extract user messages from Claude Code logs

77- `ll-history` - View completed issue statistics, analysis, and export topic-filtered excerpts from history

78- `ll-history-context` - Render a `## Historical Context` block for an issue from `.ll/history.db`

79- `ll-deps` - Cross-issue dependency analysis and validation

80- `ll-sync` - Sync local issues with GitHub Issues

81- `ll-verify-docs` - Verify documented counts match actual file counts

82- `ll-verify-package-data` - Lint __file__ escapes and verify manifest assets are in-wheel

83- `ll-verify-skills` - Check that no SKILL.md exceeds 500 lines

84- `ll-check-links` - Check markdown documentation for broken links

85- `ll-issues` - Issue management and visualization (next-id, list, show, path, sequence, impact-effort, refine-status, set-status, anchor-sweep, fingerprint, epic-progress, decisions)

86- `ll-gitignore` - Suggest and apply `.gitignore` patterns based on untracked files

87- `ll-create-extension` - Scaffold a new little-loops extension project

88- `ll-generate-schemas` - Regenerate JSON Schema files for all LLEvent types (maintainer tool)

89- `ll-learning-tests` - Query and manage the learning test registry (check/list/mark-stale)

90- `ll-logs` - Discover, extract, and analyze (sequences, scan-failures) ll-relevant log entries from Claude project logs

91- `ll-doctor` - Check host CLI capability support for little-loops features

92- `ll-ctx-stats` - Show context-window analytics for the current project (per-tool byte vs. context savings; skill-health signals)

93- `ll-adapt` - Generate host-specific artefacts for a given host (``--host codex``, etc.)

94- `ll-adapt-skills-for-codex` - Add Codex Skills API frontmatter to skills and bridge commands (alias for ll-adapt --host codex)

95- `ll-adapt-agents-for-codex` - Generate `.codex/agents/*.toml` from `agents/*.md` (alias for ll-adapt --host codex)

96

97 Install: `pip install -e "./scripts[dev]"`

98"""

99

100_CLAUDE_MD_NEW_FILE_CONTENT = "# Project Configuration\n" + _CLAUDE_MD_COMMANDS_BLOCK

101

102

103 def load_existing_config(project_root: Path) -> dict[str, Any]:

104 """Load the existing ll-config.json for *project_root* as a dict.

105

106 Resolves via :func:`little_loops.config.core.resolve_config_path` (so host

107 state dirs like ``.codex/`` are honored), returning ``{}`` when no config is

108 present or the file cannot be parsed. Shared by every ll-init write path that

109 pre-populates from — and now merges with — the existing config.

110 """

111 from little_loops.config.core import resolve_config_path

112

113 existing_path = resolve_config_path(project_root)

114 if existing_path is None:

115 return {}

116 try:

117 data = json.loads(existing_path.read_text(encoding="utf-8"))

118 except (json.JSONDecodeError, OSError):

119 return {}

120 return data if isinstance(data, dict) else {}

121

122

123 def merge_with_existing(

124 new_config: dict[str, Any],

125 existing_config: dict[str, Any],

126 force: bool,

127) -> dict[str, Any]:

128 """Layer *new_config* over *existing_config*, preserving unmodeled keys.

129

130 Fixes BUG-2310: re-running ll-init rebuilt only the keys ``build_config``

131 models and overwrote the file wholesale, silently destroying every other key

132 the user had set (sprints, commands, documents, scratch_pad sub-config,

133 history.compaction, context_monitor threshold, …).

134

135 When ``force`` is True (or there is no existing config) *new_config* is

136 returned unchanged — the documented ``--force`` "reset to template defaults"

137 contract. Otherwise the ``None``-stripped *new_config* is deep-merged over

138 *existing_config* so unmodeled keys survive while modeled keys take the new

139 values. Neither input is mutated.

140 """

141 if force or not existing_config:

142 return new_config

143 from little_loops.config.core import deep_merge

144

145 return deep_merge(existing_config, strip_none_leaves(new_config))

146

147

148 def write_config(config: dict[str, Any], ll_dir: Path, dry_run: bool = False) -> None:

149 """Write ll-config.json into *ll_dir*.

150

151 Args:

152 config: Config dict produced by build_config().

153 ll_dir: Path to the .ll/ directory.

154 dry_run: If True, print JSON to stdout; do not write files.

155 """

156 if dry_run:

157 print(json.dumps(config, indent=2))

158 return

159 ll_dir.mkdir(parents=True, exist_ok=True)

160 atomic_write_json(ll_dir / "ll-config.json", config)

161

162

163 def update_gitignore(project_root: Path, dry_run: bool = False) -> bool:

164 """Idempotently append ll state-file patterns to .gitignore.

165

166 Only missing entries are appended; existing entries are never duplicated.

167

168 Args:

169 project_root: Project root directory.

170 dry_run: If True, print planned changes; do not modify files.

171

172 Returns:

173 True if the file was created or modified; False if no changes needed.

174 """

175 gitignore_path = project_root / ".gitignore"

176 existing = gitignore_path.read_text(encoding="utf-8") if gitignore_path.exists() else ""

177 existing_lines = set(existing.splitlines())

178

179 missing = [e for e in _GITIGNORE_ENTRIES if e not in existing_lines]

180 if not missing:

181 return False

182

183 if dry_run:

184 print(f"[update] .gitignore (+{len(missing)} entries)")

185 return True

186

187 block = _GITIGNORE_COMMENT + "\n" + "\n".join(missing) + "\n"

188 if existing and not existing.endswith("\n"):

189 new_content = existing + "\n\n" + block

190 elif existing:

191 new_content = existing + "\n" + block

192 else:

193 new_content = block

194

195 atomic_write(gitignore_path, new_content)

196 return True

197

198

199 def merge_settings(

200 project_root: Path,

201 settings_file: str = ".claude/settings.local.json",

202 extra_permissions: list[str] | None = None,

203 dry_run: bool = False,

204) -> None:

205 """Merge ll- CLI tool permissions into a Claude Code settings file.

206

207 Idempotency sweep: removes stale ``Bash(ll-*`` and

208 ``Write(.ll/ll-continue-prompt.md)`` entries before re-appending the

209 canonical list.

210

211 Args:

212 project_root: Project root directory.

213 settings_file: Relative path to target settings JSON file.

214 extra_permissions: Additional entries inserted before the trailing

215 ``Write(.ll/ll-continue-prompt.md)`` entry.

216 dry_run: If True, print the target path; do not write.

217 """

218 target = project_root / settings_file

219 if target.exists():

220 try:

221 data: dict[str, Any] = json.loads(target.read_text(encoding="utf-8"))

222 except json.JSONDecodeError:

223 data = {}

224 else:

225 data = {}

226

227 perms: dict[str, Any] = data.setdefault("permissions", {})

228 allow: list[str] = list(perms.get("allow", []))

229

230 # Idempotency sweep — remove only canonical ll entries; preserve user-added Bash(ll-*) permissions.

231 allow = [e for e in allow if e not in _LL_PERMISSIONS]

232 if extra_permissions:

233 allow = [e for e in allow if e not in extra_permissions]

234

235 # Build canonical list (insert extras before trailing Write entry)

236 canonical = list(_LL_PERMISSIONS)

237 if extra_permissions:

238 canonical = canonical[:-1] + list(extra_permissions) + [canonical[-1]]

239

240 allow.extend(canonical)

241 perms["allow"] = allow

242 data["permissions"] = perms

243

244 if dry_run:

245 print(f"[update] {settings_file}")

246 if extra_permissions:

247 for perm in extra_permissions:

248 print(f" + {perm}")

249 return

250

251 target.parent.mkdir(parents=True, exist_ok=True)

252 atomic_write_json(target, data)

253

254

255 def make_issue_dirs(base_dir: Path, dry_run: bool = False) -> None:

256 """Create the standard issue-tracking subdirectories under *base_dir*.

257

258 Args:

259 base_dir: Root issues directory (e.g., .issues/).

260 dry_run: If True, print planned mkdirs; do not create directories.

261 """

262 if dry_run:

263 for sd in _ISSUE_SUBDIRS:

264 print(f"[mkdir] {base_dir / sd}")

265 return

266 for sd in _ISSUE_SUBDIRS:

267 (base_dir / sd).mkdir(parents=True, exist_ok=True)

268

269

270 def make_learning_tests_dir(ll_dir: Path, dry_run: bool = False) -> bool:

271 """Create .ll/learning-tests/ with a .gitkeep placeholder.

272

273 Args:

274 ll_dir: The .ll/ directory.

275 dry_run: If True, print planned mkdir; do not create directories.

276

277 Returns:

278 True if the directory was created; False if it already existed.

279 """

280 lt_dir = ll_dir / "learning-tests"

281 if lt_dir.exists():

282 return False

283 if dry_run:

284 print(f"[mkdir] {lt_dir}")

285 return True

286 lt_dir.mkdir(parents=True, exist_ok=True)

287 (lt_dir / ".gitkeep").touch()

288 return True

289

290

291 def deploy_goals(ll_dir: Path, templates_dir: Path, dry_run: bool = False) -> bool:

292 """Deploy the goals template to .ll/ll-goals.md (skip if already present).

293

294 Args:

295 ll_dir: The .ll/ directory.

296 templates_dir: templates/ directory containing ll-goals-template.md.

297 dry_run: If True, print planned write; do not copy files.

298

299 Returns:

300 True if deployed; False if already existed or source not found.

301 """

302 dest = ll_dir / "ll-goals.md"

303 if dest.exists():

304 return False

305 src = templates_dir / "ll-goals-template.md"

306 if not src.exists():

307 print(f" Warning: goals template source not found at {src}", file=sys.stderr)

308 return False

309 if dry_run:

310 print(f"[write] {dest} (from {src.name})")

311 return True

312 ll_dir.mkdir(parents=True, exist_ok=True)

313 atomic_write(dest, src.read_text(encoding="utf-8"))

314 return True

315

316

317 def deploy_design_tokens(

318 ll_dir: Path,

319 templates_dir: Path,

320 active_profile: str = "default",

321 dry_run: bool = False,

322) -> bool:

323 """Mirror templates/design-tokens/profiles/ into .ll/design-tokens/profiles/.

324

325 Skips silently if the destination already exists.

326

327 Args:

328 ll_dir: The .ll/ directory.

329 templates_dir: templates/ directory containing design-tokens/profiles/.

330 active_profile: Name of the active profile (for display only; not

331 written to config by this function).

332 dry_run: If True, print planned write; do not copy files.

333

334 Returns:

335 True if deployed; False if already existed or source not found.

336 """

337 src_profiles = templates_dir / "design-tokens" / "profiles"

338 dest_profiles = ll_dir / "design-tokens" / "profiles"

339 if dest_profiles.exists():

340 return False

341 if not src_profiles.exists():

342 print(

343 f" Warning: design-token profiles source not found at {src_profiles}",

344 file=sys.stderr,

345 )

346 return False

347 if dry_run:

348 print(f"[write] {dest_profiles}/ (design-token profiles)")

349 return True

350 shutil.copytree(src_profiles, dest_profiles)

351 return True

352

353

354 def deploy_issue_templates(ll_dir: Path, templates_dir: Path, dry_run: bool = False) -> bool:

355 """Copy bundled *-sections.json files to .ll/templates/ (skip if already present).

356

357 Args:

358 ll_dir: The .ll/ directory.

359 templates_dir: templates/ directory containing *-sections.json files.

360 dry_run: If True, print planned write; do not copy files.

361

362 Returns:

363 True if deployed; False if already existed or no section files found.

364 """

365 dest = ll_dir / "templates"

366 if dest.exists():

367 return False

368 section_files = list(templates_dir.glob("*-sections.json"))

369 if not section_files:

370 print(f"Warning: no *-sections.json files found in {templates_dir}", file=sys.stderr)

371 return False

372 if dry_run:

373 print(f"[write] {dest}/ (issue section templates)")

374 return True

375 dest.mkdir(parents=True, exist_ok=True)

376 for f in section_files:

377 shutil.copy2(f, dest / f.name)

378 return True

379

380

381 def write_claude_md(project_root: Path, dry_run: bool = False) -> bool:

382 """Append the canonical ## little-loops CLI Commands block to CLAUDE.md.

383

384 Detection order: .claude/CLAUDE.md, then CLAUDE.md. If neither exists,

385 creates .claude/CLAUDE.md. Idempotent: returns False without writing if

386 the section is already present.

387

388 Args:

389 project_root: Project root directory.

390 dry_run: If True, print planned action; do not write files.

391

392 Returns:

393 True if the file was created or modified; False if no changes needed.

394 """

395 dot_claude = project_root / ".claude" / "CLAUDE.md"

396 root_claude = project_root / "CLAUDE.md"

397

398 if dot_claude.exists():

399 target = dot_claude

400 elif root_claude.exists():

401 target = root_claude

402 else:

403 target = dot_claude

404

405 rel = str(target.relative_to(project_root))

406

407 if target.exists():

408 existing = target.read_text(encoding="utf-8")

409 if _CLAUDE_MD_SECTION_MARKER in existing:

410 return False

411 if dry_run:

412 print(f"[update] {rel} (append ## little-loops CLI Commands)")

413 return True

414 new_content = existing.rstrip("\n") + "\n" + _CLAUDE_MD_COMMANDS_BLOCK

415 atomic_write(target, new_content)

416 else:

417 if dry_run:

418 print(f"[write] {rel} (ll- CLI command documentation)")

419 return True

420 target.parent.mkdir(parents=True, exist_ok=True)

421 atomic_write(target, _CLAUDE_MD_NEW_FILE_CONTENT)

422

423 return True

424

425

426 def _codex_template_path() -> Path:

427 """Return the in-package path to the Codex adapter hooks.json template."""

428 return Path(__file__).parent.parent / "hooks" / "adapters" / "codex" / "hooks.json"

429

430

431 def install_codex_adapter(

432 project_root: Path,

433 plugin_root: Path,

434 force: bool = False,

435 dry_run: bool = False,

436) -> bool | None:

437 """Write .codex/hooks.json from the in-package Codex adapter template.

438

439 Reads ``little_loops/hooks/adapters/codex/hooks.json`` from the installed

440 package, substitutes ``{{LL_PLUGIN_ROOT}}`` with the ``little_loops``

441 package directory, and writes the result to ``<project_root>/.codex/hooks.json``.

442

443 Args:

444 project_root: Project root directory.

445 plugin_root: Unused; kept for call-site compatibility.

446 force: If True, overwrite an existing .codex/hooks.json.

447 dry_run: If True, print planned write; do not modify files.

448

449 Returns:

450 True if written; False if skipped (dest already exists without --force);

451 None if the source template is missing (package install corrupted).

452 """

453 template_path = _codex_template_path()

454 dest = project_root / ".codex" / "hooks.json"

455

456 if not template_path.exists():

457 return None

458

459 if dest.exists() and not force:

460 return False

461

462 package_root = str(Path(__file__).parent.parent)

463 rendered = template_path.read_text(encoding="utf-8").replace("{{LL_PLUGIN_ROOT}}", package_root)

464

465 if dry_run:

466 print("[write] .codex/hooks.json")

467 return True

468

469 dest.parent.mkdir(parents=True, exist_ok=True)

470 atomic_write(dest, rendered)

471 return True