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
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}")