Coverage for little_loops / init / writers.py: 15%
163 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -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
13# Entries added to .gitignore by ll-init (idempotently)
14_GITIGNORE_COMMENT = "# little-loops state files"
15_GITIGNORE_ENTRIES: tuple[str, ...] = (
16 ".auto-manage-state.json",
17 ".parallel-manage-state.json",
18 ".ll/ll-context-state.json",
19 ".ll/ll-sync-state.json",
20 ".ll/ll-session-events.jsonl",
21)
23# Canonical permission entries for .claude/settings*.json (Step 10 of the skill)
24_LL_PERMISSIONS: tuple[str, ...] = (
25 "Bash(ll-action:*)",
26 "Bash(ll-issues:*)",
27 "Bash(ll-auto:*)",
28 "Bash(ll-parallel:*)",
29 "Bash(ll-sprint:*)",
30 "Bash(ll-loop:*)",
31 "Bash(ll-workflows:*)",
32 "Bash(ll-messages:*)",
33 "Bash(ll-history:*)",
34 "Bash(ll-history-context:*)",
35 "Bash(ll-deps:*)",
36 "Bash(ll-sync:*)",
37 "Bash(ll-verify-docs:*)",
38 "Bash(ll-verify-package-data:*)",
39 "Bash(ll-verify-skills:*)",
40 "Bash(ll-check-links:*)",
41 "Bash(ll-gitignore:*)",
42 "Bash(ll-create-extension:*)",
43 "Bash(ll-learning-tests:*)",
44 "Bash(ll-logs:*)",
45 "Bash(ll-session:*)",
46 "Bash(ll-doctor:*)",
47 "Bash(ll-ctx-stats:*)",
48 "Bash(ll-adapt-skills-for-codex:*)",
49 "Bash(ll-adapt-agents-for-codex:*)",
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-skills-for-codex` - Add Codex Skills API frontmatter to skills and bridge commands for Codex discovery
94- `ll-adapt-agents-for-codex` - Generate `.codex/agents/*.toml` from `agents/*.md` for Codex agent-select support
96Install: `pip install -e "./scripts[dev]"`
97"""
99_CLAUDE_MD_NEW_FILE_CONTENT = "# Project Configuration\n" + _CLAUDE_MD_COMMANDS_BLOCK
102def write_config(config: dict[str, Any], ll_dir: Path, dry_run: bool = False) -> None:
103 """Write ll-config.json into *ll_dir*.
105 Args:
106 config: Config dict produced by build_config().
107 ll_dir: Path to the .ll/ directory.
108 dry_run: If True, print JSON to stdout; do not write files.
109 """
110 if dry_run:
111 print(json.dumps(config, indent=2))
112 return
113 ll_dir.mkdir(parents=True, exist_ok=True)
114 atomic_write_json(ll_dir / "ll-config.json", config)
117def update_gitignore(project_root: Path, dry_run: bool = False) -> bool:
118 """Idempotently append ll state-file patterns to .gitignore.
120 Only missing entries are appended; existing entries are never duplicated.
122 Args:
123 project_root: Project root directory.
124 dry_run: If True, print planned changes; do not modify files.
126 Returns:
127 True if the file was created or modified; False if no changes needed.
128 """
129 gitignore_path = project_root / ".gitignore"
130 existing = gitignore_path.read_text(encoding="utf-8") if gitignore_path.exists() else ""
131 existing_lines = set(existing.splitlines())
133 missing = [e for e in _GITIGNORE_ENTRIES if e not in existing_lines]
134 if not missing:
135 return False
137 if dry_run:
138 print(f"[update] .gitignore (+{len(missing)} entries)")
139 return True
141 block = _GITIGNORE_COMMENT + "\n" + "\n".join(missing) + "\n"
142 if existing and not existing.endswith("\n"):
143 new_content = existing + "\n\n" + block
144 elif existing:
145 new_content = existing + "\n" + block
146 else:
147 new_content = block
149 atomic_write(gitignore_path, new_content)
150 return True
153def merge_settings(
154 project_root: Path,
155 settings_file: str = ".claude/settings.local.json",
156 extra_permissions: list[str] | None = None,
157 dry_run: bool = False,
158) -> None:
159 """Merge ll- CLI tool permissions into a Claude Code settings file.
161 Idempotency sweep: removes stale ``Bash(ll-*`` and
162 ``Write(.ll/ll-continue-prompt.md)`` entries before re-appending the
163 canonical list.
165 Args:
166 project_root: Project root directory.
167 settings_file: Relative path to target settings JSON file.
168 extra_permissions: Additional entries inserted before the trailing
169 ``Write(.ll/ll-continue-prompt.md)`` entry.
170 dry_run: If True, print the target path; do not write.
171 """
172 target = project_root / settings_file
173 if target.exists():
174 try:
175 data: dict[str, Any] = json.loads(target.read_text(encoding="utf-8"))
176 except json.JSONDecodeError:
177 data = {}
178 else:
179 data = {}
181 perms: dict[str, Any] = data.setdefault("permissions", {})
182 allow: list[str] = list(perms.get("allow", []))
184 # Idempotency sweep
185 allow = [e for e in allow if not e.startswith("Bash(ll-")]
186 allow = [e for e in allow if e != "Write(.ll/ll-continue-prompt.md)"]
187 if extra_permissions:
188 allow = [e for e in allow if e not in extra_permissions]
190 # Build canonical list (insert extras before trailing Write entry)
191 canonical = list(_LL_PERMISSIONS)
192 if extra_permissions:
193 canonical = canonical[:-1] + list(extra_permissions) + [canonical[-1]]
195 allow.extend(canonical)
196 perms["allow"] = allow
197 data["permissions"] = perms
199 if dry_run:
200 print(f"[update] {settings_file}")
201 return
203 target.parent.mkdir(parents=True, exist_ok=True)
204 atomic_write_json(target, data)
207def make_issue_dirs(base_dir: Path, dry_run: bool = False) -> None:
208 """Create the standard issue-tracking subdirectories under *base_dir*.
210 Args:
211 base_dir: Root issues directory (e.g., .issues/).
212 dry_run: If True, print planned mkdirs; do not create directories.
213 """
214 if dry_run:
215 for sd in _ISSUE_SUBDIRS:
216 print(f"[mkdir] {base_dir / sd}")
217 return
218 for sd in _ISSUE_SUBDIRS:
219 (base_dir / sd).mkdir(parents=True, exist_ok=True)
222def make_learning_tests_dir(ll_dir: Path, dry_run: bool = False) -> bool:
223 """Create .ll/learning-tests/ with a .gitkeep placeholder.
225 Args:
226 ll_dir: The .ll/ directory.
227 dry_run: If True, print planned mkdir; do not create directories.
229 Returns:
230 True if the directory was created; False if it already existed.
231 """
232 lt_dir = ll_dir / "learning-tests"
233 if lt_dir.exists():
234 return False
235 if dry_run:
236 print(f"[mkdir] {lt_dir}")
237 return True
238 lt_dir.mkdir(parents=True, exist_ok=True)
239 (lt_dir / ".gitkeep").touch()
240 return True
243def deploy_goals(ll_dir: Path, templates_dir: Path, dry_run: bool = False) -> bool:
244 """Deploy the goals template to .ll/ll-goals.md (skip if already present).
246 Args:
247 ll_dir: The .ll/ directory.
248 templates_dir: templates/ directory containing ll-goals-template.md.
249 dry_run: If True, print planned write; do not copy files.
251 Returns:
252 True if deployed; False if already existed or source not found.
253 """
254 dest = ll_dir / "ll-goals.md"
255 if dest.exists():
256 return False
257 src = templates_dir / "ll-goals-template.md"
258 if not src.exists():
259 print(f" Warning: goals template source not found at {src}", file=sys.stderr)
260 return False
261 if dry_run:
262 print(f"[write] {dest} (from {src.name})")
263 return True
264 ll_dir.mkdir(parents=True, exist_ok=True)
265 atomic_write(dest, src.read_text(encoding="utf-8"))
266 return True
269def deploy_design_tokens(
270 ll_dir: Path,
271 templates_dir: Path,
272 active_profile: str = "default",
273 dry_run: bool = False,
274) -> bool:
275 """Mirror templates/design-tokens/profiles/ into .ll/design-tokens/profiles/.
277 Skips silently if the destination already exists.
279 Args:
280 ll_dir: The .ll/ directory.
281 templates_dir: templates/ directory containing design-tokens/profiles/.
282 active_profile: Name of the active profile (for display only; not
283 written to config by this function).
284 dry_run: If True, print planned write; do not copy files.
286 Returns:
287 True if deployed; False if already existed or source not found.
288 """
289 src_profiles = templates_dir / "design-tokens" / "profiles"
290 dest_profiles = ll_dir / "design-tokens" / "profiles"
291 if dest_profiles.exists():
292 return False
293 if not src_profiles.exists():
294 print(
295 f" Warning: design-token profiles source not found at {src_profiles}",
296 file=sys.stderr,
297 )
298 return False
299 if dry_run:
300 print(f"[write] {dest_profiles}/ (design-token profiles)")
301 return True
302 shutil.copytree(src_profiles, dest_profiles)
303 return True
306def deploy_issue_templates(ll_dir: Path, templates_dir: Path, dry_run: bool = False) -> bool:
307 """Copy bundled *-sections.json files to .ll/templates/ (skip if already present).
309 Args:
310 ll_dir: The .ll/ directory.
311 templates_dir: templates/ directory containing *-sections.json files.
312 dry_run: If True, print planned write; do not copy files.
314 Returns:
315 True if deployed; False if already existed or no section files found.
316 """
317 dest = ll_dir / "templates"
318 if dest.exists():
319 return False
320 section_files = list(templates_dir.glob("*-sections.json"))
321 if not section_files:
322 print(f"Warning: no *-sections.json files found in {templates_dir}", file=sys.stderr)
323 return False
324 if dry_run:
325 print(f"[write] {dest}/ (issue section templates)")
326 return True
327 dest.mkdir(parents=True, exist_ok=True)
328 for f in section_files:
329 shutil.copy2(f, dest / f.name)
330 return True
333def write_claude_md(project_root: Path, dry_run: bool = False) -> bool:
334 """Append the canonical ## little-loops CLI Commands block to CLAUDE.md.
336 Detection order: .claude/CLAUDE.md, then CLAUDE.md. If neither exists,
337 creates .claude/CLAUDE.md. Idempotent: returns False without writing if
338 the section is already present.
340 Args:
341 project_root: Project root directory.
342 dry_run: If True, print planned action; do not write files.
344 Returns:
345 True if the file was created or modified; False if no changes needed.
346 """
347 dot_claude = project_root / ".claude" / "CLAUDE.md"
348 root_claude = project_root / "CLAUDE.md"
350 if dot_claude.exists():
351 target = dot_claude
352 elif root_claude.exists():
353 target = root_claude
354 else:
355 target = dot_claude
357 rel = str(target.relative_to(project_root))
359 if target.exists():
360 existing = target.read_text(encoding="utf-8")
361 if _CLAUDE_MD_SECTION_MARKER in existing:
362 return False
363 if dry_run:
364 print(f"[update] {rel} (append ## little-loops CLI Commands)")
365 return True
366 new_content = existing.rstrip("\n") + "\n" + _CLAUDE_MD_COMMANDS_BLOCK
367 atomic_write(target, new_content)
368 else:
369 if dry_run:
370 print(f"[write] {rel} (ll- CLI command documentation)")
371 return True
372 target.parent.mkdir(parents=True, exist_ok=True)
373 atomic_write(target, _CLAUDE_MD_NEW_FILE_CONTENT)
375 return True
378def _codex_template_path() -> Path:
379 """Return the in-package path to the Codex adapter hooks.json template."""
380 return Path(__file__).parent.parent / "hooks" / "adapters" / "codex" / "hooks.json"
383def install_codex_adapter(
384 project_root: Path,
385 plugin_root: Path,
386 force: bool = False,
387 dry_run: bool = False,
388) -> bool | None:
389 """Write .codex/hooks.json from the in-package Codex adapter template.
391 Reads ``little_loops/hooks/adapters/codex/hooks.json`` from the installed
392 package, substitutes ``{{LL_PLUGIN_ROOT}}`` with the ``little_loops``
393 package directory, and writes the result to ``<project_root>/.codex/hooks.json``.
395 Args:
396 project_root: Project root directory.
397 plugin_root: Unused; kept for call-site compatibility.
398 force: If True, overwrite an existing .codex/hooks.json.
399 dry_run: If True, print planned write; do not modify files.
401 Returns:
402 True if written; False if skipped (dest already exists without --force);
403 None if the source template is missing (package install corrupted).
404 """
405 template_path = _codex_template_path()
406 dest = project_root / ".codex" / "hooks.json"
408 if not template_path.exists():
409 return None
411 if dest.exists() and not force:
412 return False
414 package_root = str(Path(__file__).parent.parent)
415 rendered = template_path.read_text(encoding="utf-8").replace("{{LL_PLUGIN_ROOT}}", package_root)
417 if dry_run:
418 print("[write] .codex/hooks.json")
419 return True
421 dest.parent.mkdir(parents=True, exist_ok=True)
422 atomic_write(dest, rendered)
423 return True