Coverage for little_loops / init / writers.py: 16%
115 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -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)
60def write_config(config: dict[str, Any], ll_dir: Path, dry_run: bool = False) -> None:
61 """Write ll-config.json into *ll_dir*.
63 Args:
64 config: Config dict produced by build_config().
65 ll_dir: Path to the .ll/ directory.
66 dry_run: If True, print JSON to stdout; do not write files.
67 """
68 if dry_run:
69 print(json.dumps(config, indent=2))
70 return
71 ll_dir.mkdir(parents=True, exist_ok=True)
72 atomic_write_json(ll_dir / "ll-config.json", config)
75def update_gitignore(project_root: Path, dry_run: bool = False) -> bool:
76 """Idempotently append ll state-file patterns to .gitignore.
78 Only missing entries are appended; existing entries are never duplicated.
80 Args:
81 project_root: Project root directory.
82 dry_run: If True, print planned changes; do not modify files.
84 Returns:
85 True if the file was created or modified; False if no changes needed.
86 """
87 gitignore_path = project_root / ".gitignore"
88 existing = gitignore_path.read_text(encoding="utf-8") if gitignore_path.exists() else ""
89 existing_lines = set(existing.splitlines())
91 missing = [e for e in _GITIGNORE_ENTRIES if e not in existing_lines]
92 if not missing:
93 return False
95 if dry_run:
96 print(f"[update] .gitignore (+{len(missing)} entries)")
97 return True
99 block = _GITIGNORE_COMMENT + "\n" + "\n".join(missing) + "\n"
100 if existing and not existing.endswith("\n"):
101 new_content = existing + "\n\n" + block
102 elif existing:
103 new_content = existing + "\n" + block
104 else:
105 new_content = block
107 atomic_write(gitignore_path, new_content)
108 return True
111def merge_settings(
112 project_root: Path,
113 settings_file: str = ".claude/settings.local.json",
114 extra_permissions: list[str] | None = None,
115 dry_run: bool = False,
116) -> None:
117 """Merge ll- CLI tool permissions into a Claude Code settings file.
119 Idempotency sweep: removes stale ``Bash(ll-*`` and
120 ``Write(.ll/ll-continue-prompt.md)`` entries before re-appending the
121 canonical list.
123 Args:
124 project_root: Project root directory.
125 settings_file: Relative path to target settings JSON file.
126 extra_permissions: Additional entries inserted before the trailing
127 ``Write(.ll/ll-continue-prompt.md)`` entry.
128 dry_run: If True, print the target path; do not write.
129 """
130 target = project_root / settings_file
131 if target.exists():
132 try:
133 data: dict[str, Any] = json.loads(target.read_text(encoding="utf-8"))
134 except json.JSONDecodeError:
135 data = {}
136 else:
137 data = {}
139 perms: dict[str, Any] = data.setdefault("permissions", {})
140 allow: list[str] = list(perms.get("allow", []))
142 # Idempotency sweep
143 allow = [e for e in allow if not e.startswith("Bash(ll-")]
144 allow = [e for e in allow if e != "Write(.ll/ll-continue-prompt.md)"]
145 if extra_permissions:
146 allow = [e for e in allow if e not in extra_permissions]
148 # Build canonical list (insert extras before trailing Write entry)
149 canonical = list(_LL_PERMISSIONS)
150 if extra_permissions:
151 canonical = canonical[:-1] + list(extra_permissions) + [canonical[-1]]
153 allow.extend(canonical)
154 perms["allow"] = allow
155 data["permissions"] = perms
157 if dry_run:
158 print(f"[update] {settings_file}")
159 return
161 target.parent.mkdir(parents=True, exist_ok=True)
162 atomic_write_json(target, data)
165def make_issue_dirs(base_dir: Path, dry_run: bool = False) -> None:
166 """Create the standard issue-tracking subdirectories under *base_dir*.
168 Args:
169 base_dir: Root issues directory (e.g., .issues/).
170 dry_run: If True, print planned mkdirs; do not create directories.
171 """
172 if dry_run:
173 for sd in _ISSUE_SUBDIRS:
174 print(f"[mkdir] {base_dir / sd}")
175 return
176 for sd in _ISSUE_SUBDIRS:
177 (base_dir / sd).mkdir(parents=True, exist_ok=True)
180def make_learning_tests_dir(ll_dir: Path, dry_run: bool = False) -> bool:
181 """Create .ll/learning-tests/ with a .gitkeep placeholder.
183 Args:
184 ll_dir: The .ll/ directory.
185 dry_run: If True, print planned mkdir; do not create directories.
187 Returns:
188 True if the directory was created; False if it already existed.
189 """
190 lt_dir = ll_dir / "learning-tests"
191 if lt_dir.exists():
192 return False
193 if dry_run:
194 print(f"[mkdir] {lt_dir}")
195 return True
196 lt_dir.mkdir(parents=True, exist_ok=True)
197 (lt_dir / ".gitkeep").touch()
198 return True
201def deploy_goals(ll_dir: Path, templates_dir: Path, dry_run: bool = False) -> bool:
202 """Deploy the goals template to .ll/ll-goals.md (skip if already present).
204 Args:
205 ll_dir: The .ll/ directory.
206 templates_dir: templates/ directory containing ll-goals-template.md.
207 dry_run: If True, print planned write; do not copy files.
209 Returns:
210 True if deployed; False if already existed or source not found.
211 """
212 dest = ll_dir / "ll-goals.md"
213 if dest.exists():
214 return False
215 src = templates_dir / "ll-goals-template.md"
216 if not src.exists():
217 return False
218 if dry_run:
219 print(f"[write] {dest} (from {src.name})")
220 return True
221 ll_dir.mkdir(parents=True, exist_ok=True)
222 atomic_write(dest, src.read_text(encoding="utf-8"))
223 return True
226def deploy_design_tokens(
227 ll_dir: Path,
228 templates_dir: Path,
229 active_profile: str = "default",
230 dry_run: bool = False,
231) -> bool:
232 """Mirror templates/design-tokens/profiles/ into .ll/design-tokens/profiles/.
234 Skips silently if the destination already exists.
236 Args:
237 ll_dir: The .ll/ directory.
238 templates_dir: templates/ directory containing design-tokens/profiles/.
239 active_profile: Name of the active profile (for display only; not
240 written to config by this function).
241 dry_run: If True, print planned write; do not copy files.
243 Returns:
244 True if deployed; False if already existed or source not found.
245 """
246 src_profiles = templates_dir / "design-tokens" / "profiles"
247 dest_profiles = ll_dir / "design-tokens" / "profiles"
248 if dest_profiles.exists():
249 return False
250 if not src_profiles.exists():
251 return False
252 if dry_run:
253 print(f"[write] {dest_profiles}/ (design-token profiles)")
254 return True
255 shutil.copytree(src_profiles, dest_profiles)
256 return True
259def install_codex_adapter(
260 project_root: Path,
261 plugin_root: Path,
262 force: bool = False,
263 dry_run: bool = False,
264) -> bool:
265 """Write .codex/hooks.json from the Codex adapter template.
267 Reads ``hooks/adapters/codex/hooks.json`` (plugin-relative), substitutes
268 the ``{{LL_PLUGIN_ROOT}}`` placeholder with the absolute *plugin_root*
269 path, and writes the result to ``<project_root>/.codex/hooks.json``.
271 Args:
272 project_root: Project root directory.
273 plugin_root: Absolute path to the little-loops plugin root.
274 force: If True, overwrite an existing .codex/hooks.json.
275 dry_run: If True, print planned write; do not modify files.
277 Returns:
278 True if written; False if skipped (already exists without --force, or
279 template not found).
280 """
281 template_path = plugin_root / "hooks" / "adapters" / "codex" / "hooks.json"
282 dest = project_root / ".codex" / "hooks.json"
284 if not template_path.exists():
285 return False
287 if dest.exists() and not force:
288 return False
290 rendered = template_path.read_text(encoding="utf-8").replace(
291 "{{LL_PLUGIN_ROOT}}", str(plugin_root)
292 )
294 if dry_run:
295 print("[write] .codex/hooks.json")
296 return True
298 dest.parent.mkdir(parents=True, exist_ok=True)
299 atomic_write(dest, rendered)
300 return True