Coverage for little_loops / init / writers.py: 15%
181 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:55 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:55 -0500
1"""File mutation helpers for headless ll-init."""
3from __future__ import annotations
5import json
6import shutil
7import sys
8from pathlib import Path
9from typing import Any
11from little_loops.file_utils import atomic_write, atomic_write_json
12from little_loops.init.core import strip_none_leaves
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)
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)
54_ISSUE_SUBDIRS: tuple[str, ...] = (
55 "bugs",
56 "features",
57 "enhancements",
58 "epics",
59)
61# Sentinel string used to detect whether the ll section already exists in CLAUDE.md
62_CLAUDE_MD_SECTION_MARKER = "## little-loops"
64# Canonical CLI Commands block appended/created by write_claude_md (Step 11 of the skill)
65_CLAUDE_MD_COMMANDS_BLOCK = """\
67## little-loops CLI Commands
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)
97Install: `pip install -e "./scripts[dev]"`
98"""
100_CLAUDE_MD_NEW_FILE_CONTENT = "# Project Configuration\n" + _CLAUDE_MD_COMMANDS_BLOCK
103def load_existing_config(project_root: Path) -> dict[str, Any]:
104 """Load the existing ll-config.json for *project_root* as a dict.
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
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 {}
123def 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.
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, …).
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
145 return deep_merge(existing_config, strip_none_leaves(new_config))
148def write_config(config: dict[str, Any], ll_dir: Path, dry_run: bool = False) -> None:
149 """Write ll-config.json into *ll_dir*.
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)
163def update_gitignore(project_root: Path, dry_run: bool = False) -> bool:
164 """Idempotently append ll state-file patterns to .gitignore.
166 Only missing entries are appended; existing entries are never duplicated.
168 Args:
169 project_root: Project root directory.
170 dry_run: If True, print planned changes; do not modify files.
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())
179 missing = [e for e in _GITIGNORE_ENTRIES if e not in existing_lines]
180 if not missing:
181 return False
183 if dry_run:
184 print(f"[update] .gitignore (+{len(missing)} entries)")
185 return True
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
195 atomic_write(gitignore_path, new_content)
196 return True
199def 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.
207 Idempotency sweep: removes stale ``Bash(ll-*`` and
208 ``Write(.ll/ll-continue-prompt.md)`` entries before re-appending the
209 canonical list.
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 = {}
227 perms: dict[str, Any] = data.setdefault("permissions", {})
228 allow: list[str] = list(perms.get("allow", []))
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]
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]]
240 allow.extend(canonical)
241 perms["allow"] = allow
242 data["permissions"] = perms
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
251 target.parent.mkdir(parents=True, exist_ok=True)
252 atomic_write_json(target, data)
255def make_issue_dirs(base_dir: Path, dry_run: bool = False) -> None:
256 """Create the standard issue-tracking subdirectories under *base_dir*.
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)
270def make_learning_tests_dir(ll_dir: Path, dry_run: bool = False) -> bool:
271 """Create .ll/learning-tests/ with a .gitkeep placeholder.
273 Args:
274 ll_dir: The .ll/ directory.
275 dry_run: If True, print planned mkdir; do not create directories.
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
291def 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).
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.
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
317def 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/.
325 Skips silently if the destination already exists.
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.
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
354def 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).
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.
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
381def write_claude_md(project_root: Path, dry_run: bool = False) -> bool:
382 """Append the canonical ## little-loops CLI Commands block to CLAUDE.md.
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.
388 Args:
389 project_root: Project root directory.
390 dry_run: If True, print planned action; do not write files.
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"
398 if dot_claude.exists():
399 target = dot_claude
400 elif root_claude.exists():
401 target = root_claude
402 else:
403 target = dot_claude
405 rel = str(target.relative_to(project_root))
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)
423 return True
426def _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"
431def 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.
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``.
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.
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"
456 if not template_path.exists():
457 return None
459 if dest.exists() and not force:
460 return False
462 package_root = str(Path(__file__).parent.parent)
463 rendered = template_path.read_text(encoding="utf-8").replace("{{LL_PLUGIN_ROOT}}", package_root)
465 if dry_run:
466 print("[write] .codex/hooks.json")
467 return True
469 dest.parent.mkdir(parents=True, exist_ok=True)
470 atomic_write(dest, rendered)
471 return True