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
« 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.
3Template content lives in ``ai_shell/templates/`` as plain files
4(JSON, TOML, Markdown) so they can be edited directly.
5"""
7from __future__ import annotations
9import json
10import logging
11import shutil
12from importlib import resources
13from pathlib import Path
15from rich.console import Console
17logger = logging.getLogger(__name__)
18console = Console(stderr=True)
20_TEMPLATES = resources.files("ai_shell.templates")
23# ── Helpers ─────────────────────────────────────────────────────────
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")
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]")
47# Managed paths per tool (directories and loose files)
48_CLAUDE_DIRS = [".claude"]
49_CLAUDE_FILES: list[str] = []
51_OPENCODE_DIRS = [".agents"]
52_OPENCODE_FILES = ["opencode.json"]
54_CODEX_DIRS = [".codex", ".agents"]
55_CODEX_FILES: list[str] = []
57_AIDER_DIRS: list[str] = []
58_AIDER_FILES = [".aider.conf.yml", ".aiderignore"]
60_PROJECT_DIRS: list[str] = []
61_PROJECT_FILES = [".ai-shell.yaml", ".ai-shell.yml", ".ai-shell.toml", "ai-shell.toml"]
64def _deep_merge_settings(existing: dict, template: dict) -> dict:
65 """Deep-merge *template* into *existing*, preserving user customizations.
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
86def _merge_json_file(path: Path, template_content: str) -> bool:
87 """Merge template JSON into an existing file, preserving user customizations.
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)
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
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
107def _write_file(path: Path, content: str, *, overwrite: bool) -> bool:
108 """Write *content* to *path*, creating parent dirs as needed.
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
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
123# ── Public API ──────────────────────────────────────────────────────
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.
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"
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 )
154 console.print("[bold green]Claude configuration ready.[/bold green]")
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 )
175 console.print("[bold green]Project configuration ready.[/bold green]")
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 )
199 console.print("[bold green]opencode configuration ready.[/bold green]")
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 )
220 console.print("[bold green]Codex configuration ready.[/bold green]")
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 )
246 console.print("[bold green]Aider configuration ready.[/bold green]")