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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user