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/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """dotbrain CLI.
2
+
3
+ A testable Python CLI for wiring repos into Brainspaces, seeding Brains, and
4
+ managing skills. Exposes the workflow commands plus pure path/contract helpers.
5
+ """
6
+
7
+ __version__ = "0.3.4"
@@ -0,0 +1,115 @@
1
+ """Generator for ``docs/cli-reference.md``.
2
+
3
+ The committed page is a fixed-width (80-col) snapshot of ``dotbrain <cmd> --help`` for
4
+ every non-hidden command. ``test_cli_reference.py`` regenerates and diffs against the
5
+ committed file, failing when the CLI surface drifts. Regenerate with::
6
+
7
+ uv run python -m dotbrain._cli_reference
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ import typer
20
+
21
+ from .cli import app
22
+
23
+ _REPO_ROOT = Path(__file__).resolve().parents[2]
24
+ REFERENCE_PATH = _REPO_ROOT / "docs" / "cli-reference.md"
25
+
26
+ _INTRO = "# CLI Reference\n\nReference for the public `dotbrain` CLI.\n\n"
27
+
28
+
29
+ def console_script() -> Path:
30
+ """Locate the installed ``dotbrain`` entry point (next to the venv python)."""
31
+ exe = Path(sys.executable).with_name("dotbrain")
32
+ if exe.exists():
33
+ return exe
34
+ found = shutil.which("dotbrain")
35
+ if found:
36
+ return Path(found)
37
+ raise FileNotFoundError("dotbrain console script not found; install the package first")
38
+
39
+
40
+ def command_paths() -> list[list[str]]:
41
+ """Pre-order, alphabetically-sorted command paths, skipping hidden commands."""
42
+ root = typer.main.get_command(app)
43
+
44
+ def walk(cmd: object, path: list[str]) -> list[list[str]]:
45
+ out = [path]
46
+ subs = getattr(cmd, "commands", None)
47
+ if subs:
48
+ for name in sorted(subs):
49
+ sub = subs[name]
50
+ if getattr(sub, "hidden", False):
51
+ continue
52
+ out += walk(sub, path + [name])
53
+ return out
54
+
55
+ return walk(root, [])
56
+
57
+
58
+ # Rich draws Typer's help panels with rounded corners, except where Console.legacy_windows
59
+ # is true — a captured pipe on Windows — where box.ROUNDED substitutes the square set. The
60
+ # committed doc would otherwise depend on which OS regenerated it, so normalize to square.
61
+ _SQUARE_CORNERS = str.maketrans("╭╮╰╯", "┌┐└┘")
62
+
63
+
64
+ def _help(exe: Path, path: list[str]) -> str:
65
+ # PYTHONUTF8 matters beyond decoding: Rich's Console.ascii_only checks whether the captured
66
+ # stdout's encoding starts with "utf" and falls back to ASCII box-drawing characters otherwise.
67
+ # A captured pipe's default encoding on Windows is the legacy ANSI codepage, not UTF-8, unless
68
+ # this is set — so without it, the reference doc's box borders differ only on Windows.
69
+ env = {**os.environ, "COLUMNS": "80", "PYTHONUTF8": "1"}
70
+ result = subprocess.run(
71
+ [str(exe), *path, "--help"],
72
+ capture_output=True,
73
+ encoding="utf-8",
74
+ env=env,
75
+ check=True,
76
+ )
77
+ output = result.stdout.rstrip("\n")
78
+ # Click derives its usage-line prog_name from argv[0]'s basename: the installed console
79
+ # script's actual filename, which is "dotbrain" on POSIX but "dotbrain.exe"/"dotbrain.EXE" on
80
+ # Windows. Normalize to the canonical name so the committed doc doesn't depend on platform or
81
+ # installer casing. Match the exact invoked name (extension included), not a bare "dotbrain"
82
+ # substring — the help text itself contains unrelated words like "$DOTBRAIN_HOME".
83
+ if exe.name.lower() != "dotbrain":
84
+ output = re.sub(re.escape(exe.name), "dotbrain", output, flags=re.IGNORECASE)
85
+ output = output.translate(_SQUARE_CORNERS)
86
+ return "\n".join(line.rstrip() for line in output.splitlines())
87
+
88
+
89
+ def render() -> str:
90
+ exe = console_script()
91
+ sections = []
92
+ for path in command_paths():
93
+ suffix = (" " + " ".join(path)) if path else ""
94
+ sections.append(f"## `dotbrain{suffix}`\n\n```text\n{_help(exe, path)}\n```")
95
+ return _INTRO + "\n\n".join(sections) + "\n"
96
+
97
+
98
+ def main() -> None:
99
+ # Rich narrows its console by one column when legacy_windows is on, which it always is
100
+ # for a captured subprocess pipe on Windows. The committed page is therefore the POSIX
101
+ # rendering, and test_cli_reference.py skips on Windows for the same reason. Regenerating
102
+ # here would write an 80-col page as 79 and break CI's ubuntu/macos legs, where the guard
103
+ # actually runs — so refuse rather than write a page this platform cannot validate.
104
+ if sys.platform == "win32":
105
+ raise SystemExit(
106
+ "refusing to regenerate docs/cli-reference.md on Windows: Rich renders panels one "
107
+ "column narrower here, and the committed page is the POSIX rendering that CI "
108
+ "validates. Regenerate under WSL, Linux, or macOS."
109
+ )
110
+ REFERENCE_PATH.write_text(render(), encoding="utf-8", newline="\n")
111
+ print(f"wrote {REFERENCE_PATH}")
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()
@@ -0,0 +1,573 @@
1
+ """Adopter repo attachment, detachment, and Brainspace link reconciliation.
2
+
3
+ A Brainspace is a project's private context store; an adopter repo is an external checkout wired
4
+ into it. This module owns everything repo-facing:
5
+
6
+ - Brainspace link reconciliation;
7
+ - repo path resolution for a Brainspace (``repo_for_brainspace``);
8
+ - the foreign-dotbrain guards that refuse to hijack a repo already wired elsewhere;
9
+ - ``.git/info/exclude`` and agent-context pointer (AGENTS.md/CLAUDE.md) management;
10
+ - repo attachment (``wire_repo``), verification, and detachment (``unwire_repo``).
11
+
12
+ It depends only on ``paths``: every other concept module points into it, never the reverse.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ from collections.abc import Callable, Sequence
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+
24
+ from dotbrain import paths
25
+
26
+ # A subprocess seam: same shape as ``subprocess.run`` but easy to fake in tests.
27
+ Runner = Callable[..., "subprocess.CompletedProcess[str]"]
28
+
29
+
30
+ def _default_run(
31
+ argv: Sequence[str], *, cwd: Path | None = None, env: dict | None = None, check: bool = True
32
+ ) -> "subprocess.CompletedProcess[str]":
33
+ # stdin=DEVNULL is load-bearing: bd auto-enables non-interactive mode on a non-TTY stdin,
34
+ # so destructive steps (e.g. bd init --reinit-local) skip their confirmation prompt instead
35
+ # of blocking forever on terminal input while capture_output swallows the prompt text.
36
+ return subprocess.run(
37
+ list(argv), cwd=cwd, env=env, check=check,
38
+ capture_output=True, encoding="utf-8", stdin=subprocess.DEVNULL,
39
+ )
40
+
41
+
42
+ # --------------------------------------------------------------------------- Brainspace links
43
+
44
+
45
+ @dataclass
46
+ class ReconcileResult:
47
+ created: list[str] = field(default_factory=list)
48
+ repaired: list[str] = field(default_factory=list)
49
+ skipped: list[str] = field(default_factory=list)
50
+ collisions: list[str] = field(default_factory=list)
51
+
52
+
53
+ def _symlink_directory(path: Path, target_str: str) -> None:
54
+ """Create a directory symlink at ``path`` or raise a clear privilege failure."""
55
+ try:
56
+ path.symlink_to(target_str, target_is_directory=True)
57
+ except OSError as exc:
58
+ message = paths.symlink_privilege_message(exc)
59
+ if message is None:
60
+ raise
61
+ raise RuntimeError(f"{path}: {message}") from exc
62
+
63
+
64
+ def reconcile(directory: Path, targets: dict[str, Path]) -> ReconcileResult:
65
+ """Reconcile a Brainspace link mapping into one directory."""
66
+ directory = Path(directory)
67
+ result = ReconcileResult()
68
+
69
+ for name, target in targets.items():
70
+ path = directory / name
71
+ target = Path(target)
72
+ target_str = str(target)
73
+
74
+ if not target.exists():
75
+ result.skipped.append(name)
76
+ continue
77
+
78
+ if path.is_symlink():
79
+ if paths.symlink_target_matches(os.readlink(path), target_str):
80
+ continue
81
+ path.unlink()
82
+ _symlink_directory(path, target_str)
83
+ result.repaired.append(name)
84
+ continue
85
+
86
+ if path.exists():
87
+ result.collisions.append(name)
88
+ continue
89
+
90
+ _symlink_directory(path, target_str)
91
+ result.created.append(name)
92
+
93
+ return result
94
+
95
+
96
+ # --------------------------------------------------------------------------- repo path resolution
97
+
98
+
99
+ def expand_path(raw: str, home: Path | None = None) -> Path:
100
+ """Expand a tilde-prefixed path to absolute. Mirrors bootstrap.sh expand_path.
101
+
102
+ Uses string offset (not pattern stripping) to avoid bash's tilde-in-pattern expansion bug.
103
+ """
104
+ h = Path(home) if home is not None else Path.home()
105
+ if raw == "~":
106
+ return h
107
+ if raw.startswith(("~/", "~\\")):
108
+ return h / raw[2:]
109
+ return Path(raw)
110
+
111
+
112
+ def repo_for_brainspace(
113
+ brainspace: Path,
114
+ dotbrain_home: Path,
115
+ repo_base: Path | None = None,
116
+ home: Path | None = None,
117
+ ) -> Path | None:
118
+ """Resolve the adopter repo path for a Brainspace. Mirrors bootstrap.sh repo_for_brainspace.
119
+
120
+ Resolution order:
121
+ 1. <brainspace>/.repo.local (machine-local override)
122
+ 2. <brainspace>/.repo (committed canonical pointer)
123
+ 3. dotbrain_home itself when brainspace.name == "dotbrain"
124
+ 4. repo_base/<brainspace.name> when the directory exists
125
+
126
+ A ``(brain-only)`` pointer resolves to ``None``: the Brainspace declares no adopter repo.
127
+ """
128
+ for pointer_name in (".repo.local", ".repo"):
129
+ pointer = brainspace / pointer_name
130
+ if pointer.is_file():
131
+ lines = [
132
+ l.strip() for l in pointer.read_text(encoding="utf-8").splitlines()
133
+ if l.strip() and not l.strip().startswith("#")
134
+ ]
135
+ if lines:
136
+ if lines[0] == "(brain-only)":
137
+ return None
138
+ return expand_path(lines[0], home)
139
+ if brainspace.name == "dotbrain":
140
+ return Path(dotbrain_home).resolve()
141
+ if repo_base:
142
+ candidate = Path(repo_base) / brainspace.name
143
+ if candidate.is_dir():
144
+ return candidate
145
+ return None
146
+
147
+
148
+ # --------------------------------------------------------------------------- pure helpers
149
+
150
+
151
+ def abbrev_home(path: Path, home: Path | None = None) -> str:
152
+ """Render ``path`` with ``$HOME`` collapsed to ``~`` (mirrors the script's abbrev_home)."""
153
+ home = Path(home) if home is not None else Path.home()
154
+ path = Path(path)
155
+ if path == home:
156
+ return "~"
157
+ try:
158
+ return f"~/{path.relative_to(home).as_posix()}"
159
+ except ValueError:
160
+ return path.as_posix()
161
+
162
+
163
+ def is_dotbrain_repo(repo: Path, dotbrain_home: Path) -> bool:
164
+ return Path(repo).resolve() == Path(dotbrain_home).resolve()
165
+
166
+
167
+ def target_is_outside_repo(repo: Path, path: Path) -> bool:
168
+ """True when ``path`` resolves to a location outside ``repo`` (broken target -> inside)."""
169
+ repo_real = Path(repo).resolve()
170
+ try:
171
+ target_real = Path(path).resolve(strict=True)
172
+ except OSError:
173
+ return False
174
+ return repo_real != target_real and repo_real not in target_real.parents
175
+
176
+
177
+ def is_dotbrain_checkout(root: Path) -> bool:
178
+ """True when ``root`` has the minimum structure expected of a dotbrain checkout."""
179
+ root = Path(root).resolve()
180
+ return (
181
+ (root / ".git").exists()
182
+ and any((root / d).is_dir() for d in paths.DATA_DIRS)
183
+ and (root / "templates" / ".brain" / "AGENTS.md").is_file()
184
+ )
185
+
186
+
187
+ def foreign_dotbrain_home_for_symlink(path: Path, link_name: str, dotbrain_home: Path) -> Path | None:
188
+ """Return a foreign dotbrain root when ``path`` proves it already belongs to another checkout."""
189
+ path = Path(path)
190
+ if not path.is_symlink():
191
+ return None
192
+ try:
193
+ target = path.resolve(strict=True)
194
+ except OSError:
195
+ return None
196
+ if target.name != link_name:
197
+ return None
198
+ project_dir = target.parent
199
+ data_dir = project_dir.parent
200
+ if data_dir.name not in paths.DATA_DIRS:
201
+ return None
202
+ inferred_root = data_dir.parent.resolve()
203
+ if inferred_root == Path(dotbrain_home).resolve():
204
+ return None
205
+ if not is_dotbrain_checkout(inferred_root):
206
+ return None
207
+ if target != paths.brainspace(inferred_root, project_dir.name) / link_name:
208
+ return None
209
+ return inferred_root
210
+
211
+
212
+ def ensure_not_wired_to_foreign_dotbrain(repo: Path, dotbrain_home: Path) -> None:
213
+ """Refuse rewiring when an adopter Brainspace link already resolves into another dotbrain checkout."""
214
+ repo = Path(repo)
215
+ current_root = Path(dotbrain_home).resolve()
216
+ for name in paths.BRAINSPACE_LINKS:
217
+ link = repo / name
218
+ foreign_root = foreign_dotbrain_home_for_symlink(link, name, current_root)
219
+ if foreign_root is None:
220
+ continue
221
+ raise RuntimeError(
222
+ f"{link} resolves into another dotbrain checkout at {foreign_root}; "
223
+ f"unwire {repo} or repoint {name} before wiring it to {current_root}"
224
+ )
225
+
226
+
227
+ def repo_root(repo: Path | None, run: Runner = _default_run) -> Path:
228
+ cwd = Path(repo) if repo is not None else None
229
+ argv = ["git", "rev-parse", "--show-toplevel"]
230
+ try:
231
+ res = run(argv, cwd=cwd, check=True)
232
+ except subprocess.CalledProcessError:
233
+ loc = str(cwd) if cwd else "current directory"
234
+ raise ValueError(f"{loc} is not inside a git repository; run 'git init' first or use --no-repo --name <name>")
235
+ return Path((res.stdout or "").strip()).resolve()
236
+
237
+
238
+ # --------------------------------------------------------------------------- excludes & symlinks
239
+
240
+
241
+ def ensure_exclude_line(exclude_file: Path, line: str) -> None:
242
+ exclude_file = Path(exclude_file)
243
+ exclude_file.parent.mkdir(parents=True, exist_ok=True)
244
+ existing = (
245
+ exclude_file.read_text(encoding="utf-8").splitlines()
246
+ if exclude_file.is_file()
247
+ else []
248
+ )
249
+ if line in existing:
250
+ return
251
+ body = exclude_file.read_text(encoding="utf-8") if exclude_file.is_file() else ""
252
+ if body and not body.endswith("\n"):
253
+ body += "\n"
254
+ exclude_file.write_text(body + line + "\n", encoding="utf-8", newline="\n")
255
+
256
+
257
+ def remove_exclude_line(exclude_file: Path, line: str) -> None:
258
+ exclude_file = Path(exclude_file)
259
+ if not exclude_file.is_file():
260
+ return
261
+ lines = exclude_file.read_text(encoding="utf-8").splitlines(keepends=True)
262
+ filtered = [existing for existing in lines if existing.rstrip("\r\n") != line]
263
+ if len(filtered) != len(lines):
264
+ exclude_file.write_text("".join(filtered), encoding="utf-8", newline="\n")
265
+
266
+
267
+ def git_exclude_file(repo: Path, run: Runner = _default_run) -> Path | None:
268
+ """Return the shared exclude file used by a repo and all its worktrees."""
269
+ res = run(["git", "-C", str(repo), "rev-parse", "--git-common-dir"], check=False)
270
+ raw = (res.stdout or "").strip() if res.returncode == 0 else ""
271
+ if not raw:
272
+ return None
273
+ common_dir = Path(raw)
274
+ if not common_dir.is_absolute():
275
+ common_dir = Path(repo) / common_dir
276
+ return common_dir.resolve() / "info" / "exclude"
277
+
278
+
279
+ def ensure_local_exclude_line(repo: Path, line: str, run: Runner = _default_run) -> None:
280
+ exclude_file = git_exclude_file(repo, run)
281
+ if exclude_file is not None:
282
+ ensure_exclude_line(exclude_file, line)
283
+
284
+
285
+ def remove_local_exclude_line(repo: Path, line: str, run: Runner = _default_run) -> None:
286
+ exclude_file = git_exclude_file(repo, run)
287
+ if exclude_file is not None:
288
+ remove_exclude_line(exclude_file, line)
289
+
290
+
291
+ def reconcile_link_excludes(
292
+ repo: Path,
293
+ *,
294
+ linked: Sequence[str] = (),
295
+ pruned: Sequence[str] = (),
296
+ run: Runner = _default_run,
297
+ ) -> None:
298
+ """Add and remove exact repo-relative excludes for reconciled links."""
299
+ exclude_file = git_exclude_file(repo, run)
300
+ if exclude_file is None:
301
+ return
302
+ for entry in linked:
303
+ ensure_exclude_line(exclude_file, f"/{entry.strip('/').replace(os.sep, '/')}")
304
+ for entry in pruned:
305
+ remove_exclude_line(exclude_file, f"/{entry.strip('/').replace(os.sep, '/')}")
306
+
307
+
308
+ def materialize_workspace(
309
+ repo: Path,
310
+ brainspace: Path,
311
+ name: str,
312
+ run: Runner = _default_run,
313
+ ) -> str | None:
314
+ """Replace a dotbrain-owned workspace link with a project-owned directory."""
315
+ repo = Path(repo)
316
+ # The mkdir below passes parents=True, so a .repo pointing at a path that does not
317
+ # exist would conjure the whole tree rather than fail: refreshing a Brainspace whose
318
+ # repo was moved or deleted silently created a phantom repo at the stale location.
319
+ if not repo.is_dir():
320
+ return f"{repo} does not exist; skipping {name} workspace"
321
+ workspace = repo / name
322
+ expected = Path(brainspace) / name
323
+ if workspace.is_symlink():
324
+ if not paths.symlink_target_matches(os.readlink(workspace), str(expected)):
325
+ return f"{workspace} is not a dotbrain workspace link; leaving it unchanged"
326
+ workspace.unlink()
327
+ elif workspace.exists() and not workspace.is_dir():
328
+ return f"{workspace} exists and is not a directory; leaving it unchanged"
329
+ workspace.mkdir(parents=True, exist_ok=True)
330
+ remove_local_exclude_line(repo, f"/{name}", run)
331
+ return None
332
+
333
+
334
+ def ensure_symlink(repo: Path, name: str, target: Path) -> str | None:
335
+ """Create/repair ``repo/name`` -> ``target``. Returns a warning if a real path is in the way."""
336
+ path = Path(repo) / name
337
+ target_str = str(target)
338
+ if path.is_symlink():
339
+ if paths.symlink_target_matches(os.readlink(path), target_str):
340
+ return None
341
+ path.unlink()
342
+ elif path.exists():
343
+ return f"{path} exists and is not a symlink; leaving it unchanged"
344
+ return _symlink_directory(path, target_str)
345
+
346
+
347
+ def warn_if_tracked_external_symlink(repo: Path, name: str, run: Runner = _default_run) -> str | None:
348
+ path = Path(repo) / name
349
+ if not path.is_symlink() or not target_is_outside_repo(repo, path):
350
+ return None
351
+ res = run(["git", "-C", str(repo), "ls-files", "--error-unmatch", name], check=False)
352
+ if res.returncode == 0:
353
+ return f"{path} points outside {repo} but is tracked; remove it with git rm --cached {name}"
354
+ return None
355
+
356
+
357
+ def append_pointer_to_file(file: Path, pointer: str) -> str | None:
358
+ file = Path(file)
359
+ if file.is_symlink():
360
+ try:
361
+ target = file.resolve(strict=True)
362
+ except OSError:
363
+ return f"{file} is a broken symlink; leaving it unchanged"
364
+ else:
365
+ target = file
366
+ if not target.is_file():
367
+ return f"{target} is not a regular file; leaving it unchanged"
368
+ text = target.read_text(encoding="utf-8")
369
+ if "@.brain/CLAUDE.md" in text:
370
+ return None
371
+ if text and not text.endswith("\n"):
372
+ text += "\n"
373
+ target.write_text(f"{text}\n{pointer}\n", encoding="utf-8", newline="\n")
374
+ return None
375
+
376
+
377
+ def ensure_agent_context_pointer(repo: Path, pointer: str = paths.ADOPTER_POINTER) -> list[str]:
378
+ repo = Path(repo)
379
+ agents = repo / "AGENTS.md"
380
+ claude = repo / "CLAUDE.md"
381
+ if not agents.exists() and not claude.exists():
382
+ agents.write_text(f"{pointer}\n", encoding="utf-8", newline="\n")
383
+ return []
384
+
385
+ warnings: list[str] = []
386
+ seen: set[Path] = set()
387
+ for file in (agents, claude):
388
+ if not file.exists():
389
+ continue
390
+ if file.is_symlink():
391
+ try:
392
+ target = file.resolve(strict=True)
393
+ except OSError:
394
+ warnings.append(f"{file} is a broken symlink; leaving it unchanged")
395
+ continue
396
+ else:
397
+ target = file
398
+ if target in seen:
399
+ continue
400
+ seen.add(target)
401
+ warning = append_pointer_to_file(file, pointer)
402
+ if warning:
403
+ warnings.append(warning)
404
+ return warnings
405
+
406
+
407
+ # --------------------------------------------------------------------------- attach / verify
408
+
409
+
410
+ def wire_repo(
411
+ repo: Path,
412
+ brainspace: Path,
413
+ dotbrain_home: Path,
414
+ run: Runner = _default_run,
415
+ *,
416
+ skip_beads_link: bool = False,
417
+ workspace_links: Sequence[str] = (),
418
+ ) -> list[str]:
419
+ """Link active Brainspace symlinks into ``repo`` and add local excludes."""
420
+ repo = Path(repo)
421
+ ensure_not_wired_to_foreign_dotbrain(repo, dotbrain_home)
422
+ use_local_excludes = not is_dotbrain_repo(repo, dotbrain_home)
423
+ warnings: list[str] = []
424
+ active_links: tuple[str, ...] = (".brain", *(workspace_links or ()))
425
+ if not skip_beads_link:
426
+ active_links += (".beads",)
427
+ targets = {name: Path(brainspace) / name for name in active_links}
428
+ result = reconcile(repo, targets)
429
+ for name in result.skipped:
430
+ warnings.append(f"{targets[name]} is missing; skipping {repo}/{name}")
431
+ for name in result.collisions:
432
+ warnings.append(f"{repo / name} exists and is not a symlink; leaving it unchanged")
433
+ for name in targets:
434
+ if use_local_excludes:
435
+ ensure_local_exclude_line(repo, f"/{name}", run)
436
+ tracked = warn_if_tracked_external_symlink(repo, name, run)
437
+ if tracked:
438
+ warnings.append(tracked)
439
+ return warnings
440
+
441
+
442
+ def verify_wiring(repo: Path, run: Runner = _default_run, *, expected_links: Sequence[str] = paths.BRAINSPACE_LINKS) -> list[str]:
443
+ repo = Path(repo)
444
+ warnings: list[str] = []
445
+ for name in expected_links:
446
+ link = repo / name
447
+ if not link.is_symlink():
448
+ warnings.append(f"{repo}/{name} is not wired")
449
+ continue
450
+ try:
451
+ link.resolve(strict=True)
452
+ except OSError:
453
+ warnings.append(f"{repo}/{name} is a broken symlink")
454
+ if shutil.which("bd") and (repo / ".beads").is_dir():
455
+ res = run(["bd", "-C", str(repo), "ready"], check=False)
456
+ if res.returncode != 0:
457
+ warnings.append(f"bd ready failed in {repo}")
458
+ return warnings
459
+
460
+
461
+ # --------------------------------------------------------------------------- detach
462
+
463
+
464
+ @dataclass
465
+ class UnwireResult:
466
+ project: str = ""
467
+ repo: Path | None = None
468
+ logs: list[str] = field(default_factory=list)
469
+ warnings: list[str] = field(default_factory=list)
470
+
471
+
472
+ def _managed_workspace_links(repo: Path, dotbrain_home: Path) -> list[Path]:
473
+ root = Path(dotbrain_home).resolve()
474
+ links: list[Path] = []
475
+ for name in (".claude", ".codex"):
476
+ workspace = Path(repo) / name
477
+ if workspace.is_symlink():
478
+ candidates = [workspace]
479
+ elif workspace.is_dir():
480
+ candidates = list(workspace.rglob("*"))
481
+ else:
482
+ continue
483
+ for entry in candidates:
484
+ if not entry.is_symlink():
485
+ continue
486
+ try:
487
+ if entry.resolve().is_relative_to(root):
488
+ links.append(entry)
489
+ except OSError:
490
+ continue
491
+ return links
492
+
493
+
494
+ def _remove_empty_workspace_dirs(repo: Path) -> list[Path]:
495
+ removed: list[Path] = []
496
+ for name in (".claude", ".codex"):
497
+ workspace = Path(repo) / name
498
+ for directory in sorted(
499
+ (path for path in workspace.rglob("*") if path.is_dir() and not path.is_symlink()),
500
+ key=lambda path: len(path.parts),
501
+ reverse=True,
502
+ ) if workspace.is_dir() and not workspace.is_symlink() else []:
503
+ if not any(directory.iterdir()):
504
+ directory.rmdir()
505
+ if workspace.is_dir() and not workspace.is_symlink() and not any(workspace.iterdir()):
506
+ workspace.rmdir()
507
+ removed.append(workspace)
508
+ return removed
509
+
510
+
511
+ def unwire_repo(
512
+ repo: Path,
513
+ dry_run: bool = False,
514
+ *,
515
+ dotbrain_home: Path | None = None,
516
+ run: Runner = _default_run,
517
+ ) -> UnwireResult:
518
+ """Remove agent workspace symlinks, exclude entries, and the adopter-context pointer.
519
+
520
+ With ``dry_run`` the repo is left untouched; logs report what would be removed.
521
+ """
522
+ result = UnwireResult(repo=repo)
523
+ verb = "would remove" if dry_run else "removed"
524
+
525
+ managed_links = _managed_workspace_links(repo, dotbrain_home) if dotbrain_home else []
526
+ managed_entries = [link.relative_to(repo).as_posix() for link in managed_links]
527
+
528
+ for name in paths.BRAINSPACE_LINKS:
529
+ link = repo / name
530
+ if link.is_symlink():
531
+ if not dry_run:
532
+ link.unlink()
533
+ result.logs.append(f"{verb} symlink {name}")
534
+
535
+ for link, entry in zip(managed_links, managed_entries):
536
+ if not dry_run:
537
+ link.unlink()
538
+ result.logs.append(f"{verb} workspace link {entry}")
539
+
540
+ exclude = git_exclude_file(repo, run)
541
+ if exclude and exclude.is_file():
542
+ lines = exclude.read_text(encoding="utf-8").splitlines(keepends=True)
543
+ excludes = {*paths.EXCLUDE_ENTRIES, "/.claude", "/.codex"}
544
+ excludes.update(f"/{entry}" for entry in managed_entries)
545
+ filtered = [l for l in lines if l.rstrip("\r\n") not in excludes]
546
+ if len(filtered) < len(lines):
547
+ if not dry_run:
548
+ exclude.write_text("".join(filtered), encoding="utf-8", newline="\n")
549
+ result.logs.append(f"{verb} dotbrain ignore rules from .git/info/exclude")
550
+
551
+ if not dry_run:
552
+ for workspace in _remove_empty_workspace_dirs(repo):
553
+ result.logs.append(f"removed empty workspace {workspace.name}")
554
+
555
+ for fname in ("AGENTS.md", "CLAUDE.md"):
556
+ f = repo / fname
557
+ if not f.exists() or f.is_symlink():
558
+ continue
559
+ text = f.read_text(encoding="utf-8")
560
+ pointer_lines = set(paths.ADOPTER_POINTER.strip().splitlines())
561
+ if any(pl in text for pl in pointer_lines):
562
+ if not dry_run:
563
+ cleaned = "\n".join(
564
+ l for l in text.splitlines() if l.strip() not in pointer_lines
565
+ ).strip()
566
+ f.write_text(
567
+ cleaned + "\n" if cleaned else "",
568
+ encoding="utf-8",
569
+ newline="\n",
570
+ )
571
+ result.logs.append(f"{verb} agent-context pointer from {fname}")
572
+
573
+ return result