diff --git a/README.md b/README.md index 9e395a0..d34e85b 100644 --- a/README.md +++ b/README.md @@ -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 ` | 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 ` 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 | diff --git a/cmd/bdrive/hooks.go b/cmd/bdrive/hooks.go new file mode 100644 index 0000000..ffb89c7 --- /dev/null +++ b/cmd/bdrive/hooks.go @@ -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 +} diff --git a/cmd/bdrive/main.go b/cmd/bdrive/main.go index 1258d29..52d5c65 100644 --- a/cmd/bdrive/main.go +++ b/cmd/bdrive/main.go @@ -36,6 +36,7 @@ everything keeps working offline; changes sync when the remote is reachable.`, shareCmd(), stopCmd(), syncCmd(), + hooksCmd(), statusCmd(), logCmd(), webCmd(), diff --git a/go.mod b/go.mod index 71c44e0..bf2bcec 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 704b4b8..b7c5347 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/agenthooks/agenthooks.go b/internal/agenthooks/agenthooks.go new file mode 100644 index 0000000..f6200bd --- /dev/null +++ b/internal/agenthooks/agenthooks.go @@ -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 " session " +// (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. 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. 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() +} diff --git a/internal/agenthooks/agenthooks_test.go b/internal/agenthooks/agenthooks_test.go new file mode 100644 index 0000000..30b1a54 --- /dev/null +++ b/internal/agenthooks/agenthooks_test.go @@ -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 +} diff --git a/plugin/commands/init.md b/plugin/commands/init.md index a6eb740..dd13a7a 100644 --- a/plugin/commands/init.md +++ b/plugin/commands/init.md @@ -52,7 +52,16 @@ Follow these steps: bdrive init --name --shared wiki # in a repo: only ./wiki syncs ``` -5. **Verify**: run `bdrive status ` and confirm the daemon is +5. **Register agent sync hooks**: run `bdrive hooks install `. 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 ` 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 diff --git a/plugin/commands/install.md b/plugin/commands/install.md index 42baaa6..1adaf7d 100644 --- a/plugin/commands/install.md +++ b/plugin/commands/install.md @@ -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 " +session "` — 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 diff --git a/plugin/skills/beardrive/SKILL.md b/plugin/skills/beardrive/SKILL.md index 5829acf..b7ad61d 100644 --- a/plugin/skills/beardrive/SKILL.md +++ b/plugin/skills/beardrive/SKILL.md @@ -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 []` (`--forget` also unregisters) | | One sync cycle now | `bdrive sync []` | +| Register agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes) | `bdrive hooks install []` — 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 []` | | Change history | `bdrive log [] [-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 `, 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 ` — 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 ` 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//history?path=\|prefix=`, `GET /api/p//blob?sha=` | | Web server: viewer + multi-project sync hub (read-only unless `--upload`) | `bdrive web [ \| ]` (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 `//`, 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 ""` 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 "` 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 ` note. + +### Agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes) + +`bdrive hooks install []` 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) | `/.claude/settings.json` | `UserPromptSubmit` / `PostToolUse` (Write\|Edit) | +| Codex (ChatGPT) | `/.codex/hooks.json` | `UserPromptSubmit` / `PostToolUse` (apply_patch) — user must `/hooks`-trust the layer once | +| Gemini CLI | `/.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 ` session `. 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