dotbrain 0.3.4__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.
- dotbrain/__init__.py +7 -0
- dotbrain/_cli_reference.py +115 -0
- dotbrain/adopter_repos.py +573 -0
- dotbrain/beads.py +511 -0
- dotbrain/bootstrap.py +199 -0
- dotbrain/brainspaces.py +238 -0
- dotbrain/cli.py +660 -0
- dotbrain/config.py +428 -0
- dotbrain/doctor.py +279 -0
- dotbrain/hooks.py +84 -0
- dotbrain/migrate.py +304 -0
- dotbrain/paths.py +139 -0
- dotbrain/resource_loader.py +47 -0
- dotbrain/resources/__init__.py +1 -0
- dotbrain/resources/agents/claude/implementer.md +47 -0
- dotbrain/resources/agents/claude/investigator.md +35 -0
- dotbrain/resources/agents/claude/reviewer.md +38 -0
- dotbrain/resources/agents/claude/verifier.md +35 -0
- dotbrain/resources/agents/codex/implementer.toml +26 -0
- dotbrain/resources/agents/codex/investigator.toml +21 -0
- dotbrain/resources/agents/codex/reviewer.toml +27 -0
- dotbrain/resources/agents/codex/verifier.toml +21 -0
- dotbrain/resources/config.yaml +20 -0
- dotbrain/resources/core.yaml +18 -0
- dotbrain/resources/templates/brain/AGENTS.md +9 -0
- dotbrain/resources/templates/brain/DOTBRAIN.md +105 -0
- dotbrain/resources/templates/brain/adr/README.md +8 -0
- dotbrain/resources/templates/brain/designs/README.md +28 -0
- dotbrain/resources/templates/brain/docs/README.md +9 -0
- dotbrain/resources/templates/brain/project.yaml +27 -0
- dotbrain/resources/templates/gitignore +17 -0
- dotbrain/skills.py +264 -0
- dotbrain/subagents.py +252 -0
- dotbrain/updater.py +105 -0
- dotbrain/workflows.py +529 -0
- dotbrain-0.3.4.dist-info/METADATA +21 -0
- dotbrain-0.3.4.dist-info/RECORD +40 -0
- dotbrain-0.3.4.dist-info/WHEEL +4 -0
- dotbrain-0.3.4.dist-info/entry_points.txt +2 -0
- dotbrain-0.3.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Per-project identity. Values below are active defaults; edit as needed.
|
|
2
|
+
# The tool reads this file; dotbrain refresh may overwrite shared sections.
|
|
3
|
+
|
|
4
|
+
execution-engine: beads
|
|
5
|
+
|
|
6
|
+
agents:
|
|
7
|
+
- claude
|
|
8
|
+
- codex
|
|
9
|
+
|
|
10
|
+
# Public-facing tracker for issue intake and contributor collaboration, never private work tracking.
|
|
11
|
+
# One of: none, gh, linear.
|
|
12
|
+
# Set public-tracker-id when activating a tracker (e.g. "owner/repo" for gh).
|
|
13
|
+
public-tracker: none
|
|
14
|
+
# public-tracker-id: ""
|
|
15
|
+
|
|
16
|
+
# beads: deviations from global defaults only — uncomment the keys you need.
|
|
17
|
+
# mode: embedded # one of: embedded, server, none
|
|
18
|
+
# remote: https://doltremoteapi.dolthub.com/owner/repo
|
|
19
|
+
# database: custom_name
|
|
20
|
+
|
|
21
|
+
# Skills linked into this project's agent workspaces. Operator-owned.
|
|
22
|
+
# skills:
|
|
23
|
+
# - some-collection/some-skill
|
|
24
|
+
#
|
|
25
|
+
# Project-scoped subagents only. The packaged four are always wired into active workspaces.
|
|
26
|
+
# subagents:
|
|
27
|
+
# - some-project-only-subagent
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Generated cache state.
|
|
2
|
+
/.cache/
|
|
3
|
+
|
|
4
|
+
# Runtime state inside Brainspaces.
|
|
5
|
+
brainspaces/*/.dolt/
|
|
6
|
+
brainspaces/*/*.db
|
|
7
|
+
brainspaces/*/.beads-credential-key
|
|
8
|
+
brainspaces/*/.beads/proxieddb/
|
|
9
|
+
brainspaces/*/.beads/metadata.json
|
|
10
|
+
brainspaces/*/.beads/interactions.jsonl
|
|
11
|
+
brainspaces/*/.beads/dolt-config.log
|
|
12
|
+
brainspaces/*/.claude/settings.local.json
|
|
13
|
+
brainspaces/*/.claude/skills/
|
|
14
|
+
brainspaces/*/.codex/skills/
|
|
15
|
+
brainspaces/*/.claude/agents/
|
|
16
|
+
brainspaces/*/.codex/agents/
|
|
17
|
+
brainspaces/*/.repo.local
|
dotbrain/skills.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Operator skill config and symlink linking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Iterable, Mapping, Sequence
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from dotbrain import paths
|
|
15
|
+
|
|
16
|
+
DEFAULT_TARGETS: dict[str, str] = {
|
|
17
|
+
"claude-code": "~/.claude/skills",
|
|
18
|
+
"codex": "~/.codex/skills",
|
|
19
|
+
}
|
|
20
|
+
@dataclass
|
|
21
|
+
class GlobalConfig:
|
|
22
|
+
"""Operator-configurable skill-link settings."""
|
|
23
|
+
|
|
24
|
+
targets: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_TARGETS))
|
|
25
|
+
global_extra: tuple[str, ...] = ()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class LinkResult:
|
|
30
|
+
"""Result of creating/pruning skill links."""
|
|
31
|
+
|
|
32
|
+
linked: list[str] = field(default_factory=list)
|
|
33
|
+
pruned: list[str] = field(default_factory=list)
|
|
34
|
+
stashed: list[Path] = field(default_factory=list)
|
|
35
|
+
warnings: list[str] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _clean(values: object, *, exclude: Iterable[str] = ()) -> tuple[str, ...]:
|
|
39
|
+
if values is None:
|
|
40
|
+
return ()
|
|
41
|
+
if isinstance(values, str):
|
|
42
|
+
values = [values]
|
|
43
|
+
if not isinstance(values, (list, tuple)):
|
|
44
|
+
return ()
|
|
45
|
+
|
|
46
|
+
excluded = set(exclude)
|
|
47
|
+
out: list[str] = []
|
|
48
|
+
seen: set[str] = set()
|
|
49
|
+
for value in values:
|
|
50
|
+
if not isinstance(value, str):
|
|
51
|
+
continue
|
|
52
|
+
value = value.strip().strip("/")
|
|
53
|
+
if not value or value in excluded or value in seen:
|
|
54
|
+
continue
|
|
55
|
+
out.append(value)
|
|
56
|
+
seen.add(value)
|
|
57
|
+
return tuple(out)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def discover_skills(skills_root: Path) -> list[str]:
|
|
61
|
+
"""Return skill paths under a skills root."""
|
|
62
|
+
|
|
63
|
+
skills_root = Path(skills_root)
|
|
64
|
+
if not skills_root.is_dir():
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
out: list[str] = []
|
|
68
|
+
for skill_md in skills_root.rglob("SKILL.md"):
|
|
69
|
+
if "node_modules" in skill_md.parts:
|
|
70
|
+
continue
|
|
71
|
+
out.append(skill_md.parent.relative_to(skills_root).as_posix())
|
|
72
|
+
return sorted(out)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _read_yaml_mapping(path: Path) -> dict[str, object]:
|
|
76
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
77
|
+
if not isinstance(data, dict):
|
|
78
|
+
raise ValueError(f"{path}: expected a YAML mapping")
|
|
79
|
+
return data
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_global_config(path: Path) -> GlobalConfig:
|
|
83
|
+
"""Parse optional operator skill-link config."""
|
|
84
|
+
|
|
85
|
+
path = Path(path)
|
|
86
|
+
if not path.is_file():
|
|
87
|
+
return GlobalConfig()
|
|
88
|
+
|
|
89
|
+
data = _read_yaml_mapping(path)
|
|
90
|
+
targets = data.get("targets") or {}
|
|
91
|
+
if not isinstance(targets, dict) or not all(
|
|
92
|
+
isinstance(k, str) and isinstance(v, str) for k, v in targets.items()
|
|
93
|
+
):
|
|
94
|
+
raise ValueError(f"{path}: targets must be a mapping of runtime -> destination")
|
|
95
|
+
|
|
96
|
+
merged_targets = dict(DEFAULT_TARGETS)
|
|
97
|
+
merged_targets.update(targets)
|
|
98
|
+
return GlobalConfig(
|
|
99
|
+
targets=merged_targets,
|
|
100
|
+
global_extra=_clean(data.get("global_extra", data.get("extra"))),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def render_global_config(
|
|
105
|
+
targets: dict[str, str],
|
|
106
|
+
global_extra: Iterable[str],
|
|
107
|
+
) -> str:
|
|
108
|
+
"""Render the optional operator-owned skill-link config."""
|
|
109
|
+
|
|
110
|
+
lines = [
|
|
111
|
+
"version: 1",
|
|
112
|
+
"# Operator skill-link config — your global skills, linked into every agent",
|
|
113
|
+
"# session. List global skills under global_extra.",
|
|
114
|
+
"targets:",
|
|
115
|
+
]
|
|
116
|
+
for key, value in targets.items():
|
|
117
|
+
lines.append(f" {key}: {value}")
|
|
118
|
+
extra = _clean(global_extra)
|
|
119
|
+
if extra:
|
|
120
|
+
lines.append("global_extra:")
|
|
121
|
+
for skill in extra:
|
|
122
|
+
lines.append(f" - {skill}")
|
|
123
|
+
else:
|
|
124
|
+
lines.append("global_extra: []")
|
|
125
|
+
return "\n".join(lines) + "\n"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def reconcile_global_config(path: Path) -> GlobalConfig:
|
|
129
|
+
"""Normalize optional operator config when present."""
|
|
130
|
+
|
|
131
|
+
path = Path(path)
|
|
132
|
+
config = load_global_config(path)
|
|
133
|
+
if path.is_file():
|
|
134
|
+
desired = render_global_config(
|
|
135
|
+
config.targets,
|
|
136
|
+
config.global_extra,
|
|
137
|
+
)
|
|
138
|
+
if path.read_text(encoding="utf-8") != desired:
|
|
139
|
+
path.write_text(desired, encoding="utf-8", newline="\n")
|
|
140
|
+
return config
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def project_link_set(extras: Iterable[str]) -> tuple[str, ...]:
|
|
144
|
+
"""Return the operator's deduplicated per-project skill selection."""
|
|
145
|
+
|
|
146
|
+
return _clean(extras)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def stash_collision(target: Path) -> Path:
|
|
150
|
+
tmp = target.parent / ".tmp"
|
|
151
|
+
tmp.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
moved = tmp / f"{target.name}.{int(time.time())}"
|
|
153
|
+
target.rename(moved)
|
|
154
|
+
return moved
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _points_into(link: Path, root: Path) -> bool:
|
|
158
|
+
try:
|
|
159
|
+
return link.resolve().is_relative_to(root.resolve())
|
|
160
|
+
except FileNotFoundError:
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _resolve_skill_source(dotbrain_home: Path, skill_path: str) -> Path | None:
|
|
165
|
+
private_src = dotbrain_home / "skills" / skill_path
|
|
166
|
+
return private_src if private_src.is_dir() else None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _remove_legacy_skill_cache(dotbrain_home: Path) -> None:
|
|
170
|
+
cache = Path(dotbrain_home) / ".cache" / "skills"
|
|
171
|
+
if cache.is_dir() and not cache.is_symlink():
|
|
172
|
+
shutil.rmtree(cache)
|
|
173
|
+
elif cache.exists() or cache.is_symlink():
|
|
174
|
+
cache.unlink()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def link_into(
|
|
178
|
+
dotbrain_home: Path,
|
|
179
|
+
skills_dir: Path,
|
|
180
|
+
skill_paths: Sequence[str],
|
|
181
|
+
*,
|
|
182
|
+
label: str = "",
|
|
183
|
+
prune_owned_only: bool = False,
|
|
184
|
+
preserve_collisions: bool = False,
|
|
185
|
+
) -> LinkResult:
|
|
186
|
+
"""Link skill paths into an agent runtime skills directory."""
|
|
187
|
+
|
|
188
|
+
dotbrain_home = Path(dotbrain_home)
|
|
189
|
+
skills_dir = Path(skills_dir)
|
|
190
|
+
skills_dir.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
|
|
192
|
+
prefix = f"{label}/" if label else ""
|
|
193
|
+
result = LinkResult()
|
|
194
|
+
wanted: set[str] = set()
|
|
195
|
+
_remove_legacy_skill_cache(dotbrain_home)
|
|
196
|
+
cache_root = (dotbrain_home / ".cache" / "skills").resolve()
|
|
197
|
+
private_root = (dotbrain_home / "skills").resolve()
|
|
198
|
+
|
|
199
|
+
for skill_path in skill_paths:
|
|
200
|
+
src = _resolve_skill_source(dotbrain_home, skill_path)
|
|
201
|
+
if src is None or not src.is_dir():
|
|
202
|
+
result.warnings.append(f"skill not found: {skill_path}")
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
dest = skills_dir / Path(skill_path).name
|
|
206
|
+
wanted.add(dest.name)
|
|
207
|
+
owned = dest.is_symlink() and (
|
|
208
|
+
_points_into(dest, private_root) or _points_into(dest, cache_root)
|
|
209
|
+
)
|
|
210
|
+
if preserve_collisions and (dest.exists() or dest.is_symlink()) and not owned:
|
|
211
|
+
result.warnings.append(f"{dest} exists and was not created by dotbrain; skipping")
|
|
212
|
+
continue
|
|
213
|
+
if dest.exists() and not dest.is_symlink():
|
|
214
|
+
result.stashed.append(stash_collision(dest))
|
|
215
|
+
if dest.is_symlink() or dest.exists():
|
|
216
|
+
dest.unlink()
|
|
217
|
+
try:
|
|
218
|
+
dest.symlink_to(os.path.relpath(src, skills_dir), target_is_directory=True)
|
|
219
|
+
except OSError as exc:
|
|
220
|
+
message = paths.symlink_privilege_message(exc)
|
|
221
|
+
if message is None:
|
|
222
|
+
raise
|
|
223
|
+
raise RuntimeError(f"{dest}: {message}") from exc
|
|
224
|
+
result.linked.append(f"{prefix}{dest.name}")
|
|
225
|
+
|
|
226
|
+
for entry in sorted(skills_dir.iterdir()):
|
|
227
|
+
if not entry.is_symlink() or entry.name in wanted:
|
|
228
|
+
continue
|
|
229
|
+
if prune_owned_only and not (
|
|
230
|
+
_points_into(entry, private_root) or _points_into(entry, cache_root)
|
|
231
|
+
):
|
|
232
|
+
continue
|
|
233
|
+
entry.unlink()
|
|
234
|
+
result.pruned.append(f"{prefix}{entry.name}")
|
|
235
|
+
|
|
236
|
+
return result
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def link_project(
|
|
240
|
+
dotbrain_home: Path,
|
|
241
|
+
brainspace: Path,
|
|
242
|
+
workspaces: Sequence[str],
|
|
243
|
+
skill_paths: Sequence[str],
|
|
244
|
+
*,
|
|
245
|
+
workspace_dirs: Mapping[str, Path] | None = None,
|
|
246
|
+
) -> LinkResult:
|
|
247
|
+
brainspace = Path(brainspace)
|
|
248
|
+
result = LinkResult()
|
|
249
|
+
for workspace in workspaces:
|
|
250
|
+
workspace_dir = workspace_dirs.get(workspace) if workspace_dirs else None
|
|
251
|
+
skills_dir = (workspace_dir or brainspace / workspace) / "skills"
|
|
252
|
+
ws_result = link_into(
|
|
253
|
+
dotbrain_home,
|
|
254
|
+
skills_dir,
|
|
255
|
+
skill_paths,
|
|
256
|
+
label=f"{workspace}/skills",
|
|
257
|
+
prune_owned_only=True,
|
|
258
|
+
preserve_collisions=True,
|
|
259
|
+
)
|
|
260
|
+
result.linked += ws_result.linked
|
|
261
|
+
result.pruned += ws_result.pruned
|
|
262
|
+
result.stashed += ws_result.stashed
|
|
263
|
+
result.warnings += ws_result.warnings
|
|
264
|
+
return result
|
dotbrain/subagents.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Vendor-native subagent config and symlink linking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Mapping, Sequence
|
|
10
|
+
|
|
11
|
+
from dotbrain import resource_loader, skills
|
|
12
|
+
|
|
13
|
+
AGENT_TARGETS: dict[str, str] = {
|
|
14
|
+
"claude-code": "~/.claude/agents",
|
|
15
|
+
"codex": "~/.codex/agents",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
RUNTIME_SPEC: dict[str, tuple[str, str]] = {
|
|
19
|
+
"claude-code": ("claude", ".md"),
|
|
20
|
+
"codex": ("codex", ".toml"),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
WORKSPACE_RUNTIME: dict[str, str] = {
|
|
24
|
+
".claude": "claude-code",
|
|
25
|
+
".codex": "codex",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _required_core() -> tuple[str, ...]:
|
|
30
|
+
"""Read the packaged project subagent core from core.yaml."""
|
|
31
|
+
|
|
32
|
+
import yaml
|
|
33
|
+
|
|
34
|
+
src = resource_loader.resource("core.yaml")
|
|
35
|
+
data = yaml.safe_load(src.read_text(encoding="utf-8")) or {}
|
|
36
|
+
if not isinstance(data, dict):
|
|
37
|
+
raise ValueError("packaged core.yaml: expected a YAML mapping")
|
|
38
|
+
subagents_data = data.get("subagents")
|
|
39
|
+
if not isinstance(subagents_data, dict):
|
|
40
|
+
raise ValueError("packaged core.yaml: expected a subagents mapping")
|
|
41
|
+
return skills._clean(subagents_data.get("project_required"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
PROJECT_BASELINE = _required_core()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class GlobalConfig:
|
|
49
|
+
"""Operator-configurable global subagent-link settings."""
|
|
50
|
+
|
|
51
|
+
targets: dict[str, str] = field(default_factory=lambda: dict(AGENT_TARGETS))
|
|
52
|
+
global_names: tuple[str, ...] = ()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def render_global_subagents(names: Sequence[str] = ()) -> str:
|
|
56
|
+
lines = [
|
|
57
|
+
"# Global vendor-native subagents linked into personal agent homes.",
|
|
58
|
+
"# Remove entries to prune dotbrain-managed links on the next relink.",
|
|
59
|
+
"# Project-scoped extras belong in project.yaml; this file is for personal-home reach and extras.",
|
|
60
|
+
"# Optional target overrides (defaults shown):",
|
|
61
|
+
"# targets:",
|
|
62
|
+
"# claude-code: ~/.claude/agents",
|
|
63
|
+
"# codex: ~/.codex/agents",
|
|
64
|
+
]
|
|
65
|
+
cleaned = skills._clean(names)
|
|
66
|
+
if not cleaned:
|
|
67
|
+
lines += ["# global:", "# - some-shared-subagent"]
|
|
68
|
+
return "\n".join(lines) + "\n"
|
|
69
|
+
lines.append("global:")
|
|
70
|
+
lines.extend(f" - {name}" for name in cleaned)
|
|
71
|
+
return "\n".join(lines) + "\n"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _copy_resource_file(resource_path: str, dest: Path) -> None:
|
|
75
|
+
if dest.exists() or dest.is_symlink():
|
|
76
|
+
if dest.is_dir() and not dest.is_symlink():
|
|
77
|
+
shutil.rmtree(dest)
|
|
78
|
+
else:
|
|
79
|
+
dest.unlink()
|
|
80
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
dest.write_text(
|
|
82
|
+
resource_loader.resource(resource_path).read_text(encoding="utf-8"),
|
|
83
|
+
encoding="utf-8",
|
|
84
|
+
newline="\n",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def rehydrate_packaged_subagents(dotbrain_home: Path) -> list[Path]:
|
|
89
|
+
root = Path(dotbrain_home)
|
|
90
|
+
cached: list[Path] = []
|
|
91
|
+
for rel, src in resource_loader.iter_resource_files("agents"):
|
|
92
|
+
dest = root / ".cache" / "agents" / rel
|
|
93
|
+
if dest.exists() or dest.is_symlink():
|
|
94
|
+
if dest.is_dir() and not dest.is_symlink():
|
|
95
|
+
shutil.rmtree(dest)
|
|
96
|
+
else:
|
|
97
|
+
dest.unlink()
|
|
98
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
dest.write_text(
|
|
100
|
+
src.read_text(encoding="utf-8"),
|
|
101
|
+
encoding="utf-8",
|
|
102
|
+
newline="\n",
|
|
103
|
+
)
|
|
104
|
+
cached.append(dest)
|
|
105
|
+
return cached
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def seed_private_subagents(dotbrain_home: Path) -> list[Path]:
|
|
109
|
+
"""Seed bundled examples into the private agents tree without overwriting overrides."""
|
|
110
|
+
|
|
111
|
+
root = Path(dotbrain_home)
|
|
112
|
+
seeded: list[Path] = []
|
|
113
|
+
for rel, src in resource_loader.iter_resource_files("agents"):
|
|
114
|
+
dest = root / "agents" / rel
|
|
115
|
+
if dest.exists() or dest.is_symlink():
|
|
116
|
+
continue
|
|
117
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
dest.write_text(
|
|
119
|
+
src.read_text(encoding="utf-8"),
|
|
120
|
+
encoding="utf-8",
|
|
121
|
+
newline="\n",
|
|
122
|
+
)
|
|
123
|
+
seeded.append(dest)
|
|
124
|
+
return seeded
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _resolve_subagent_files(dotbrain_home: Path, name: str) -> dict[str, Path]:
|
|
128
|
+
resolved: dict[str, Path] = {}
|
|
129
|
+
root = Path(dotbrain_home)
|
|
130
|
+
for runtime, (subdir, ext) in RUNTIME_SPEC.items():
|
|
131
|
+
private_src = root / "agents" / subdir / f"{name}{ext}"
|
|
132
|
+
if private_src.is_file():
|
|
133
|
+
resolved[runtime] = private_src
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
resource_path = f"agents/{subdir}/{name}{ext}"
|
|
137
|
+
try:
|
|
138
|
+
resource = resource_loader.resource(resource_path)
|
|
139
|
+
except FileNotFoundError:
|
|
140
|
+
continue
|
|
141
|
+
if not resource.is_file():
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
cached = root / ".cache" / "agents" / subdir / f"{name}{ext}"
|
|
145
|
+
_copy_resource_file(resource_path, cached)
|
|
146
|
+
resolved[runtime] = cached
|
|
147
|
+
return resolved
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def link_files_into(
|
|
151
|
+
dotbrain_home: Path,
|
|
152
|
+
target_dir: Path,
|
|
153
|
+
files: Sequence[Path],
|
|
154
|
+
*,
|
|
155
|
+
label: str,
|
|
156
|
+
preserve_collisions: bool = False,
|
|
157
|
+
) -> skills.LinkResult:
|
|
158
|
+
target_dir = Path(target_dir)
|
|
159
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
private_root = (Path(dotbrain_home) / "agents").resolve()
|
|
161
|
+
cache_root = (Path(dotbrain_home) / ".cache" / "agents").resolve()
|
|
162
|
+
result = skills.LinkResult()
|
|
163
|
+
wanted: set[str] = set()
|
|
164
|
+
prefix = f"{label}/" if label else ""
|
|
165
|
+
|
|
166
|
+
for src in files:
|
|
167
|
+
dest = target_dir / src.name
|
|
168
|
+
wanted.add(dest.name)
|
|
169
|
+
owned = dest.is_symlink() and (
|
|
170
|
+
skills._points_into(dest, private_root) or skills._points_into(dest, cache_root)
|
|
171
|
+
)
|
|
172
|
+
if preserve_collisions and (dest.exists() or dest.is_symlink()) and not owned:
|
|
173
|
+
result.warnings.append(f"{dest} exists and was not created by dotbrain; skipping")
|
|
174
|
+
continue
|
|
175
|
+
if dest.exists() and not dest.is_symlink():
|
|
176
|
+
result.stashed.append(skills.stash_collision(dest))
|
|
177
|
+
if dest.is_symlink() or dest.exists():
|
|
178
|
+
dest.unlink()
|
|
179
|
+
dest.symlink_to(os.path.relpath(src, target_dir))
|
|
180
|
+
result.linked.append(f"{prefix}{dest.name}")
|
|
181
|
+
|
|
182
|
+
for entry in sorted(target_dir.iterdir()):
|
|
183
|
+
if not entry.is_symlink() or entry.name in wanted:
|
|
184
|
+
continue
|
|
185
|
+
owned = skills._points_into(entry, private_root) or skills._points_into(entry, cache_root)
|
|
186
|
+
if not owned:
|
|
187
|
+
continue
|
|
188
|
+
entry.unlink()
|
|
189
|
+
result.pruned.append(f"{prefix}{entry.name}")
|
|
190
|
+
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def load_global_config(dotbrain_home: Path) -> GlobalConfig:
|
|
195
|
+
path = Path(dotbrain_home) / "agents" / "agents.yaml"
|
|
196
|
+
if not path.is_file():
|
|
197
|
+
return GlobalConfig()
|
|
198
|
+
data = skills._read_yaml_mapping(path)
|
|
199
|
+
targets = data.get("targets") or {}
|
|
200
|
+
if not isinstance(targets, dict) or not all(
|
|
201
|
+
isinstance(k, str) and isinstance(v, str) for k, v in targets.items()
|
|
202
|
+
):
|
|
203
|
+
raise ValueError(f"{path}: targets must be a mapping of runtime -> destination")
|
|
204
|
+
merged_targets = dict(AGENT_TARGETS)
|
|
205
|
+
merged_targets.update(targets)
|
|
206
|
+
return GlobalConfig(
|
|
207
|
+
targets=merged_targets,
|
|
208
|
+
global_names=skills._clean(data.get("global")),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def load_global_subagents(dotbrain_home: Path) -> tuple[str, ...]:
|
|
213
|
+
return load_global_config(dotbrain_home).global_names
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def project_link_set(extras: Sequence[str]) -> tuple[str, ...]:
|
|
217
|
+
"""Compose per-project link set: required core + operator extras."""
|
|
218
|
+
|
|
219
|
+
return PROJECT_BASELINE + skills._clean(extras, exclude=PROJECT_BASELINE)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def link_project_subagents(
|
|
223
|
+
dotbrain_home: Path,
|
|
224
|
+
brainspace: Path,
|
|
225
|
+
workspaces: Sequence[str],
|
|
226
|
+
names: Sequence[str],
|
|
227
|
+
*,
|
|
228
|
+
workspace_dirs: Mapping[str, Path] | None = None,
|
|
229
|
+
) -> skills.LinkResult:
|
|
230
|
+
root = Path(dotbrain_home)
|
|
231
|
+
resolved = {name: _resolve_subagent_files(root, name) for name in names}
|
|
232
|
+
result = skills.LinkResult()
|
|
233
|
+
|
|
234
|
+
result.warnings += [f"subagent not found: {name}" for name, files in resolved.items() if not files]
|
|
235
|
+
|
|
236
|
+
for workspace in workspaces:
|
|
237
|
+
runtime = WORKSPACE_RUNTIME.get(workspace)
|
|
238
|
+
if runtime is None:
|
|
239
|
+
continue
|
|
240
|
+
files = [resolved[name][runtime] for name in names if runtime in resolved[name]]
|
|
241
|
+
ws_result = link_files_into(
|
|
242
|
+
root,
|
|
243
|
+
((workspace_dirs.get(workspace) if workspace_dirs else None) or Path(brainspace) / workspace) / "agents",
|
|
244
|
+
files,
|
|
245
|
+
label=f"{workspace}/agents",
|
|
246
|
+
preserve_collisions=True,
|
|
247
|
+
)
|
|
248
|
+
result.linked += ws_result.linked
|
|
249
|
+
result.pruned += ws_result.pruned
|
|
250
|
+
result.stashed += ws_result.stashed
|
|
251
|
+
result.warnings += ws_result.warnings
|
|
252
|
+
return result
|
dotbrain/updater.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Update released dotbrain CLI installations from PyPI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
from importlib import metadata
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
from urllib.error import URLError
|
|
12
|
+
from urllib.request import Request, urlopen
|
|
13
|
+
|
|
14
|
+
PACKAGE = "dotbrain"
|
|
15
|
+
_PYPI_URL = f"https://pypi.org/pypi/{PACKAGE}/json"
|
|
16
|
+
_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
|
|
17
|
+
Fetch = Callable[[str], dict[str, Any]]
|
|
18
|
+
Run = Callable[..., subprocess.CompletedProcess[str]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UpdateError(RuntimeError):
|
|
22
|
+
"""The CLI could not safely update itself."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def update_cli(current_version: str, *, fetch: Fetch | None = None,
|
|
26
|
+
run: Run = subprocess.run) -> str | None:
|
|
27
|
+
"""Install the latest stable release, returning its version or ``None`` when current."""
|
|
28
|
+
if _is_editable_install():
|
|
29
|
+
raise UpdateError("editable installs are updated from their checkout; use git pull there")
|
|
30
|
+
|
|
31
|
+
fetch = fetch or _fetch_json
|
|
32
|
+
target_version = _latest_version(fetch(_PYPI_URL))
|
|
33
|
+
if _version_parts(target_version) <= _version_parts(current_version):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
command = ["uv", "tool", "install", "--force", f"{PACKAGE}=={target_version}"]
|
|
37
|
+
if _is_windows():
|
|
38
|
+
_defer_install(command)
|
|
39
|
+
return target_version
|
|
40
|
+
try:
|
|
41
|
+
result = run(command, check=False)
|
|
42
|
+
except FileNotFoundError as exc:
|
|
43
|
+
raise UpdateError("uv is not on PATH; install uv and retry") from exc
|
|
44
|
+
if result.returncode:
|
|
45
|
+
raise UpdateError(f"uv could not install {target_version}; the existing CLI was left unchanged")
|
|
46
|
+
return target_version
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_windows() -> bool:
|
|
50
|
+
return os.name == "nt"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _defer_install(command: list[str]) -> None:
|
|
54
|
+
"""Run after this Windows launcher releases its executable lock."""
|
|
55
|
+
subprocess.Popen(
|
|
56
|
+
["cmd", "/c", f"ping -n 2 127.0.0.1 >NUL & {subprocess.list2cmdline(command)}"],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_editable_install() -> bool:
|
|
61
|
+
try:
|
|
62
|
+
distribution = metadata.distribution("dotbrain")
|
|
63
|
+
except metadata.PackageNotFoundError:
|
|
64
|
+
return False
|
|
65
|
+
raw = next(
|
|
66
|
+
(
|
|
67
|
+
distribution.locate_file(path).read_text(encoding="utf-8")
|
|
68
|
+
for path in distribution.files or ()
|
|
69
|
+
if path.name == "direct_url.json"
|
|
70
|
+
),
|
|
71
|
+
None,
|
|
72
|
+
)
|
|
73
|
+
if not raw:
|
|
74
|
+
return False
|
|
75
|
+
try:
|
|
76
|
+
return bool(json.loads(raw).get("dir_info", {}).get("editable"))
|
|
77
|
+
except (ValueError, AttributeError):
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _fetch_json(url: str) -> dict[str, Any]:
|
|
82
|
+
request = Request(url, headers={"Accept": "application/json", "User-Agent": "dotbrain"})
|
|
83
|
+
try:
|
|
84
|
+
with urlopen(request, timeout=10) as response: # noqa: S310 - fixed PyPI URL
|
|
85
|
+
data = json.load(response)
|
|
86
|
+
except (OSError, URLError, ValueError) as exc:
|
|
87
|
+
raise UpdateError("could not reach PyPI to check for updates") from exc
|
|
88
|
+
if not isinstance(data, dict):
|
|
89
|
+
raise UpdateError("PyPI returned an invalid update response")
|
|
90
|
+
return data
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _latest_version(release: dict[str, Any]) -> str:
|
|
94
|
+
info = release.get("info", {}) if isinstance(release.get("info"), dict) else {}
|
|
95
|
+
version = str(info.get("version", ""))
|
|
96
|
+
if not _VERSION_RE.fullmatch(version):
|
|
97
|
+
raise UpdateError("PyPI did not return a stable dotbrain release")
|
|
98
|
+
return version
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _version_parts(version: str) -> tuple[int, int, int]:
|
|
102
|
+
match = _VERSION_RE.fullmatch(version)
|
|
103
|
+
if not match:
|
|
104
|
+
raise UpdateError(f"unsupported installed version: {version}")
|
|
105
|
+
return tuple(int(part) for part in match.groups())
|