Files
beardrive/internal/agenthooks/agenthooks_test.go
T
4e34d03e14 feat(hooks): user-scope agent sync hooks, one-command setup, --only scoping (#71)
* feat(hooks): register agent sync hooks per machine, not per project

Agent platforms read hook config only from the directory a session starts
in — never a parent, never a subfolder. Project-level hooks therefore fired
only for sessions that happened to start at the mount, and, living inside a
synced folder, they replicated one machine's agent config to the whole team
(a second writer of a file bdrive already owns). Claude Code additionally
ignores project hooks until the folder is trusted, so in practice they were
often inert without any visible sign.

Hooks now go to each platform's user config, once per machine, covering
every session in every folder; the existing shell guard keeps them a no-op
outside BearDrive projects. Install migrates away blocks older versions
wrote into projects, and `bdrive hooks uninstall` removes ours while leaving
foreign hooks untouched.

Setup is also one command now. init absorbs the skill install, prints the
hub link, and takes --server, so connecting to a named hub no longer needs a
separate login; the runbook forbids preflight and command chaining, since
each distinct command costs the user a permission prompt. For plugin users a
PreToolUse hook auto-approves bdrive's own setup subcommands — narrowly: any
shell operator in the command disqualifies it.

Also drops --shared in favor of `init . --only wiki,docs`, which writes a
managed block of .bdriveignore rules instead of a second scope mechanism.
Because those rules sync, `sync --prune` now refuses on a scoped project
rather than stripping everything outside the scope from the hub for everyone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ

* docs: fix stale claims an audit found against the new CLI

An audit of every doc surface against the code turned up claims that the
user-scope hook move and the one-command init made false: project-level
hooks "riding the repo", the Claude trust prompt, Codex's //hooks project
layer, `--no-hooks` skipping the skill (it does not), prune reconciling
against a per-device scope (it now refuses on a scoped project), and
`--scan-interval`/`--remote-interval` documented as init flags when they
only exist on `bdrive daemon run`.

Also documents the surface added today — `--server`, `bdrive hooks
uninstall`, and the plugin's PreToolUse auto-approval — refreshes the two
sample `init` transcripts to the real output, and corrects hook matchers
that had drifted from agenthooks.go.

`bdrive scope` told users to narrow an existing mount with `bdrive init .
--only <dirs>`, which resume then ignored — a dead end. Init now applies
--only on resume, writing the scope block, so the advice works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:08:45 +09:00

536 lines
18 KiB
Go

package agenthooks
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func readJSON(t *testing.T, path string) map[string]any {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("%s is not valid JSON: %v", path, err)
}
return m
}
func TestDetect(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
folder := t.TempDir()
if got := Detect(folder); len(got) != 0 {
t.Fatalf("nothing configured, detected %v", got)
}
// project-level dirs
os.MkdirAll(filepath.Join(folder, ".codex"), 0o755)
os.MkdirAll(filepath.Join(folder, ".gemini"), 0o755)
// home-level dirs
os.MkdirAll(filepath.Join(home, ".claude"), 0o755)
os.MkdirAll(filepath.Join(home, ".hermes"), 0o755)
got := Detect(folder)
want := []string{"claude", "codex", "gemini", "hermes"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("detected %v, want %v", got, want)
}
}
func TestInstallJSONPlatforms(t *testing.T) {
t.Setenv("HOME", t.TempDir())
folder := t.TempDir()
results, err := Install(folder, []string{"claude", "codex", "gemini"})
if err != nil {
t.Fatal(err)
}
for _, r := range results {
if !r.Changed {
t.Fatalf("%s: fresh install reported unchanged", r.Agent)
}
}
// Claude: both events present, push is async, command carries the label.
cl := readJSON(t, ConfigPath("", "claude"))
hooks := cl["hooks"].(map[string]any)
for _, ev := range []string{"UserPromptSubmit", "PostToolUse"} {
if _, ok := hooks[ev]; !ok {
t.Fatalf("claude missing %s", ev)
}
}
raw, _ := json.Marshal(cl)
if !strings.Contains(string(raw), "claude-code session $s") {
t.Fatal("claude hook lacks its session-note label")
}
if !strings.Contains(string(raw), "bdrive sync . --hook claude-code") {
t.Fatal("claude pull hook should use --hook mode (link-formula injection)")
}
if !strings.Contains(string(raw), `"async":true`) {
t.Fatal("claude push hook should be async")
}
// …and the read hook, on its own matcher.
if !strings.Contains(string(raw), "bdrive read-log") || !strings.Contains(string(raw), `"matcher":"Read|Grep|Bash"`) {
t.Fatalf("claude read hook missing: %s", raw)
}
// Codex: same schema, its own label and matcher, no async field.
cx, _ := json.Marshal(readJSON(t, ConfigPath("", "codex")))
if !strings.Contains(string(cx), "codex session $s") || !strings.Contains(string(cx), "apply_patch") {
t.Fatalf("codex hooks wrong: %s", cx)
}
if strings.Contains(string(cx), "async") {
t.Fatal("codex should not get the claude-only async field")
}
if !strings.Contains(string(cx), "bdrive read-log") || !strings.Contains(string(cx), `"matcher":"read_file|shell"`) {
t.Fatalf("codex read hook missing: %s", cx)
}
// Gemini: its own event names and ms timeout.
gm, _ := json.Marshal(readJSON(t, ConfigPath("", "gemini")))
for _, want := range []string{"BeforeAgent", "AfterTool", "gemini session $s", "30000", "bdrive read-log", "read_file|read_many_files|search_file_content|run_shell_command"} {
if !strings.Contains(string(gm), want) {
t.Fatalf("gemini hooks missing %q: %s", want, gm)
}
}
}
func TestInstallIdempotentAndPreserving(t *testing.T) {
t.Setenv("HOME", t.TempDir())
folder := t.TempDir()
// Pre-existing user hook must survive the merge.
pre := `{"permissions":{"allow":["Bash(ls:*)"]},"hooks":{"PostToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo mine"}]}]}}`
os.MkdirAll(filepath.Join(folder, ".claude"), 0o755) // detection
os.MkdirAll(filepath.Dir(ConfigPath("", "claude")), 0o755) // where hooks live now
os.WriteFile(ConfigPath("", "claude"), []byte(pre), 0o644)
if _, err := Install(folder, []string{"claude"}); err != nil {
t.Fatal(err)
}
cfg := readJSON(t, ConfigPath("", "claude"))
raw, _ := json.Marshal(cfg)
if !strings.Contains(string(raw), "echo mine") {
t.Fatal("merge dropped the user's existing hook")
}
if _, ok := cfg["permissions"]; !ok {
t.Fatal("merge dropped unrelated settings keys")
}
if got := len(cfg["hooks"].(map[string]any)["PostToolUse"].([]any)); got != 3 {
t.Fatalf("PostToolUse groups = %d, want user's + our push + our read", got)
}
// Second install: no change, byte-identical file.
before, _ := os.ReadFile(ConfigPath("", "claude"))
results, err := Install(folder, []string{"claude"})
if err != nil {
t.Fatal(err)
}
if results[0].Changed {
t.Fatal("re-install reported a change")
}
after, _ := os.ReadFile(ConfigPath("", "claude"))
if string(before) != string(after) {
t.Fatal("re-install rewrote the file")
}
}
// A config from before the read hook existed (sync hooks only) gains just
// the read group on re-install — the sync hooks are not duplicated.
func TestInstallUpgradesSyncOnlyConfig(t *testing.T) {
t.Setenv("HOME", t.TempDir())
folder := t.TempDir()
old := `{"hooks":{
"UserPromptSubmit":[{"hooks":[{"type":"command","command":"sh -c 'bdrive sync .'"}]}],
"PostToolUse":[{"matcher":"Write|Edit|MultiEdit","hooks":[{"type":"command","command":"sh -c 'bdrive sync .'"}]}]}}`
os.MkdirAll(filepath.Join(folder, ".claude"), 0o755) // detection
os.MkdirAll(filepath.Dir(ConfigPath("", "claude")), 0o755) // where hooks live now
os.WriteFile(ConfigPath("", "claude"), []byte(old), 0o644)
results, err := Install(folder, []string{"claude"})
if err != nil {
t.Fatal(err)
}
if !results[0].Changed {
t.Fatal("upgrade install reported unchanged")
}
cfg := readJSON(t, ConfigPath("", "claude"))
hooks := cfg["hooks"].(map[string]any)
if got := len(hooks["UserPromptSubmit"].([]any)); got != 1 {
t.Fatalf("UserPromptSubmit groups = %d, want the old one only", got)
}
if got := len(hooks["PostToolUse"].([]any)); got != 2 {
t.Fatalf("PostToolUse groups = %d, want old push + new read", got)
}
raw, _ := json.Marshal(cfg)
if !strings.Contains(string(raw), "bdrive read-log") {
t.Fatal("upgrade did not add the read hook")
}
}
// A config registered when the read hook only matched "Read" gets its
// matcher upgraded in place on re-install — coverage improvements must
// reach existing projects without duplicating groups.
func TestInstallUpgradesReadMatcher(t *testing.T) {
t.Setenv("HOME", t.TempDir())
folder := t.TempDir()
old := `{"hooks":{
"UserPromptSubmit":[{"hooks":[{"type":"command","command":"sh -c 'bdrive sync .'"}]}],
"PostToolUse":[
{"matcher":"Write|Edit|MultiEdit","hooks":[{"type":"command","command":"sh -c 'bdrive sync .'"}]},
{"matcher":"Read","hooks":[{"type":"command","command":"sh -c 'bdrive read-log .'"}]}]}}`
os.MkdirAll(filepath.Join(folder, ".claude"), 0o755) // detection
os.MkdirAll(filepath.Dir(ConfigPath("", "claude")), 0o755) // where hooks live now
os.WriteFile(ConfigPath("", "claude"), []byte(old), 0o644)
results, err := Install(folder, []string{"claude"})
if err != nil {
t.Fatal(err)
}
if !results[0].Changed {
t.Fatal("matcher upgrade reported unchanged")
}
cfg := readJSON(t, ConfigPath("", "claude"))
hooks := cfg["hooks"].(map[string]any)
if got := len(hooks["PostToolUse"].([]any)); got != 2 {
t.Fatalf("PostToolUse groups = %d, want push + read (no duplicates)", got)
}
raw, _ := json.Marshal(cfg)
if !strings.Contains(string(raw), `"matcher":"Read|Grep|Bash"`) {
t.Fatalf("read matcher not upgraded: %s", raw)
}
if strings.Contains(string(raw), `"matcher":"Read"`+",") {
t.Fatalf("old matcher left behind: %s", raw)
}
// And it settles: the next install is a no-op.
results, _ = Install(folder, []string{"claude"})
if results[0].Changed {
t.Fatal("re-install after upgrade reported a change")
}
}
// A config registered before the link-formula hook existed gets its pull
// command converged in place on re-install — no duplicate groups, and the
// next install settles to a no-op.
func TestInstallUpgradesPullCommand(t *testing.T) {
t.Setenv("HOME", t.TempDir())
folder := t.TempDir()
old := `{"hooks":{
"UserPromptSubmit":[{"hooks":[{"type":"command","command":"sh -c 'bdrive sync .'","timeout":30}]}],
"PostToolUse":[
{"matcher":"Write|Edit|MultiEdit","hooks":[{"type":"command","command":"sh -c 'bdrive sync .'","timeout":30,"async":true}]},
{"matcher":"Read|Grep|Bash","hooks":[{"type":"command","command":"sh -c 'bdrive read-log .'","timeout":30,"async":true}]}]}}`
os.MkdirAll(filepath.Join(folder, ".claude"), 0o755) // detection
os.MkdirAll(filepath.Dir(ConfigPath("", "claude")), 0o755) // where hooks live now
os.WriteFile(ConfigPath("", "claude"), []byte(old), 0o644)
results, err := Install(folder, []string{"claude"})
if err != nil {
t.Fatal(err)
}
if !results[0].Changed {
t.Fatal("pull-command upgrade reported unchanged")
}
cfg := readJSON(t, ConfigPath("", "claude"))
hooks := cfg["hooks"].(map[string]any)
if got := len(hooks["UserPromptSubmit"].([]any)); got != 1 {
t.Fatalf("UserPromptSubmit groups = %d, want converged single group", got)
}
if got := len(hooks["PostToolUse"].([]any)); got != 2 {
t.Fatalf("PostToolUse groups = %d, want push + read (no duplicates)", got)
}
raw, _ := json.Marshal(cfg)
if !strings.Contains(string(raw), "bdrive sync . --hook claude-code") {
t.Fatalf("pull command not upgraded: %s", raw)
}
// Settles: the next install is a no-op.
results, _ = Install(folder, []string{"claude"})
if results[0].Changed {
t.Fatal("re-install after upgrade reported a change")
}
}
func TestInstallHermesYAML(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
os.MkdirAll(filepath.Join(home, ".hermes"), 0o755)
// Existing config keys must survive.
os.WriteFile(filepath.Join(home, ".hermes", "config.yaml"),
[]byte("model: hermes-4\nhooks_auto_accept: false\n"), 0o644)
results, err := Install(t.TempDir(), []string{"hermes"})
if err != nil {
t.Fatal(err)
}
if !results[0].Changed {
t.Fatal("fresh hermes install reported unchanged")
}
data, _ := os.ReadFile(filepath.Join(home, ".hermes", "config.yaml"))
var m map[string]any
if err := yaml.Unmarshal(data, &m); err != nil {
t.Fatalf("config.yaml no longer parses: %v", err)
}
if m["model"] != "hermes-4" {
t.Fatal("merge dropped existing hermes config")
}
hooks := m["hooks"].(map[string]any)
for _, ev := range []string{"pre_llm_call", "post_tool_call"} {
if _, ok := hooks[ev]; !ok {
t.Fatalf("hermes missing %s", ev)
}
}
if got := len(hooks["post_tool_call"].([]any)); got != 2 {
t.Fatalf("hermes post_tool_call groups = %d, want push + read", got)
}
if !strings.Contains(string(data), "hermes session $s") {
t.Fatal("hermes hook lacks its session-note label")
}
if !strings.Contains(string(data), "bdrive read-log") {
t.Fatal("hermes read hook missing")
}
// Idempotent.
results, _ = Install(t.TempDir(), []string{"hermes"})
if results[0].Changed {
t.Fatal("hermes re-install reported a change")
}
}
func TestInstallAutoUsesDetection(t *testing.T) {
t.Setenv("HOME", t.TempDir())
folder := t.TempDir()
os.MkdirAll(filepath.Join(folder, ".gemini"), 0o755)
results, err := Install(folder, nil)
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].Agent != "gemini" {
t.Fatalf("auto install = %+v, want just gemini", results)
}
}
func TestInstallUnknownAgent(t *testing.T) {
if _, err := Install(t.TempDir(), []string{"cursor"}); err == nil {
t.Fatal("unknown agent should error")
}
}
// The generated hook command must extract a session id from hook stdin JSON
// and invoke bdrive with the platform label — run it for real against a fake
// bdrive to pin the shell behavior on every platform's payload shape.
func TestHookCommandExtraction(t *testing.T) {
if _, err := os.Stat("/bin/sh"); err != nil {
t.Skip("no /bin/sh")
}
dir := t.TempDir()
makeMount(t, dir)
bin := filepath.Join(dir, "bin")
os.MkdirAll(bin, 0o755)
fake := "#!/bin/sh\necho \"$@\" > \"" + dir + "/args.txt\"\n"
os.WriteFile(filepath.Join(bin, "bdrive"), []byte(fake), 0o755)
payloads := map[string]string{
"claude-code": `{"session_id":"abc-123","hook_event_name":"UserPromptSubmit"}`,
"codex": `{"session_id":"th_042","turn_id":"t1","cwd":"/x"}`,
"gemini": `{"session_id":"g-9f","timestamp":"2026-07-11T00:00:00Z"}`,
"hermes": `{"hook_event_name":"pre_llm_call","tool_name":null,"session_id":"sess_abc123"}`,
}
for label, payload := range payloads {
os.Remove(filepath.Join(dir, "args.txt"))
cmdline := hookCommand(label)
sh := "cd " + dir + " && PATH=" + bin + ":$PATH " + cmdline
if err := runShell(t, sh, payload); err != nil {
t.Fatalf("%s: %v", label, err)
}
got, err := os.ReadFile(filepath.Join(dir, "args.txt"))
if err != nil {
t.Fatalf("%s: hook never called bdrive: %v", label, err)
}
want := "sync . --note " + label + " session "
if !strings.Contains(string(got), want) {
t.Fatalf("%s: bdrive argv = %q, want it to contain %q", label, got, want)
}
}
}
// The read hook must hand the event JSON through to `bdrive read-log`
// untouched — the binary does the parsing, the shell only guards.
func TestReadHookCommand(t *testing.T) {
if _, err := os.Stat("/bin/sh"); err != nil {
t.Skip("no /bin/sh")
}
dir := t.TempDir()
makeMount(t, dir)
bin := filepath.Join(dir, "bin")
os.MkdirAll(bin, 0o755)
fake := "#!/bin/sh\necho \"$@\" > \"" + dir + "/args.txt\"\ncat > \"" + dir + "/stdin.txt\"\n"
os.WriteFile(filepath.Join(bin, "bdrive"), []byte(fake), 0o755)
payload := `{"session_id":"abc","tool_name":"Read","tool_input":{"file_path":"/x/wiki/a.md"}}`
sh := "cd " + dir + " && PATH=" + bin + ":$PATH " + readHookCommand()
if err := runShell(t, sh, payload); err != nil {
t.Fatal(err)
}
args, err := os.ReadFile(filepath.Join(dir, "args.txt"))
if err != nil {
t.Fatalf("read hook never called bdrive: %v", err)
}
if !strings.Contains(string(args), "read-log .") {
t.Fatalf("bdrive argv = %q, want read-log .", args)
}
stdin, _ := os.ReadFile(filepath.Join(dir, "stdin.txt"))
if string(stdin) != payload {
t.Fatalf("event JSON not passed through: %q", stdin)
}
// Outside a bdrive project the hook exits before invoking anything.
os.Remove(filepath.Join(dir, "args.txt"))
os.RemoveAll(filepath.Join(dir, ".bdrive"))
if err := runShell(t, sh, payload); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "args.txt")); !os.IsNotExist(err) {
t.Fatal("hook invoked bdrive outside a project")
}
}
// makeMount marks a folder as a mount root the way the hook guard tests for
// it: the config file, not just the directory — `$BDRIVE_HOME` is a `.bdrive`
// directory too, and must never read as a project.
func makeMount(t *testing.T, dir string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(dir, ".bdrive"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".bdrive", "config.json"), []byte(`{"id":"m-test"}`), 0o644); err != nil {
t.Fatal(err)
}
}
// The guard has to fire from wherever the agent session happens to start:
// inside a synced subfolder (walk up), at a root above the mounts (registry),
// and nowhere else — including the home directory, whose ~/.bdrive holds
// device state rather than a project.
func TestHookGuardFindsMountFromAnyDirectory(t *testing.T) {
if _, err := os.Stat("/bin/sh"); err != nil {
t.Skip("no /bin/sh")
}
root := t.TempDir()
mount := filepath.Join(root, "wiki")
inside := filepath.Join(mount, "notes")
unrelated := filepath.Join(root, "src")
for _, d := range []string{inside, unrelated} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
makeMount(t, mount)
bin := filepath.Join(root, "bin")
os.MkdirAll(bin, 0o755)
fake := "#!/bin/sh\necho called > \"" + root + "/called.txt\"\n"
os.WriteFile(filepath.Join(bin, "bdrive"), []byte(fake), 0o755)
// A registry naming the mount is what lets a session ABOVE it fire.
home := filepath.Join(root, "home")
os.MkdirAll(filepath.Join(home, ".bdrive"), 0o755)
os.WriteFile(filepath.Join(home, ".bdrive", "mounts.json"),
[]byte(`{"m-test":{"path":"`+mount+`"}}`), 0o644)
for _, tc := range []struct {
dir string
want bool
why string
}{
{mount, true, "at the mount root"},
{inside, true, "inside the mount (walk up)"},
{root, true, "above the mount (registry)"},
{unrelated, false, "a sibling folder with no mount"},
{home, false, "the home dir, whose .bdrive is device state"},
} {
os.Remove(filepath.Join(root, "called.txt"))
sh := "cd " + tc.dir + " && HOME=" + home + " BDRIVE_HOME=" + filepath.Join(home, ".bdrive") +
" PATH=" + bin + ":$PATH " + hookCommand("claude-code")
if err := runShell(t, sh, `{"session_id":"s1"}`); err != nil {
t.Fatalf("%s: %v", tc.why, err)
}
_, err := os.Stat(filepath.Join(root, "called.txt"))
if fired := err == nil; fired != tc.want {
t.Errorf("%s: hook fired = %v, want %v", tc.why, fired, tc.want)
}
}
}
func runShell(t *testing.T, script, stdin string) error {
t.Helper()
cmd := exec.Command("/bin/sh", "-c", script)
cmd.Stdin = strings.NewReader(stdin)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%v: %s", err, out)
}
return nil
}
// Uninstall is the exact inverse of install: our blocks go, everything the
// user had stays, and a config we never touched is left byte-identical.
func TestUninstallRemovesOnlyOurHooks(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
folder := t.TempDir()
os.MkdirAll(filepath.Join(folder, ".claude"), 0o755)
os.MkdirAll(filepath.Dir(ConfigPath("", "claude")), 0o755)
pre := `{"permissions":{"allow":["Bash(ls:*)"]},"hooks":{"PostToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo mine"}]}]}}`
os.WriteFile(ConfigPath("", "claude"), []byte(pre), 0o644)
before, _ := os.ReadFile(ConfigPath("", "claude"))
if _, err := Install(folder, []string{"claude"}); err != nil {
t.Fatal(err)
}
results, err := Uninstall([]string{"claude"})
if err != nil {
t.Fatal(err)
}
if !results[0].Changed {
t.Fatal("uninstall reported nothing removed")
}
after := readJSON(t, ConfigPath("", "claude"))
raw, _ := json.Marshal(after)
if strings.Contains(string(raw), "bdrive") {
t.Fatalf("uninstall left our hooks behind: %s", raw)
}
if !strings.Contains(string(raw), "echo mine") {
t.Fatalf("uninstall removed a hook that was not ours: %s", raw)
}
if _, ok := after["permissions"]; !ok {
t.Fatal("uninstall dropped unrelated settings keys")
}
// Round trip preserves the file's content (formatting may differ).
var b map[string]any
json.Unmarshal(before, &b)
rb, _ := json.Marshal(b)
if string(rb) != string(raw) {
t.Fatalf("install+uninstall changed the config:\nbefore %s\nafter %s", rb, raw)
}
// Uninstalling again is a silent no-op.
results, err = Uninstall([]string{"claude"})
if err != nil || results[0].Changed {
t.Fatalf("second uninstall: changed=%v err=%v", results[0].Changed, err)
}
}