Files
4031495c81 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>
2026-08-11 05:04:12 +09:00

60 lines
2.4 KiB
Go

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)
}
}
}