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:
Snow W. Lee (Sungwon)
2026-07-16 10:30:11 -07:00
committed by GitHub
12 changed files with 310 additions and 46 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 |
+7
View File
@@ -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(&note, "note", "", "session context stamped onto changes (e.g. an agent session id); shown in history; empty clears")
c.Flags().DurationVar(&noteTTL, "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
}
+79
View File
@@ -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
}
+99
View File
@@ -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())
}
}
+59 -36
View File
@@ -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 {
+44
View File
@@ -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)
+1 -1
View File
@@ -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"
+4 -3
View File
@@ -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 —
+1 -1
View File
@@ -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"
}
+11
View File
@@ -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
+3 -3
View File
@@ -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) |