mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(cli,docs): say that agent skills sync, and refuse ~/.claude as a mount root (BEA-117) (#138)
`.claude/skills/**` has always synced — deliberately, per the reservation rule's own comment — but the only sentence saying so sits under the heading "What beardrive does not sync". Nobody knows. Track B, the one real bug: `bdrive init ~/.claude` was accepted. The reserved-path rule matches ".claude/settings.json" on its directory segment, so at that mount root the file is bare "settings.json" — reserved by nothing — along with .credentials.json and every saved session under projects/. New exported config.AgentConfigDir folds the keys of agentHookConfigs the way ReservedDir folds (case, trailing dots), and init refuses before any network call or file write. Only that direction leaks: a mount CONTAINING ~/.claude still sees .claude/settings.json, reserved at any depth. Track A, the content job: a README Features bullet stating the positive claim, a 7th use-case page (plus its astro.config.mjs sidebar entry, without which it is invisible), and a `skills` template appended last to the registry so `docs` keeps the RECOMMENDED badge. The embed directive becomes `//go:embed all:files` — a plain pattern drops dot-prefixed paths silently, so the template whose whole payload is .claude/skills/<name>/SKILL.md would have shipped empty. templates_test.go's every-directory-holds-a-file rule now marks ancestors, not just the direct parent: skills is the first template more than one level deep, and the rule was stricter than its own stated reason (an intermediate directory on the way to a file is not empty). Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d3d92bf904
commit
4031495c81
@@ -109,6 +109,13 @@ actually read (and which hot ones nobody maintains).
|
||||
- **Conflict-safe** — concurrent edits resolve deterministically
|
||||
(last-writer-wins), and the losing version is preserved as a
|
||||
`name.bdrive-conflict-<device>-<time>` file. Nothing is silently dropped.
|
||||
- **Your agent's skills sync too** — `.claude/skills`, `.claude/commands`,
|
||||
`.claude/agents`, `AGENTS.md` and `CLAUDE.md` are ordinary files in the
|
||||
project, so a skill one person writes is on every teammate's disk before
|
||||
their agent's next turn — no export, no registry, no MCP server per client.
|
||||
Agent **hook** configuration never syncs: sharing what an agent *reads* is
|
||||
the product; sharing what it *runs* is not. Start a project from the
|
||||
`skills` template (`bdrive init --template skills`) for the shape.
|
||||
- **Selective sync** — a gitignore-style `.bdriveignore` opts files out, and
|
||||
`bdrive init . --only wiki,docs` (or the interactive prompt) narrows a mount
|
||||
to some of its subfolders by writing those same rules for you.
|
||||
@@ -229,7 +236,7 @@ hub's own storage, never something a syncing client points at directly:
|
||||
|---|---|
|
||||
| `bdrive login [server-url]` | Sign this device in (browser flow — the page names the account this terminal would act as and lets you switch before approving; `--device` forces the approval-link flow, and shells without a TTY fall back to it automatically; default server beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host). Switch hubs with `bdrive login <new-url>` |
|
||||
| `bdrive logout` | Sign this device out — revoke this device's token on the hub and clear it locally (`--forget` also drops the remembered server) |
|
||||
| `bdrive init [folder]` | Create/connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY, flags (`--name/--project/--server/--only/--template/--yes`) for scripts; `--template docs\|wiki\|para` starts the project from a structure (directories plus the `AGENTS.md` that explains them) instead of an empty folder; registers agent sync hooks and the login autostart in each platform's user config (`--no-hooks` skips the hooks), prints the project link; re-run to resume |
|
||||
| `bdrive init [folder]` | Create/connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY, flags (`--name/--project/--server/--only/--template/--yes`) for scripts; `--template docs\|wiki\|para\|skills` starts the project from a structure (directories plus the `AGENTS.md` that explains them) instead of an empty folder; registers agent sync hooks and the login autostart in each platform's user config (`--no-hooks` skips the hooks), prints the project link; re-run to resume |
|
||||
| `bdrive resume` | Restart the sync daemon for every project on this device that isn't paused — after a reboot, a crash, or a manual kill. Idempotent; this is what the login agent runs |
|
||||
| `bdrive autostart [install\|uninstall]` | Show, add, or remove the login registration that runs `bdrive resume` after a reboot — a launchd user agent on macOS, a systemd user unit on Linux. `bdrive init` installs it; `--no-autostart` skips it |
|
||||
| `bdrive stop [folder]` | Stop syncing, including agent sync hooks (files stay; `bdrive init` resumes) |
|
||||
@@ -419,7 +426,7 @@ subfolders** (e.g. `./wiki`). Every question has a flag (`--name`,
|
||||
prompts — it creates-or-joins a project named after the folder, empty, and
|
||||
syncs everything.
|
||||
|
||||
`--template docs`, `wiki` or `para` starts a **new** project from a
|
||||
`--template docs`, `wiki`, `para` or `skills` starts a **new** project from a
|
||||
structure rather than an empty folder: a small directory skeleton plus the
|
||||
`AGENTS.md` that tells an agent where a new note goes, when something is
|
||||
archived, and what a good filename looks like — which is the part that keeps
|
||||
@@ -433,7 +440,7 @@ joining a project that already exists never restructures it, and
|
||||
`--template` is refused together with `--only` (scope rules live in the
|
||||
synced `.bdriveignore`, so a scope that left out the template's folders would
|
||||
hide them for the whole team). Creating a project in the web UI offers the
|
||||
same three starting points. It writes `.bdrive/config.json`, seeds a starter
|
||||
same starting points. It writes `.bdrive/config.json`, seeds a starter
|
||||
`.bdriveignore` (node_modules, build dirs, caches, `.env*`), and starts the
|
||||
daemon — local changes are detected within seconds, and the agent sync
|
||||
hooks sync at every turn boundary. Not signed in yet? init runs the login
|
||||
|
||||
@@ -68,9 +68,10 @@ classDiagram
|
||||
+ReservedDir(name) .git .bdrive
|
||||
+ReservedName(name) .DS_Store, tmp
|
||||
+AgentHookConfig(p) bool
|
||||
+AgentConfigDir(name) bool
|
||||
+ReservedPath(p) bool
|
||||
}
|
||||
note for ReservedPath "Case-and-trailing-dot folded, so `.BDRIVE.` is still reserved. AgentHookConfig names the files an agent EXECUTES (.claude/settings.json, .codex/*, .gemini, .hermes, .mcp.json at any depth) — a synced project must never be able to hand a teammate's agent new hooks. Deliberately its own list, not derived from internal/agenthooks: config imports nothing"
|
||||
note for ReservedPath "Case-and-trailing-dot folded, so `.BDRIVE.` is still reserved. AgentHookConfig names the files an agent EXECUTES (.claude/settings.json, .codex/*, .gemini, .hermes, .mcp.json at any depth) — a synced project must never be able to hand a teammate's agent new hooks. Deliberately its own list, not derived from internal/agenthooks: config imports nothing. AgentConfigDir answers about ONE segment, for init's mount-root gate: the reserved rule only sees segments BELOW a root, so mounting ~/.claude itself leaves settings.json a bare top-level file"
|
||||
|
||||
class UnderRoot {
|
||||
<<internal/store — one rule>>
|
||||
@@ -187,11 +188,11 @@ classDiagram
|
||||
|
||||
class Templates {
|
||||
<<internal/templates>>
|
||||
go:embed files/docs, files/wiki, files/para
|
||||
go:embed all:files — docs, wiki, para, skills
|
||||
List / Get / Names
|
||||
WriteTo(dir) skips existing paths
|
||||
}
|
||||
note for Templates "init --template <name>: refused with --only and on an unknown name BEFORE any write. The hub seeds at creation (the CLI creates through POST /api/projects), so WriteTo runs only as the fallback for a hub too old to know the field, and in an already-initialized folder — the agent's post-init path. Skipping existing paths is what makes a double-seed a no-op"
|
||||
note for Templates "The embed directive is `all:` — a plain pattern drops dot-prefixed paths silently, and the skills template IS .claude/skills/<name>/SKILL.md. init --template <name>: refused with --only and on an unknown name BEFORE any write. The hub seeds at creation (the CLI creates through POST /api/projects), so WriteTo runs only as the fallback for a hub too old to know the field, and in an already-initialized folder — the agent's post-init path. Skipping existing paths is what makes a double-seed a no-op"
|
||||
|
||||
class syncBlocked {
|
||||
<<gate>>
|
||||
@@ -276,6 +277,7 @@ classDiagram
|
||||
Commands --> openSession : after the gate
|
||||
openSession --> MountRegistry : ResolveMount — self-heal only, never enrolls
|
||||
Commands --> Templates : init --template (seed) / init resume (agent's post-init path)
|
||||
Commands ..> ReservedPath : init refuses an AgentConfigDir mount root
|
||||
Commands --> startSync : init
|
||||
startSync --> MountRegistry : EnrollMount — the only writer
|
||||
startSync --> PausedMarker : clears
|
||||
|
||||
@@ -30,7 +30,7 @@ flowchart LR
|
||||
|
||||
store["object store (hub-owned)<br/>internal/remote: file:// s3:// gs://<br/>blobs + per-device journals"]
|
||||
|
||||
tpl["internal/templates<br/>go:embed'ed starting structures<br/>(docs, wiki, para: skeleton + AGENTS.md)"]
|
||||
tpl["internal/templates<br/>go:embed'ed starting structures<br/>(docs, wiki, para, skills: skeleton + AGENTS.md)"]
|
||||
|
||||
docs["web/docs — docs.beardrive.ai<br/>Astro/Starlight, deploys separately"]
|
||||
cloud["cloud/ (PRIVATE nested repo, gitignored)<br/>managed beardrive.ai: swaps AuthProvider,<br/>QuotaProvider, MetaStore seams"]
|
||||
|
||||
@@ -182,6 +182,16 @@ the folder was renamed or moved.`,
|
||||
"this device's credentials and every project's local data, so this is not a "+
|
||||
"project folder", folder, home)
|
||||
}
|
||||
// Same hole one directory over, and the one a user told to "sync
|
||||
// my skills" walks straight into with `bdrive init ~/.claude`.
|
||||
// filepath.Base on the already-absolute folder is the whole
|
||||
// check — no lookup that an unresolvable path could disable.
|
||||
if config.AgentConfigDir(filepath.Base(folder)) {
|
||||
return fmt.Errorf("%s is an agent's configuration directory: the reserved-path rule "+
|
||||
"only covers segments BELOW a mount root, so mounting it makes settings.json, "+
|
||||
".credentials.json and your saved sessions ordinary top-level files that sync "+
|
||||
"to the whole team. Sync %s instead", folder, filepath.Join(folder, "skills"))
|
||||
}
|
||||
if projectID != "" && projectName != "" {
|
||||
return fmt.Errorf("--project and --name are mutually exclusive")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package main
|
||||
|
||||
// Round 13 — the twin of round 7's $BDRIVE_HOME guard, one directory over.
|
||||
//
|
||||
// Round 7 closed `bdrive init $BDRIVE_HOME`. The identical shape exists for an
|
||||
// AGENT's config directory: the reserved-path rule matches
|
||||
// ".claude/settings.json" on its directory segment, and a mount whose ROOT is
|
||||
// ~/.claude leaves that file as bare "settings.json" — reserved by nothing.
|
||||
// Same for .credentials.json and everything under projects/, which is the
|
||||
// transcript of every session ever run on the machine.
|
||||
//
|
||||
// Every test asserts the SECURE behaviour. Helpers are prefixed secfx13; the
|
||||
// hub fixture and the real-binary runner are reused from sec_init_test.go.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// secfx13Claude builds a realistic ~/.claude: the hook config the reservation
|
||||
// rule exists to block, the credential file beside it, and one saved session
|
||||
// transcript. Each carries its own sentinel so a leak names itself.
|
||||
func secfx13Claude(t *testing.T, parent string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(parent, ".claude")
|
||||
if err := os.MkdirAll(filepath.Join(dir, "projects", "some-project"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write := func(p, body string) {
|
||||
if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(p)), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("settings.json", `{"hooks":{"PreToolUse":[{"command":"SECFX13-HOOK-COMMAND"}]}}`)
|
||||
write(".credentials.json", `{"token":"SECFX13-CREDENTIAL"}`)
|
||||
write("projects/some-project/session.jsonl", `{"text":"SECFX13-TRANSCRIPT"}`)
|
||||
// The one thing in here that SHOULD be shareable, so the test can tell
|
||||
// "refused the directory" apart from "refused everything".
|
||||
if err := os.MkdirAll(filepath.Join(dir, "skills", "example"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "skills", "example", "SKILL.md"),
|
||||
[]byte("# example skill\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// `bdrive init ~/.claude` must refuse before it writes or calls anything.
|
||||
//
|
||||
// The assertion that matters is the last one: whatever init printed, no
|
||||
// sentinel from the agent's config directory may reach the hub.
|
||||
func TestSec_Init_RefusesAnAgentConfigDirectoryAsAMountRoot(t *testing.T) {
|
||||
e := secinitNewEnv(t, secinitHubOpts{})
|
||||
|
||||
claude := secfx13Claude(t, t.TempDir())
|
||||
|
||||
defer e.run(claude, "stop", claude)
|
||||
out, err := e.run(claude, "init", "--name", "claude-config", "--server", e.url, "--yes")
|
||||
if err == nil {
|
||||
t.Errorf("init mounted an agent's configuration directory:\n%s\n"+
|
||||
"the reserved-path rule only covers segments BELOW a mount root, so at this root "+
|
||||
"settings.json, .credentials.json and every saved session transcript are ordinary "+
|
||||
"top-level files", out)
|
||||
} else if !strings.Contains(out, ".claude") {
|
||||
t.Errorf("the refusal does not name the directory, so a user cannot act on it:\n%s", out)
|
||||
}
|
||||
if _, serr := os.Stat(filepath.Join(claude, ".bdrive", "config.json")); serr == nil {
|
||||
t.Error("init wrote .bdrive/config.json before refusing; the guard must land before any file write")
|
||||
}
|
||||
if len(e.sentAuth()) != 0 {
|
||||
t.Error("init reached the hub before refusing; the guard must land before any network call")
|
||||
}
|
||||
|
||||
e.run(claude, "sync")
|
||||
for key, body := range e.pushed() {
|
||||
for _, sentinel := range []string{"SECFX13-HOOK-COMMAND", "SECFX13-CREDENTIAL", "SECFX13-TRANSCRIPT"} {
|
||||
if strings.Contains(string(body), sentinel) {
|
||||
t.Fatalf("%s from the agent's config directory was pushed to the hub as object %s:\n%s",
|
||||
sentinel, key, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every directory that keys a reserved hook config, under the spellings the
|
||||
// filesystem folds onto it. A guard that only knows the exact string
|
||||
// ".claude" is bypassed by the name the same directory also answers to.
|
||||
func TestSec_Init_RefusesEveryAgentConfigDirectorySpelling(t *testing.T) {
|
||||
e := secinitNewEnv(t, secinitHubOpts{})
|
||||
|
||||
for _, name := range []string{".claude", ".codex", ".gemini", ".hermes", ".CLAUDE"} {
|
||||
dir := filepath.Join(t.TempDir(), name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "settings.json"),
|
||||
[]byte(`{"hooks":"SECFX13-`+name+`"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := e.run(dir, "init", "--name", "cfg"+name, "--server", e.url, "--yes")
|
||||
if err == nil {
|
||||
e.run(dir, "stop", dir)
|
||||
t.Errorf("init mounted %s: its hook config is a top-level file at this root\n%s", name, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The positive case, which is the whole point of the feature this guard ships
|
||||
// with: ~/.claude/skills is what every doc tells people to sync, and
|
||||
// filepath.Base of it is "skills".
|
||||
func TestSec_Init_StillMountsTheSkillsDirectory(t *testing.T) {
|
||||
e := secinitNewEnv(t, secinitHubOpts{})
|
||||
|
||||
claude := secfx13Claude(t, t.TempDir())
|
||||
skills := filepath.Join(claude, "skills")
|
||||
|
||||
defer e.run(skills, "stop", skills)
|
||||
out, err := e.run(skills, "init", "--name", "team-skills", "--server", e.url, "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("init refused %s, the directory the docs tell users to sync: %v\n%s", skills, err, out)
|
||||
}
|
||||
if _, serr := os.Stat(filepath.Join(skills, ".bdrive", "config.json")); serr != nil {
|
||||
t.Fatalf("init reported success but wrote no project config: %v", serr)
|
||||
}
|
||||
|
||||
// And mounting it carries the skill without carrying anything from the
|
||||
// agent config directory above it.
|
||||
e.run(skills, "sync")
|
||||
var sawSkill bool
|
||||
for key, body := range e.pushed() {
|
||||
if strings.Contains(string(body), "# example skill") {
|
||||
sawSkill = true
|
||||
}
|
||||
for _, sentinel := range []string{"SECFX13-HOOK-COMMAND", "SECFX13-CREDENTIAL", "SECFX13-TRANSCRIPT"} {
|
||||
if strings.Contains(string(body), sentinel) {
|
||||
t.Fatalf("mounting %s pushed %s from its parent as object %s", skills, sentinel, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawSkill {
|
||||
t.Error("the skill file never reached the hub — sharing what an agent reads is the product")
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,30 @@ var agentHookConfigs = map[string][]string{
|
||||
".hermes": {"config.yaml"},
|
||||
}
|
||||
|
||||
// AgentConfigDir reports whether a path segment names an agent's
|
||||
// configuration directory — the keys of agentHookConfigs — under the same
|
||||
// case and trailing-dot folding ReservedDir explains.
|
||||
//
|
||||
// It exists for one caller: `bdrive init` refusing such a directory as a
|
||||
// MOUNT ROOT. The reserved-path rule only covers segments BELOW a root, so
|
||||
// mounting ~/.claude leaves its settings.json a top-level file with no
|
||||
// directory segment to match on — along with .credentials.json and every
|
||||
// saved session under projects/. Only that direction leaks: a mount that
|
||||
// CONTAINS ~/.claude sees .claude/settings.json, reserved at any depth.
|
||||
//
|
||||
// Exported here rather than spelled as a literal list in cmd/bdrive for the
|
||||
// reason agentHookConfigs' own comment gives: a second copy of that list is
|
||||
// how .mcp.json drifted out of it once already.
|
||||
func AgentConfigDir(name string) bool {
|
||||
name = strings.TrimRight(name, ". ")
|
||||
for dir := range agentHookConfigs {
|
||||
if strings.EqualFold(name, dir) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// agentHookFiles are the same thing at the folder ROOT, with no agent config
|
||||
// directory to key on: `.mcp.json` is Claude Code's project-scoped MCP server
|
||||
// definition. Reserved at any depth rather than at the root only, for the
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/runbear-io/beardrive/internal/config"
|
||||
)
|
||||
|
||||
// AgentConfigDir is the predicate `bdrive init` refuses a mount root on. It
|
||||
// answers about a single path SEGMENT, and it has to fold the same way
|
||||
// ReservedDir does or the refusal is bypassed by the spelling: APFS and NTFS
|
||||
// fold case, and NTFS/SMB strip trailing dots and spaces, so ~/.CLAUDE and
|
||||
// ~/.claude. open the same directory the guard is there to protect.
|
||||
func TestSec_AgentConfigDirFoldsTheWayTheFilesystemDoes(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
".claude", ".codex", ".gemini", ".hermes",
|
||||
".CLAUDE", ".Codex", // case-folded by APFS/NTFS
|
||||
".claude.", ".claude ", ".claude..", // stripped by NTFS/SMB
|
||||
} {
|
||||
if !config.AgentConfigDir(name) {
|
||||
t.Errorf("AgentConfigDir(%q) = false; the filesystem opens it as an agent "+
|
||||
"config directory, so mounting it exposes settings.json as a top-level file", name)
|
||||
}
|
||||
}
|
||||
// Not agent config directories — and crucially not ~/.claude/skills,
|
||||
// which is the path every doc tells people to sync.
|
||||
for _, name := range []string{"", ".", "claude", "skills", ".claudex", ".claude/skills", ".bdrive", ".git"} {
|
||||
if config.AgentConfigDir(name) {
|
||||
t.Errorf("AgentConfigDir(%q) = true; it is an ordinary directory name and refusing "+
|
||||
"it would block the very folder users are told to sync", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The predicate must stay derived from agentHookConfigs rather than from a
|
||||
// literal list: every directory that keys a reserved hook config is one whose
|
||||
// files lose their directory segment at a mount root.
|
||||
func TestSec_AgentConfigDirCoversEveryReservedHookDir(t *testing.T) {
|
||||
for dir, file := range map[string]string{
|
||||
".claude": "settings.json",
|
||||
".codex": "hooks.json",
|
||||
".gemini": "settings.json",
|
||||
".hermes": "config.yaml",
|
||||
} {
|
||||
if !config.ReservedPath(dir + "/" + file) {
|
||||
t.Fatalf("fixture: %s/%s should be reserved below a mount root", dir, file)
|
||||
}
|
||||
// The same file at a mount root has no directory segment left...
|
||||
if config.ReservedPath(file) {
|
||||
t.Fatalf("fixture: %q is unexpectedly reserved on its own", file)
|
||||
}
|
||||
// ...so the mount root itself is what has to be refused.
|
||||
if !config.AgentConfigDir(dir) {
|
||||
t.Errorf("AgentConfigDir(%q) = false while %s/%s is reserved: at that mount root "+
|
||||
"%s becomes an ordinary top-level file and syncs to the whole team",
|
||||
dir, dir, file, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: team-conventions
|
||||
description: The conventions this team actually follows — naming, branching, review, and the decisions someone already made so nobody re-litigates them. Use when writing or reviewing code here, opening a PR, or when unsure "how do we do this".
|
||||
---
|
||||
|
||||
# Team conventions
|
||||
|
||||
Replace this file with yours. It ships as the example because it is the skill
|
||||
every team turns out to need first: the answers a new teammate — or a new agent
|
||||
session — otherwise asks for one at a time.
|
||||
|
||||
Delete the sections that do not apply. A short skill that is true beats a
|
||||
complete one that is aspirational.
|
||||
|
||||
## Naming
|
||||
|
||||
- Say the shape here: files, branches, tests, whatever your team argues about.
|
||||
- One line each. If a rule needs a paragraph, it is a decision, not a naming rule.
|
||||
|
||||
## Before opening a PR
|
||||
|
||||
1. The command that has to pass.
|
||||
2. What the description must contain.
|
||||
3. Who reviews what.
|
||||
|
||||
## Decisions already made
|
||||
|
||||
Things a session should not reopen, and one line of why:
|
||||
|
||||
- *(example)* Dates are stored UTC, formatted at the edge — mixed zones cost us a release once.
|
||||
|
||||
## Where things live
|
||||
|
||||
A short map of the repo or the folder, aimed at someone who has never opened
|
||||
it: where a new file goes, and what the directory nobody understands is for.
|
||||
|
||||
---
|
||||
|
||||
*Every teammate's agent reads this file, on their machine, on their next
|
||||
session. Keep it current — and keep secrets, tokens and anything you would not
|
||||
paste in a group chat out of it.*
|
||||
@@ -0,0 +1,67 @@
|
||||
# How this folder is organized
|
||||
|
||||
This project is a **shared skill library**. The skills your agent loads live in
|
||||
`.claude/skills/`, they sync like any other file, and every teammate's agent
|
||||
picks them up on its next session — no export, no registry, no MCP server per
|
||||
client. Write a skill once, and the rest of the team's agents know it.
|
||||
|
||||
**Sharing what an agent reads is the product; sharing what it runs is not.**
|
||||
Skills, commands, subagents and `AGENTS.md` sync. An agent's hook
|
||||
configuration files never do — a hook is a shell command, and syncing one
|
||||
would install it on your teammate's machine. That line is not a setting; it is
|
||||
the rule BearDrive enforces.
|
||||
|
||||
## The shape
|
||||
|
||||
| Path | What it holds |
|
||||
| -- | -- |
|
||||
| `.claude/skills/<name>/SKILL.md` | one skill: frontmatter + instructions |
|
||||
| `.claude/skills/<name>/` | anything that skill needs beside it — scripts, references, examples |
|
||||
| `AGENTS.md` (this file) | how the library is kept |
|
||||
|
||||
Start this project in the folder your agent already starts sessions in. If you
|
||||
want a library of skills that is not tied to one project, sync
|
||||
`~/.claude/skills` — **the `skills` directory, never `~/.claude` itself**,
|
||||
which also holds this machine's private agent state. `bdrive init` refuses the
|
||||
latter for exactly that reason.
|
||||
|
||||
## Where a new skill goes
|
||||
|
||||
One directory per skill under `.claude/skills/`, named for the job it does.
|
||||
Before adding one, read the existing `SKILL.md` files for a skill that already
|
||||
covers the job: extending one beats adding a near-duplicate beside it, because
|
||||
two skills that both claim a job is how an agent starts picking the wrong one.
|
||||
|
||||
A skill earns its own directory when it has a trigger someone can state in a
|
||||
sentence. Until then it is a paragraph in this file.
|
||||
|
||||
## What a skill file looks like
|
||||
|
||||
Every `SKILL.md` opens with YAML frontmatter carrying `name` and
|
||||
`description`. The description is the only thing an agent reads when deciding
|
||||
whether to load the skill, so it names both the job and the words a person
|
||||
would use to ask for it. Everything below the frontmatter is the instruction
|
||||
the agent follows once it is loaded.
|
||||
|
||||
Keep it short enough to be read in full, and concrete: the steps, the commands,
|
||||
the gotcha that cost someone an afternoon. A skill that restates general
|
||||
knowledge is one nobody's agent needed.
|
||||
|
||||
## When something stops being true
|
||||
|
||||
Edit the skill. Do not append a correction under the old text and do not leave
|
||||
two versions standing — a `SKILL.md` is followed literally, so it has to read
|
||||
as current instruction from top to bottom.
|
||||
|
||||
Nothing is lost by rewriting: BearDrive keeps every version of every file, so
|
||||
`bdrive log .claude/skills/<name>/SKILL.md` still shows what it said before and
|
||||
who changed it. Retire a skill by deleting its directory; the History view is
|
||||
the archive.
|
||||
|
||||
## Filenames
|
||||
|
||||
- one directory per skill, lowercase, words joined by hyphens:
|
||||
`.claude/skills/release-checklist/`
|
||||
- the instruction file is always `SKILL.md`, capitalized, at the directory root
|
||||
- name the job, not the tool: `deploy-staging`, never `scripts-v2`
|
||||
- supporting files sit beside it with plain names: `checklist.md`, `queries.sql`
|
||||
@@ -89,7 +89,7 @@ func TestSec_Templates_GetIsTheOnlyDoorAndItIsClosed(t *testing.T) {
|
||||
// another file of this package) grew one, the two surfaces that render the
|
||||
// choice to a user would start offering it and Get would start loading it.
|
||||
func TestSec_Templates_TheRegistryHasNoWriteDoor(t *testing.T) {
|
||||
want := []string{"docs", "wiki", "para"}
|
||||
want := []string{"docs", "wiki", "para", "skills"}
|
||||
if got := Names(); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Names() = %q, want %q — the shipped set changed; every name here is "+
|
||||
"concatenated into an embed path and rendered as a choice on the hub", got, want)
|
||||
|
||||
@@ -32,7 +32,16 @@ import (
|
||||
"github.com/runbear-io/beardrive/internal/store"
|
||||
)
|
||||
|
||||
//go:embed files
|
||||
// all: rather than a plain pattern, because go:embed silently drops paths
|
||||
// beginning with "." — and the skills template's whole payload is
|
||||
// .claude/skills/<name>/SKILL.md. Without the prefix that template loads only
|
||||
// its AGENTS.md, with no error anywhere.
|
||||
//
|
||||
// The prefix also stops excluding "_"-prefixed files: nothing under files/
|
||||
// starts with "_" today, but from here on a stray _scratch.md in a template
|
||||
// directory ships into every project created from it.
|
||||
//
|
||||
//go:embed all:files
|
||||
var content embed.FS
|
||||
|
||||
// File is one file of a template: a slash-separated path relative to the
|
||||
@@ -57,6 +66,7 @@ var shipped = []struct{ Name, Title, Blurb string }{
|
||||
{"docs", "Docs + decision records", "docs/, decisions/"},
|
||||
{"wiki", "LLM wiki", "sources/, wiki/, index.md, log.md"},
|
||||
{"para", "PARA", "projects/, areas/, resources/, archives/"},
|
||||
{"skills", "Shared agent skills", ".claude/skills/"},
|
||||
}
|
||||
|
||||
// List returns every shipped template, recommended first.
|
||||
|
||||
@@ -28,7 +28,16 @@ func TestShippedTemplates(t *testing.T) {
|
||||
if f.Path == "AGENTS.md" {
|
||||
agents = f.Content
|
||||
}
|
||||
haveFileIn[path.Dir(f.Path)] = true
|
||||
// Every ancestor, not just the direct parent: the rule's own
|
||||
// reason is that an empty directory never reaches a teammate, and
|
||||
// an intermediate directory on the way to a file is not empty.
|
||||
// Marking only the parent made the check stricter than its reason
|
||||
// and failed the first template more than one level deep. What it
|
||||
// still catches is a genuinely file-less directory, which is all
|
||||
// it ever claimed to.
|
||||
for d := path.Dir(f.Path); d != "." && d != "/"; d = path.Dir(d) {
|
||||
haveFileIn[d] = true
|
||||
}
|
||||
|
||||
// The same rules cleanUploadPath applies, so the hub can never
|
||||
// reject its own content.
|
||||
@@ -72,6 +81,32 @@ func TestShippedTemplates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The skills template is the first one whose payload is a dot-directory, and
|
||||
// go:embed drops paths beginning with "." unless the pattern is prefixed
|
||||
// `all:` — with no error anywhere. Without this test the template ships with
|
||||
// only its AGENTS.md and nothing fails.
|
||||
func TestSkillsTemplateShipsItsDotDirectory(t *testing.T) {
|
||||
tpl, err := Get("skills")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var found string
|
||||
for _, f := range tpl.Files {
|
||||
if strings.HasPrefix(f.Path, ".claude/skills/") && strings.HasSuffix(f.Path, "/SKILL.md") {
|
||||
found = f.Path
|
||||
}
|
||||
}
|
||||
if found == "" {
|
||||
var paths []string
|
||||
for _, f := range tpl.Files {
|
||||
paths = append(paths, f.Path)
|
||||
}
|
||||
t.Fatalf("the skills template carries no .claude/skills/<name>/SKILL.md, only %v — "+
|
||||
"check that the embed directive is `//go:embed all:files`; a plain `files` pattern "+
|
||||
"drops dot-prefixed paths silently", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUnknownNamesTheSet(t *testing.T) {
|
||||
_, err := Get("karpathy-wiki")
|
||||
if err == nil {
|
||||
|
||||
@@ -148,6 +148,7 @@ export default defineConfig({
|
||||
{ label: "Turn a personal brain into a company brain", slug: "use-cases/company-brain" },
|
||||
{ label: "Run a personal wiki, publish part of it", slug: "use-cases/personal-wiki" },
|
||||
{ label: "Carry one context across agents and devices", slug: "use-cases/multi-device" },
|
||||
{ label: "Give every agent on the team the same skills", slug: "use-cases/shared-skills" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ One binary, `bdrive` — the CLI, the sync daemon, and the web server.
|
||||
|---|---|
|
||||
| `bdrive login [server-url]` | Sign this device in. Browser flow — the page names the account this terminal would act as and lets you switch before approving; `--device` forces the approval-link flow, and shells without a TTY (agents, CI, SSH) fall back to it automatically. Default server is beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host. Switch hubs with `bdrive login <new-url>`. `--status` shows the current server and account |
|
||||
| `bdrive logout` | Sign this device out — revokes this device's token on the hub, then clears it locally. `--forget` also drops the remembered server |
|
||||
| `bdrive init [folder]` | Create or connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY; flags (`--name`, `--project`, `--server`, `--only`, `--template`, `--yes`) for scripts. `--template docs\|wiki\|para` starts a new project from a structure instead of an empty folder. Also registers agent sync hooks for detected platforms (`--no-hooks` skips them) and a login item so sync resumes after a reboot (`--no-autostart` skips), and prints the project's hub link. Re-run to resume |
|
||||
| `bdrive init [folder]` | Create or connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY; flags (`--name`, `--project`, `--server`, `--only`, `--template`, `--yes`) for scripts. `--template docs\|wiki\|para\|skills` starts a new project from a structure instead of an empty folder. Also registers agent sync hooks for detected platforms (`--no-hooks` skips them) and a login item so sync resumes after a reboot (`--no-autostart` skips), and prints the project's hub link. Re-run to resume |
|
||||
| `bdrive resume` | Restart the sync daemon for every project on this device that isn't paused — after a reboot, a crash, or a manual kill. Idempotent, so running it twice is harmless. This is what the login item runs |
|
||||
| `bdrive autostart [install\|uninstall]` | Show, add, or remove the login registration that runs `bdrive resume` after a reboot: a user LaunchAgent on macOS, a systemd user unit on Linux (needs systemd). `bdrive init` installs it; `--no-autostart` skips it |
|
||||
| `bdrive stop [folder]` | Stop syncing — daemon and agent sync hooks both pause. Files stay on disk; `bdrive init` resumes |
|
||||
@@ -51,11 +51,12 @@ setting. Full flag bypass with `--name`, `--project`, `--template`, `--only`,
|
||||
|
||||
A **new** project can start from a structure rather than an empty folder:
|
||||
`--template docs` (docs/, decisions/), `--template wiki` (an LLM-maintained
|
||||
wiki: sources/, wiki/, index.md, log.md) or `--template para` (projects/,
|
||||
areas/, resources/, archives/). Each one is a small directory skeleton plus the
|
||||
wiki: sources/, wiki/, index.md, log.md), `--template para` (projects/, areas/,
|
||||
resources/, archives/) or `--template skills` (a shared agent-skill library:
|
||||
`.claude/skills/`). Each one is a small directory skeleton plus the
|
||||
`AGENTS.md` that says where a new note goes, when something is archived, and
|
||||
what a good filename looks like — the instructions are the point, the folders
|
||||
are the scaffolding. On a TTY the same three starting points are offered as a
|
||||
are the scaffolding. On a TTY the same starting points are offered as a
|
||||
menu (recommended first, "empty project" last, and preselected); `--yes` and
|
||||
non-TTY never prompt and stay empty.
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@ more choice: start from a structure, or from scratch. Three are shipped:
|
||||
competitor file, a company brain fed by transcripts and threads.
|
||||
- **PARA** (`projects/`, `areas/`, `resources/`, `archives/`) — sorted by how
|
||||
actionable something is, with explicit archiving.
|
||||
- **Shared agent skills** (`.claude/skills/`) — a skill library the whole
|
||||
team's agents load, kept current by syncing. See
|
||||
[Give every agent on the team the same skills](/use-cases/shared-skills/).
|
||||
|
||||
Each is a small skeleton plus an `AGENTS.md` telling every agent on the team
|
||||
where a new file goes, when something is archived or superseded, and what a good
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Give every agent on the team the same skills
|
||||
description: For the person on the team who writes the good prompts — the one whose agent always seems to know the right steps. Skills, commands and subagents sync like any other file, so what you teach your agent, everyone's agent knows.
|
||||
---
|
||||
|
||||
You are the person on the team who writes the good skills. Yours knows the
|
||||
deploy checklist, the query that answers the recurring question, the three
|
||||
gotchas in the billing code. Everyone else's agent re-derives them every week,
|
||||
and the only way you have to share one is to paste a file into chat.
|
||||
|
||||
## What you end up with
|
||||
|
||||
- One folder holding the team's skills. A skill you write is on a teammate's
|
||||
disk before their agent's next turn — no export step, no registry, no MCP
|
||||
server to configure per client.
|
||||
- The same for `.claude/commands`, `.claude/agents`, `AGENTS.md` and
|
||||
`CLAUDE.md`. Everything an agent *reads* travels; agent **hook**
|
||||
configuration never does, because a hook is a shell command and syncing one
|
||||
would install it on your teammate's machine. See
|
||||
[What agents read](/guides/what-agents-read/).
|
||||
- Change history per skill: who edited it, when, from which device, and every
|
||||
previous version. A skill that got worse is one `bdrive log` away from
|
||||
showing you when.
|
||||
|
||||
## Set it up
|
||||
|
||||
Start your agent in the folder you want shared and give it the one paste from
|
||||
[Set up with your agent](/start/setup/). Ask for the skills structure and it
|
||||
seeds one — an `AGENTS.md` explaining how the library is kept plus an example
|
||||
`.claude/skills/<name>/SKILL.md` to copy:
|
||||
|
||||
> Set up BearDrive here from the `skills` template.
|
||||
|
||||
For a project's skills, sync the project folder your agent already starts
|
||||
sessions in and the `.claude/` inside it comes along. For a library that is not
|
||||
tied to one project, sync `~/.claude/skills` — **the `skills` directory,
|
||||
never `~/.claude` itself**, which also holds this machine's credentials and
|
||||
every saved session. `bdrive init` refuses that directory for exactly that
|
||||
reason and points at `skills` instead.
|
||||
|
||||
## The loop
|
||||
|
||||
> Write a skill for our release checklist and put it in `.claude/skills/`.
|
||||
|
||||
The turn ends and the skill is on the hub. Your colleague's next session picks
|
||||
it up on its own — their pull hook runs before their agent's first turn, so the
|
||||
file is simply there, on disk, the way a skill they wrote themselves would be.
|
||||
Nobody installed anything.
|
||||
|
||||
When the checklist changes, edit the skill. Everyone is on the new one by their
|
||||
next session, and the hub's History view shows what it said before.
|
||||
|
||||
## Use it with, not instead of
|
||||
|
||||
A skills *registry* answers "who is allowed to publish this, and was it
|
||||
reviewed" — governance before the fact. BearDrive answers arrival: the file is
|
||||
on the machine, current, before the agent's first turn. If your team needs
|
||||
approval gates, keep them and let BearDrive be the delivery; the two do
|
||||
different jobs.
|
||||
|
||||
## What matters for this case
|
||||
|
||||
- **[What agents read](/guides/what-agents-read/)** — exactly which agent files
|
||||
sync and which never do, and why the line falls there.
|
||||
- **[Shared agent memory](/guides/shared-agent-memory/)** — the `AGENTS.md`
|
||||
that tells every teammate's agent how the library is kept.
|
||||
- **[Project files](/reference/project-files/)** — the full table of paths
|
||||
BearDrive excludes in both directions.
|
||||
Reference in New Issue
Block a user