Coverage for little_loops / init / writers.py: 15%
142 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-16 13:11 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-16 13:11 -0500
1"""File mutation helpers for headless ll-init."""
3from __future__ import annotations
5import json
6import shutil
7from pathlib import Path
8from typing import Any
10from little_loops.file_utils import atomic_write, atomic_write_json
12# Entries added to .gitignore by ll-init (idempotently)
13_GITIGNORE_COMMENT = "# little-loops state files"
14_GITIGNORE_ENTRIES: tuple[str, ...] = (
15 ".auto-manage-state.json",
16 ".parallel-manage-state.json",
17 ".ll/ll-context-state.json",
18 ".ll/ll-sync-state.json",
19)
21# Canonical permission entries for .claude/settings*.json (Step 10 of the skill)
22_LL_PERMISSIONS: tuple[str, ...] = (
23 "Bash(ll-action:*)",
24 "Bash(ll-issues:*)",
25 "Bash(ll-auto:*)",
26 "Bash(ll-parallel:*)",
27 "Bash(ll-sprint:*)",
28 "Bash(ll-loop:*)",
29 "Bash(ll-workflows:*)",
30 "Bash(ll-messages:*)",
31 "Bash(ll-history:*)",
32 "Bash(ll-history-context:*)",
33 "Bash(ll-deps:*)",
34 "Bash(ll-sync:*)",
35 "Bash(ll-verify-docs:*)",
36 "Bash(ll-verify-skills:*)",
37 "Bash(ll-check-links:*)",
38 "Bash(ll-gitignore:*)",
39 "Bash(ll-create-extension:*)",
40 "Bash(ll-learning-tests:*)",
41 "Bash(ll-logs:*)",
42 "Bash(ll-session:*)",
43 "Bash(ll-doctor:*)",
44 "Bash(ll-ctx-stats:*)",
45 "Bash(ll-adapt-skills-for-codex:*)",
46 "Bash(ll-adapt-agents-for-codex:*)",
47 "Bash(ll-harness:*)",
48 "Write(.ll/ll-continue-prompt.md)",
49)
51_ISSUE_SUBDIRS: tuple[str, ...] = (
52 "bugs",
53 "features",
54 "enhancements",
55 "completed",
56 "deferred",
57)
59# Sentinel string used to detect whether the ll section already exists in CLAUDE.md
60_CLAUDE_MD_SECTION_MARKER = "## little-loops"
62# Canonical CLI Commands block appended/created by write_claude_md (Step 11 of the skill)
63_CLAUDE_MD_COMMANDS_BLOCK = """\
65## little-loops CLI Commands
67- `ll-action` - Invoke ll skills as one-shot commands with JSON-structured output
68- `ll-harness` - One-shot runner evaluation (skill, cmd, mcp, prompt, dsl) with exit-code and semantic criteria
69- `ll-auto` - Process all backlog issues sequentially in priority order
70- `ll-parallel` - Process issues concurrently using isolated git worktrees
71- `ll-sprint` - Define and execute curated issue sets with dependency-aware ordering
72- `ll-loop` - Execute FSM-based automation loops
73- `ll-workflows` - Identify multi-step workflow patterns from user message history
74- `ll-messages` - Extract user messages from Claude Code logs
75- `ll-history` - View completed issue statistics, analysis, and export topic-filtered excerpts from history
76- `ll-history-context` - Render a `## Historical Context` block for an issue from `.ll/history.db`
77- `ll-deps` - Cross-issue dependency analysis and validation
78- `ll-sync` - Sync local issues with GitHub Issues
79- `ll-verify-docs` - Verify documented counts match actual file counts
80- `ll-verify-skills` - Check that no SKILL.md exceeds 500 lines
81- `ll-check-links` - Check markdown documentation for broken links
82- `ll-issues` - Issue management and visualization (next-id, list, show, path, sequence, impact-effort, refine-status, set-status, anchor-sweep, fingerprint, epic-progress, decisions)
83- `ll-gitignore` - Suggest and apply `.gitignore` patterns based on untracked files
84- `ll-create-extension` - Scaffold a new little-loops extension project
85- `ll-generate-schemas` - Regenerate JSON Schema files for all LLEvent types (maintainer tool)
86- `ll-learning-tests` - Query and manage the learning test registry (check/list/mark-stale)
87- `ll-logs` - Discover, extract, and analyze (sequences, scan-failures) ll-relevant log entries from Claude project logs
88- `ll-doctor` - Check host CLI capability support for little-loops features
89- `ll-ctx-stats` - Show context-window analytics for the current project (per-tool byte vs. context savings; skill-health signals)
90- `ll-adapt-skills-for-codex` - Add Codex Skills API frontmatter to skills and bridge commands for Codex discovery
91- `ll-adapt-agents-for-codex` - Generate `.codex/agents/*.toml` from `agents/*.md` for Codex agent-select support
93Install: `pip install -e "./scripts[dev]"`
94"""
96_CLAUDE_MD_NEW_FILE_CONTENT = "# Project Configuration\n" + _CLAUDE_MD_COMMANDS_BLOCK
99def write_config(config: dict[str, Any], ll_dir: Path, dry_run: bool = False) -> None:
100 """Write ll-config.json into *ll_dir*.
102 Args:
103 config: Config dict produced by build_config().
104 ll_dir: Path to the .ll/ directory.
105 dry_run: If True, print JSON to stdout; do not write files.
106 """
107 if dry_run:
108 print(json.dumps(config, indent=2))
109 return
110 ll_dir.mkdir(parents=True, exist_ok=True)
111 atomic_write_json(ll_dir / "ll-config.json", config)
114def update_gitignore(project_root: Path, dry_run: bool = False) -> bool:
115 """Idempotently append ll state-file patterns to .gitignore.
117 Only missing entries are appended; existing entries are never duplicated.
119 Args:
120 project_root: Project root directory.
121 dry_run: If True, print planned changes; do not modify files.
123 Returns:
124 True if the file was created or modified; False if no changes needed.
125 """
126 gitignore_path = project_root / ".gitignore"
127 existing = gitignore_path.read_text(encoding="utf-8") if gitignore_path.exists() else ""
128 existing_lines = set(existing.splitlines())
130 missing = [e for e in _GITIGNORE_ENTRIES if e not in existing_lines]
131 if not missing:
132 return False
134 if dry_run:
135 print(f"[update] .gitignore (+{len(missing)} entries)")
136 return True
138 block = _GITIGNORE_COMMENT + "\n" + "\n".join(missing) + "\n"
139 if existing and not existing.endswith("\n"):
140 new_content = existing + "\n\n" + block
141 elif existing:
142 new_content = existing + "\n" + block
143 else:
144 new_content = block
146 atomic_write(gitignore_path, new_content)
147 return True
150def merge_settings(
151 project_root: Path,
152 settings_file: str = ".claude/settings.local.json",
153 extra_permissions: list[str] | None = None,
154 dry_run: bool = False,
155) -> None:
156 """Merge ll- CLI tool permissions into a Claude Code settings file.
158 Idempotency sweep: removes stale ``Bash(ll-*`` and
159 ``Write(.ll/ll-continue-prompt.md)`` entries before re-appending the
160 canonical list.
162 Args:
163 project_root: Project root directory.
164 settings_file: Relative path to target settings JSON file.
165 extra_permissions: Additional entries inserted before the trailing
166 ``Write(.ll/ll-continue-prompt.md)`` entry.
167 dry_run: If True, print the target path; do not write.
168 """
169 target = project_root / settings_file
170 if target.exists():
171 try:
172 data: dict[str, Any] = json.loads(target.read_text(encoding="utf-8"))
173 except json.JSONDecodeError:
174 data = {}
175 else:
176 data = {}
178 perms: dict[str, Any] = data.setdefault("permissions", {})
179 allow: list[str] = list(perms.get("allow", []))
181 # Idempotency sweep
182 allow = [e for e in allow if not e.startswith("Bash(ll-")]
183 allow = [e for e in allow if e != "Write(.ll/ll-continue-prompt.md)"]
184 if extra_permissions:
185 allow = [e for e in allow if e not in extra_permissions]
187 # Build canonical list (insert extras before trailing Write entry)
188 canonical = list(_LL_PERMISSIONS)
189 if extra_permissions:
190 canonical = canonical[:-1] + list(extra_permissions) + [canonical[-1]]
192 allow.extend(canonical)
193 perms["allow"] = allow
194 data["permissions"] = perms
196 if dry_run:
197 print(f"[update] {settings_file}")
198 return
200 target.parent.mkdir(parents=True, exist_ok=True)
201 atomic_write_json(target, data)
204def make_issue_dirs(base_dir: Path, dry_run: bool = False) -> None:
205 """Create the standard issue-tracking subdirectories under *base_dir*.
207 Args:
208 base_dir: Root issues directory (e.g., .issues/).
209 dry_run: If True, print planned mkdirs; do not create directories.
210 """
211 if dry_run:
212 for sd in _ISSUE_SUBDIRS:
213 print(f"[mkdir] {base_dir / sd}")
214 return
215 for sd in _ISSUE_SUBDIRS:
216 (base_dir / sd).mkdir(parents=True, exist_ok=True)
219def make_learning_tests_dir(ll_dir: Path, dry_run: bool = False) -> bool:
220 """Create .ll/learning-tests/ with a .gitkeep placeholder.
222 Args:
223 ll_dir: The .ll/ directory.
224 dry_run: If True, print planned mkdir; do not create directories.
226 Returns:
227 True if the directory was created; False if it already existed.
228 """
229 lt_dir = ll_dir / "learning-tests"
230 if lt_dir.exists():
231 return False
232 if dry_run:
233 print(f"[mkdir] {lt_dir}")
234 return True
235 lt_dir.mkdir(parents=True, exist_ok=True)
236 (lt_dir / ".gitkeep").touch()
237 return True
240def deploy_goals(ll_dir: Path, templates_dir: Path, dry_run: bool = False) -> bool:
241 """Deploy the goals template to .ll/ll-goals.md (skip if already present).
243 Args:
244 ll_dir: The .ll/ directory.
245 templates_dir: templates/ directory containing ll-goals-template.md.
246 dry_run: If True, print planned write; do not copy files.
248 Returns:
249 True if deployed; False if already existed or source not found.
250 """
251 dest = ll_dir / "ll-goals.md"
252 if dest.exists():
253 return False
254 src = templates_dir / "ll-goals-template.md"
255 if not src.exists():
256 return False
257 if dry_run:
258 print(f"[write] {dest} (from {src.name})")
259 return True
260 ll_dir.mkdir(parents=True, exist_ok=True)
261 atomic_write(dest, src.read_text(encoding="utf-8"))
262 return True
265def deploy_design_tokens(
266 ll_dir: Path,
267 templates_dir: Path,
268 active_profile: str = "default",
269 dry_run: bool = False,
270) -> bool:
271 """Mirror templates/design-tokens/profiles/ into .ll/design-tokens/profiles/.
273 Skips silently if the destination already exists.
275 Args:
276 ll_dir: The .ll/ directory.
277 templates_dir: templates/ directory containing design-tokens/profiles/.
278 active_profile: Name of the active profile (for display only; not
279 written to config by this function).
280 dry_run: If True, print planned write; do not copy files.
282 Returns:
283 True if deployed; False if already existed or source not found.
284 """
285 src_profiles = templates_dir / "design-tokens" / "profiles"
286 dest_profiles = ll_dir / "design-tokens" / "profiles"
287 if dest_profiles.exists():
288 return False
289 if not src_profiles.exists():
290 return False
291 if dry_run:
292 print(f"[write] {dest_profiles}/ (design-token profiles)")
293 return True
294 shutil.copytree(src_profiles, dest_profiles)
295 return True
298def write_claude_md(project_root: Path, dry_run: bool = False) -> bool:
299 """Append the canonical ## little-loops CLI Commands block to CLAUDE.md.
301 Detection order: .claude/CLAUDE.md, then CLAUDE.md. If neither exists,
302 creates .claude/CLAUDE.md. Idempotent: returns False without writing if
303 the section is already present.
305 Args:
306 project_root: Project root directory.
307 dry_run: If True, print planned action; do not write files.
309 Returns:
310 True if the file was created or modified; False if no changes needed.
311 """
312 dot_claude = project_root / ".claude" / "CLAUDE.md"
313 root_claude = project_root / "CLAUDE.md"
315 if dot_claude.exists():
316 target = dot_claude
317 elif root_claude.exists():
318 target = root_claude
319 else:
320 target = dot_claude
322 rel = str(target.relative_to(project_root))
324 if target.exists():
325 existing = target.read_text(encoding="utf-8")
326 if _CLAUDE_MD_SECTION_MARKER in existing:
327 return False
328 if dry_run:
329 print(f"[update] {rel} (append ## little-loops CLI Commands)")
330 return True
331 new_content = existing.rstrip("\n") + "\n" + _CLAUDE_MD_COMMANDS_BLOCK
332 atomic_write(target, new_content)
333 else:
334 if dry_run:
335 print(f"[write] {rel} (ll- CLI command documentation)")
336 return True
337 target.parent.mkdir(parents=True, exist_ok=True)
338 atomic_write(target, _CLAUDE_MD_NEW_FILE_CONTENT)
340 return True
343def install_codex_adapter(
344 project_root: Path,
345 plugin_root: Path,
346 force: bool = False,
347 dry_run: bool = False,
348) -> bool:
349 """Write .codex/hooks.json from the Codex adapter template.
351 Reads ``hooks/adapters/codex/hooks.json`` (plugin-relative), substitutes
352 the ``{{LL_PLUGIN_ROOT}}`` placeholder with the absolute *plugin_root*
353 path, and writes the result to ``<project_root>/.codex/hooks.json``.
355 Args:
356 project_root: Project root directory.
357 plugin_root: Absolute path to the little-loops plugin root.
358 force: If True, overwrite an existing .codex/hooks.json.
359 dry_run: If True, print planned write; do not modify files.
361 Returns:
362 True if written; False if skipped (already exists without --force, or
363 template not found).
364 """
365 template_path = plugin_root / "hooks" / "adapters" / "codex" / "hooks.json"
366 dest = project_root / ".codex" / "hooks.json"
368 if not template_path.exists():
369 return False
371 if dest.exists() and not force:
372 return False
374 rendered = template_path.read_text(encoding="utf-8").replace(
375 "{{LL_PLUGIN_ROOT}}", str(plugin_root)
376 )
378 if dry_run:
379 print("[write] .codex/hooks.json")
380 return True
382 dest.parent.mkdir(parents=True, exist_ok=True)
383 atomic_write(dest, rendered)
384 return True