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:
Snow Lee (Sungwon)
2026-08-11 05:04:12 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent d3d92bf904
commit 4031495c81
16 changed files with 488 additions and 14 deletions
+10 -3
View File
@@ -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
+5 -3
View File
@@ -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:filesdocs, 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
+1 -1
View File
@@ -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"]
+10
View File
@@ -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")
}
+146
View File
@@ -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")
}
}
+24
View File
@@ -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
+59
View File
@@ -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.*
+67
View File
@@ -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`
+1 -1
View File
@@ -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)
+11 -1
View File
@@ -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.
+36 -1
View File
@@ -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 {
+1
View File
@@ -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" },
],
},
{
+5 -4
View File
@@ -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.
+3
View File
@@ -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.