Coverage for src / ai_shell / scaffold.py: 100%

109 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-14 22:12 +0000

1"""Project scaffolding for ai-shell init and tool-specific setup. 

2 

3Template content lives in ``ai_shell/templates/`` as plain files 

4(JSON, TOML, Markdown) so they can be edited directly. 

5""" 

6 

7from __future__ import annotations 

8 

9import json 

10import logging 

11import shutil 

12from importlib import resources 

13from pathlib import Path 

14 

15from rich.console import Console 

16 

17logger = logging.getLogger(__name__) 

18console = Console(stderr=True) 

19 

20_TEMPLATES = resources.files("ai_shell.templates") 

21 

22 

23# ── Helpers ───────────────────────────────────────────────────────── 

24 

25 

26def _read_template(*parts: str) -> str: 

27 """Read a template file from the ``ai_shell.templates`` package.""" 

28 ref = _TEMPLATES.joinpath(*parts) 

29 content = ref.read_text(encoding="utf-8") 

30 return content.replace("\r\n", "\n") 

31 

32 

33def _clean_paths(target_dir: Path, dirs: list[str], files: list[str]) -> None: 

34 """Remove directories and files managed by scaffolding.""" 

35 for d in dirs: 

36 path = target_dir / d 

37 if path.exists(): 

38 shutil.rmtree(path) 

39 console.print(f"[red]Removed: {path}[/red]") 

40 for f in files: 

41 path = target_dir / f 

42 if path.exists(): 

43 path.unlink() 

44 console.print(f"[red]Removed: {path}[/red]") 

45 

46 

47# Managed paths per tool (directories and loose files) 

48_CLAUDE_DIRS = [".claude"] 

49_CLAUDE_FILES: list[str] = [] 

50 

51_OPENCODE_DIRS = [".agents"] 

52_OPENCODE_FILES = ["opencode.json"] 

53 

54_CODEX_DIRS = [".codex", ".agents"] 

55_CODEX_FILES: list[str] = [] 

56 

57_AIDER_DIRS: list[str] = [] 

58_AIDER_FILES = [".aider.conf.yml", ".aiderignore"] 

59 

60_PROJECT_DIRS: list[str] = [] 

61_PROJECT_FILES = [".ai-shell.yaml", ".ai-shell.yml", ".ai-shell.toml", "ai-shell.toml"] 

62 

63 

64def _deep_merge_settings(existing: dict, template: dict) -> dict: 

65 """Deep-merge *template* into *existing*, preserving user customizations. 

66 

67 - Dict values: recurse 

68 - List values: append entries from *template* not already present 

69 - Scalars: keep *existing* value 

70 - Keys only in *template*: add them 

71 """ 

72 result = dict(existing) 

73 for key, template_value in template.items(): 

74 if key not in result: 

75 result[key] = template_value 

76 elif isinstance(result[key], dict) and isinstance(template_value, dict): 

77 result[key] = _deep_merge_settings(result[key], template_value) 

78 elif isinstance(result[key], list) and isinstance(template_value, list): 

79 existing_set = set(result[key]) 

80 new_entries = [e for e in template_value if e not in existing_set] 

81 result[key] = result[key] + new_entries 

82 # else: keep existing scalar value 

83 return result 

84 

85 

86def _merge_json_file(path: Path, template_content: str) -> bool: 

87 """Merge template JSON into an existing file, preserving user customizations. 

88 

89 If the file does not exist, writes the template as-is. 

90 Returns ``True`` if the file was written/merged, ``False`` if skipped. 

91 """ 

92 template = json.loads(template_content) 

93 

94 if not path.exists(): 

95 path.parent.mkdir(parents=True, exist_ok=True) 

96 path.write_text(template_content, encoding="utf-8", newline="\n") 

97 console.print(f"[green]Created: {path}[/green]") 

98 return True 

99 

100 existing = json.loads(path.read_text(encoding="utf-8")) 

101 merged = _deep_merge_settings(existing, template) 

102 path.write_text(json.dumps(merged, indent=2) + "\n", encoding="utf-8", newline="\n") 

103 console.print(f"[green]Merged: {path}[/green]") 

104 return True 

105 

106 

107def _write_file(path: Path, content: str, *, overwrite: bool) -> bool: 

108 """Write *content* to *path*, creating parent dirs as needed. 

109 

110 Returns ``True`` if the file was written, ``False`` if skipped. 

111 """ 

112 if path.exists() and not overwrite: 

113 console.print(f"[yellow]Skipped (already exists): {path}[/yellow]") 

