agent-bootstrap 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_bootstrap-0.1.0.dist-info/METADATA +171 -0
- agent_bootstrap-0.1.0.dist-info/RECORD +15 -0
- agent_bootstrap-0.1.0.dist-info/WHEEL +5 -0
- agent_bootstrap-0.1.0.dist-info/entry_points.txt +3 -0
- agent_bootstrap-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_bootstrap-0.1.0.dist-info/top_level.txt +1 -0
- bootstrapper/__init__.py +1 -0
- bootstrapper/__main__.py +3 -0
- bootstrapper/agents.py +143 -0
- bootstrapper/cli.py +424 -0
- bootstrapper/composer.py +179 -0
- bootstrapper/dirtree.py +115 -0
- bootstrapper/github_utils.py +68 -0
- bootstrapper/presets.py +242 -0
- bootstrapper/templates.py +327 -0
bootstrapper/cli.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""Typer CLI for project-bootstrapper (and projboot)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from . import dirtree
|
|
11
|
+
from .agents import SUPPORTED_AGENTS
|
|
12
|
+
from .composer import compose_project_files, run_post_hooks
|
|
13
|
+
from .presets import (
|
|
14
|
+
get_config_dir,
|
|
15
|
+
init_config_dir,
|
|
16
|
+
load_presets,
|
|
17
|
+
resolve_preset,
|
|
18
|
+
)
|
|
19
|
+
from .templates import LICENSES, get_template, render, template_names
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
help="Scaffold projects and AI agent harnesses from curated templates, presets, or dirtrees.",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_vars(pairs: list[str]) -> dict[str, str]:
|
|
28
|
+
"""Turn ``["k=v", ...]`` into a dict, splitting on the first ``=``."""
|
|
29
|
+
out: dict[str, str] = {}
|
|
30
|
+
for pair in pairs:
|
|
31
|
+
if "=" not in pair:
|
|
32
|
+
raise typer.BadParameter(f"--var expects key=value, got {pair!r}")
|
|
33
|
+
key, _, value = pair.partition("=")
|
|
34
|
+
out[key.strip()] = value
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _variables(
|
|
39
|
+
name: str, description: str, holder: str, extra: dict[str, str] | None = None
|
|
40
|
+
) -> dict[str, str]:
|
|
41
|
+
variables = {
|
|
42
|
+
"name": name,
|
|
43
|
+
"description": description,
|
|
44
|
+
"year": str(dt.date.today().year),
|
|
45
|
+
"holder": holder or name,
|
|
46
|
+
}
|
|
47
|
+
variables.update(extra or {}) # user --var overrides built-ins
|
|
48
|
+
return variables
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _license_text(license: str, variables: dict[str, str]) -> str | None:
|
|
52
|
+
if license == "none":
|
|
53
|
+
return None
|
|
54
|
+
body = LICENSES.get(license)
|
|
55
|
+
if body is None:
|
|
56
|
+
typer.echo(f"Unknown license '{license}'. Skipping.")
|
|
57
|
+
return None
|
|
58
|
+
return render(body, **variables)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _safe_join(base: Path, rel: str) -> Path:
|
|
62
|
+
"""Join ``rel`` onto ``base``, refusing empty paths or ones that escape it."""
|
|
63
|
+
if not rel:
|
|
64
|
+
raise ValueError("Refusing to write an empty path")
|
|
65
|
+
candidate = (base / rel).resolve()
|
|
66
|
+
root = base.resolve()
|
|
67
|
+
if candidate != root and root not in candidate.parents:
|
|
68
|
+
raise ValueError(f"Refusing to write outside target dir: {rel!r}")
|
|
69
|
+
return candidate
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _write_local(target: Path, files: dict[str, str], force: bool) -> None:
|
|
73
|
+
if target.exists() and any(target.iterdir()) and not force:
|
|
74
|
+
typer.echo(f"Refusing to overwrite non-empty dir {target}/ (use --force)", err=True)
|
|
75
|
+
raise typer.Exit(code=1)
|
|
76
|
+
# Validate every path first so an escaping/empty path aborts before any
|
|
77
|
+
# file is written, instead of leaving a half-generated project behind.
|
|
78
|
+
try:
|
|
79
|
+
planned = [(content, _safe_join(target, rel)) for rel, content in files.items()]
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
typer.echo(f"Invalid output path: {exc}", err=True)
|
|
82
|
+
raise typer.Exit(code=1) from exc
|
|
83
|
+
for content, path in planned:
|
|
84
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
path.write_text(content)
|
|
86
|
+
typer.echo(f"Scaffolded {len(files)} file(s) into {target}/")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _push_github(
|
|
90
|
+
files: dict[str, str],
|
|
91
|
+
*,
|
|
92
|
+
name: str,
|
|
93
|
+
description: str,
|
|
94
|
+
private: bool,
|
|
95
|
+
org: str | None,
|
|
96
|
+
force: bool,
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Create a GitHub repository and upload all scaffolded files.
|
|
99
|
+
|
|
100
|
+
Returns the repo's clone URL so callers can wire a local checkout.
|
|
101
|
+
"""
|
|
102
|
+
# Imported lazily so --local and tests never require PyGithub/network.
|
|
103
|
+
from .github_utils import (
|
|
104
|
+
RepoExistsError,
|
|
105
|
+
add_file,
|
|
106
|
+
create_repo,
|
|
107
|
+
get_github,
|
|
108
|
+
get_owner,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
gh = get_github()
|
|
112
|
+
owner = get_owner(gh, org)
|
|
113
|
+
try:
|
|
114
|
+
repo = create_repo(owner, name, description, private, force=force)
|
|
115
|
+
except RepoExistsError as exc:
|
|
116
|
+
typer.echo(
|
|
117
|
+
f"Repo {exc.full_name} already exists; refusing to overwrite its files "
|
|
118
|
+
"(pass --force to update them).",
|
|
119
|
+
err=True,
|
|
120
|
+
)
|
|
121
|
+
raise typer.Exit(code=1) from exc
|
|
122
|
+
typer.echo(f"Repo ready: {repo.clone_url}")
|
|
123
|
+
for path, content in files.items():
|
|
124
|
+
add_file(repo, path, content, f"Add {path}")
|
|
125
|
+
typer.echo(f" + {path}")
|
|
126
|
+
typer.echo("Done.")
|
|
127
|
+
return repo.clone_url
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _emit(
|
|
131
|
+
files: dict[str, str],
|
|
132
|
+
*,
|
|
133
|
+
name: str,
|
|
134
|
+
description: str,
|
|
135
|
+
private: bool,
|
|
136
|
+
org: str | None,
|
|
137
|
+
local: bool,
|
|
138
|
+
force: bool,
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Write the scaffold to disk (--local) or push it to a new GitHub repo."""
|
|
141
|
+
if local:
|
|
142
|
+
_write_local(Path(name), files, force)
|
|
143
|
+
return
|
|
144
|
+
_push_github(
|
|
145
|
+
files,
|
|
146
|
+
name=name,
|
|
147
|
+
description=description,
|
|
148
|
+
private=private,
|
|
149
|
+
org=org,
|
|
150
|
+
force=force,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _build_template_files(
|
|
155
|
+
template: str, variables: dict[str, str], license: str
|
|
156
|
+
) -> dict[str, str]:
|
|
157
|
+
spec = get_template(template)
|
|
158
|
+
files = {path: render(body, **variables) for path, body in spec["files"].items()}
|
|
159
|
+
lic = _license_text(license, variables)
|
|
160
|
+
if lic is not None:
|
|
161
|
+
files["LICENSE"] = lic
|
|
162
|
+
return files
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _tree_to_files(tree_file: Path, gitkeep: bool) -> dict[str, str]:
|
|
166
|
+
"""Materialize a dirtree layout as empty files (dirs are implicit in git)."""
|
|
167
|
+
tree = dirtree.read_tree_file(tree_file)
|
|
168
|
+
entries = dirtree.flatten(tree)
|
|
169
|
+
root_name = next(iter(tree))
|
|
170
|
+
root_prefix = f"{root_name}/"
|
|
171
|
+
|
|
172
|
+
files: dict[str, str] = {}
|
|
173
|
+
for e in entries:
|
|
174
|
+
if not e.startswith(root_prefix) or e == root_prefix:
|
|
175
|
+
continue
|
|
176
|
+
rel = e[len(root_prefix) :]
|
|
177
|
+
if not rel.endswith("/"): # a file: touch it empty
|
|
178
|
+
files[rel] = ""
|
|
179
|
+
|
|
180
|
+
if gitkeep:
|
|
181
|
+
for d in dirtree.empty_dirs(entries):
|
|
182
|
+
if d == root_name or not d.startswith(root_prefix):
|
|
183
|
+
continue
|
|
184
|
+
rel_dir = d[len(root_prefix) :].rstrip("/")
|
|
185
|
+
if rel_dir:
|
|
186
|
+
files[f"{rel_dir}/.gitkeep"] = ""
|
|
187
|
+
|
|
188
|
+
if not files:
|
|
189
|
+
raise typer.BadParameter("tree produced no files")
|
|
190
|
+
return files
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@app.command("list-templates")
|
|
194
|
+
def list_templates() -> None:
|
|
195
|
+
"""Show available templates and their descriptions."""
|
|
196
|
+
for name in template_names():
|
|
197
|
+
typer.echo(f" {name:24} {get_template(name)['description']}")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.command("list-presets")
|
|
201
|
+
def list_presets_cmd() -> None:
|
|
202
|
+
"""Show available workflow presets (built-in + ~/.config/projboot/presets.toml)."""
|
|
203
|
+
presets = load_presets()
|
|
204
|
+
for name in sorted(presets):
|
|
205
|
+
p = presets[name]
|
|
206
|
+
agents_str = ", ".join(p.agents) if p.agents else "none"
|
|
207
|
+
typer.echo(
|
|
208
|
+
f" {name:16} arch={p.arch} outputs={p.outputs_dir}/ "
|
|
209
|
+
f"scripts={p.scripts_dir}/ prompts={p.prompts_file} agents=[{agents_str}]"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@app.command("config-init")
|
|
214
|
+
def config_init(
|
|
215
|
+
force: bool = typer.Option(
|
|
216
|
+
False, "--force", help="Overwrite existing files in ~/.config/projboot/"
|
|
217
|
+
),
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Materialize starter presets.toml, skills/, and remember/ in ~/.config/projboot/."""
|
|
220
|
+
target_dir = get_config_dir()
|
|
221
|
+
written = init_config_dir(target_dir, force=force)
|
|
222
|
+
typer.echo(f"Initialized {len(written)} file(s) in {target_dir}/")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@app.command()
|
|
226
|
+
def create(
|
|
227
|
+
name: str = typer.Argument(..., help="Project/repository name"),
|
|
228
|
+
lang: str = typer.Option(
|
|
229
|
+
"python",
|
|
230
|
+
"--lang",
|
|
231
|
+
"-l",
|
|
232
|
+
help="Language/base template (python, python-microservice, typescript, rails, helm-chart, none)",
|
|
233
|
+
),
|
|
234
|
+
agent: list[str] = typer.Option(
|
|
235
|
+
[],
|
|
236
|
+
"--agent",
|
|
237
|
+
"-a",
|
|
238
|
+
help=f"AI assistant overlay (repeatable): {', '.join(SUPPORTED_AGENTS)}",
|
|
239
|
+
),
|
|
240
|
+
preset: str = typer.Option(
|
|
241
|
+
"default",
|
|
242
|
+
"--preset",
|
|
243
|
+
"-p",
|
|
244
|
+
help="Workflow preset name (see list-presets)",
|
|
245
|
+
),
|
|
246
|
+
arch: str | None = typer.Option(
|
|
247
|
+
None, "--arch", help="Override architecture style in agent instructions"
|
|
248
|
+
),
|
|
249
|
+
outputs_dir: str | None = typer.Option(
|
|
250
|
+
None, "--outputs-dir", help="Override directory for generated outputs"
|
|
251
|
+
),
|
|
252
|
+
scripts_dir: str | None = typer.Option(
|
|
253
|
+
None, "--scripts-dir", help="Override directory for helper scripts"
|
|
254
|
+
),
|
|
255
|
+
prompts_file: str | None = typer.Option(
|
|
256
|
+
None, "--prompts-file", help="Override ordered prompts catalog file path"
|
|
257
|
+
),
|
|
258
|
+
remote: str | None = typer.Option(
|
|
259
|
+
None, "--remote", "-r", help="Remote provider (e.g. 'github')"
|
|
260
|
+
),
|
|
261
|
+
create_remote: bool = typer.Option(
|
|
262
|
+
False, "--create", help="Create the repository on the remote provider"
|
|
263
|
+
),
|
|
264
|
+
clone: bool = typer.Option(
|
|
265
|
+
False, "--clone", help="Keep local working directory initialized alongside remote creation"
|
|
266
|
+
),
|
|
267
|
+
description: str = typer.Option("", help="Short project description"),
|
|
268
|
+
private: bool = typer.Option(True, help="Make the remote repository private"),
|
|
269
|
+
org: str | None = typer.Option(None, help="GitHub organization"),
|
|
270
|
+
license: str = typer.Option("none", help="License: mit/apache2/gpl3/none"),
|
|
271
|
+
holder: str = typer.Option("", help="Copyright holder for the license"),
|
|
272
|
+
variable: list[str] = typer.Option(
|
|
273
|
+
[], "--var", "-V", help="Extra template variable as key=value (repeatable)"
|
|
274
|
+
),
|
|
275
|
+
no_git: bool = typer.Option(
|
|
276
|
+
False, "--no-git", help="Skip running `git init` locally"
|
|
277
|
+
),
|
|
278
|
+
no_graphify: bool = typer.Option(
|
|
279
|
+
False, "--no-graphify", help="Skip running `graphify update .` locally"
|
|
280
|
+
),
|
|
281
|
+
force: bool = typer.Option(
|
|
282
|
+
False, "--force", help="Overwrite a non-empty local target directory"
|
|
283
|
+
),
|
|
284
|
+
) -> None:
|
|
285
|
+
"""Create an AI-agent-enabled project with universal harness, presets, and assistant overlays."""
|
|
286
|
+
if remote is not None and remote.lower() not in ("github", "gh"):
|
|
287
|
+
raise typer.BadParameter(f"Unsupported remote {remote!r}. Supported: github")
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
resolved_preset = resolve_preset(
|
|
291
|
+
preset,
|
|
292
|
+
arch=arch,
|
|
293
|
+
outputs_dir=outputs_dir,
|
|
294
|
+
scripts_dir=scripts_dir,
|
|
295
|
+
prompts_file=prompts_file,
|
|
296
|
+
agents=agent,
|
|
297
|
+
)
|
|
298
|
+
files = compose_project_files(
|
|
299
|
+
name=name,
|
|
300
|
+
description=description,
|
|
301
|
+
lang=lang,
|
|
302
|
+
preset=resolved_preset,
|
|
303
|
+
license=license,
|
|
304
|
+
holder=holder,
|
|
305
|
+
extra_vars=_parse_vars(variable),
|
|
306
|
+
include_graphify=not no_graphify,
|
|
307
|
+
)
|
|
308
|
+
except KeyError as exc:
|
|
309
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
310
|
+
except ValueError as exc:
|
|
311
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
312
|
+
|
|
313
|
+
target = Path(name)
|
|
314
|
+
_write_local(target, files, force)
|
|
315
|
+
|
|
316
|
+
# Upload only when --create is given; --remote just names the provider
|
|
317
|
+
# (defaulting to github), so the two flags no longer overlap silently.
|
|
318
|
+
clone_url: str | None = None
|
|
319
|
+
if create_remote:
|
|
320
|
+
if remote is None:
|
|
321
|
+
typer.echo("No --remote provider given; defaulting to github.")
|
|
322
|
+
clone_url = _push_github(
|
|
323
|
+
files,
|
|
324
|
+
name=name,
|
|
325
|
+
description=description,
|
|
326
|
+
private=private,
|
|
327
|
+
org=org,
|
|
328
|
+
force=force,
|
|
329
|
+
)
|
|
330
|
+
elif remote:
|
|
331
|
+
typer.echo(f"Remote '{remote}' selected but --create not given; not uploading.")
|
|
332
|
+
|
|
333
|
+
# --clone should leave a checkout connected to the remote: configure origin
|
|
334
|
+
# and make an initial commit. This needs git, so it's skipped under --no-git.
|
|
335
|
+
connect_url = clone_url if clone else None
|
|
336
|
+
if clone and no_git:
|
|
337
|
+
typer.echo("Note: --clone needs git but --no-git was set; repo not connected.")
|
|
338
|
+
connect_url = None
|
|
339
|
+
|
|
340
|
+
for msg in run_post_hooks(
|
|
341
|
+
target,
|
|
342
|
+
init_git=not no_git,
|
|
343
|
+
run_graphify=not no_graphify,
|
|
344
|
+
connect_remote_url=connect_url,
|
|
345
|
+
make_commit=bool(connect_url),
|
|
346
|
+
):
|
|
347
|
+
typer.echo(f" * {msg}")
|
|
348
|
+
|
|
349
|
+
if connect_url:
|
|
350
|
+
typer.echo(
|
|
351
|
+
"Local repo has origin + an initial commit; files were uploaded via the "
|
|
352
|
+
"API, so `git fetch` and reconcile before `git push`."
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@app.command()
|
|
357
|
+
def init(
|
|
358
|
+
name: str = typer.Option(..., help="Project/repository name"),
|
|
359
|
+
template: str = typer.Option(
|
|
360
|
+
"python-microservice", help="Template name (see list-templates)"
|
|
361
|
+
),
|
|
362
|
+
description: str = typer.Option("", help="Short description"),
|
|
363
|
+
private: bool = typer.Option(True, help="Make the repo private"),
|
|
364
|
+
org: str | None = typer.Option(None, help="GitHub organization"),
|
|
365
|
+
license: str = typer.Option("none", help="License: mit/apache2/gpl3/none"),
|
|
366
|
+
holder: str = typer.Option("", help="Copyright holder for the license"),
|
|
367
|
+
variable: list[str] = typer.Option(
|
|
368
|
+
[], "--var", "-V", help="Extra template variable as key=value (repeatable)"
|
|
369
|
+
),
|
|
370
|
+
local: bool = typer.Option(
|
|
371
|
+
False, "--local", help="Write to ./<name>/ instead of GitHub"
|
|
372
|
+
),
|
|
373
|
+
force: bool = typer.Option(
|
|
374
|
+
False, "--force", help="Overwrite a non-empty --local target dir"
|
|
375
|
+
),
|
|
376
|
+
) -> None:
|
|
377
|
+
"""Create a project scaffold from a curated template."""
|
|
378
|
+
variables = _variables(name, description, holder, _parse_vars(variable))
|
|
379
|
+
files = _build_template_files(template, variables, license)
|
|
380
|
+
_emit(
|
|
381
|
+
files,
|
|
382
|
+
name=name,
|
|
383
|
+
description=description,
|
|
384
|
+
private=private,
|
|
385
|
+
org=org,
|
|
386
|
+
local=local,
|
|
387
|
+
force=force,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@app.command("init-from-tree")
|
|
392
|
+
def init_from_tree(
|
|
393
|
+
name: str = typer.Option(..., help="Project/repository name"),
|
|
394
|
+
tree_file: Path = typer.Option(
|
|
395
|
+
..., "--tree-file", exists=True, readable=True, help="Path to dirtree file"
|
|
396
|
+
),
|
|
397
|
+
description: str = typer.Option("", help="Short description"),
|
|
398
|
+
private: bool = typer.Option(True, help="Make the repo private"),
|
|
399
|
+
org: str | None = typer.Option(None, help="GitHub organization"),
|
|
400
|
+
gitkeep: bool = typer.Option(
|
|
401
|
+
False, help="Add .gitkeep so empty directories survive"
|
|
402
|
+
),
|
|
403
|
+
local: bool = typer.Option(
|
|
404
|
+
False, "--local", help="Write to ./<name>/ instead of GitHub"
|
|
405
|
+
),
|
|
406
|
+
force: bool = typer.Option(
|
|
407
|
+
False, "--force", help="Overwrite a non-empty --local target dir"
|
|
408
|
+
),
|
|
409
|
+
) -> None:
|
|
410
|
+
"""Create a project scaffold from a dirtree layout file."""
|
|
411
|
+
files = _tree_to_files(tree_file, gitkeep)
|
|
412
|
+
_emit(
|
|
413
|
+
files,
|
|
414
|
+
name=name,
|
|
415
|
+
description=description,
|
|
416
|
+
private=private,
|
|
417
|
+
org=org,
|
|
418
|
+
local=local,
|
|
419
|
+
force=force,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
if __name__ == "__main__":
|
|
424
|
+
app()
|
bootstrapper/composer.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Layered file composition pipeline and post-scaffold hooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
from .agents import agent_overlay_files, universal_harness_files
|
|
11
|
+
from .presets import PresetConfig
|
|
12
|
+
from .templates import LICENSES, get_template, render
|
|
13
|
+
|
|
14
|
+
DEFAULT_GRAPHIFY_BIN = Path("/Users/mac/.local/bin/graphify")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PathCollisionError(ValueError):
|
|
18
|
+
"""Two composition layers produce the same path with different content."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _merge(dst: dict[str, str], src: dict[str, str], layer: str) -> None:
|
|
22
|
+
"""Merge ``src`` into ``dst``, refusing silent overwrites of differing files."""
|
|
23
|
+
for key, value in src.items():
|
|
24
|
+
if key in dst and dst[key] != value:
|
|
25
|
+
raise PathCollisionError(
|
|
26
|
+
f"path '{key}' is produced by the '{layer}' layer but already "
|
|
27
|
+
"exists with different content; adjust --prompts-file/"
|
|
28
|
+
"--outputs-dir/--scripts-dir or the agent set to avoid the clash"
|
|
29
|
+
)
|
|
30
|
+
dst[key] = value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def compose_project_files(
|
|
34
|
+
*,
|
|
35
|
+
name: str,
|
|
36
|
+
description: str,
|
|
37
|
+
lang: str,
|
|
38
|
+
preset: PresetConfig,
|
|
39
|
+
license: str = "none",
|
|
40
|
+
holder: str = "",
|
|
41
|
+
extra_vars: dict[str, str] | None = None,
|
|
42
|
+
config_dir: Path | None = None,
|
|
43
|
+
include_graphify: bool = True,
|
|
44
|
+
) -> dict[str, str]:
|
|
45
|
+
"""Compose and render all files for an AI-enabled project scaffold."""
|
|
46
|
+
variables: dict[str, str] = {
|
|
47
|
+
"name": name,
|
|
48
|
+
"description": description or f"{name} project",
|
|
49
|
+
"year": str(dt.date.today().year),
|
|
50
|
+
"holder": holder or name,
|
|
51
|
+
"arch": preset.arch,
|
|
52
|
+
"outputs_dir": preset.outputs_dir,
|
|
53
|
+
"scripts_dir": preset.scripts_dir,
|
|
54
|
+
"prompts_file": preset.prompts_file,
|
|
55
|
+
}
|
|
56
|
+
if extra_vars:
|
|
57
|
+
variables.update(extra_vars)
|
|
58
|
+
|
|
59
|
+
raw_files: dict[str, str] = {}
|
|
60
|
+
_merge(raw_files, get_template(lang)["files"], "base template")
|
|
61
|
+
_merge(
|
|
62
|
+
raw_files,
|
|
63
|
+
universal_harness_files(preset, config_dir=config_dir),
|
|
64
|
+
"agent harness",
|
|
65
|
+
)
|
|
66
|
+
_merge(raw_files, agent_overlay_files(preset.agents), "assistant overlays")
|
|
67
|
+
|
|
68
|
+
if license != "none":
|
|
69
|
+
lic_body = LICENSES.get(license)
|
|
70
|
+
if lic_body is not None:
|
|
71
|
+
_merge(raw_files, {"LICENSE": lic_body}, "license")
|
|
72
|
+
|
|
73
|
+
rendered = {
|
|
74
|
+
render(path, **variables): render(body, **variables)
|
|
75
|
+
for path, body in raw_files.items()
|
|
76
|
+
}
|
|
77
|
+
if not include_graphify:
|
|
78
|
+
rendered = _strip_graphify(rendered)
|
|
79
|
+
return rendered
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _strip_graphify(files: dict[str, str]) -> dict[str, str]:
|
|
83
|
+
"""Drop graphify scaffolding and any line that instructs using graphify.
|
|
84
|
+
|
|
85
|
+
Keeps generated instructions honest when graphify was skipped (--no-graphify)
|
|
86
|
+
so the project never mandates a tool it did not set up.
|
|
87
|
+
"""
|
|
88
|
+
out: dict[str, str] = {}
|
|
89
|
+
for path, body in files.items():
|
|
90
|
+
if "graphify" in path.lower():
|
|
91
|
+
continue
|
|
92
|
+
kept = [ln for ln in body.splitlines() if "graphify" not in ln.lower()]
|
|
93
|
+
text = "\n".join(kept)
|
|
94
|
+
if body.endswith("\n") and text:
|
|
95
|
+
text += "\n"
|
|
96
|
+
out[path] = text
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _find_graphify() -> str | None:
|
|
101
|
+
which_bin = shutil.which("graphify")
|
|
102
|
+
if which_bin:
|
|
103
|
+
return which_bin
|
|
104
|
+
if DEFAULT_GRAPHIFY_BIN.is_file():
|
|
105
|
+
return str(DEFAULT_GRAPHIFY_BIN)
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def run_post_hooks(
|
|
110
|
+
target: Path,
|
|
111
|
+
*,
|
|
112
|
+
init_git: bool = True,
|
|
113
|
+
run_graphify: bool = True,
|
|
114
|
+
connect_remote_url: str | None = None,
|
|
115
|
+
make_commit: bool = False,
|
|
116
|
+
) -> list[str]:
|
|
117
|
+
"""Run best-effort local post-scaffold hooks (git init/remote/commit, graphify)."""
|
|
118
|
+
messages: list[str] = []
|
|
119
|
+
|
|
120
|
+
if init_git:
|
|
121
|
+
try:
|
|
122
|
+
subprocess.run(
|
|
123
|
+
["git", "init", "-b", "main"],
|
|
124
|
+
cwd=target,
|
|
125
|
+
check=True,
|
|
126
|
+
capture_output=True,
|
|
127
|
+
text=True,
|
|
128
|
+
)
|
|
129
|
+
messages.append("Initialized git repository (main)")
|
|
130
|
+
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
|
131
|
+
messages.append(f"Skipped git init: {exc}")
|
|
132
|
+
|
|
133
|
+
if connect_remote_url:
|
|
134
|
+
if _git_ok(target, ["git", "remote", "add", "origin", connect_remote_url]):
|
|
135
|
+
messages.append(f"Added origin remote: {connect_remote_url}")
|
|
136
|
+
elif _git_ok(target, ["git", "remote", "set-url", "origin", connect_remote_url]):
|
|
137
|
+
messages.append(f"Updated origin remote: {connect_remote_url}")
|
|
138
|
+
|
|
139
|
+
if make_commit:
|
|
140
|
+
_git_ok(target, ["git", "add", "-A"])
|
|
141
|
+
if _git_ok(
|
|
142
|
+
target,
|
|
143
|
+
[
|
|
144
|
+
"git",
|
|
145
|
+
"-c", "user.name=projboot",
|
|
146
|
+
"-c", "user.email=projboot@localhost",
|
|
147
|
+
"commit",
|
|
148
|
+
"-m", "Bootstrap project scaffold",
|
|
149
|
+
],
|
|
150
|
+
):
|
|
151
|
+
messages.append("Created initial commit")
|
|
152
|
+
|
|
153
|
+
if run_graphify:
|
|
154
|
+
graphify_bin = _find_graphify()
|
|
155
|
+
if graphify_bin:
|
|
156
|
+
try:
|
|
157
|
+
subprocess.run(
|
|
158
|
+
[graphify_bin, "update", "."],
|
|
159
|
+
cwd=target,
|
|
160
|
+
check=True,
|
|
161
|
+
capture_output=True,
|
|
162
|
+
text=True,
|
|
163
|
+
)
|
|
164
|
+
messages.append("Initialized graphify knowledge graph (graphify-out/)")
|
|
165
|
+
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
|
166
|
+
messages.append(f"Skipped graphify update: {exc}")
|
|
167
|
+
else:
|
|
168
|
+
messages.append("Skipped graphify update (graphify binary not found)")
|
|
169
|
+
|
|
170
|
+
return messages
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _git_ok(cwd: Path, cmd: list[str]) -> bool:
|
|
174
|
+
"""Run a best-effort git command; True on success, False otherwise."""
|
|
175
|
+
try:
|
|
176
|
+
subprocess.run(cmd, cwd=cwd, check=True, capture_output=True, text=True)
|
|
177
|
+
return True
|
|
178
|
+
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
179
|
+
return False
|