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
+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 {