mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Merge pull request #28 from runbear-io/feat/link-convention-hook
Gated hub links on every mentioned file path, injected fresh each turn
This commit is contained in:
@@ -67,7 +67,7 @@ The real coverage is the integration tests in `internal/syncer/syncer_test.go`:
|
||||
|
||||
## Claude Code plugin
|
||||
|
||||
`plugin/` is a Claude Code plugin (skill + `/beardrive:install` + `/beardrive:init` + `/beardrive:status` commands + turn-boundary sync hooks). `/beardrive:install` (`plugin/commands/install.md`) is the team onboarding flow: binary, login, init, a consent-gated two-file agent orientation (synced `<shared>/AGENTS.md` map + repo-root `AGENTS.md`/`CLAUDE.md` pointer — see SKILL.md "Teaching agents the folder"), and project-level hooks in `.claude/settings.json` (blocking pull at UserPromptSubmit, async push on PostToolUse Write/Edit) so teammates without the plugin still sync, published via the marketplace manifest at `.claude-plugin/marketplace.json` (`/plugin marketplace add runbear-io/beardrive`). The canonical skill lives at `plugin/skills/beardrive/SKILL.md`; `.claude/skills/beardrive` is a symlink to it. The hook script `plugin/scripts/beardrive-sync.sh` (and the inline project-level hook commands) must stay a fast no-op for folders without a `.bdrive/` dir — it runs on every turn in every project.
|
||||
`plugin/` is a Claude Code plugin (skill + `/beardrive:install` + `/beardrive:init` + `/beardrive:status` commands + turn-boundary sync hooks). `/beardrive:install` (`plugin/commands/install.md`) is the team onboarding flow: binary, login, init, a consent-gated two-file agent orientation (synced `<shared>/AGENTS.md` map + repo-root `AGENTS.md`/`CLAUDE.md` pointer — see SKILL.md "Teaching agents the folder"), and project-level hooks in `.claude/settings.json` (blocking pull at UserPromptSubmit — which, via `bdrive sync --hook`, also injects the project's gated-link formula as additionalContext so agents append `path` [🔗](hub link) to every synced path they mention — async push on PostToolUse Write/Edit) so teammates without the plugin still sync, published via the marketplace manifest at `.claude-plugin/marketplace.json` (`/plugin marketplace add runbear-io/beardrive`). The canonical skill lives at `plugin/skills/beardrive/SKILL.md`; `.claude/skills/beardrive` is a symlink to it. The hook script `plugin/scripts/beardrive-sync.sh` (and the inline project-level hook commands) must stay a fast no-op for folders without a `.bdrive/` dir — it runs on every turn in every project.
|
||||
|
||||
## Docs to keep in sync
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ hub's own storage, never something a syncing client points at directly:
|
||||
| `bdrive stop [folder]` | Stop syncing (files stay; `bdrive init` resumes) |
|
||||
| `bdrive url [path]` | Internal hub link for a file/folder (sign-in + membership required; `--sync` pushes first; no arg = project home). Computed locally |
|
||||
| `bdrive share <file>` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) |
|
||||
| `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 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. `--hook <label>` is agent-hook plumbing: event JSON on stdin, sync + note, gated-link formula (Claude Code hook JSON) on stdout |
|
||||
| `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, agent-read tracking; idempotent (`--agent` overrides detection) |
|
||||
| `bdrive read-log [folder]` | Hook plumbing: queue agent file reads from a hook event (JSON on stdin) for the hub's read heatmap — native reads, grep matches, and files named in shell commands; drained on the next sync. Registered by `bdrive hooks install` |
|
||||
| `bdrive status [folder]` | Projects, daemon state, pending changes |
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
func syncCmd() *cobra.Command {
|
||||
var note string
|
||||
var noteTTL time.Duration
|
||||
var hookLabel string
|
||||
c := &cobra.Command{
|
||||
Use: "sync [folder]",
|
||||
Short: "Sync a mounted folder with its remote now",
|
||||
@@ -24,6 +25,11 @@ func syncCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hookLabel != "" {
|
||||
// Agent-hook mode: event JSON on stdin, silent best-effort
|
||||
// sync, link-formula context on stdout. Never fails.
|
||||
return runHookSync(cmd, folder, hookLabel)
|
||||
}
|
||||
sess, proj, err := openSession(cmd.Context(), folder, true)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -51,6 +57,7 @@ func syncCmd() *cobra.Command {
|
||||
}
|
||||
c.Flags().StringVar(¬e, "note", "", "session context stamped onto changes (e.g. an agent session id); shown in history; empty clears")
|
||||
c.Flags().DurationVar(¬eTTL, "note-ttl", 30*time.Minute, "how long the note keeps applying to daemon-committed changes")
|
||||
c.Flags().StringVar(&hookLabel, "hook", "", "agent-hook mode: read the platform's hook event JSON from stdin, sync with a session note labeled by this value, and emit the project's link-formula context (Claude Code hook JSON) on stdout")
|
||||
return c
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// `bdrive sync --hook <label>` is the agent-hook flavor of sync, run by the
|
||||
// Claude Code UserPromptSubmit hook at every turn start. It does three
|
||||
// things: pulls (a normal cycle), stamps the session note so every change
|
||||
// this turn is attributed to the agent session, and — the part that keeps
|
||||
// agents current no matter how stale their skill copy is — emits the
|
||||
// project's gated-link formula as additionalContext, so the agent can
|
||||
// append a hub link to any synced file path it mentions.
|
||||
//
|
||||
// Everything is best-effort: a hook must never fail the turn, so every
|
||||
// error path is a silent, successful exit.
|
||||
|
||||
// hookNoteTTL mirrors `bdrive sync --note-ttl`'s default: the daemon's own
|
||||
// scans keep stamping this session's changes for a while.
|
||||
const hookNoteTTL = 30 * time.Minute
|
||||
|
||||
func runHookSync(cmd *cobra.Command, folder, label string) error {
|
||||
// The platform pipes its event JSON on stdin; the session id is all we
|
||||
// need from it here.
|
||||
data, _ := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1<<20))
|
||||
var event struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &event) // malformed input: just sync
|
||||
|
||||
sess, proj, err := openSession(cmd.Context(), folder, true)
|
||||
if err != nil {
|
||||
return nil // not a mount / no session: fast no-op
|
||||
}
|
||||
defer closeSession(sess)
|
||||
|
||||
if event.SessionID != "" {
|
||||
note := label + " session " + event.SessionID
|
||||
if err := sess.Store.SaveNote(note, hookNoteTTL); err == nil {
|
||||
sess.Note = note
|
||||
}
|
||||
}
|
||||
|
||||
// The pull. Offline is fine — the link formula below is still valid
|
||||
// for teammates who are online.
|
||||
if _, err := sess.Cycle(cmd.Context()); err != nil {
|
||||
return nil // never break the turn
|
||||
}
|
||||
|
||||
server, projectID, err := splitHubRemote(proj.Remote)
|
||||
if err != nil {
|
||||
return nil // non-hub remote: nothing to link to
|
||||
}
|
||||
base := server + "/" + projectID
|
||||
|
||||
out := map[string]any{
|
||||
"hookSpecificOutput": map[string]any{
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": fmt.Sprintf(
|
||||
"beardrive: this folder syncs to %s (the project's hub page; files are at %s/<url-encoded path>). "+
|
||||
"Link convention: whenever you mention a synced file's path in prose, append its gated hub link on an emoji, formatted exactly as: `<path>` [🔗](%s/<url-encoded path>) — the path stays plain text, the hyperlink goes on the emoji only. "+
|
||||
"These links require hub sign-in + project membership, so they are safe to paste anywhere internal. "+
|
||||
"Only link files that actually sync (inside the shared scope, not ignored); keep paths inside code blocks or commands plain; give a raw URL only when the user needs to paste it outside this conversation. "+
|
||||
"`bdrive share <file>` mints PUBLIC no-account links — use it only when the user explicitly asks for a public link.",
|
||||
base, base, base),
|
||||
},
|
||||
}
|
||||
enc, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), string(enc))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/runbear-io/beardrive/internal/config"
|
||||
"github.com/runbear-io/beardrive/internal/store"
|
||||
)
|
||||
|
||||
// `bdrive sync --hook` must emit the gated-link formula as Claude Code
|
||||
// hook JSON, stamp the session note, and stay a silent no-op everywhere
|
||||
// else — a hook must never fail the turn.
|
||||
func TestSyncHookMode(t *testing.T) {
|
||||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||||
folder := t.TempDir()
|
||||
folder, _ = filepath.EvalSymlinks(folder)
|
||||
proj, err := config.SaveProject(folder, config.Project{
|
||||
Volume: "wiki",
|
||||
Remote: "https://hub.example.com/p/p-12345678", // unreachable: cycle degrades offline, formula still valid
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := syncCmd()
|
||||
var out bytes.Buffer
|
||||
c.SetOut(&out)
|
||||
c.SetIn(strings.NewReader(`{"session_id":"sess-42","prompt":"hello"}`))
|
||||
c.SetArgs([]string{folder, "--hook", "claude-code"})
|
||||
if err := c.Execute(); err != nil {
|
||||
t.Fatalf("hook mode must never fail: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{
|
||||
`"hookSpecificOutput"`,
|
||||
`"hookEventName":"UserPromptSubmit"`,
|
||||
"https://hub.example.com/p-12345678", // base URL: remote minus /p
|
||||
"[🔗](", // the emoji-link convention
|
||||
"code blocks", // paths in code blocks stay plain
|
||||
"PUBLIC", // bdrive share stays opt-in
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("hook output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The session note was stamped for the daemon's follow-up scans.
|
||||
vdir, err := config.VolumeDir(proj.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := store.Open(vdir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if note := st.LoadNote(); note != "claude-code session sess-42" {
|
||||
t.Errorf("note = %q, want the stamped session", note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncHookModeNoOps(t *testing.T) {
|
||||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||||
|
||||
// Not a mount: silent success, no output.
|
||||
c := syncCmd()
|
||||
var out bytes.Buffer
|
||||
c.SetOut(&out)
|
||||
c.SetIn(strings.NewReader(`{"session_id":"x"}`))
|
||||
c.SetArgs([]string{t.TempDir(), "--hook", "claude-code"})
|
||||
if err := c.Execute(); err != nil {
|
||||
t.Fatalf("non-mount must be a silent no-op: %v", err)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("non-mount emitted output: %s", out.String())
|
||||
}
|
||||
|
||||
// Garbage stdin: still sync, still emit, never fail.
|
||||
folder := t.TempDir()
|
||||
folder, _ = filepath.EvalSymlinks(folder)
|
||||
if _, err := config.SaveProject(folder, config.Project{
|
||||
Volume: "wiki", Remote: "https://hub.example.com/p/p-12345678",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c2 := syncCmd()
|
||||
out.Reset()
|
||||
c2.SetOut(&out)
|
||||
c2.SetIn(strings.NewReader("not json at all"))
|
||||
c2.SetArgs([]string{folder, "--hook", "claude-code"})
|
||||
if err := c2.Execute(); err != nil {
|
||||
t.Fatalf("garbage stdin must not fail: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), `"hookSpecificOutput"`) {
|
||||
t.Fatalf("formula not emitted on garbage stdin: %s", out.String())
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,17 @@ func hookCommand(label string) string {
|
||||
`else bdrive sync . >/dev/null 2>&1 || true; fi'`
|
||||
}
|
||||
|
||||
// hookPullCommand is Claude Code's turn-start hook: `bdrive sync --hook`
|
||||
// pulls, stamps the session note, and emits the project's gated-link
|
||||
// formula as additionalContext (hookSpecificOutput JSON on stdout — which
|
||||
// is why stdout must NOT be discarded here). Claude-only: the JSON
|
||||
// contract is Claude Code's.
|
||||
func hookPullCommand(label string) string {
|
||||
return `sh -c '` +
|
||||
`cd "${CLAUDE_PROJECT_DIR:-.}" && [ -d .bdrive ] && command -v bdrive >/dev/null || exit 0; ` +
|
||||
`bdrive sync . --hook ` + label + ` 2>/dev/null'`
|
||||
}
|
||||
|
||||
// readHookCommand queues agent file reads for the hub's read heatmap:
|
||||
// `bdrive read-log` parses the hook's stdin JSON itself and only appends to
|
||||
// a local spool, so this stays cheap enough to run on every read-tool call.
|
||||
@@ -94,7 +105,8 @@ var platforms = map[string]platform{
|
||||
// files the command names (`read-log` mines both payloads).
|
||||
// Glob stays unmatched on purpose — listing names isn't reading.
|
||||
return mergeJSONHooks(filepath.Join(folder, ".claude", "settings.json"),
|
||||
"UserPromptSubmit", "PostToolUse", "Write|Edit|MultiEdit", "Read|Grep|Bash", "claude-code", 30, true)
|
||||
"UserPromptSubmit", "PostToolUse", "Write|Edit|MultiEdit", "Read|Grep|Bash", "claude-code", 30, true,
|
||||
hookPullCommand("claude-code"))
|
||||
},
|
||||
},
|
||||
"codex": {
|
||||
@@ -104,7 +116,7 @@ var platforms = map[string]platform{
|
||||
// Codex reads mostly happen through shell commands; read-log
|
||||
// mines the command line for the files it names.
|
||||
return mergeJSONHooks(filepath.Join(folder, ".codex", "hooks.json"),
|
||||
"UserPromptSubmit", "PostToolUse", "apply_patch", "read_file|shell", "codex", 30, false)
|
||||
"UserPromptSubmit", "PostToolUse", "apply_patch", "read_file|shell", "codex", 30, false, "")
|
||||
},
|
||||
note: "run /hooks inside Codex once to trust the project's .codex layer",
|
||||
},
|
||||
@@ -115,7 +127,7 @@ var platforms = map[string]platform{
|
||||
// Gemini uses its own event names and millisecond timeouts.
|
||||
return mergeJSONHooks(filepath.Join(folder, ".gemini", "settings.json"),
|
||||
"BeforeAgent", "AfterTool", "write_file|replace|edit",
|
||||
"read_file|read_many_files|search_file_content|run_shell_command", "gemini", 30000, false)
|
||||
"read_file|read_many_files|search_file_content|run_shell_command", "gemini", 30000, false, "")
|
||||
},
|
||||
},
|
||||
"hermes": {
|
||||
@@ -194,7 +206,7 @@ func Install(folder string, agents []string) ([]Result, error) {
|
||||
// hooks.<Event> is an array of {matcher?, hooks: [{type: "command", ...}]}
|
||||
// groups). Push and read share the tool-use event under different matchers,
|
||||
// each idempotent on its own marker.
|
||||
func mergeJSONHooks(path, pullEvent, pushEvent, pushMatcher, readMatcher, label string, timeout int, async bool) (string, bool, error) {
|
||||
func mergeJSONHooks(path, pullEvent, pushEvent, pushMatcher, readMatcher, label string, timeout int, async bool, pullCmd string) (string, bool, error) {
|
||||
root := map[string]any{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
@@ -209,8 +221,11 @@ func mergeJSONHooks(path, pullEvent, pushEvent, pushMatcher, readMatcher, label
|
||||
root["hooks"] = hooks
|
||||
}
|
||||
cmd := hookCommand(label)
|
||||
if pullCmd == "" {
|
||||
pullCmd = cmd
|
||||
}
|
||||
pull := map[string]any{"hooks": []any{map[string]any{
|
||||
"type": "command", "command": cmd, "timeout": timeout,
|
||||
"type": "command", "command": pullCmd, "timeout": timeout,
|
||||
"statusMessage": "beardrive: pulling latest files",
|
||||
}}}
|
||||
pushHook := map[string]any{"type": "command", "command": cmd, "timeout": timeout}
|
||||
@@ -224,22 +239,23 @@ func mergeJSONHooks(path, pullEvent, pushEvent, pushMatcher, readMatcher, label
|
||||
|
||||
changed := false
|
||||
for _, g := range []struct {
|
||||
event string
|
||||
group map[string]any
|
||||
marker string
|
||||
matcher string // non-empty: keep an already-registered group's matcher current
|
||||
event string
|
||||
group map[string]any
|
||||
marker string
|
||||
}{
|
||||
{pullEvent, pull, marker, ""},
|
||||
{pushEvent, push, marker, pushMatcher},
|
||||
{pushEvent, read, readMarker, readMatcher},
|
||||
{pullEvent, pull, marker},
|
||||
{pushEvent, push, marker},
|
||||
{pushEvent, read, readMarker},
|
||||
} {
|
||||
arr, _ := hooks[g.event].([]any)
|
||||
if grp := findMarkerGroup(arr, g.marker); grp != nil {
|
||||
// Already registered — but upgrade a stale matcher in place so
|
||||
// coverage improvements reach existing projects on reinstall
|
||||
// (e.g. the read hook growing from "Read" to "Read|Grep|Bash").
|
||||
if g.matcher != "" && grp["matcher"] != g.matcher {
|
||||
grp["matcher"] = g.matcher
|
||||
if idx := indexOfMarkerGroup(arr, g.marker); idx >= 0 {
|
||||
// Already registered. These are OUR managed groups (marker-
|
||||
// identified): converge them to the current shape so command,
|
||||
// matcher, and flag improvements reach existing projects on
|
||||
// reinstall instead of being frozen by the idempotency check.
|
||||
if !jsonEqual(arr[idx], g.group) {
|
||||
arr[idx] = g.group
|
||||
hooks[g.event] = arr
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
@@ -274,21 +290,21 @@ func installHermes(string) (string, bool, error) {
|
||||
}
|
||||
cmd := hookCommand("hermes")
|
||||
groups := []struct {
|
||||
event string
|
||||
group map[string]any
|
||||
marker string
|
||||
matcher string
|
||||
event string
|
||||
group map[string]any
|
||||
marker string
|
||||
}{
|
||||
{"pre_llm_call", map[string]any{"command": cmd, "timeout": 30}, marker, ""},
|
||||
{"post_tool_call", map[string]any{"matcher": "write_file|patch", "command": cmd, "timeout": 30}, marker, "write_file|patch"},
|
||||
{"post_tool_call", map[string]any{"matcher": "read_file|grep|bash", "command": readHookCommand(), "timeout": 30}, readMarker, "read_file|grep|bash"},
|
||||
{"pre_llm_call", map[string]any{"command": cmd, "timeout": 30}, marker},
|
||||
{"post_tool_call", map[string]any{"matcher": "write_file|patch", "command": cmd, "timeout": 30}, marker},
|
||||
{"post_tool_call", map[string]any{"matcher": "read_file|grep|bash", "command": readHookCommand(), "timeout": 30}, readMarker},
|
||||
}
|
||||
changed := false
|
||||
for _, g := range groups {
|
||||
arr, _ := hooks[g.event].([]any)
|
||||
if grp := findMarkerGroup(arr, g.marker); grp != nil {
|
||||
if g.matcher != "" && grp["matcher"] != g.matcher {
|
||||
grp["matcher"] = g.matcher
|
||||
if idx := indexOfMarkerGroup(arr, g.marker); idx >= 0 {
|
||||
if !jsonEqual(arr[idx], g.group) {
|
||||
arr[idx] = g.group
|
||||
hooks[g.event] = arr
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
@@ -312,16 +328,23 @@ func containsMarker(v any, m string) bool {
|
||||
return err == nil && strings.Contains(string(data), m)
|
||||
}
|
||||
|
||||
// findMarkerGroup returns the hook group in the array that carries the
|
||||
// marker (so its matcher can be upgraded in place), or nil.
|
||||
func findMarkerGroup(arr []any, m string) map[string]any {
|
||||
for _, it := range arr {
|
||||
grp, ok := it.(map[string]any)
|
||||
if ok && containsMarker(grp, m) {
|
||||
return grp
|
||||
// indexOfMarkerGroup returns the index of the hook group carrying the
|
||||
// marker (so the group can be converged in place), or -1.
|
||||
func indexOfMarkerGroup(arr []any, m string) int {
|
||||
for i, it := range arr {
|
||||
if grp, ok := it.(map[string]any); ok && containsMarker(grp, m) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return -1
|
||||
}
|
||||
|
||||
// jsonEqual compares two values by canonical JSON (map keys sorted), so a
|
||||
// group loaded from disk and a freshly-built one compare structurally.
|
||||
func jsonEqual(a, b any) bool {
|
||||
da, err1 := json.Marshal(a)
|
||||
db, err2 := json.Marshal(b)
|
||||
return err1 == nil && err2 == nil && string(da) == string(db)
|
||||
}
|
||||
|
||||
func writeConfig(path string, marshal func() ([]byte, error)) error {
|
||||
|
||||
@@ -72,6 +72,9 @@ func TestInstallJSONPlatforms(t *testing.T) {
|
||||
if !strings.Contains(string(raw), "claude-code session $s") {
|
||||
t.Fatal("claude hook lacks its session-note label")
|
||||
}
|
||||
if !strings.Contains(string(raw), "bdrive sync . --hook claude-code") {
|
||||
t.Fatal("claude pull hook should use --hook mode (link-formula injection)")
|
||||
}
|
||||
if !strings.Contains(string(raw), `"async":true`) {
|
||||
t.Fatal("claude push hook should be async")
|
||||
}
|
||||
@@ -213,6 +216,47 @@ func TestInstallUpgradesReadMatcher(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A config registered before the link-formula hook existed gets its pull
|
||||
// command converged in place on re-install — no duplicate groups, and the
|
||||
// next install settles to a no-op.
|
||||
func TestInstallUpgradesPullCommand(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
folder := t.TempDir()
|
||||
old := `{"hooks":{
|
||||
"UserPromptSubmit":[{"hooks":[{"type":"command","command":"sh -c 'bdrive sync .'","timeout":30}]}],
|
||||
"PostToolUse":[
|
||||
{"matcher":"Write|Edit|MultiEdit","hooks":[{"type":"command","command":"sh -c 'bdrive sync .'","timeout":30,"async":true}]},
|
||||
{"matcher":"Read|Grep|Bash","hooks":[{"type":"command","command":"sh -c 'bdrive read-log .'","timeout":30,"async":true}]}]}}`
|
||||
os.MkdirAll(filepath.Join(folder, ".claude"), 0o755)
|
||||
os.WriteFile(filepath.Join(folder, ".claude", "settings.json"), []byte(old), 0o644)
|
||||
|
||||
results, err := Install(folder, []string{"claude"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !results[0].Changed {
|
||||
t.Fatal("pull-command upgrade reported unchanged")
|
||||
}
|
||||
cfg := readJSON(t, filepath.Join(folder, ".claude", "settings.json"))
|
||||
hooks := cfg["hooks"].(map[string]any)
|
||||
if got := len(hooks["UserPromptSubmit"].([]any)); got != 1 {
|
||||
t.Fatalf("UserPromptSubmit groups = %d, want converged single group", got)
|
||||
}
|
||||
if got := len(hooks["PostToolUse"].([]any)); got != 2 {
|
||||
t.Fatalf("PostToolUse groups = %d, want push + read (no duplicates)", got)
|
||||
}
|
||||
raw, _ := json.Marshal(cfg)
|
||||
if !strings.Contains(string(raw), "bdrive sync . --hook claude-code") {
|
||||
t.Fatalf("pull command not upgraded: %s", raw)
|
||||
}
|
||||
|
||||
// Settles: the next install is a no-op.
|
||||
results, _ = Install(folder, []string{"claude"})
|
||||
if results[0].Changed {
|
||||
t.Fatal("re-install after upgrade reported a change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallHermesYAML(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "beardrive",
|
||||
"displayName": "BearDrive",
|
||||
"description": "BearDrive — the open-source Google Drive for AI agents: mount folders that stay in sync across devices and teammates through a self-hostable BearDrive hub (backed by S3/GCS/any object store), with turn-boundary sync, per-change attribution, and full change history. CLI: bdrive.",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"author": {
|
||||
"name": "runbear",
|
||||
"url": "https://github.com/runbear-io"
|
||||
|
||||
@@ -73,9 +73,10 @@ this (adapt the folder name; create the file if missing):
|
||||
propagate to everyone within seconds and every change is tracked (who,
|
||||
when, which device). Read `wiki/AGENTS.md` before working there. Put
|
||||
shareable artifacts — reports, notes, plans — in `wiki/` so the team
|
||||
sees them, and include the file's internal link in your reply
|
||||
(`bdrive url wiki/<file>` — teammates sign in to view). Never put
|
||||
secrets here (`bdrive share wiki/<file>` mints fully public URLs).
|
||||
sees them, and whenever you mention a synced file's path, append its
|
||||
gated link on an emoji: `` `wiki/<file>` `` [🔗](\<hub link>) —
|
||||
`bdrive url wiki/<file>` prints the link (teammates sign in to view).
|
||||
Never put secrets here (`bdrive share` mints fully public URLs).
|
||||
```
|
||||
|
||||
Point at the synced `AGENTS.md` rather than duplicating its conventions —
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/beardrive-sync.sh\"",
|
||||
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/beardrive-pull.sh\"",
|
||||
"timeout": 30,
|
||||
"statusMessage": "beardrive: pulling latest files"
|
||||
}
|
||||
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
# Turn-start hook: pull the team's latest files and hand Claude the
|
||||
# project's gated-link formula (hookSpecificOutput JSON on stdout), so any
|
||||
# synced file path the agent mentions gets a hub link on a 🔗 — computed
|
||||
# fresh from the binary each turn, immune to stale skill copies. `bdrive
|
||||
# sync --hook` reads the event JSON from stdin itself (session note) and
|
||||
# never fails the turn. Fast no-op outside beardrive projects.
|
||||
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
|
||||
[ -d .bdrive ] || exit 0
|
||||
command -v bdrive >/dev/null 2>&1 || exit 0
|
||||
exec bdrive sync . --hook claude-code 2>/dev/null
|
||||
@@ -16,7 +16,7 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing
|
||||
| Start syncing a project (create/connect; the front door) | `bdrive init [<folder>]` — interactive on a TTY; flags `--name <x>` / `--project <id>` / `--shared <dir>` / `--yes` for scripts and agents (NEVER prompts without a TTY). Re-run to resume, including after the folder was renamed/moved. Runs the login flow first (against your hub URL) if the device has no session. |
|
||||
| Run the daemon in the foreground | `bdrive init -f` |
|
||||
| Stop syncing | `bdrive stop [<folder>]` (`--forget` also unregisters) |
|
||||
| One sync cycle now | `bdrive sync [<folder>]` |
|
||||
| One sync cycle now | `bdrive sync [<folder>]` — `--note <text>` stamps session context; `--hook <label>` is the Claude turn-start hook's plumbing (event JSON in, sync + note, gated-link formula out) |
|
||||
| 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/read-tracking hooks into each one's own hook config, idempotently; bare `bdrive hooks` shows the status table |
|
||||
| Record agent file reads (hook plumbing) | `bdrive read-log [<folder>]` — parses a hook event JSON from stdin and queues in-project reads locally (native reads, grep matches, and files named in shell commands); drained to the hub on the next sync as agent traffic in the read heatmap. Registered automatically by `bdrive hooks install`; rarely run by hand |
|
||||
| Mounts + daemon + pending state | `bdrive status [<folder>]` |
|
||||
@@ -100,7 +100,7 @@ Hub projects belong to an **organization**: only members of the project's org ca
|
||||
|
||||
A hub stores its metadata (accounts, projects, orgs, invites, shares, devices — never files or journals, which stay in object storage) in a database chosen by the config's `database` block: `{"driver":"file"}` (default, JSON under `$BDRIVE_HOME`), `{"driver":"sqlite","dsn":"…/hub.db"}`, or `{"driver":"postgres","dsn":"postgres://…"}` for a managed Postgres such as Supabase. file/sqlite are single-writer; Postgres backs multiple instances. If a teammate's `bdrive init --project <id>` gets 403/404 or the project list looks empty, the missing invite is the reason. Public share links (`bdrive share`) intentionally bypass the org wall; internal links (`bdrive url`) stay behind it — prefer them for teammates, and reserve `bdrive share` for people outside the hub.
|
||||
|
||||
**Share what you make**: whenever you create a shareable artifact in the synced folder — a report, plan, analysis, or export (.md, .html, .csv, .pdf, …) — get its internal link with `bdrive url <file>` and include it in your reply. The sync hooks push within seconds so the link resolves almost immediately; use `bdrive url <file> --sync` when the reader will click right away. Never mint a public `bdrive share` link for this unless the user asks for one.
|
||||
**Link what you mention** (and especially what you make): whenever you mention a synced file's path in prose, append its gated hub link on an emoji, formatted exactly as — `` `<path>` `` [🔗](\<hub>/\<project-id>/\<url-encoded path>) — the path stays plain text (it is the local path), the hyperlink goes on the emoji only. The base URL comes from `.bdrive/config.json`: the `remote` is `<hub>/p/<project-id>`, so file links are `<hub>/<project-id>/<path>` (drop the `/p`); `bdrive url <file>` computes any single link for you. These links require hub sign-in + project membership — safe to paste anywhere internal. Rules: only link files that actually sync (inside the shared scope, not ignored); keep paths inside code blocks/commands plain; when you just created the file, the sync hooks push within seconds (`bdrive url <file> --sync` if the reader will click immediately); give a raw URL only when the user needs to paste it outside the conversation. On Claude Code the turn-start hook injects this formula automatically. Never mint a public `bdrive share` link unless the user explicitly asks for one.
|
||||
|
||||
### Renames and moves
|
||||
|
||||
@@ -136,7 +136,7 @@ agent platform it detects (by config dir, in the project or home):
|
||||
|
||||
| Platform | Config it writes | Pull / push / read events |
|
||||
|---|---|---|
|
||||
| Claude Code (& Cowork) | `<project>/.claude/settings.json` | `UserPromptSubmit` / `PostToolUse` (Write\|Edit) / `PostToolUse` (Read\|Grep\|Bash) |
|
||||
| Claude Code (& Cowork) | `<project>/.claude/settings.json` | `UserPromptSubmit` (pull + injects the gated-link formula) / `PostToolUse` (Write\|Edit) / `PostToolUse` (Read\|Grep\|Bash) |
|
||||
| Codex (ChatGPT) | `<project>/.codex/hooks.json` | `UserPromptSubmit` / `PostToolUse` (apply_patch) / `PostToolUse` (read_file\|shell, best-effort) — user must `/hooks`-trust the layer once |
|
||||
| Gemini CLI | `<project>/.gemini/settings.json` | `BeforeAgent` / `AfterTool` (write_file\|replace) / `AfterTool` (read_file\|read_many_files\|search\|shell) |
|
||||
| Hermes | `~/.hermes/config.yaml` (per-user) | `pre_llm_call` / `post_tool_call` (write_file\|patch) / `post_tool_call` (read_file\|grep\|bash) |
|
||||
|
||||
Reference in New Issue
Block a user