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