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

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 

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

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

62 

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) 

73 

74 

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

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

77 

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

79 

80 Args: 

81 project_root: Project root directory. 

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

83 

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

90 

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

92 if not missing: 

93 return False 

94 

95 if dry_run: 

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

97 return True 

98 

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 

106 

107 atomic_write(gitignore_path, new_content) 

108 return True 

109 

110 

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. 

118 

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

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

121 canonical list. 

122 

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

138 

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

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

141 

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] 

147 

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

152 

153 allow.extend(canonical) 

154 perms["allow"] = allow 

155 data["permissions"] = perms 

156 

157 if dry_run: 

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

159 return 

160 

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

162 atomic_write_json(target, data) 

163 

164 

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

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

167 

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) 

178 

179 

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

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

182 

183 Args: 

184 ll_dir: The .ll/ directory. 

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

186 

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 

199 

200 

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

203 

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. 

208 

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 

224 

225 

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

233 

234 Skips silently if the destination already exists. 

235 

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. 

242 

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 

257 

258 

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. 

266 

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

270 

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. 

276 

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" 

283 

284 if not template_path.exists(): 

285 return False 

286 

287 if dest.exists() and not force: 

288 return False 

289 

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

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

292 ) 

293 

294 if dry_run: 

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

296 return True 

297 

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

299 atomic_write(dest, rendered) 

300 return True