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