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
dotbrain/cli.py
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
"""dotbrain CLI entrypoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from dotbrain import __version__, updater
|
|
12
|
+
from dotbrain import doctor as doctor_mod
|
|
13
|
+
from dotbrain import adopter_repos, beads as beads_mod, bootstrap as bootstrap_mod, config, brainspaces, hooks, migrate, paths, resource_loader, skills, subagents, workflows
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
help="dotbrain CLI for wiring project Brainspaces and skills into coding agents.",
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
invoke_without_command=True,
|
|
19
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
20
|
+
)
|
|
21
|
+
skills_app = typer.Typer(help="Link dotbrain skills into agent runtimes.", no_args_is_help=True)
|
|
22
|
+
agents_app = typer.Typer(help="Link dotbrain vendor-native subagents into agent runtimes.", no_args_is_help=True)
|
|
23
|
+
beads_app = typer.Typer(help="Manage beads tracker state and backend.", no_args_is_help=True)
|
|
24
|
+
hook_app = typer.Typer(help="Run dotbrain hook entrypoints.", no_args_is_help=True)
|
|
25
|
+
app.add_typer(skills_app, name="skills")
|
|
26
|
+
app.add_typer(agents_app, name="agents")
|
|
27
|
+
app.add_typer(beads_app, name="beads")
|
|
28
|
+
app.add_typer(hook_app, name="hook")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.callback()
|
|
32
|
+
def main(
|
|
33
|
+
ctx: typer.Context,
|
|
34
|
+
version: bool = typer.Option(False, "--version", is_eager=True, help="Show the dotbrain version."),
|
|
35
|
+
) -> None:
|
|
36
|
+
if version:
|
|
37
|
+
typer.echo(__version__)
|
|
38
|
+
raise typer.Exit()
|
|
39
|
+
if ctx.invoked_subcommand is None:
|
|
40
|
+
typer.echo(ctx.get_help())
|
|
41
|
+
raise typer.Exit()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@hook_app.command("session-start")
|
|
45
|
+
def hook_session_start(args: list[str] = typer.Argument(None)) -> None:
|
|
46
|
+
"""Emit a wired repo's Brain context. Fail-open: silent and exit 0 when there is none."""
|
|
47
|
+
|
|
48
|
+
hooks.emit_brain_context()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command()
|
|
52
|
+
def bootstrap(
|
|
53
|
+
only: Optional[str] = typer.Option(None, "--only", help="skills"),
|
|
54
|
+
skip_skills: bool = typer.Option(False, "--skip-skills"),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Prepare this machine for dotbrain: global skill and subagent links."""
|
|
57
|
+
if only and only != "skills":
|
|
58
|
+
raise typer.BadParameter(f"invalid --only: {only}")
|
|
59
|
+
|
|
60
|
+
root = paths.resolve_dotbrain_home()
|
|
61
|
+
|
|
62
|
+
# Seed data root (config.yaml, skills/skills.yaml) if missing.
|
|
63
|
+
try:
|
|
64
|
+
dr_result = bootstrap_mod.ensure_data_root(root)
|
|
65
|
+
except RuntimeError as exc:
|
|
66
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
67
|
+
if dr_result.created:
|
|
68
|
+
typer.echo(f"[bootstrap] created data root: {root}")
|
|
69
|
+
if dr_result.git_initialized:
|
|
70
|
+
typer.echo(f"[bootstrap] initialized git checkout: {root}")
|
|
71
|
+
if dr_result.config_seeded:
|
|
72
|
+
typer.echo(f"[bootstrap] seeded config.yaml into {root}")
|
|
73
|
+
if dr_result.skills_seeded:
|
|
74
|
+
typer.echo(f"[bootstrap] seeded skills/skills.yaml into {root}")
|
|
75
|
+
if dr_result.agents_seeded:
|
|
76
|
+
typer.echo(f"[bootstrap] seeded agents/agents.yaml into {root}")
|
|
77
|
+
|
|
78
|
+
run_skills = only == "skills" or (only is None and not skip_skills)
|
|
79
|
+
|
|
80
|
+
if run_skills:
|
|
81
|
+
try:
|
|
82
|
+
_render_global_skill_link(root, "all")
|
|
83
|
+
_render_global_agent_link(root, "all")
|
|
84
|
+
except RuntimeError as exc:
|
|
85
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Doctor's glyphs, with an ASCII fallback. A Windows console still running a legacy
|
|
89
|
+
# code page (cp1252) cannot encode these, and typer.echo raises UnicodeEncodeError
|
|
90
|
+
# mid-report — a health check that crashes on the machine least likely to be healthy.
|
|
91
|
+
_GLYPHS = {"ok": "✓", "warn": "⚠", "error": "✖", "rule": "─", "arrow": "→"}
|
|
92
|
+
_ASCII_GLYPHS = {"ok": "+", "warn": "!", "error": "x", "rule": "-", "arrow": "->"}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _glyphs() -> dict[str, str]:
|
|
96
|
+
"""Unicode glyphs when stdout can encode them, ASCII when it cannot."""
|
|
97
|
+
|
|
98
|
+
encoding = getattr(sys.stdout, "encoding", None) or "ascii"
|
|
99
|
+
try:
|
|
100
|
+
"".join(_GLYPHS.values()).encode(encoding)
|
|
101
|
+
except (UnicodeEncodeError, LookupError):
|
|
102
|
+
return _ASCII_GLYPHS
|
|
103
|
+
return _GLYPHS
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _render_doctor(report: doctor_mod.DoctorReport) -> None:
|
|
107
|
+
ok, warn, err = 0, 0, 0
|
|
108
|
+
glyph = _glyphs()
|
|
109
|
+
rule = glyph["rule"]
|
|
110
|
+
arrow = glyph["arrow"]
|
|
111
|
+
|
|
112
|
+
def _icon(status: str) -> str:
|
|
113
|
+
return glyph.get(status, "?")
|
|
114
|
+
|
|
115
|
+
typer.echo("\ndotbrain doctor")
|
|
116
|
+
typer.echo(rule * 60)
|
|
117
|
+
|
|
118
|
+
typer.echo("\nMachine readiness")
|
|
119
|
+
typer.echo(rule * 40)
|
|
120
|
+
for f in report.machine:
|
|
121
|
+
if f.status == "ok":
|
|
122
|
+
ok += 1
|
|
123
|
+
elif f.status == "warn":
|
|
124
|
+
warn += 1
|
|
125
|
+
else:
|
|
126
|
+
err += 1
|
|
127
|
+
typer.echo(f" {_icon(f.status)} {f.message}")
|
|
128
|
+
if f.suggestion:
|
|
129
|
+
typer.echo(f" {arrow} {f.suggestion}")
|
|
130
|
+
|
|
131
|
+
if report.projects:
|
|
132
|
+
typer.echo(f"\nProjects ({len(report.projects)})")
|
|
133
|
+
typer.echo(rule * 40)
|
|
134
|
+
for name, findings in report.projects.items():
|
|
135
|
+
for f in findings:
|
|
136
|
+
if f.status == "ok":
|
|
137
|
+
ok += 1
|
|
138
|
+
elif f.status == "warn":
|
|
139
|
+
warn += 1
|
|
140
|
+
else:
|
|
141
|
+
err += 1
|
|
142
|
+
typer.echo(f" {_icon(f.status)} [{name}] {f.message}")
|
|
143
|
+
if f.suggestion:
|
|
144
|
+
typer.echo(f" {arrow} {f.suggestion}")
|
|
145
|
+
|
|
146
|
+
typer.echo(f"\n{rule * 60}")
|
|
147
|
+
typer.echo(f" {ok} ok {warn} warnings {err} errors")
|
|
148
|
+
|
|
149
|
+
if err > 0:
|
|
150
|
+
typer.echo("\nNext: fix errors then re-run 'dotbrain doctor'")
|
|
151
|
+
raise typer.Exit(1)
|
|
152
|
+
elif warn > 0:
|
|
153
|
+
typer.echo("\nNext: 'dotbrain wire --all' (wire projects)")
|
|
154
|
+
typer.echo(" 'dotbrain bootstrap' (link global skills and subagents)")
|
|
155
|
+
typer.echo(" 'dotbrain beads load --all' (hydrate beads)")
|
|
156
|
+
else:
|
|
157
|
+
typer.echo("\nMachine is healthy. Run 'bd ready' for available work.")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command()
|
|
161
|
+
def doctor() -> None:
|
|
162
|
+
"""Read-only health check: machine readiness, project wiring, beads state drift."""
|
|
163
|
+
root = paths.resolve_dotbrain_home()
|
|
164
|
+
report = doctor_mod.run_doctor(root)
|
|
165
|
+
_render_doctor(report)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command()
|
|
169
|
+
def update() -> None:
|
|
170
|
+
"""Update this released CLI to the latest stable PyPI release."""
|
|
171
|
+
try:
|
|
172
|
+
target = updater.update_cli(__version__)
|
|
173
|
+
except updater.UpdateError as exc:
|
|
174
|
+
typer.echo(f"dotbrain update: {exc}", err=True)
|
|
175
|
+
raise typer.Exit(1) from exc
|
|
176
|
+
if target is None:
|
|
177
|
+
typer.echo(f"dotbrain {__version__} is already current")
|
|
178
|
+
elif sys.platform == "win32":
|
|
179
|
+
typer.echo(f"dotbrain update to {target} is starting; run 'dotbrain --version' in a moment to verify")
|
|
180
|
+
else:
|
|
181
|
+
typer.echo(f"dotbrain updated from {__version__} to {target}; run 'dotbrain --version' to verify")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@app.command()
|
|
185
|
+
def wire(
|
|
186
|
+
all: bool = typer.Option(False, "--all", help="Wire every adopter repo to its Brainspace (brain seeding and symlinks)."), # noqa: A002
|
|
187
|
+
repo: Optional[str] = typer.Option(None, "--repo", help="Repo to wire. Defaults to the current git repo."),
|
|
188
|
+
name: Optional[str] = typer.Option(None, "--name", help="Project/Brainspace name. Defaults to repo dir name."),
|
|
189
|
+
dotbrain: Optional[str] = typer.Option(None, "--dotbrain", help="dotbrain checkout. Defaults to $DOTBRAIN_HOME/inferred."),
|
|
190
|
+
skip_beads: bool = typer.Option(False, "--skip-beads", help="Do not initialize .beads when missing."),
|
|
191
|
+
remote: str = typer.Option("", "--beads-remote", help="Initialize beads from this Dolt remote."),
|
|
192
|
+
server_host: Optional[str] = typer.Option(None, "--beads-server-host", help="Init beads against an external Dolt sql-server. Defaults to beads.server.host in config.yaml."),
|
|
193
|
+
server_port: Optional[str] = typer.Option(None, "--beads-server-port", help="Dolt sql-server port. Defaults to beads.server.port in config.yaml."),
|
|
194
|
+
server_user: Optional[str] = typer.Option(None, "--beads-server-user", help="Dolt sql-server user. Defaults to beads.server.user in config.yaml."),
|
|
195
|
+
database: str = typer.Option("", "--beads-database", help="Dolt database name. Defaults to project name."),
|
|
196
|
+
no_repo: bool = typer.Option(False, "--no-repo", help="Create a brain-only Brainspace (no code repo). Requires --name."),
|
|
197
|
+
repo_base: Optional[Path] = typer.Option(None, "--repo-base", help="Base directory for adopter repos (default: ~/repos/projects)."),
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Create or repair a project Brainspace and wire an adopter repo.
|
|
200
|
+
|
|
201
|
+
Without --all: wire one project. With --all: reconcile every Brainspace.
|
|
202
|
+
"""
|
|
203
|
+
root = Path(dotbrain) if dotbrain else paths.resolve_dotbrain_home()
|
|
204
|
+
if all:
|
|
205
|
+
if repo or name or no_repo or remote:
|
|
206
|
+
raise typer.BadParameter("--all is mutually exclusive with --repo, --name, --no-repo, and --beads-remote")
|
|
207
|
+
try:
|
|
208
|
+
result = workflows.wire_all_projects(root, repo_base=repo_base)
|
|
209
|
+
except (ValueError, RuntimeError) as exc:
|
|
210
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
211
|
+
for line in result.logs:
|
|
212
|
+
typer.echo(f"[wire] {line}")
|
|
213
|
+
for w in result.warnings:
|
|
214
|
+
typer.echo(f"[wire] warning: {w}", err=True)
|
|
215
|
+
return
|
|
216
|
+
cfg = config.load_config(root)
|
|
217
|
+
server_host = server_host if server_host is not None else cfg.beads_server.host
|
|
218
|
+
server_port = server_port if server_port is not None else cfg.beads_server.port
|
|
219
|
+
server_user = server_user if server_user is not None else cfg.beads_server.user
|
|
220
|
+
try:
|
|
221
|
+
result = workflows.wire_project(
|
|
222
|
+
dotbrain_home=root,
|
|
223
|
+
repo=Path(repo) if repo else None,
|
|
224
|
+
project=name,
|
|
225
|
+
no_repo=no_repo,
|
|
226
|
+
run_beads=not skip_beads,
|
|
227
|
+
remote=remote,
|
|
228
|
+
server_host=server_host,
|
|
229
|
+
server_port=server_port,
|
|
230
|
+
server_user=server_user,
|
|
231
|
+
database=database,
|
|
232
|
+
)
|
|
233
|
+
except (ValueError, RuntimeError) as exc:
|
|
234
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
235
|
+
|
|
236
|
+
for line in result.logs:
|
|
237
|
+
typer.echo(f"[wire] {line}")
|
|
238
|
+
for warning in result.warnings:
|
|
239
|
+
typer.echo(f"[wire] warning: {warning}", err=True)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@app.command()
|
|
243
|
+
def refresh(
|
|
244
|
+
all: bool = typer.Option(False, "--all", help="Refresh every project workspace."), # noqa: A002
|
|
245
|
+
name: Optional[str] = typer.Option(None, "--name", help="Refresh one project by Brainspace name."),
|
|
246
|
+
repo_base: Optional[Path] = typer.Option(None, "--repo-base", help="Base directory for repo discovery."),
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Refresh Brain/workspace files, repo links, beads state, and project skills."""
|
|
249
|
+
if all and name:
|
|
250
|
+
raise typer.BadParameter("--all is mutually exclusive with --name")
|
|
251
|
+
if not all and not name:
|
|
252
|
+
raise typer.BadParameter("use --all or --name")
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
root = paths.resolve_dotbrain_home()
|
|
256
|
+
if all:
|
|
257
|
+
result = workflows.refresh_projects(root, repo_base=repo_base)
|
|
258
|
+
else:
|
|
259
|
+
assert name is not None
|
|
260
|
+
result = workflows.refresh_project(root, name, repo_base=repo_base)
|
|
261
|
+
except (ValueError, RuntimeError) as exc:
|
|
262
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
263
|
+
|
|
264
|
+
for line in result.logs:
|
|
265
|
+
typer.echo(f"[refresh] {line}")
|
|
266
|
+
for warning in result.warnings:
|
|
267
|
+
typer.echo(f"[refresh] warning: {warning}", err=True)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@app.command()
|
|
271
|
+
def unwire(
|
|
272
|
+
all: bool = typer.Option(False, "--all", help="Unwire every project Brainspace (keep only; see per-project --archive/--delete for destructive offboard)."), # noqa: A002
|
|
273
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Adopter repo path; defaults to cwd"),
|
|
274
|
+
name: Optional[str] = typer.Option(None, "--name", help="Project/Brainspace name"),
|
|
275
|
+
no_repo: bool = typer.Option(False, "--no-repo", help="Only offboard the named Brainspace; do not edit an adopter repo."),
|
|
276
|
+
archive: bool = typer.Option(False, "--archive", help="Move Brainspace to <data-dir>/.archive/"),
|
|
277
|
+
delete: bool = typer.Option(False, "--delete", help="Remove the Brainspace (destructive)"),
|
|
278
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview the offboard without performing it."),
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Disconnect an adopter repo from its Brainspace.
|
|
281
|
+
|
|
282
|
+
Offboards the Brainspace only (keep/archive/delete). To drop a server-backend project's
|
|
283
|
+
remote beads database, use `dotbrain beads drop-db` separately.
|
|
284
|
+
"""
|
|
285
|
+
if all:
|
|
286
|
+
if repo or name or no_repo:
|
|
287
|
+
raise typer.BadParameter("--all is mutually exclusive with --repo, --name, and --no-repo")
|
|
288
|
+
if archive or delete:
|
|
289
|
+
raise typer.BadParameter("--all does not support --archive or --delete; use per-project unwire for destructive offboard")
|
|
290
|
+
results = workflows.unwire_all_projects(
|
|
291
|
+
dotbrain_home=paths.resolve_dotbrain_home(),
|
|
292
|
+
dry_run=dry_run,
|
|
293
|
+
)
|
|
294
|
+
for result in results:
|
|
295
|
+
for line in result.logs:
|
|
296
|
+
typer.echo(f"[{result.project}] {line}")
|
|
297
|
+
for w in result.warnings:
|
|
298
|
+
typer.echo(f"[{result.project}] warning: {w}", err=True)
|
|
299
|
+
return
|
|
300
|
+
if archive and delete:
|
|
301
|
+
typer.echo("error: --archive and --delete are mutually exclusive", err=True)
|
|
302
|
+
raise typer.Exit(2)
|
|
303
|
+
if no_repo and repo is not None:
|
|
304
|
+
raise typer.BadParameter("--no-repo is mutually exclusive with --repo")
|
|
305
|
+
if no_repo and not name:
|
|
306
|
+
raise typer.BadParameter("--no-repo requires --name")
|
|
307
|
+
offboard = "archive" if archive else "delete" if delete else "keep"
|
|
308
|
+
result = workflows.unwire_project(
|
|
309
|
+
dotbrain_home=paths.resolve_dotbrain_home(),
|
|
310
|
+
repo=repo,
|
|
311
|
+
project=name,
|
|
312
|
+
no_repo=no_repo,
|
|
313
|
+
offboard=offboard,
|
|
314
|
+
dry_run=dry_run,
|
|
315
|
+
)
|
|
316
|
+
for line in result.logs:
|
|
317
|
+
typer.echo(line)
|
|
318
|
+
for w in result.warnings:
|
|
319
|
+
typer.echo(f"warning: {w}", err=True)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _resolve_beads_server(
|
|
323
|
+
server_host: Optional[str],
|
|
324
|
+
server_port: Optional[str],
|
|
325
|
+
server_user: Optional[str],
|
|
326
|
+
ssh_host: Optional[str],
|
|
327
|
+
) -> tuple[str, str, str, str]:
|
|
328
|
+
"""Resolve sql-server connection from flags, falling back to config.yaml beads.server."""
|
|
329
|
+
cfg = config.load_config(paths.resolve_dotbrain_home())
|
|
330
|
+
host = server_host if server_host is not None else cfg.beads_server.host
|
|
331
|
+
port = server_port if server_port is not None else cfg.beads_server.port
|
|
332
|
+
user = server_user if server_user is not None else cfg.beads_server.user
|
|
333
|
+
ssh = ssh_host if ssh_host is not None else cfg.beads_server.ssh_host
|
|
334
|
+
if not host:
|
|
335
|
+
raise typer.BadParameter(
|
|
336
|
+
"no Dolt sql-server configured; set beads.server.host in config.yaml "
|
|
337
|
+
"or pass --beads-server-host (embedded-backend projects have no remote database)"
|
|
338
|
+
)
|
|
339
|
+
return host, port, user, ssh
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@app.command("drop-beads-db", hidden=True)
|
|
343
|
+
@beads_app.command("drop-db")
|
|
344
|
+
def drop_beads_db(
|
|
345
|
+
name: str = typer.Argument(..., help="Beads database name to drop (usually the project name)."),
|
|
346
|
+
yes: bool = typer.Option(False, "--yes", help="Confirm the destructive drop."),
|
|
347
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview the drop without running it."),
|
|
348
|
+
ssh_host: Optional[str] = typer.Option(None, "--beads-ssh-host", help="SSH hop that can reach the sql-server; empty connects directly. Defaults to beads.server.ssh_host."),
|
|
349
|
+
server_host: Optional[str] = typer.Option(None, "--beads-server-host", help="Dolt sql-server host. Defaults to beads.server.host."),
|
|
350
|
+
server_port: Optional[str] = typer.Option(None, "--beads-server-port", help="Dolt sql-server port. Defaults to beads.server.port."),
|
|
351
|
+
server_user: Optional[str] = typer.Option(None, "--beads-server-user", help="Dolt sql-server user. Defaults to beads.server.user."),
|
|
352
|
+
) -> None:
|
|
353
|
+
"""Drop a project's remote beads database on the shared Dolt sql-server."""
|
|
354
|
+
if not (yes or dry_run):
|
|
355
|
+
raise typer.BadParameter("beads drop-db requires --yes (or --dry-run)")
|
|
356
|
+
host, port, user, ssh = _resolve_beads_server(
|
|
357
|
+
server_host, server_port, server_user, ssh_host
|
|
358
|
+
)
|
|
359
|
+
try:
|
|
360
|
+
log = beads_mod.drop_remote_beads_database(
|
|
361
|
+
name, server_host=host, server_port=port, server_user=user, ssh_host=ssh, dry_run=dry_run
|
|
362
|
+
)
|
|
363
|
+
except ValueError as exc: # unsafe/protected database name
|
|
364
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
365
|
+
typer.echo(log)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@app.command("list-beads-db", hidden=True)
|
|
369
|
+
@beads_app.command("list-db")
|
|
370
|
+
def list_beads_db(
|
|
371
|
+
ssh_host: Optional[str] = typer.Option(None, "--beads-ssh-host", help="SSH hop that can reach the sql-server; empty connects directly. Defaults to beads.server.ssh_host."),
|
|
372
|
+
server_host: Optional[str] = typer.Option(None, "--beads-server-host", help="Dolt sql-server host. Defaults to beads.server.host."),
|
|
373
|
+
server_port: Optional[str] = typer.Option(None, "--beads-server-port", help="Dolt sql-server port. Defaults to beads.server.port."),
|
|
374
|
+
server_user: Optional[str] = typer.Option(None, "--beads-server-user", help="Dolt sql-server user. Defaults to beads.server.user."),
|
|
375
|
+
) -> None:
|
|
376
|
+
"""List the databases on the shared Dolt sql-server."""
|
|
377
|
+
host, port, user, ssh = _resolve_beads_server(
|
|
378
|
+
server_host, server_port, server_user, ssh_host
|
|
379
|
+
)
|
|
380
|
+
for db in beads_mod.list_remote_beads_databases(
|
|
381
|
+
server_host=host, server_port=port, server_user=user, ssh_host=ssh
|
|
382
|
+
):
|
|
383
|
+
typer.echo(db)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@app.command("migrate-beads", hidden=True)
|
|
387
|
+
@beads_app.command("migrate")
|
|
388
|
+
def migrate_beads(
|
|
389
|
+
repo: Optional[str] = typer.Option(None, "--repo", help="Wired repo path; project name is its dir name."),
|
|
390
|
+
name: Optional[str] = typer.Option(None, "--name", help="Project/Brainspace name to migrate."),
|
|
391
|
+
all_projects: bool = typer.Option(False, "--all", help="Migrate every embedded Brainspace."),
|
|
392
|
+
dotbrain: Optional[str] = typer.Option(None, "--dotbrain", help="dotbrain checkout. Defaults to $DOTBRAIN_HOME/inferred."),
|
|
393
|
+
server_host: Optional[str] = typer.Option(None, "--beads-server-host", help="Target Dolt sql-server host. Defaults to beads.server.host in config.yaml."),
|
|
394
|
+
server_port: Optional[str] = typer.Option(None, "--beads-server-port", help="Dolt sql-server port. Defaults to beads.server.port in config.yaml."),
|
|
395
|
+
server_user: Optional[str] = typer.Option(None, "--beads-server-user", help="Dolt sql-server user. Defaults to beads.server.user in config.yaml."),
|
|
396
|
+
database: str = typer.Option("", "--beads-database", help="Dolt database name (single-project only). Defaults to project name."),
|
|
397
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print the planned bd sequence without running it."),
|
|
398
|
+
) -> None:
|
|
399
|
+
"""Migrate a local-only (embedded Dolt) beads tracker onto the remote sql-server, history intact."""
|
|
400
|
+
root = Path(dotbrain) if dotbrain else paths.resolve_dotbrain_home()
|
|
401
|
+
cfg = config.load_config(root)
|
|
402
|
+
host = server_host if server_host is not None else cfg.beads_server.host
|
|
403
|
+
port = server_port if server_port is not None else cfg.beads_server.port
|
|
404
|
+
user = server_user if server_user is not None else cfg.beads_server.user
|
|
405
|
+
if not host:
|
|
406
|
+
raise typer.BadParameter("no --beads-server-host given and none in config.yaml")
|
|
407
|
+
if all_projects and (repo or name):
|
|
408
|
+
raise typer.BadParameter("--all is mutually exclusive with --repo/--name")
|
|
409
|
+
|
|
410
|
+
if all_projects:
|
|
411
|
+
results = migrate.migrate_all(
|
|
412
|
+
dotbrain_home=root,
|
|
413
|
+
server_host=host,
|
|
414
|
+
server_port=port,
|
|
415
|
+
server_user=user,
|
|
416
|
+
dry_run=dry_run,
|
|
417
|
+
)
|
|
418
|
+
else:
|
|
419
|
+
project = name or adopter_repos.repo_root(Path(repo) if repo else None).name
|
|
420
|
+
results = [
|
|
421
|
+
migrate.safe_migrate_project(
|
|
422
|
+
dotbrain_home=root,
|
|
423
|
+
project=project,
|
|
424
|
+
server_host=host,
|
|
425
|
+
server_port=port,
|
|
426
|
+
server_user=user,
|
|
427
|
+
database=database,
|
|
428
|
+
dry_run=dry_run,
|
|
429
|
+
)
|
|
430
|
+
]
|
|
431
|
+
|
|
432
|
+
for r in results:
|
|
433
|
+
for line in r.logs:
|
|
434
|
+
typer.echo(f"[beads migrate] {line}")
|
|
435
|
+
if dry_run:
|
|
436
|
+
typer.echo(f"[beads migrate] {r.project}: planned bd sequence:")
|
|
437
|
+
for argv in r.planned_commands:
|
|
438
|
+
typer.echo(f" {' '.join(argv)}")
|
|
439
|
+
for w in r.warnings:
|
|
440
|
+
typer.echo(f"[beads migrate] warning: {w}", err=True)
|
|
441
|
+
|
|
442
|
+
failed = [
|
|
443
|
+
r for r in results
|
|
444
|
+
if r.status in {"aborted-count-mismatch", "migrated-unverified", "failed"}
|
|
445
|
+
]
|
|
446
|
+
if failed and not dry_run:
|
|
447
|
+
raise typer.Exit(1)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@beads_app.command("load")
|
|
451
|
+
def beads_load(
|
|
452
|
+
all: bool = typer.Option(False, "--all", help="Load tracker state for every Brainspace."), # noqa: A002
|
|
453
|
+
repo: Optional[str] = typer.Option(None, "--repo", help="Repo whose Brainspace to load. Defaults to the current git repo."),
|
|
454
|
+
name: Optional[str] = typer.Option(None, "--name", help="Project/Brainspace name to load."),
|
|
455
|
+
dotbrain: Optional[str] = typer.Option(None, "--dotbrain", help="dotbrain checkout. Defaults to $DOTBRAIN_HOME/inferred."),
|
|
456
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview what would be hydrated/pulled without mutating anything."),
|
|
457
|
+
) -> None:
|
|
458
|
+
"""Hydrate local beads state from tracked declarations: attach server trackers, init embedded
|
|
459
|
+
ones, then pull. Pull-only reconcile: never pushes, never touches symlinks or hooks.
|
|
460
|
+
|
|
461
|
+
Without --all: load one project (by --name, or the --repo/cwd repo). With --all: every brainspace
|
|
462
|
+
root declared to use beads.
|
|
463
|
+
"""
|
|
464
|
+
root = Path(dotbrain) if dotbrain else paths.resolve_dotbrain_home()
|
|
465
|
+
if all:
|
|
466
|
+
if repo or name:
|
|
467
|
+
raise typer.BadParameter("--all is mutually exclusive with --repo and --name")
|
|
468
|
+
projects = None
|
|
469
|
+
else:
|
|
470
|
+
if repo and name:
|
|
471
|
+
raise typer.BadParameter("--repo and --name are mutually exclusive")
|
|
472
|
+
try:
|
|
473
|
+
project = name or adopter_repos.repo_root(Path(repo) if repo else None).name
|
|
474
|
+
except ValueError as exc:
|
|
475
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
476
|
+
projects = [project]
|
|
477
|
+
|
|
478
|
+
result = beads_mod.pull_beads_for_all(root, projects=projects, dry_run=dry_run)
|
|
479
|
+
for line in result.logs:
|
|
480
|
+
typer.echo(f"[beads load] {line}")
|
|
481
|
+
for warning in result.warnings:
|
|
482
|
+
typer.echo(f"[beads load] warning: {warning}", err=True)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
_AGENT_WORKSPACES = {
|
|
486
|
+
"all": (".claude", ".codex"),
|
|
487
|
+
"claude-code": (".claude",),
|
|
488
|
+
"codex": (".codex",),
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@skills_app.command("list", hidden=True)
|
|
493
|
+
def skills_list() -> None:
|
|
494
|
+
"""List all skills in the skills tree."""
|
|
495
|
+
root = paths.resolve_dotbrain_home()
|
|
496
|
+
for skill in skills.discover_skills(root / "skills"):
|
|
497
|
+
typer.echo(skill)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@skills_app.command("link")
|
|
501
|
+
def skills_link(
|
|
502
|
+
target: str = typer.Option("all", "--target", help="claude-code | codex | all"),
|
|
503
|
+
scope: str = typer.Option("all", "--scope", help="global | project | all"),
|
|
504
|
+
project: Optional[str] = typer.Option(
|
|
505
|
+
None, "--project", help="limit project scope to one Brainspace by name"
|
|
506
|
+
),
|
|
507
|
+
) -> None:
|
|
508
|
+
"""Link skills into agent runtimes.
|
|
509
|
+
|
|
510
|
+
Both scopes are curated include-lists. Project links each project's
|
|
511
|
+
``project.yaml`` ``skills:`` selection into its agent workspaces. Global
|
|
512
|
+
links the operator's optional global selection into each runtime's skills
|
|
513
|
+
directory.
|
|
514
|
+
"""
|
|
515
|
+
if target not in {"claude-code", "codex", "all"}:
|
|
516
|
+
raise typer.BadParameter(f"invalid --target: {target}")
|
|
517
|
+
if scope not in {"global", "project", "all"}:
|
|
518
|
+
raise typer.BadParameter(f"invalid --scope: {scope}")
|
|
519
|
+
|
|
520
|
+
root = paths.resolve_dotbrain_home()
|
|
521
|
+
try:
|
|
522
|
+
if scope in {"project", "all"}:
|
|
523
|
+
_link_projects_native(root, target, project)
|
|
524
|
+
if scope in {"global", "all"}:
|
|
525
|
+
if project:
|
|
526
|
+
typer.echo("skill-link: warning: --project is ignored for global scope", err=True)
|
|
527
|
+
_render_global_skill_link(root, target)
|
|
528
|
+
except RuntimeError as exc:
|
|
529
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _link_projects_native(root: Path, target: str, project: Optional[str]) -> None:
|
|
533
|
+
workspaces = _AGENT_WORKSPACES[target]
|
|
534
|
+
if project:
|
|
535
|
+
brainspace = paths.brainspace(root, project)
|
|
536
|
+
if not brainspace.is_dir():
|
|
537
|
+
raise typer.BadParameter(f"no Brainspace: {paths.data_dir(root).name}/{project}")
|
|
538
|
+
brainspace_paths = [brainspace]
|
|
539
|
+
else:
|
|
540
|
+
brainspace_paths = paths.brainspaces(root)
|
|
541
|
+
for brainspace in brainspace_paths:
|
|
542
|
+
config.migrate_legacy_skill_manifest(root, brainspace.name)
|
|
543
|
+
extras = config.load_project_skills(root, brainspace.name)
|
|
544
|
+
skill_paths = skills.project_link_set(extras)
|
|
545
|
+
declared_workspaces = brainspaces.active_agent_workspaces(brainspace, root)
|
|
546
|
+
active_workspaces = tuple(ws for ws in workspaces if ws in declared_workspaces)
|
|
547
|
+
repo = adopter_repos.repo_for_brainspace(brainspace, root)
|
|
548
|
+
workspace_dirs, workspace_warnings = workflows.project_workspace_dirs(
|
|
549
|
+
brainspace, repo, active_workspaces
|
|
550
|
+
)
|
|
551
|
+
result = skills.link_project(
|
|
552
|
+
root,
|
|
553
|
+
brainspace,
|
|
554
|
+
tuple(workspace_dirs),
|
|
555
|
+
skill_paths,
|
|
556
|
+
workspace_dirs=workspace_dirs,
|
|
557
|
+
)
|
|
558
|
+
if repo is not None:
|
|
559
|
+
adopter_repos.reconcile_link_excludes(
|
|
560
|
+
repo,
|
|
561
|
+
linked=tuple(entry for entry in result.linked if entry.startswith((".claude/", ".codex/"))),
|
|
562
|
+
pruned=tuple(entry for entry in result.pruned if entry.startswith((".claude/", ".codex/"))),
|
|
563
|
+
)
|
|
564
|
+
for warning in workspace_warnings:
|
|
565
|
+
typer.echo(f"skill-link: warning: {warning} (project {brainspace.name})", err=True)
|
|
566
|
+
for warning in result.warnings:
|
|
567
|
+
typer.echo(f"skill-link: warning: {warning} (project {brainspace.name})", err=True)
|
|
568
|
+
for pruned in result.pruned:
|
|
569
|
+
typer.echo(f" pruned stale {brainspace.name}/{pruned}")
|
|
570
|
+
typer.echo(f"project: linked {len(result.linked)} skill(s) into {brainspace.name}")
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _render_global_skill_link(root: Path, target: str) -> None:
|
|
574
|
+
result = bootstrap_mod.link_global_skills(root, target)
|
|
575
|
+
for warning in result.warnings:
|
|
576
|
+
typer.echo(f"skill-link: warning: {warning}", err=True)
|
|
577
|
+
for line in result.logs:
|
|
578
|
+
typer.echo(line if line.startswith("global:") else f" {line}")
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
@agents_app.command("list", hidden=True)
|
|
582
|
+
def agents_list() -> None:
|
|
583
|
+
root = paths.resolve_dotbrain_home()
|
|
584
|
+
names: set[str] = set()
|
|
585
|
+
for subdir, ext in subagents.RUNTIME_SPEC.values():
|
|
586
|
+
runtime_dir = root / "agents" / subdir
|
|
587
|
+
if runtime_dir.is_dir():
|
|
588
|
+
for path in runtime_dir.glob(f"*{ext}"):
|
|
589
|
+
names.add(path.stem)
|
|
590
|
+
try:
|
|
591
|
+
resource_dir = resource_loader.resource(f"agents/{subdir}")
|
|
592
|
+
except FileNotFoundError:
|
|
593
|
+
continue
|
|
594
|
+
if resource_dir.is_dir():
|
|
595
|
+
for entry in resource_dir.iterdir():
|
|
596
|
+
if entry.is_file() and entry.name.endswith(ext):
|
|
597
|
+
names.add(Path(entry.name).stem)
|
|
598
|
+
for name in sorted(names):
|
|
599
|
+
typer.echo(name)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@agents_app.command("link")
|
|
603
|
+
def agents_link(
|
|
604
|
+
target: str = typer.Option("all", "--target", help="claude-code | codex | all"),
|
|
605
|
+
scope: str = typer.Option("all", "--scope", help="global | project | all"),
|
|
606
|
+
project: Optional[str] = typer.Option(
|
|
607
|
+
None,
|
|
608
|
+
"--project",
|
|
609
|
+
help="Limit project linking to a single Brainspace by name.",
|
|
610
|
+
),
|
|
611
|
+
) -> None:
|
|
612
|
+
if target not in {"claude-code", "codex", "all"}:
|
|
613
|
+
raise typer.BadParameter(f"invalid --target: {target}")
|
|
614
|
+
if scope not in {"global", "project", "all"}:
|
|
615
|
+
raise typer.BadParameter(f"invalid --scope: {scope}")
|
|
616
|
+
|
|
617
|
+
root = paths.resolve_dotbrain_home()
|
|
618
|
+
if scope in {"project", "all"}:
|
|
619
|
+
brainspaces_to_link = [paths.brainspace(root, project)] if project else paths.brainspaces(root)
|
|
620
|
+
if project and not brainspaces_to_link[0].exists():
|
|
621
|
+
raise typer.BadParameter(f"unknown project: {project}")
|
|
622
|
+
for brainspace in brainspaces_to_link:
|
|
623
|
+
names = subagents.project_link_set(config.load_project_subagents(root, brainspace.name))
|
|
624
|
+
declared_workspaces = brainspaces.active_agent_workspaces(brainspace, root)
|
|
625
|
+
active_workspaces = tuple(ws for ws in _AGENT_WORKSPACES[target] if ws in declared_workspaces)
|
|
626
|
+
repo = adopter_repos.repo_for_brainspace(brainspace, root)
|
|
627
|
+
workspace_dirs, workspace_warnings = workflows.project_workspace_dirs(
|
|
628
|
+
brainspace, repo, active_workspaces
|
|
629
|
+
)
|
|
630
|
+
result = subagents.link_project_subagents(
|
|
631
|
+
root,
|
|
632
|
+
brainspace,
|
|
633
|
+
tuple(workspace_dirs),
|
|
634
|
+
names,
|
|
635
|
+
workspace_dirs=workspace_dirs,
|
|
636
|
+
)
|
|
637
|
+
if repo is not None:
|
|
638
|
+
adopter_repos.reconcile_link_excludes(
|
|
639
|
+
repo,
|
|
640
|
+
linked=tuple(entry for entry in result.linked if entry.startswith((".claude/", ".codex/"))),
|
|
641
|
+
pruned=tuple(entry for entry in result.pruned if entry.startswith((".claude/", ".codex/"))),
|
|
642
|
+
)
|
|
643
|
+
for warning in workspace_warnings:
|
|
644
|
+
typer.echo(f"agent-link: warning: {warning} (project {brainspace.name})", err=True)
|
|
645
|
+
for warning in result.warnings:
|
|
646
|
+
typer.echo(f"agent-link: warning: {warning} (project {brainspace.name})", err=True)
|
|
647
|
+
for pruned in result.pruned:
|
|
648
|
+
typer.echo(f" pruned stale {brainspace.name}/{pruned}")
|
|
649
|
+
typer.echo(f"project: linked {len(result.linked)} subagent file(s) into {brainspace.name}")
|
|
650
|
+
|
|
651
|
+
if scope in {"global", "all"}:
|
|
652
|
+
_render_global_agent_link(root, target)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _render_global_agent_link(root: Path, target: str) -> None:
|
|
656
|
+
result = bootstrap_mod.link_global_subagents(root, target)
|
|
657
|
+
for warning in result.warnings:
|
|
658
|
+
typer.echo(f"agent-link: warning: {warning}", err=True)
|
|
659
|
+
for line in result.logs:
|
|
660
|
+
typer.echo(line if line.startswith("global:") else f" {line}")
|