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.
Files changed (40) hide show
  1. dotbrain/__init__.py +7 -0
  2. dotbrain/_cli_reference.py +115 -0
  3. dotbrain/adopter_repos.py +573 -0
  4. dotbrain/beads.py +511 -0
  5. dotbrain/bootstrap.py +199 -0
  6. dotbrain/brainspaces.py +238 -0
  7. dotbrain/cli.py +660 -0
  8. dotbrain/config.py +428 -0
  9. dotbrain/doctor.py +279 -0
  10. dotbrain/hooks.py +84 -0
  11. dotbrain/migrate.py +304 -0
  12. dotbrain/paths.py +139 -0
  13. dotbrain/resource_loader.py +47 -0
  14. dotbrain/resources/__init__.py +1 -0
  15. dotbrain/resources/agents/claude/implementer.md +47 -0
  16. dotbrain/resources/agents/claude/investigator.md +35 -0
  17. dotbrain/resources/agents/claude/reviewer.md +38 -0
  18. dotbrain/resources/agents/claude/verifier.md +35 -0
  19. dotbrain/resources/agents/codex/implementer.toml +26 -0
  20. dotbrain/resources/agents/codex/investigator.toml +21 -0
  21. dotbrain/resources/agents/codex/reviewer.toml +27 -0
  22. dotbrain/resources/agents/codex/verifier.toml +21 -0
  23. dotbrain/resources/config.yaml +20 -0
  24. dotbrain/resources/core.yaml +18 -0
  25. dotbrain/resources/templates/brain/AGENTS.md +9 -0
  26. dotbrain/resources/templates/brain/DOTBRAIN.md +105 -0
  27. dotbrain/resources/templates/brain/adr/README.md +8 -0
  28. dotbrain/resources/templates/brain/designs/README.md +28 -0
  29. dotbrain/resources/templates/brain/docs/README.md +9 -0
  30. dotbrain/resources/templates/brain/project.yaml +27 -0
  31. dotbrain/resources/templates/gitignore +17 -0
  32. dotbrain/skills.py +264 -0
  33. dotbrain/subagents.py +252 -0
  34. dotbrain/updater.py +105 -0
  35. dotbrain/workflows.py +529 -0
  36. dotbrain-0.3.4.dist-info/METADATA +21 -0
  37. dotbrain-0.3.4.dist-info/RECORD +40 -0
  38. dotbrain-0.3.4.dist-info/WHEEL +4 -0
  39. dotbrain-0.3.4.dist-info/entry_points.txt +2 -0
  40. 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())