mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Fable 5
parent
c1f0d0f8ee
commit
626a9c0a07
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -36,6 +36,7 @@ everything keeps working offline; changes sync when the remote is reachable.`,
|
||||
shareCmd(),
|
||||
stopCmd(),
|
||||
syncCmd(),
|
||||
hooksCmd(),
|
||||
statusCmd(),
|
||||
logCmd(),
|
||||
webCmd(),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user