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/dirtree.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Parser for classic `tree`-style directory listings.
|
|
2
|
+
|
|
3
|
+
Handles both Unicode (``├── └── │``) and ASCII (``|-- `-- |``) glyphs,
|
|
4
|
+
plus plain indentation-only layouts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# Each prefix element is the 4-char column block preceding a depth level.
|
|
12
|
+
_UNICODE_PREFIXES = (" ", "│ ")
|
|
13
|
+
_ASCII_PREFIXES = (" ", "| ")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _strip_connector(stripped: str) -> tuple[str, bool]:
|
|
17
|
+
"""Return (name, is_dir) after removing a tree connector, if present."""
|
|
18
|
+
for c in ("├── ", "└── ", "|-- ", "`-- "):
|
|
19
|
+
if stripped.startswith(c):
|
|
20
|
+
body = stripped[len(c) :]
|
|
21
|
+
return body.rstrip(), body.endswith("/")
|
|
22
|
+
return stripped.rstrip(), stripped.endswith("/")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_dirtree(text: str) -> dict:
|
|
26
|
+
"""Parse a dirtree listing into a nested ``{name: children}`` dict.
|
|
27
|
+
|
|
28
|
+
A directory's children live under its key; files map to ``None``.
|
|
29
|
+
The root entry (first non-blank line) becomes the outermost dict key.
|
|
30
|
+
"""
|
|
31
|
+
lines = [ln for ln in text.splitlines() if ln.strip()]
|
|
32
|
+
if not lines:
|
|
33
|
+
return {}
|
|
34
|
+
|
|
35
|
+
root_raw, root_is_dir = _strip_connector(lines[0].strip())
|
|
36
|
+
root_name = root_raw.rstrip("/")
|
|
37
|
+
if not root_is_dir and "." in root_name:
|
|
38
|
+
# Single-file "tree" output: root itself is a file.
|
|
39
|
+
return {root_name: None}
|
|
40
|
+
|
|
41
|
+
root_children: dict = {}
|
|
42
|
+
root: dict = {root_name: root_children}
|
|
43
|
+
# stack[k] is the children dict for entries at depth k; stack[0] = root's.
|
|
44
|
+
stack: list[dict] = [root_children]
|
|
45
|
+
|
|
46
|
+
for line in lines[1:]:
|
|
47
|
+
prefixes, name_part = _split_prefix(line)
|
|
48
|
+
level = len(prefixes)
|
|
49
|
+
name, is_dir = _strip_connector(name_part.strip())
|
|
50
|
+
name = name.rstrip("/")
|
|
51
|
+
|
|
52
|
+
# Clamp to the deepest known frame, then drop deeper frames so the
|
|
53
|
+
# parent is the dict at this line's level.
|
|
54
|
+
level = min(level, len(stack) - 1)
|
|
55
|
+
del stack[level + 1 :]
|
|
56
|
+
parent = stack[level]
|
|
57
|
+
|
|
58
|
+
if is_dir:
|
|
59
|
+
child: dict = {}
|
|
60
|
+
parent[name] = child
|
|
61
|
+
stack.append(child)
|
|
62
|
+
else:
|
|
63
|
+
parent[name] = None
|
|
64
|
+
|
|
65
|
+
return root
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _split_prefix(line: str) -> tuple[list[str], str]:
|
|
69
|
+
"""Split a body line into fixed-width prefix columns and the remainder."""
|
|
70
|
+
prefixes: list[str] = []
|
|
71
|
+
rest = line
|
|
72
|
+
candidates = _UNICODE_PREFIXES + _ASCII_PREFIXES
|
|
73
|
+
while len(rest) >= 4 and rest[:4] in candidates:
|
|
74
|
+
prefixes.append(rest[:4])
|
|
75
|
+
rest = rest[4:]
|
|
76
|
+
return prefixes, rest
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def flatten(tree: dict, prefix: str = "") -> list[str]:
|
|
80
|
+
"""Yield newline-free paths for every file and directory in a parsed tree.
|
|
81
|
+
|
|
82
|
+
Directories are suffixed with ``/``; files are not.
|
|
83
|
+
"""
|
|
84
|
+
out: list[str] = []
|
|
85
|
+
for name, children in tree.items():
|
|
86
|
+
path = f"{prefix}{name}"
|
|
87
|
+
if children is None:
|
|
88
|
+
out.append(path)
|
|
89
|
+
else:
|
|
90
|
+
out.append(f"{path}/")
|
|
91
|
+
out.extend(flatten(children, f"{path}/"))
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def empty_dirs(entries: list[str]) -> list[str]:
|
|
96
|
+
"""Directories (explicit or implied) that contain no files."""
|
|
97
|
+
dirs: set[str] = set()
|
|
98
|
+
files: set[str] = set()
|
|
99
|
+
for e in entries:
|
|
100
|
+
if e.endswith("/"):
|
|
101
|
+
dirs.add(e.rstrip("/"))
|
|
102
|
+
else:
|
|
103
|
+
files.add(e)
|
|
104
|
+
parts = e.split("/")
|
|
105
|
+
for i in range(1, len(parts)): # all ancestors are dirs
|
|
106
|
+
dirs.add("/".join(parts[:i]))
|
|
107
|
+
return sorted(
|
|
108
|
+
d
|
|
109
|
+
for d in dirs
|
|
110
|
+
if not any(f == d or f.startswith(d + "/") for f in files)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def read_tree_file(path: Path) -> dict:
|
|
115
|
+
return parse_dirtree(path.read_text())
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""GitHub token handling and repo/file helpers (PyGithub)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from github import Github
|
|
8
|
+
from github.GithubException import GithubException, UnknownObjectException
|
|
9
|
+
from github.Repository import Repository
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RepoExistsError(RuntimeError):
|
|
13
|
+
"""The target repository already exists on the remote."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, full_name: str) -> None:
|
|
16
|
+
super().__init__(full_name)
|
|
17
|
+
self.full_name = full_name
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_github() -> Github:
|
|
21
|
+
token = os.getenv("GITHUB_TOKEN")
|
|
22
|
+
if not token:
|
|
23
|
+
raise SystemExit("Environment variable GITHUB_TOKEN not set.")
|
|
24
|
+
return Github(token)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_owner(gh: Github, org: str | None):
|
|
28
|
+
return gh.get_organization(org) if org else gh.get_user()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def create_repo(owner, name: str, description: str, private: bool, *, force: bool = False) -> Repository:
|
|
32
|
+
"""Return the remote repo, creating it if missing.
|
|
33
|
+
|
|
34
|
+
Refuses to reuse an existing repo unless ``force`` is set, so a push never
|
|
35
|
+
silently overwrites an unrelated repository's files.
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
existing = owner.get_repo(name)
|
|
39
|
+
except (UnknownObjectException, ValueError):
|
|
40
|
+
return owner.create_repo(name=name, description=description, private=private)
|
|
41
|
+
if not force:
|
|
42
|
+
raise RepoExistsError(getattr(existing, "full_name", name))
|
|
43
|
+
return existing
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def add_file(
|
|
47
|
+
repo: Repository,
|
|
48
|
+
path: str,
|
|
49
|
+
content: str,
|
|
50
|
+
message: str = "Add file",
|
|
51
|
+
branch: str | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Create a file, or overwrite it if it already exists.
|
|
54
|
+
|
|
55
|
+
We try ``create_file`` first: it bootstraps a brand-new repo (no commits, no
|
|
56
|
+
default branch yet) in one call, which ``get_contents`` cannot do. A 422
|
|
57
|
+
means the path already exists, so we re-read its blob SHA and update instead.
|
|
58
|
+
Intermediate directories are created implicitly from the path.
|
|
59
|
+
"""
|
|
60
|
+
kwargs = {"branch": branch} if branch else {}
|
|
61
|
+
try:
|
|
62
|
+
repo.create_file(path, message, content, **kwargs)
|
|
63
|
+
except GithubException as exc:
|
|
64
|
+
if exc.status != 422:
|
|
65
|
+
raise
|
|
66
|
+
ref = branch or repo.default_branch
|
|
67
|
+
existing = repo.get_contents(path, ref=ref)
|
|
68
|
+
repo.update_file(path, message, content, existing.sha, **kwargs)
|
bootstrapper/presets.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Preset management and ~/.config/projboot/ user store loader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
import tomllib
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class PresetConfig:
|
|
13
|
+
name: str
|
|
14
|
+
arch: str = "modular"
|
|
15
|
+
outputs_dir: str = "outputs"
|
|
16
|
+
scripts_dir: str = "scripts"
|
|
17
|
+
prompts_file: str = "PROMPTS.md"
|
|
18
|
+
agents: list[str] = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
BUILTIN_PRESETS: dict[str, PresetConfig] = {
|
|
22
|
+
"default": PresetConfig(
|
|
23
|
+
name="default",
|
|
24
|
+
arch="modular",
|
|
25
|
+
outputs_dir="outputs",
|
|
26
|
+
scripts_dir="scripts",
|
|
27
|
+
prompts_file="PROMPTS.md",
|
|
28
|
+
agents=[],
|
|
29
|
+
),
|
|
30
|
+
"clean-arch": PresetConfig(
|
|
31
|
+
name="clean-arch",
|
|
32
|
+
arch="clean-architecture",
|
|
33
|
+
outputs_dir="artifacts/outputs",
|
|
34
|
+
scripts_dir="scripts",
|
|
35
|
+
prompts_file="docs/PROMPTS.md",
|
|
36
|
+
agents=["claude", "gh-copilot"],
|
|
37
|
+
),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
DEFAULT_PRESETS_TOML = """\
|
|
41
|
+
# projboot workflow presets
|
|
42
|
+
# Customize folders, architecture style, and default AI agents per preset.
|
|
43
|
+
|
|
44
|
+
[presets.default]
|
|
45
|
+
arch = "modular"
|
|
46
|
+
outputs_dir = "outputs"
|
|
47
|
+
scripts_dir = "scripts"
|
|
48
|
+
prompts_file = "PROMPTS.md"
|
|
49
|
+
agents = []
|
|
50
|
+
|
|
51
|
+
[presets.clean-arch]
|
|
52
|
+
arch = "clean-architecture"
|
|
53
|
+
outputs_dir = "artifacts/outputs"
|
|
54
|
+
scripts_dir = "scripts"
|
|
55
|
+
prompts_file = "docs/PROMPTS.md"
|
|
56
|
+
agents = ["claude", "gh-copilot"]
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
DEFAULT_REMEMBER_FILES: dict[str, str] = {
|
|
60
|
+
"MEMORY.md": (
|
|
61
|
+
"# Project Memory ($name)\n\n"
|
|
62
|
+
"Persistent context and facts for AI agents working in `$name`.\n\n"
|
|
63
|
+
"## Key Conventions\n"
|
|
64
|
+
"- Architecture: `$arch`\n"
|
|
65
|
+
"- Generated outputs directory: `$outputs_dir/`\n"
|
|
66
|
+
"- Helper scripts directory: `$scripts_dir/`\n"
|
|
67
|
+
"- Ordered prompts file: `$prompts_file`\n"
|
|
68
|
+
),
|
|
69
|
+
"decisions.md": (
|
|
70
|
+
"# Architectural & Product Decisions ($name)\n\n"
|
|
71
|
+
"Log key technical decisions chronologically so agents preserve context across sessions.\n\n"
|
|
72
|
+
"## $year — Initial Bootstrap\n"
|
|
73
|
+
"- Bootstrapped `$name` with `$arch` architecture.\n"
|
|
74
|
+
),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
DEFAULT_SKILL_FILES: dict[str, str] = {
|
|
78
|
+
"project-conventions/SKILL.md": (
|
|
79
|
+
"---\n"
|
|
80
|
+
"name: project-conventions\n"
|
|
81
|
+
"description: Core workspace layout, output directories, and prompt tracking rules for $name.\n"
|
|
82
|
+
"---\n\n"
|
|
83
|
+
"# Project Conventions for `$name`\n\n"
|
|
84
|
+
"1. **Architecture**: Follow the `$arch` architecture pattern.\n"
|
|
85
|
+
"2. **Outputs**: Write generated artifacts, reports, and exports to `$outputs_dir/`.\n"
|
|
86
|
+
"3. **Scripts**: Save reusable automation and helper scripts in `$scripts_dir/`.\n"
|
|
87
|
+
"4. **Prompts**: Keep reusable and ordered prompts documented in `$prompts_file`.\n"
|
|
88
|
+
"5. **Memory**: Read and update `.remember/MEMORY.md` and `.remember/decisions.md`.\n"
|
|
89
|
+
"6. **Knowledge Graph**: Use `graphify-out/` and run `graphify update .` after code edits.\n"
|
|
90
|
+
),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def validate_rel_path(value: str, field: str) -> str:
|
|
95
|
+
"""Ensure a preset path is a safe, repo-relative POSIX path.
|
|
96
|
+
|
|
97
|
+
Rejects absolute paths, ``~`` home expansion, and any ``..`` traversal so a
|
|
98
|
+
custom --outputs-dir/--scripts-dir/--prompts-file cannot escape the project
|
|
99
|
+
root or overwrite files outside it.
|
|
100
|
+
"""
|
|
101
|
+
raw = value.strip()
|
|
102
|
+
if not raw:
|
|
103
|
+
raise ValueError(f"{field} must not be empty")
|
|
104
|
+
if "\x00" in raw:
|
|
105
|
+
raise ValueError(f"{field} contains a null byte")
|
|
106
|
+
norm = raw.replace("\\", "/")
|
|
107
|
+
if norm.startswith("/") or norm.startswith("~"):
|
|
108
|
+
raise ValueError(f"{field} must be relative to the project root: {value!r}")
|
|
109
|
+
parts = PurePosixPath(norm).parts
|
|
110
|
+
if ".." in parts:
|
|
111
|
+
raise ValueError(f"{field} must not contain '..': {value!r}")
|
|
112
|
+
return norm
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_config_dir() -> Path:
|
|
116
|
+
env_dir = os.environ.get("PROJBOOT_HOME")
|
|
117
|
+
if env_dir:
|
|
118
|
+
return Path(env_dir).expanduser()
|
|
119
|
+
return Path.home() / ".config" / "projboot"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def load_presets(config_dir: Path | None = None) -> dict[str, PresetConfig]:
|
|
123
|
+
root = config_dir if config_dir is not None else get_config_dir()
|
|
124
|
+
presets: dict[str, PresetConfig] = dict(BUILTIN_PRESETS)
|
|
125
|
+
|
|
126
|
+
toml_path = root / "presets.toml"
|
|
127
|
+
if not toml_path.is_file():
|
|
128
|
+
return presets
|
|
129
|
+
|
|
130
|
+
data = tomllib.loads(toml_path.read_text())
|
|
131
|
+
raw_presets = data.get("presets", {})
|
|
132
|
+
if not isinstance(raw_presets, dict):
|
|
133
|
+
return presets
|
|
134
|
+
|
|
135
|
+
for name, cfg in raw_presets.items():
|
|
136
|
+
if not isinstance(cfg, dict):
|
|
137
|
+
continue
|
|
138
|
+
base = presets.get(name, PresetConfig(name=name))
|
|
139
|
+
presets[name] = PresetConfig(
|
|
140
|
+
name=name,
|
|
141
|
+
arch=str(cfg.get("arch", base.arch)),
|
|
142
|
+
outputs_dir=str(cfg.get("outputs_dir", base.outputs_dir)),
|
|
143
|
+
scripts_dir=str(cfg.get("scripts_dir", base.scripts_dir)),
|
|
144
|
+
prompts_file=str(cfg.get("prompts_file", base.prompts_file)),
|
|
145
|
+
agents=[str(a) for a in cfg.get("agents", base.agents)],
|
|
146
|
+
)
|
|
147
|
+
return presets
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def resolve_preset(
|
|
151
|
+
preset_name: str = "default",
|
|
152
|
+
*,
|
|
153
|
+
arch: str | None = None,
|
|
154
|
+
outputs_dir: str | None = None,
|
|
155
|
+
scripts_dir: str | None = None,
|
|
156
|
+
prompts_file: str | None = None,
|
|
157
|
+
agents: list[str] | None = None,
|
|
158
|
+
config_dir: Path | None = None,
|
|
159
|
+
) -> PresetConfig:
|
|
160
|
+
presets = load_presets(config_dir)
|
|
161
|
+
if preset_name not in presets:
|
|
162
|
+
available = ", ".join(sorted(presets))
|
|
163
|
+
raise KeyError(f"Unknown preset '{preset_name}'. Available: {available}")
|
|
164
|
+
base = presets[preset_name]
|
|
165
|
+
|
|
166
|
+
merged_agents: list[str] = list(base.agents)
|
|
167
|
+
for a in agents or []:
|
|
168
|
+
if a not in merged_agents:
|
|
169
|
+
merged_agents.append(a)
|
|
170
|
+
|
|
171
|
+
return PresetConfig(
|
|
172
|
+
name=base.name,
|
|
173
|
+
arch=arch if arch is not None else base.arch,
|
|
174
|
+
outputs_dir=validate_rel_path(
|
|
175
|
+
(outputs_dir if outputs_dir is not None else base.outputs_dir).rstrip("/"),
|
|
176
|
+
"--outputs-dir",
|
|
177
|
+
),
|
|
178
|
+
scripts_dir=validate_rel_path(
|
|
179
|
+
(scripts_dir if scripts_dir is not None else base.scripts_dir).rstrip("/"),
|
|
180
|
+
"--scripts-dir",
|
|
181
|
+
),
|
|
182
|
+
prompts_file=validate_rel_path(
|
|
183
|
+
prompts_file if prompts_file is not None else base.prompts_file,
|
|
184
|
+
"--prompts-file",
|
|
185
|
+
),
|
|
186
|
+
agents=merged_agents,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _collect_dir_files(directory: Path) -> dict[str, str]:
|
|
191
|
+
out: dict[str, str] = {}
|
|
192
|
+
if not directory.is_dir():
|
|
193
|
+
return out
|
|
194
|
+
for path in sorted(directory.rglob("*")):
|
|
195
|
+
if path.is_file() and not path.name.startswith("."):
|
|
196
|
+
rel = path.relative_to(directory).as_posix()
|
|
197
|
+
out[rel] = path.read_text()
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def load_user_store_files(config_dir: Path | None = None) -> dict[str, str]:
|
|
202
|
+
"""Load `.remember/` and `.agents/skills/` templates from user store or built-in defaults."""
|
|
203
|
+
root = config_dir if config_dir is not None else get_config_dir()
|
|
204
|
+
files: dict[str, str] = {}
|
|
205
|
+
|
|
206
|
+
user_remember = _collect_dir_files(root / "remember")
|
|
207
|
+
remember_source = user_remember if user_remember else DEFAULT_REMEMBER_FILES
|
|
208
|
+
for rel, content in remember_source.items():
|
|
209
|
+
files[f".remember/{rel}"] = content
|
|
210
|
+
|
|
211
|
+
user_skills = _collect_dir_files(root / "skills")
|
|
212
|
+
skills_source = user_skills if user_skills else DEFAULT_SKILL_FILES
|
|
213
|
+
for rel, content in skills_source.items():
|
|
214
|
+
files[f".agents/skills/{rel}"] = content
|
|
215
|
+
|
|
216
|
+
return files
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def init_config_dir(config_dir: Path | None = None, *, force: bool = False) -> list[Path]:
|
|
220
|
+
"""Materialize default presets.toml, skills/, and remember/ into the user config dir."""
|
|
221
|
+
root = config_dir if config_dir is not None else get_config_dir()
|
|
222
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
written: list[Path] = []
|
|
224
|
+
|
|
225
|
+
targets: list[tuple[Path, str]] = [
|
|
226
|
+
(root / "presets.toml", DEFAULT_PRESETS_TOML),
|
|
227
|
+
*[
|
|
228
|
+
(root / "remember" / rel, body)
|
|
229
|
+
for rel, body in DEFAULT_REMEMBER_FILES.items()
|
|
230
|
+
],
|
|
231
|
+
*[
|
|
232
|
+
(root / "skills" / rel, body)
|
|
233
|
+
for rel, body in DEFAULT_SKILL_FILES.items()
|
|
234
|
+
],
|
|
235
|
+
]
|
|
236
|
+
for path, content in targets:
|
|
237
|
+
if path.exists() and not force:
|
|
238
|
+
continue
|
|
239
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
path.write_text(content)
|
|
241
|
+
written.append(path)
|
|
242
|
+
return written
|