114 return False 

115 

116 label = "Updated" if path.exists() else "Created" 

117 path.parent.mkdir(parents=True, exist_ok=True) 

118 path.write_text(content, encoding="utf-8", newline="\n") 

119 console.print(f"[green]{label}: {path}[/green]") 

120 return True 

121 

122 

123# ── Public API ────────────────────────────────────────────────────── 

124 

125 

126def scaffold_claude( 

127 target_dir: Path, 

128 *, 

129 overwrite: bool = False, 

130 clean: bool = False, 

131 merge: bool = False, 

132) -> None: 

133 """Create ``.claude/`` directory with settings. 

134 

135 Skills are delivered via the ``augint-workflow`` plugin from ``ai-cc-tools`` 

136 and no longer scaffolded here. 

137 """ 

138 if clean: 

139 _clean_paths(target_dir, _CLAUDE_DIRS, _CLAUDE_FILES) 

140 overwrite = True 

141 claude_dir = target_dir / ".claude" 

142 

143 # settings.json 

144 settings_template = _read_template("claude", "settings.json") 

145 if merge: 

146 _merge_json_file(claude_dir / "settings.json", settings_template) 

147 else: 

148 _write_file( 

149 claude_dir / "settings.json", 

150 settings_template, 

151 overwrite=overwrite, 

152 ) 

153 

154 console.print("[bold green]Claude configuration ready.[/bold green]") 

155 

156 

157def scaffold_project( 

158 target_dir: Path, 

159 *, 

160 overwrite: bool = False, 

161 clean: bool = False, 

162 merge: bool = False, 

163) -> None: 

164 """Create ``.ai-shell.yaml`` in *target_dir*.""" 

165 if clean: 

166 _clean_paths(target_dir, _PROJECT_DIRS, _PROJECT_FILES) 

167 overwrite = True 

168 effective_overwrite = overwrite or merge 

169 _write_file( 

170 target_dir / ".ai-shell.yaml", 

171 _read_template("ai-shell.yaml"), 

172 overwrite=effective_overwrite, 

173 ) 

174 

175 console.print("[bold green]Project configuration ready.[/bold green]") 

176 

177 

178def scaffold_opencode( 

179 target_dir: Path, 

180 *, 

181 overwrite: bool = False, 

182 clean: bool = False, 

183 merge: bool = False, 

184) -> None: 

185 """Create opencode config (``opencode.json``).""" 

186 if clean: 

187 _clean_paths(target_dir, _OPENCODE_DIRS, _OPENCODE_FILES) 

188 overwrite = True 

189 opencode_template = _read_template("opencode", "opencode.json") 

190 if merge: 

191 _merge_json_file(target_dir / "opencode.json", opencode_template) 

192 else: 

193 _write_file( 

194 target_dir / "opencode.json", 

195 opencode_template, 

196 overwrite=overwrite, 

197 ) 

198 

199 console.print("[bold green]opencode configuration ready.[/bold green]") 

200 

201 

202def scaffold_codex( 

203 target_dir: Path, 

204 *, 

205 overwrite: bool = False, 

206 clean: bool = False, 

207 merge: bool = False, 

208) -> None: 

209 """Create ``.codex/`` config.""" 

210 if clean: 

211 _clean_paths(target_dir, _CODEX_DIRS, _CODEX_FILES) 

212 overwrite = True 

213 effective_overwrite = overwrite or merge 

214 _write_file( 

215 target_dir / ".codex" / "config.toml", 

216 _read_template("codex", "config.toml"), 

217 overwrite=effective_overwrite, 

218 ) 

219 

220 console.print("[bold green]Codex configuration ready.[/bold green]") 

221 

222 

223def scaffold_aider( 

224 target_dir: Path, 

225 *, 

226 overwrite: bool = False, 

227 clean: bool = False, 

228 merge: bool = False, 

229) -> None: 

230 """Create aider config files.""" 

231 if clean: 

232 _clean_paths(target_dir, _AIDER_DIRS, _AIDER_FILES) 

233 overwrite = True 

234 effective_overwrite = overwrite or merge 

235 _write_file( 

236 target_dir / ".aider.conf.yml", 

237 _read_template("aider", "aider.conf.yml"), 

238 overwrite=effective_overwrite, 

239 ) 

240 _write_file( 

241 target_dir / ".aiderignore", 

242 _read_template("aider", "aiderignore"), 

243 overwrite=effective_overwrite, 

244 ) 

245 

246 console.print("[bold green]Aider configuration ready.[/bold green]")