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

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

2 

3from __future__ import annotations 

4 

5import json 

6import shutil 

7from pathlib import Path 

8from typing import Any 

9 

10from little_loops.file_utils import atomic_write, atomic_write_json 

11 

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) 

20 

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) 

50 

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

52 "bugs", 

53 "features", 

54 "enhancements", 

55 "completed", 

56 "deferred", 

57) 

58 

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

60_CLAUDE_MD_SECTION_MARKER = "## little-loops" 

61 

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

63_CLAUDE_MD_COMMANDS_BLOCK = """\ 

64 

65## little-loops CLI Commands 

66 

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 

92 

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

94""" 

95 

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

97 

98 

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

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

101 

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) 

112 

113 

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

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

116 

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

118 

119 Args: 

120 project_root: Project root directory. 

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

122 

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()) 

129 

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

131 if not missing: 

132 return False 

133 

134 if dry_run: 

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

136 return True 

137 

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 

145 

146 atomic_write(gitignore_path, new_content) 

147 return True 

148 

149 

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. 

157 

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

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

160 canonical list. 

161 

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 = {} 

177 

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

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

180 

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] 

186 

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]] 

191 

192 allow.extend(canonical) 

193 perms["allow"] = allow 

194 data["permissions"] = perms 

195 

196 if dry_run: 

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

198 return 

199 

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

201 atomic_write_json(target, data) 

202 

203 

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

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

206 

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) 

217 

218 

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

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

221 

222 Args: 

223 ll_dir: The .ll/ directory. 

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

225 

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 

238 

239 

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). 

242 

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. 

247 

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 

263 

264 

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/. 

272 

273 Skips silently if the destination already exists. 

274 

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. 

281 

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 

296 

297 

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

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

300 

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. 

304 

305 Args: 

306 project_root: Project root directory. 

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

308 

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" 

314 

315 if dot_claude.exists(): 

316 target = dot_claude 

317 elif root_claude.exists(): 

318 target = root_claude 

319 else: 

320 target = dot_claude 

321 

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

323 

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) 

339 

340 return True 

341 

342 

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. 

350 

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``. 

354 

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. 

360 

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" 

367 

368 if not template_path.exists(): 

369 return False 

370 

371 if dest.exists() and not force: 

372 return False 

373 

374 rendered = template_path.read_text(encoding="utf-8").replace( 

375 "{{LL_PLUGIN_ROOT}}", str(plugin_root) 

376 ) 

377 

378 if dry_run: 

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

380 return True 

381 

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

383 atomic_write(dest, rendered) 

384 return True