feat(cli): bdrive hooks — agent-agnostic sync hook registration

`bdrive hooks install` detects the agent platforms in use — Claude Code
(.claude/), Codex (.codex/), Gemini CLI (.gemini/), Hermes (~/.hermes/) —
and idempotently merges beardrive's turn-boundary sync hooks into each
platform's own hook config (JSON for claude/codex/gemini, YAML for
hermes), preserving existing hooks. All four pipe hook JSON with a
session_id, so one POSIX-sh hook command serves every platform: pull at
turn start, push after edits, changes stamped "<agent> session <id>".
Bare `bdrive hooks` prints the detection/registration table.

The beardrive skill now runs it automatically after `bdrive init`, and
/beardrive:install's hand-maintained settings.json block is replaced by
the command, so the hook content has one source of truth in the binary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
This commit is contained in:
Snow Lee
2026-07-11 10:57:07 -07:00
co-authored by Claude Fable 5
parent c1f0d0f8ee
commit 626a9c0a07
10 changed files with 670 additions and 43 deletions
+2 -1
View File
@@ -121,7 +121,8 @@ beardrive uses each provider's standard credential chain — nothing beardrive-s
| `bdrive init [folder]` | Create/connect a project and start syncing — interactive on a TTY, flags (`--name/--project/--shared/--yes`) for scripts; re-run to resume |
| `bdrive stop [folder]` | Stop syncing (files stay; `bdrive init` resumes) |
| `bdrive share <file>` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) |
| `bdrive sync [folder]` | Run one sync cycle now |
| `bdrive sync [folder]` | Run one sync cycle now. `--note <text>` stamps session context (e.g. an agent session id) onto changes — shown in `bdrive log` and hub history; keeps applying to daemon-committed changes until `--note-ttl` (default 30m) expires |
| `bdrive hooks [install]` | Register turn-boundary sync hooks with detected agent platforms (Claude Code, Codex, Gemini CLI, Hermes) — pull each turn, push after edits, session-note stamping; idempotent (`--agent` overrides detection) |
| `bdrive status [folder]` | Projects, daemon state, pending changes |
| `bdrive log [folder] [-p path] [-n N]` | Change history: account, device, time, file |
| `bdrive web [folder \| storage-root-url]` | Web server: viewer (rendered markdown, downloads, history), uploads, multi-project sync hub |
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/runbear-io/beardrive/internal/agenthooks"
)
// bdrive hooks — register BearDrive's turn-boundary sync hooks with whatever
// AI agent platforms the user works with (Claude Code, Codex, Gemini CLI,
// Hermes). One command instead of hand-editing four config formats; the
// beardrive skill runs it right after `bdrive init`.
func hooksCmd() *cobra.Command {
c := &cobra.Command{
Use: "hooks",
Short: "Show which AI agent platforms have beardrive sync hooks registered",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
folder, err := absFolder(nil)
if err != nil {
return err
}
detected := map[string]bool{}
for _, a := range agenthooks.Detect(folder) {
detected[a] = true
}
for _, a := range agenthooks.Agents {
state := "not detected"
if detected[a] {
state = "detected, hooks not registered"
if agenthooks.Registered(folder, a) {
state = "hooks registered"
}
}
fmt.Printf(" %-8s %-32s %s\n", a, state, agenthooks.ConfigPath(folder, a))
}
fmt.Println("\nregister with: bdrive hooks install [--agent claude,codex,gemini,hermes]")
return nil
},
}
var agentsFlag string
install := &cobra.Command{
Use: "install [folder]",
Short: "Register sync hooks for detected agent platforms (or --agent list)",
Long: "Registers beardrive's sync hooks with each agent platform's own hook\n" +
"config: files pull before every turn and push after edits, and changes\n" +
"are stamped with the agent session that made them (`bdrive sync --note`).\n" +
"Merging is idempotent and preserves hooks you already have.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
folder, err := absFolder(args)
if err != nil {
return err
}
var agents []string
if agentsFlag != "" && agentsFlag != "auto" {
agents = strings.Split(agentsFlag, ",")
}
results, err := agenthooks.Install(folder, agents)
if err != nil {
return err
}
if len(results) == 0 {
fmt.Println("no agent platforms detected (looked for .claude/, .codex/, .gemini/ here or in ~; ~/.hermes/)")
fmt.Println("pick explicitly: bdrive hooks install --agent claude,codex,gemini,hermes")
return nil
}
for _, r := range results {
state := "already registered"
if r.Changed {
state = "registered"
}
fmt.Printf(" %-8s %s → %s\n", r.Agent, state, r.Path)
if r.Note != "" {
fmt.Printf(" note: %s\n", r.Note)
}
}
return nil
},
}
install.Flags().StringVar(&agentsFlag, "agent", "auto", "comma-separated platforms (claude,codex,gemini,hermes) or auto")
c.AddCommand(install)
return c
}
+1
View File
@@ -36,6 +36,7 @@ everything keeps working offline; changes sync when the remote is reachable.`,
shareCmd(),
stopCmd(),
syncCmd(),
hooksCmd(),
statusCmd(),
logCmd(),
webCmd(),
+1
View File
@@ -16,6 +16,7 @@ require (
golang.org/x/crypto v0.51.0
golang.org/x/sync v0.21.0
google.golang.org/api v0.284.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.53.0
)
+8
View File
@@ -130,6 +130,10 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
@@ -146,6 +150,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -243,6 +249,8 @@ google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zN
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+267
View File
@@ -0,0 +1,267 @@
// Package agenthooks detects which AI agent platforms a user works with and
// registers BearDrive's sync hooks in each platform's own hook config, so
// files sync at turn boundaries no matter which agent edits them.
//
// Every supported platform runs command hooks the same way — spawn a shell
// command, pipe event JSON (with a session_id) on stdin — so one hook command
// works everywhere; only the config file format and event names differ:
//
// claude .claude/settings.json UserPromptSubmit / PostToolUse (project)
// codex .codex/hooks.json UserPromptSubmit / PostToolUse (project)
// gemini .gemini/settings.json BeforeAgent / AfterTool (project)
// hermes ~/.hermes/config.yaml pre_llm_call / post_tool_call (user)
//
// The hook syncs the project and stamps changes with "<agent> session <id>"
// (see `bdrive sync --note`), so hub history links every change to the agent
// session that made it. Hooks are fast no-ops outside bdrive projects.
package agenthooks
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"github.com/runbear-io/beardrive/internal/store"
)
// marker identifies our hooks inside a config, for idempotency and status.
const marker = "bdrive sync"
// Agent names, in the order they are reported.
var Agents = []string{"claude", "codex", "gemini", "hermes"}
// Result reports what Install did for one agent platform.
type Result struct {
Agent string
Path string // config file the hooks live in
Changed bool // false = already registered
Note string // extra step the user must take, if any
}
// hookCommand is the one shell command every platform runs: sync the project
// if it is a bdrive mount, stamping changes with the agent session id parsed
// from the hook's stdin JSON. POSIX sh only — no jq, no bashisms.
func hookCommand(label string) string {
return `sh -c '` +
`cd "${CLAUDE_PROJECT_DIR:-.}" && [ -d .bdrive ] && command -v bdrive >/dev/null || exit 0; ` +
`s=; [ -t 0 ] || s=$(head -c 8192 2>/dev/null | tr -d \" | sed -n "s/.*session_id[[:space:]]*:[[:space:]]*\([a-zA-Z0-9_-]*\).*/\1/p" | head -n 1); ` +
`if [ -n "$s" ]; then bdrive sync . --note "` + label + ` session $s" >/dev/null 2>&1 || true; ` +
`else bdrive sync . >/dev/null 2>&1 || true; fi'`
}
type platform struct {
label string // session-note label
projectDir string // presence of this dir (project or home) = detected
userLevel bool // config lives in the home dir, not the project
install func(folder string) (path string, changed bool, err error)
note string
}
var platforms = map[string]platform{
"claude": {
label: "claude-code",
projectDir: ".claude",
install: func(folder string) (string, bool, error) {
return mergeJSONHooks(filepath.Join(folder, ".claude", "settings.json"),
"UserPromptSubmit", "PostToolUse", "Write|Edit|MultiEdit", "claude-code", 30, true)
},
},
"codex": {
label: "codex",
projectDir: ".codex",
install: func(folder string) (string, bool, error) {
return mergeJSONHooks(filepath.Join(folder, ".codex", "hooks.json"),
"UserPromptSubmit", "PostToolUse", "apply_patch", "codex", 30, false)
},
note: "run /hooks inside Codex once to trust the project's .codex layer",
},
"gemini": {
label: "gemini",
projectDir: ".gemini",
install: func(folder string) (string, bool, error) {
// Gemini uses its own event names and millisecond timeouts.
return mergeJSONHooks(filepath.Join(folder, ".gemini", "settings.json"),
"BeforeAgent", "AfterTool", "write_file|replace|edit", "gemini", 30000, false)
},
},
"hermes": {
label: "hermes",
userLevel: true,
install: installHermes,
},
}
// Detect reports which agent platforms are in use, judged by their config
// dirs existing in the project or the home directory.
func Detect(folder string) []string {
home, _ := os.UserHomeDir()
var found []string
for _, name := range Agents {
p := platforms[name]
switch {
case p.userLevel:
if dirExists(filepath.Join(home, "."+name)) {
found = append(found, name)
}
case dirExists(filepath.Join(folder, p.projectDir)) ||
(home != "" && dirExists(filepath.Join(home, p.projectDir))):
found = append(found, name)
}
}
return found
}
// Registered reports whether an agent's config already carries our hooks.
func Registered(folder, agent string) bool {
data, err := os.ReadFile(ConfigPath(folder, agent))
return err == nil && strings.Contains(string(data), marker)
}
// ConfigPath returns where an agent's hooks are (or would be) registered.
func ConfigPath(folder, agent string) string {
switch agent {
case "claude":
return filepath.Join(folder, ".claude", "settings.json")
case "codex":
return filepath.Join(folder, ".codex", "hooks.json")
case "gemini":
return filepath.Join(folder, ".gemini", "settings.json")
case "hermes":
home, _ := os.UserHomeDir()
return filepath.Join(home, ".hermes", "config.yaml")
}
return ""
}
// Install registers the sync hooks for the given agents ("auto"/empty =
// every detected platform). Merging is idempotent and preserves whatever
// hooks the config already has.
func Install(folder string, agents []string) ([]Result, error) {
if len(agents) == 0 || (len(agents) == 1 && agents[0] == "auto") {
agents = Detect(folder)
}
var out []Result
for _, name := range agents {
p, ok := platforms[name]
if !ok {
return out, fmt.Errorf("unknown agent %q (supported: %s)", name, strings.Join(Agents, ", "))
}
path, changed, err := p.install(folder)
if err != nil {
return out, fmt.Errorf("%s: %w", name, err)
}
out = append(out, Result{Agent: name, Path: path, Changed: changed, Note: p.note})
}
return out, nil
}
// mergeJSONHooks adds the pull + push hook pair to a Claude-style hooks JSON
// file (Claude, Codex, and Gemini all use this shape: hooks.<Event> is an
// array of {matcher?, hooks: [{type: "command", ...}]} groups).
func mergeJSONHooks(path, pullEvent, pushEvent, pushMatcher, label string, timeout int, async bool) (string, bool, error) {
root := map[string]any{}
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, &root); err != nil {
return path, false, fmt.Errorf("parse %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return path, false, err
}
hooks, ok := root["hooks"].(map[string]any)
if !ok {
hooks = map[string]any{}
root["hooks"] = hooks
}
cmd := hookCommand(label)
pull := map[string]any{"hooks": []any{map[string]any{
"type": "command", "command": cmd, "timeout": timeout,
"statusMessage": "beardrive: pulling latest files",
}}}
pushHook := map[string]any{"type": "command", "command": cmd, "timeout": timeout}
if async {
pushHook["async"] = true
}
push := map[string]any{"matcher": pushMatcher, "hooks": []any{pushHook}}
changed := false
for event, group := range map[string]any{pullEvent: pull, pushEvent: push} {
arr, _ := hooks[event].([]any)
if containsMarker(arr) {
continue
}
hooks[event] = append(arr, group)
changed = true
}
if !changed {
return path, false, nil
}
return path, true, writeConfig(path, func() ([]byte, error) {
return json.MarshalIndent(root, "", " ")
})
}
// installHermes merges the hook pair into ~/.hermes/config.yaml
// (hooks.<event> is an array of {matcher?, command, timeout}).
func installHermes(string) (string, bool, error) {
path := ConfigPath("", "hermes")
root := map[string]any{}
if data, err := os.ReadFile(path); err == nil {
if err := yaml.Unmarshal(data, &root); err != nil {
return path, false, fmt.Errorf("parse %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return path, false, err
}
hooks, ok := root["hooks"].(map[string]any)
if !ok {
hooks = map[string]any{}
root["hooks"] = hooks
}
cmd := hookCommand("hermes")
groups := map[string]any{
"pre_llm_call": map[string]any{"command": cmd, "timeout": 30},
"post_tool_call": map[string]any{"matcher": "write_file|patch", "command": cmd, "timeout": 30},
}
changed := false
for event, group := range groups {
arr, _ := hooks[event].([]any)
if containsMarker(arr) {
continue
}
hooks[event] = append(arr, group)
changed = true
}
if !changed {
return path, false, nil
}
return path, true, writeConfig(path, func() ([]byte, error) {
return yaml.Marshal(root)
})
}
// containsMarker reports whether a hook array already holds one of ours.
// Serializing sidesteps walking every platform's nesting by hand.
func containsMarker(v any) bool {
data, err := json.Marshal(v)
return err == nil && strings.Contains(string(data), marker)
}
func writeConfig(path string, marshal func() ([]byte, error)) error {
data, err := marshal()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return store.WriteFileAtomic(path, append(data, '\n'), 0o644)
}
func dirExists(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}
+243
View File
@@ -0,0 +1,243 @@
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, filepath.Join(folder, ".claude", "settings.json"))
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), `"async":true`) {
t.Fatal("claude push hook should be async")
}
// Codex: same schema, its own label and matcher, no async field.
cx, _ := json.Marshal(readJSON(t, filepath.Join(folder, ".codex", "hooks.json")))
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")
}
// Gemini: its own event names and ms timeout.
gm, _ := json.Marshal(readJSON(t, filepath.Join(folder, ".gemini", "settings.json")))
for _, want := range []string{"BeforeAgent", "AfterTool", "gemini session $s", "30000"} {
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)
os.WriteFile(filepath.Join(folder, ".claude", "settings.json"), []byte(pre), 0o644)
if _, err := Install(folder, []string{"claude"}); err != nil {
t.Fatal(err)
}
cfg := readJSON(t, filepath.Join(folder, ".claude", "settings.json"))
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 != 2 {
t.Fatalf("PostToolUse groups = %d, want user's + ours", got)
}
// Second install: no change, byte-identical file.
before, _ := os.ReadFile(filepath.Join(folder, ".claude", "settings.json"))
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(filepath.Join(folder, ".claude", "settings.json"))
if string(before) != string(after) {
t.Fatal("re-install rewrote the file")
}
}
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 !strings.Contains(string(data), "hermes session $s") {
t.Fatal("hermes hook lacks its session-note label")
}
// 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()
os.MkdirAll(filepath.Join(dir, ".bdrive"), 0o755)
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)
}
}
}
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
}
+10 -1
View File
@@ -52,7 +52,16 @@ Follow these steps:
bdrive init --name <project> --shared wiki # in a repo: only ./wiki syncs
```
5. **Verify**: run `bdrive status <folder>` and confirm the daemon is
5. **Register agent sync hooks**: run `bdrive hooks install <folder>`. It
detects the agent platforms in use (Claude Code, Codex, Gemini CLI,
Hermes — by their config dirs in the project or home) and idempotently
merges beardrive's sync hooks into each platform's own hook config, so
files pull at every turn start, push after edits, and every change is
stamped with the agent session that made it. Tell the user which
platforms got hooks; if Codex is among them, mention they must run
`/hooks` inside Codex once to trust the project's `.codex` layer.
6. **Verify**: run `bdrive status <folder>` and confirm the daemon is
running and pending is 0. Summarize: project name/id, what syncs, and
that edits propagate to every team member within seconds. Offer a
consent-gated CLAUDE.md note and tell the user how teammates connect
+19 -40
View File
@@ -61,49 +61,28 @@ every change is tracked (who, when, from which device).
the URL.
```
## 5. Register project-level sync hooks (ask first)
## 5. Register agent sync hooks
Ask: "Want me to register sync hooks in `.claude/settings.json` so files
sync automatically during Claude sessions — for every teammate, plugin or
not?" If yes, merge this into the project's `.claude/settings.json`
(create it if missing; preserve existing hooks — append to the arrays,
never overwrite them):
Run `bdrive hooks install` in the project. It detects the agent platforms
in use — Claude Code (`.claude/`), Codex (`.codex/`), Gemini CLI
(`.gemini/`), Hermes (`~/.hermes/`) — and idempotently merges beardrive's
sync hooks into each platform's own hook config, preserving any hooks
already there. Project-level files (`.claude/settings.json`,
`.codex/hooks.json`, `.gemini/settings.json`) ride the repo, so every
teammate gets them — plugin or not, whatever agent they use; Hermes hooks
are per-user (`~/.hermes/config.yaml`).
```json
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "sh -c 'cd \"${CLAUDE_PROJECT_DIR:-.}\" && [ -d .bdrive ] && command -v bdrive >/dev/null && bdrive sync . >/dev/null 2>&1 || true'",
"timeout": 30,
"statusMessage": "beardrive: pulling latest files"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "sh -c 'cd \"${CLAUDE_PROJECT_DIR:-.}\" && [ -d .bdrive ] && command -v bdrive >/dev/null && bdrive sync . >/dev/null 2>&1 || true'",
"async": true
}
]
}
]
}
}
```
The registered hooks pull before every turn (the agent always reads the
team's latest files), push right after edits (artifacts land on the server
seconds after they're created — daemon or no daemon), and stamp every
change with the agent session that made it (`bdrive sync --note "<agent>
session <id>"` — visible in `bdrive log` and the hub's history views).
They are fast no-ops in folders without `.bdrive/`.
The pull at prompt-submit means Claude always reads the team's latest
files; the async push after each Write/Edit means artifacts land on the
server seconds after Claude creates them — daemon or no daemon. Both are
fast no-ops in folders without `.bdrive/`.
Tell the user which platforms got hooks (`bdrive hooks` shows the status
table). If Codex is among them, mention they must run `/hooks` inside
Codex once to trust the project's `.codex` layer. To register a platform
that wasn't detected: `bdrive hooks install --agent claude,codex,gemini,hermes`.
## 6. Verify and summarize
+31 -1
View File
@@ -17,13 +17,14 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing
| Run the daemon in the foreground | `bdrive init -f` |
| Stop syncing | `bdrive stop [<folder>]` (`--forget` also unregisters) |
| One sync cycle now | `bdrive sync [<folder>]` |
| Register agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes) | `bdrive hooks install [<folder>]` — auto-detects the platforms in use and merges pull/push/session-note hooks into each one's own hook config, idempotently; bare `bdrive hooks` shows the status table |
| Mounts + daemon + pending state | `bdrive status [<folder>]` |
| Change history | `bdrive log [<folder>] [-p path] [-n N]` |
| This device's identity | `bdrive whoami` |
| Sign this device in (once per device) | `bdrive login [url]` — bare form uses the remembered server or beardrive.ai. Opens the sign-in page in a browser (sign-up available there); the terminal completes on its own and stores a per-device token. `--device` prints a code to approve from any browser (SSH/headless); `--status` shows server + account. Password reset: "Forgot password?" on the sign-in page (emailed via the server's SMTP config, or the link appears in the server log). **Switch hubs** with `bdrive login <new-url>`, then re-run `bdrive init` in each folder. |
| Sign this device out | `bdrive logout` — clears the saved token + account (folders untouched); `--forget` also drops the remembered server. The device token stays valid server-side until it expires — revoke it from the hub's device list to be sure. |
| Share a synced file publicly by URL | `bdrive share <file>` — prints a link anyone can open (HTML renders as a page, markdown rendered, PDFs inline; sandboxed; always the latest content; no account needed). `--expires 24h` for self-destructing links; `--list` / `--revoke <token-or-url>` to manage. Put generated reports in the shared folder, sync, then share. |
| Set up a project for a Claude Code team | `/beardrive:install` — installs the CLI, signs in, runs init (whole/shared folder), offers a CLAUDE.md section about the shared folder, and registers project-level hooks (blocking pull at prompt-submit, async push after Write/Edit) in `.claude/settings.json` |
| Set up a project for a Claude Code team | `/beardrive:install` — installs the CLI, signs in, runs init (whole/shared folder), offers a CLAUDE.md section about the shared folder, and registers agent sync hooks via `bdrive hooks install` (pull at turn start, push after edits, session-note stamping — for every detected platform, not just Claude) |
| Per-file / folder change history in the web UI | History button (file versions or project feed) and per-folder ⌚ — each entry: account, time, device (name/OS/IP), view/download of that exact version. API: `GET /api/p/<id>/history?path=\|prefix=`, `GET /api/p/<id>/blob?sha=` |
| Web server: viewer + multi-project sync hub (read-only unless `--upload`) | `bdrive web [<folder> \| <storage-root-url>]` (serves cwd by default, `--addr :4173`; `-c config.json` reads remote/addr/upload/projects_db/database/auth settings from a file, explicit flags win; a storage root URL makes it a hub hosting many projects at `<root>/<project-id>/`, registry in `--projects-db` file, default `$BDRIVE_HOME/projects.json`; `--upload` lets browsers add files, client devices push, and projects be created — direct to storage via expiring presigned URLs on S3/GCS, relayed through the server for `file://`; `--upload-ttl 15m`; clients never see the remote URL or credentials; hub projects are walled by org membership — invite teammates from the web UI; the viewer has a ⌘K palette for fuzzy file search, project switching, and quick actions) |
@@ -114,6 +115,35 @@ Renaming/moving a project folder is safe: the daemon notices its folder vanished
- Verify credentials and the remote end-to-end.
- Sync once even when the daemon is stopped.
**Session-linked notes**: `bdrive sync --note "<text>"` stamps the text onto
every change this cycle commits — and persists it (in the mount's volume
store, never synced) so changes the background daemon commits over the next
`--note-ttl` (default 30m) carry it too. Notes appear in `bdrive log` (as
`[note]`) and under each entry in the hub's history views. The plugin's sync
hooks pass `--note "claude-code session <session-id>"` automatically, so
every change made during a Claude Code session is traceable to the session
that made it. An explicit empty `--note ""` clears the persisted note;
conflict-copy ops keep their own `conflict copy of <path>` note.
### Agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes)
`bdrive hooks install [<folder>]` registers turn-boundary sync for every
agent platform it detects (by config dir, in the project or home):
| Platform | Config it writes | Pull / push events |
|---|---|---|
| Claude Code (& Cowork) | `<project>/.claude/settings.json` | `UserPromptSubmit` / `PostToolUse` (Write\|Edit) |
| Codex (ChatGPT) | `<project>/.codex/hooks.json` | `UserPromptSubmit` / `PostToolUse` (apply_patch) — user must `/hooks`-trust the layer once |
| Gemini CLI | `<project>/.gemini/settings.json` | `BeforeAgent` / `AfterTool` (write_file\|replace) |
| Hermes | `~/.hermes/config.yaml` (per-user) | `pre_llm_call` / `post_tool_call` (write_file\|patch) |
Every platform pipes hook JSON with a `session_id`, so one hook command
serves all four: it syncs the project (fast no-op outside bdrive folders)
and stamps changes with `<agent> session <id>`. Merging is idempotent and
preserves existing hooks; `--agent claude,codex,gemini,hermes` overrides
detection; bare `bdrive hooks` prints the detection/registration table.
Project-level configs ride the repo, so hooks reach the whole team.
### Examples to walk a user through
```sh