Merge pull request #12 from runbear-io/feat/session-notes-agent-hooks

Folder views, session-linked notes, and agent-agnostic sync hooks
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-11 10:57:56 -07:00
committed by GitHub
20 changed files with 1103 additions and 88 deletions
+2 -1
View File
@@ -121,7 +121,8 @@ beardrive uses each provider's standard credential chain — nothing beardrive-s
| `bdrive init [folder]` | Create/connect a project and start syncing — interactive on a TTY, flags (`--name/--project/--shared/--yes`) for scripts; re-run to resume |
| `bdrive stop [folder]` | Stop syncing (files stay; `bdrive init` resumes) |
| `bdrive share <file>` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) |
| `bdrive sync [folder]` | Run one sync cycle now |
| `bdrive sync [folder]` | Run one sync cycle now. `--note <text>` stamps session context (e.g. an agent session id) onto changes — shown in `bdrive log` and hub history; keeps applying to daemon-committed changes until `--note-ttl` (default 30m) expires |
| `bdrive hooks [install]` | Register turn-boundary sync hooks with detected agent platforms (Claude Code, Codex, Gemini CLI, Hermes) — pull each turn, push after edits, session-note stamping; idempotent (`--agent` overrides detection) |
| `bdrive status [folder]` | Projects, daemon state, pending changes |
| `bdrive log [folder] [-p path] [-n N]` | Change history: account, device, time, file |
| `bdrive web [folder \| storage-root-url]` | Web server: viewer (rendered markdown, downloads, history), uploads, multi-project sync hub |
+16 -1
View File
@@ -13,7 +13,9 @@ import (
)
func syncCmd() *cobra.Command {
return &cobra.Command{
var note string
var noteTTL time.Duration
c := &cobra.Command{
Use: "sync [folder]",
Short: "Sync a mounted folder with its remote now",
Args: cobra.MaximumNArgs(1),
@@ -27,6 +29,16 @@ func syncCmd() *cobra.Command {
return err
}
defer closeSession(sess)
if cmd.Flags().Changed("note") {
// Persist the note so the daemon's own scans stamp it too —
// history then links every change from this working session
// to its context, not just the ones this invocation catches.
// An explicit empty --note clears it. Expires after --note-ttl.
if err := sess.Store.SaveNote(note, noteTTL); err != nil {
return err
}
sess.Note = note
}
sess.OnProgress = progressReporter()
res, err := sess.Cycle(cmd.Context())
if err != nil {
@@ -37,6 +49,9 @@ func syncCmd() *cobra.Command {
return nil
},
}
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")
return c
}
func statusCmd() *cobra.Command {
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/runbear-io/beardrive/internal/agenthooks"
)
// bdrive hooks — register BearDrive's turn-boundary sync hooks with whatever
// AI agent platforms the user works with (Claude Code, Codex, Gemini CLI,
// Hermes). One command instead of hand-editing four config formats; the
// beardrive skill runs it right after `bdrive init`.
func hooksCmd() *cobra.Command {
c := &cobra.Command{
Use: "hooks",
Short: "Show which AI agent platforms have beardrive sync hooks registered",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
folder, err := absFolder(nil)
if err != nil {
return err
}
detected := map[string]bool{}
for _, a := range agenthooks.Detect(folder) {
detected[a] = true
}
for _, a := range agenthooks.Agents {
state := "not detected"
if detected[a] {
state = "detected, hooks not registered"
if agenthooks.Registered(folder, a) {
state = "hooks registered"
}
}
fmt.Printf(" %-8s %-32s %s\n", a, state, agenthooks.ConfigPath(folder, a))
}
fmt.Println("\nregister with: bdrive hooks install [--agent claude,codex,gemini,hermes]")
return nil
},
}
var agentsFlag string
install := &cobra.Command{
Use: "install [folder]",
Short: "Register sync hooks for detected agent platforms (or --agent list)",
Long: "Registers beardrive's sync hooks with each agent platform's own hook\n" +
"config: files pull before every turn and push after edits, and changes\n" +
"are stamped with the agent session that made them (`bdrive sync --note`).\n" +
"Merging is idempotent and preserves hooks you already have.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
folder, err := absFolder(args)
if err != nil {
return err
}
var agents []string
if agentsFlag != "" && agentsFlag != "auto" {
agents = strings.Split(agentsFlag, ",")
}
results, err := agenthooks.Install(folder, agents)
if err != nil {
return err
}
if len(results) == 0 {
fmt.Println("no agent platforms detected (looked for .claude/, .codex/, .gemini/ here or in ~; ~/.hermes/)")
fmt.Println("pick explicitly: bdrive hooks install --agent claude,codex,gemini,hermes")
return nil
}
for _, r := range results {
state := "already registered"
if r.Changed {
state = "registered"
}
fmt.Printf(" %-8s %s → %s\n", r.Agent, state, r.Path)
if r.Note != "" {
fmt.Printf(" note: %s\n", r.Note)
}
}
return nil
},
}
install.Flags().StringVar(&agentsFlag, "agent", "auto", "comma-separated platforms (claude,codex,gemini,hermes) or auto")
c.AddCommand(install)
return c
}
+1
View File
@@ -36,6 +36,7 @@ everything keeps working offline; changes sync when the remote is reachable.`,
shareCmd(),
stopCmd(),
syncCmd(),
hooksCmd(),
statusCmd(),
logCmd(),
webCmd(),
+1
View File
@@ -16,6 +16,7 @@ require (
golang.org/x/crypto v0.51.0
golang.org/x/sync v0.21.0
google.golang.org/api v0.284.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.53.0
)
+8
View File
@@ -130,6 +130,10 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
@@ -146,6 +150,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -243,6 +249,8 @@ google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zN
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+267
View File
@@ -0,0 +1,267 @@
// Package agenthooks detects which AI agent platforms a user works with and
// registers BearDrive's sync hooks in each platform's own hook config, so
// files sync at turn boundaries no matter which agent edits them.
//
// Every supported platform runs command hooks the same way — spawn a shell
// command, pipe event JSON (with a session_id) on stdin — so one hook command
// works everywhere; only the config file format and event names differ:
//
// claude .claude/settings.json UserPromptSubmit / PostToolUse (project)
// codex .codex/hooks.json UserPromptSubmit / PostToolUse (project)
// gemini .gemini/settings.json BeforeAgent / AfterTool (project)
// hermes ~/.hermes/config.yaml pre_llm_call / post_tool_call (user)
//
// The hook syncs the project and stamps changes with "<agent> session <id>"
// (see `bdrive sync --note`), so hub history links every change to the agent
// session that made it. Hooks are fast no-ops outside bdrive projects.
package agenthooks
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"github.com/runbear-io/beardrive/internal/store"
)
// marker identifies our hooks inside a config, for idempotency and status.
const marker = "bdrive sync"
// Agent names, in the order they are reported.
var Agents = []string{"claude", "codex", "gemini", "hermes"}
// Result reports what Install did for one agent platform.
type Result struct {
Agent string
Path string // config file the hooks live in
Changed bool // false = already registered
Note string // extra step the user must take, if any
}
// hookCommand is the one shell command every platform runs: sync the project
// if it is a bdrive mount, stamping changes with the agent session id parsed
// from the hook's stdin JSON. POSIX sh only — no jq, no bashisms.
func hookCommand(label string) string {
return `sh -c '` +
`cd "${CLAUDE_PROJECT_DIR:-.}" && [ -d .bdrive ] && command -v bdrive >/dev/null || exit 0; ` +
`s=; [ -t 0 ] || s=$(head -c 8192 2>/dev/null | tr -d \" | sed -n "s/.*session_id[[:space:]]*:[[:space:]]*\([a-zA-Z0-9_-]*\).*/\1/p" | head -n 1); ` +
`if [ -n "$s" ]; then bdrive sync . --note "` + label + ` session $s" >/dev/null 2>&1 || true; ` +
`else bdrive sync . >/dev/null 2>&1 || true; fi'`
}
type platform struct {
label string // session-note label
projectDir string // presence of this dir (project or home) = detected
userLevel bool // config lives in the home dir, not the project
install func(folder string) (path string, changed bool, err error)
note string
}
var platforms = map[string]platform{
"claude": {
label: "claude-code",
projectDir: ".claude",
install: func(folder string) (string, bool, error) {
return mergeJSONHooks(filepath.Join(folder, ".claude", "settings.json"),
"UserPromptSubmit", "PostToolUse", "Write|Edit|MultiEdit", "claude-code", 30, true)
},
},
"codex": {
label: "codex",
projectDir: ".codex",
install: func(folder string) (string, bool, error) {
return mergeJSONHooks(filepath.Join(folder, ".codex", "hooks.json"),
"UserPromptSubmit", "PostToolUse", "apply_patch", "codex", 30, false)
},
note: "run /hooks inside Codex once to trust the project's .codex layer",
},
"gemini": {
label: "gemini",
projectDir: ".gemini",
install: func(folder string) (string, bool, error) {
// Gemini uses its own event names and millisecond timeouts.
return mergeJSONHooks(filepath.Join(folder, ".gemini", "settings.json"),
"BeforeAgent", "AfterTool", "write_file|replace|edit", "gemini", 30000, false)
},
},
"hermes": {
label: "hermes",
userLevel: true,
install: installHermes,
},
}
// Detect reports which agent platforms are in use, judged by their config
// dirs existing in the project or the home directory.
func Detect(folder string) []string {
home, _ := os.UserHomeDir()
var found []string
for _, name := range Agents {
p := platforms[name]
switch {
case p.userLevel:
if dirExists(filepath.Join(home, "."+name)) {
found = append(found, name)
}
case dirExists(filepath.Join(folder, p.projectDir)) ||
(home != "" && dirExists(filepath.Join(home, p.projectDir))):
found = append(found, name)
}
}
return found
}
// Registered reports whether an agent's config already carries our hooks.
func Registered(folder, agent string) bool {
data, err := os.ReadFile(ConfigPath(folder, agent))
return err == nil && strings.Contains(string(data), marker)
}
// ConfigPath returns where an agent's hooks are (or would be) registered.
func ConfigPath(folder, agent string) string {
switch agent {
case "claude":
return filepath.Join(folder, ".claude", "settings.json")
case "codex":
return filepath.Join(folder, ".codex", "hooks.json")
case "gemini":
return filepath.Join(folder, ".gemini", "settings.json")
case "hermes":
home, _ := os.UserHomeDir()
return filepath.Join(home, ".hermes", "config.yaml")
}
return ""
}
// Install registers the sync hooks for the given agents ("auto"/empty =
// every detected platform). Merging is idempotent and preserves whatever
// hooks the config already has.
func Install(folder string, agents []string) ([]Result, error) {
if len(agents) == 0 || (len(agents) == 1 && agents[0] == "auto") {
agents = Detect(folder)
}
var out []Result
for _, name := range agents {
p, ok := platforms[name]
if !ok {
return out, fmt.Errorf("unknown agent %q (supported: %s)", name, strings.Join(Agents, ", "))
}
path, changed, err := p.install(folder)
if err != nil {
return out, fmt.Errorf("%s: %w", name, err)
}
out = append(out, Result{Agent: name, Path: path, Changed: changed, Note: p.note})
}
return out, nil
}
// mergeJSONHooks adds the pull + push hook pair to a Claude-style hooks JSON
// file (Claude, Codex, and Gemini all use this shape: hooks.<Event> is an
// array of {matcher?, hooks: [{type: "command", ...}]} groups).
func mergeJSONHooks(path, pullEvent, pushEvent, pushMatcher, label string, timeout int, async bool) (string, bool, error) {
root := map[string]any{}
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, &root); err != nil {
return path, false, fmt.Errorf("parse %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return path, false, err
}
hooks, ok := root["hooks"].(map[string]any)
if !ok {
hooks = map[string]any{}
root["hooks"] = hooks
}
cmd := hookCommand(label)
pull := map[string]any{"hooks": []any{map[string]any{
"type": "command", "command": cmd, "timeout": timeout,
"statusMessage": "beardrive: pulling latest files",
}}}
pushHook := map[string]any{"type": "command", "command": cmd, "timeout": timeout}
if async {
pushHook["async"] = true
}
push := map[string]any{"matcher": pushMatcher, "hooks": []any{pushHook}}
changed := false
for event, group := range map[string]any{pullEvent: pull, pushEvent: push} {
arr, _ := hooks[event].([]any)
if containsMarker(arr) {
continue
}
hooks[event] = append(arr, group)
changed = true
}
if !changed {
return path, false, nil
}
return path, true, writeConfig(path, func() ([]byte, error) {
return json.MarshalIndent(root, "", " ")
})
}
// installHermes merges the hook pair into ~/.hermes/config.yaml
// (hooks.<event> is an array of {matcher?, command, timeout}).
func installHermes(string) (string, bool, error) {
path := ConfigPath("", "hermes")
root := map[string]any{}
if data, err := os.ReadFile(path); err == nil {
if err := yaml.Unmarshal(data, &root); err != nil {
return path, false, fmt.Errorf("parse %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return path, false, err
}
hooks, ok := root["hooks"].(map[string]any)
if !ok {
hooks = map[string]any{}
root["hooks"] = hooks
}
cmd := hookCommand("hermes")
groups := map[string]any{
"pre_llm_call": map[string]any{"command": cmd, "timeout": 30},
"post_tool_call": map[string]any{"matcher": "write_file|patch", "command": cmd, "timeout": 30},
}
changed := false
for event, group := range groups {
arr, _ := hooks[event].([]any)
if containsMarker(arr) {
continue
}
hooks[event] = append(arr, group)
changed = true
}
if !changed {
return path, false, nil
}
return path, true, writeConfig(path, func() ([]byte, error) {
return yaml.Marshal(root)
})
}
// containsMarker reports whether a hook array already holds one of ours.
// Serializing sidesteps walking every platform's nesting by hand.
func containsMarker(v any) bool {
data, err := json.Marshal(v)
return err == nil && strings.Contains(string(data), marker)
}
func writeConfig(path string, marshal func() ([]byte, error)) error {
data, err := marshal()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return store.WriteFileAtomic(path, append(data, '\n'), 0o644)
}
func dirExists(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}
+243
View File
@@ -0,0 +1,243 @@
package agenthooks
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func readJSON(t *testing.T, path string) map[string]any {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("%s is not valid JSON: %v", path, err)
}
return m
}
func TestDetect(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
folder := t.TempDir()
if got := Detect(folder); len(got) != 0 {
t.Fatalf("nothing configured, detected %v", got)
}
// project-level dirs
os.MkdirAll(filepath.Join(folder, ".codex"), 0o755)
os.MkdirAll(filepath.Join(folder, ".gemini"), 0o755)
// home-level dirs
os.MkdirAll(filepath.Join(home, ".claude"), 0o755)
os.MkdirAll(filepath.Join(home, ".hermes"), 0o755)
got := Detect(folder)
want := []string{"claude", "codex", "gemini", "hermes"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("detected %v, want %v", got, want)
}
}
func TestInstallJSONPlatforms(t *testing.T) {
t.Setenv("HOME", t.TempDir())
folder := t.TempDir()
results, err := Install(folder, []string{"claude", "codex", "gemini"})
if err != nil {
t.Fatal(err)
}
for _, r := range results {
if !r.Changed {
t.Fatalf("%s: fresh install reported unchanged", r.Agent)
}
}
// Claude: both events present, push is async, command carries the label.
cl := readJSON(t, filepath.Join(folder, ".claude", "settings.json"))
hooks := cl["hooks"].(map[string]any)
for _, ev := range []string{"UserPromptSubmit", "PostToolUse"} {
if _, ok := hooks[ev]; !ok {
t.Fatalf("claude missing %s", ev)
}
}
raw, _ := json.Marshal(cl)
if !strings.Contains(string(raw), "claude-code session $s") {
t.Fatal("claude hook lacks its session-note label")
}
if !strings.Contains(string(raw), `"async":true`) {
t.Fatal("claude push hook should be async")
}
// Codex: same schema, its own label and matcher, no async field.
cx, _ := json.Marshal(readJSON(t, filepath.Join(folder, ".codex", "hooks.json")))
if !strings.Contains(string(cx), "codex session $s") || !strings.Contains(string(cx), "apply_patch") {
t.Fatalf("codex hooks wrong: %s", cx)
}
if strings.Contains(string(cx), "async") {
t.Fatal("codex should not get the claude-only async field")
}
// Gemini: its own event names and ms timeout.
gm, _ := json.Marshal(readJSON(t, filepath.Join(folder, ".gemini", "settings.json")))
for _, want := range []string{"BeforeAgent", "AfterTool", "gemini session $s", "30000"} {
if !strings.Contains(string(gm), want) {
t.Fatalf("gemini hooks missing %q: %s", want, gm)
}
}
}
func TestInstallIdempotentAndPreserving(t *testing.T) {
t.Setenv("HOME", t.TempDir())
folder := t.TempDir()
// Pre-existing user hook must survive the merge.
pre := `{"permissions":{"allow":["Bash(ls:*)"]},"hooks":{"PostToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo mine"}]}]}}`
os.MkdirAll(filepath.Join(folder, ".claude"), 0o755)
os.WriteFile(filepath.Join(folder, ".claude", "settings.json"), []byte(pre), 0o644)
if _, err := Install(folder, []string{"claude"}); err != nil {
t.Fatal(err)
}
cfg := readJSON(t, filepath.Join(folder, ".claude", "settings.json"))
raw, _ := json.Marshal(cfg)
if !strings.Contains(string(raw), "echo mine") {
t.Fatal("merge dropped the user's existing hook")
}
if _, ok := cfg["permissions"]; !ok {
t.Fatal("merge dropped unrelated settings keys")
}
if got := len(cfg["hooks"].(map[string]any)["PostToolUse"].([]any)); got != 2 {
t.Fatalf("PostToolUse groups = %d, want user's + ours", got)
}
// Second install: no change, byte-identical file.
before, _ := os.ReadFile(filepath.Join(folder, ".claude", "settings.json"))
results, err := Install(folder, []string{"claude"})
if err != nil {
t.Fatal(err)
}
if results[0].Changed {
t.Fatal("re-install reported a change")
}
after, _ := os.ReadFile(filepath.Join(folder, ".claude", "settings.json"))
if string(before) != string(after) {
t.Fatal("re-install rewrote the file")
}
}
func TestInstallHermesYAML(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
os.MkdirAll(filepath.Join(home, ".hermes"), 0o755)
// Existing config keys must survive.
os.WriteFile(filepath.Join(home, ".hermes", "config.yaml"),
[]byte("model: hermes-4\nhooks_auto_accept: false\n"), 0o644)
results, err := Install(t.TempDir(), []string{"hermes"})
if err != nil {
t.Fatal(err)
}
if !results[0].Changed {
t.Fatal("fresh hermes install reported unchanged")
}
data, _ := os.ReadFile(filepath.Join(home, ".hermes", "config.yaml"))
var m map[string]any
if err := yaml.Unmarshal(data, &m); err != nil {
t.Fatalf("config.yaml no longer parses: %v", err)
}
if m["model"] != "hermes-4" {
t.Fatal("merge dropped existing hermes config")
}
hooks := m["hooks"].(map[string]any)
for _, ev := range []string{"pre_llm_call", "post_tool_call"} {
if _, ok := hooks[ev]; !ok {
t.Fatalf("hermes missing %s", ev)
}
}
if !strings.Contains(string(data), "hermes session $s") {
t.Fatal("hermes hook lacks its session-note label")
}
// Idempotent.
results, _ = Install(t.TempDir(), []string{"hermes"})
if results[0].Changed {
t.Fatal("hermes re-install reported a change")
}
}
func TestInstallAutoUsesDetection(t *testing.T) {
t.Setenv("HOME", t.TempDir())
folder := t.TempDir()
os.MkdirAll(filepath.Join(folder, ".gemini"), 0o755)
results, err := Install(folder, nil)
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].Agent != "gemini" {
t.Fatalf("auto install = %+v, want just gemini", results)
}
}
func TestInstallUnknownAgent(t *testing.T) {
if _, err := Install(t.TempDir(), []string{"cursor"}); err == nil {
t.Fatal("unknown agent should error")
}
}
// The generated hook command must extract a session id from hook stdin JSON
// and invoke bdrive with the platform label — run it for real against a fake
// bdrive to pin the shell behavior on every platform's payload shape.
func TestHookCommandExtraction(t *testing.T) {
if _, err := os.Stat("/bin/sh"); err != nil {
t.Skip("no /bin/sh")
}
dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, ".bdrive"), 0o755)
bin := filepath.Join(dir, "bin")
os.MkdirAll(bin, 0o755)
fake := "#!/bin/sh\necho \"$@\" > \"" + dir + "/args.txt\"\n"
os.WriteFile(filepath.Join(bin, "bdrive"), []byte(fake), 0o755)
payloads := map[string]string{
"claude-code": `{"session_id":"abc-123","hook_event_name":"UserPromptSubmit"}`,
"codex": `{"session_id":"th_042","turn_id":"t1","cwd":"/x"}`,
"gemini": `{"session_id":"g-9f","timestamp":"2026-07-11T00:00:00Z"}`,
"hermes": `{"hook_event_name":"pre_llm_call","tool_name":null,"session_id":"sess_abc123"}`,
}
for label, payload := range payloads {
os.Remove(filepath.Join(dir, "args.txt"))
cmdline := hookCommand(label)
sh := "cd " + dir + " && PATH=" + bin + ":$PATH " + cmdline
if err := runShell(t, sh, payload); err != nil {
t.Fatalf("%s: %v", label, err)
}
got, err := os.ReadFile(filepath.Join(dir, "args.txt"))
if err != nil {
t.Fatalf("%s: hook never called bdrive: %v", label, err)
}
want := "sync . --note " + label + " session "
if !strings.Contains(string(got), want) {
t.Fatalf("%s: bdrive argv = %q, want it to contain %q", label, got, want)
}
}
}
func runShell(t *testing.T, script, stdin string) error {
t.Helper()
cmd := exec.Command("/bin/sh", "-c", script)
cmd.Stdin = strings.NewReader(stdin)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%v: %s", err, out)
}
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package store
import (
"os"
"path/filepath"
"time"
)
// The session note is transient per-volume context — "why edits are being
// made right now" (e.g. a Claude Code session id set by the plugin's sync
// hook). Whichever scanner commits an op while the note is live (the daemon
// or a one-shot `bdrive sync`) stamps it into Op.Note, so provenance doesn't
// depend on winning the race against the daemon's scan interval. The TTL
// keeps a stale note from mislabeling unrelated edits made hours later.
// Note is the on-disk shape of the session note.
type Note struct {
Text string `json:"text"`
Expires time.Time `json:"expires,omitzero"` // zero = never expires
}
func (s *Store) notePath() string { return filepath.Join(s.dir, "note.json") }
// SaveNote sets the session note. ttl > 0 bounds its life; empty text clears.
func (s *Store) SaveNote(text string, ttl time.Duration) error {
if text == "" {
return s.ClearNote()
}
n := Note{Text: text}
if ttl > 0 {
n.Expires = time.Now().Add(ttl)
}
return WriteJSONAtomic(s.notePath(), n)
}
// ClearNote removes the session note.
func (s *Store) ClearNote() error {
err := os.Remove(s.notePath())
if os.IsNotExist(err) {
return nil
}
return err
}
// LoadNote returns the live session note, or "" if none is set or it has
// expired. Never errors: a missing or unreadable note is just no note.
func (s *Store) LoadNote() string {
var n Note
if err := readJSON(s.notePath(), &n); err != nil {
return ""
}
if !n.Expires.IsZero() && time.Now().After(n.Expires) {
return ""
}
return n.Text
}
+68
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/store"
)
@@ -207,3 +208,70 @@ func TestConflictCopyNameMatchesDocumentedGlob(t *testing.T) {
t.Fatalf("conflict copy name %q should sanitize device names", name)
}
}
// Session-linked notes: a note set for the session (explicitly, or persisted
// in the store by `bdrive sync --note`) is stamped onto every op that scan
// commits, travels through the remote to peers, and expires with its TTL —
// so history can link each change to the agent session that made it.
func TestSessionNoteStampsOps(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
// Explicit note on the session (one-shot `bdrive sync --note`).
a.Note = "claude-code session s-123"
write(t, a.Folder, "wiki/plan.md", "v1")
cycle(t, a)
cycle(t, b)
ops, err := b.Store.DeviceOps("deva")
if err != nil || len(ops) != 1 {
t.Fatalf("peer copy of deva journal: %v (%d ops)", err, len(ops))
}
if ops[0].Note != "claude-code session s-123" {
t.Fatalf("note on peer = %q", ops[0].Note)
}
// Persisted note: an empty Session.Note falls back to the store note, the
// path the daemon takes after a one-shot sync persisted it.
a.Note = ""
if err := a.Store.SaveNote("claude-code session s-456", time.Hour); err != nil {
t.Fatal(err)
}
write(t, a.Folder, "wiki/plan.md", "v2")
cycle(t, a)
ops, _ = a.Store.DeviceOps("deva")
if got := ops[len(ops)-1].Note; got != "claude-code session s-456" {
t.Fatalf("persisted-note op = %q", got)
}
// Deletes carry the note too.
if err := os.Remove(filepath.Join(a.Folder, "wiki", "plan.md")); err != nil {
t.Fatal(err)
}
cycle(t, a)
ops, _ = a.Store.DeviceOps("deva")
last := ops[len(ops)-1]
if last.Kind != journal.KindDelete || last.Note != "claude-code session s-456" {
t.Fatalf("delete op = %+v", last)
}
// An expired note stops applying: later edits stay unlabeled.
if err := a.Store.SaveNote("stale", time.Millisecond); err != nil {
t.Fatal(err)
}
time.Sleep(10 * time.Millisecond)
write(t, a.Folder, "wiki/new.md", "x")
cycle(t, a)
ops, _ = a.Store.DeviceOps("deva")
if got := ops[len(ops)-1].Note; got != "" {
t.Fatalf("expired note leaked onto op: %q", got)
}
// Clearing removes the note file entirely.
if err := a.Store.SaveNote("", 0); err != nil {
t.Fatal(err)
}
if got := a.Store.LoadNote(); got != "" {
t.Fatalf("cleared note = %q", got)
}
}
+11 -1
View File
@@ -55,6 +55,12 @@ type Session struct {
// history shows who changed what. Zero on offline/no-auth setups —
// Device.Author remains the fallback identity.
Account config.Settings
// Note, when set, is stamped into every op this session commits — session
// context like "claude-code session <id>". Empty means fall back to the
// store's persisted session note (store.LoadNote), which lets a one-shot
// `bdrive sync --note` leave context that the daemon's later scans also
// stamp. Conflict-copy ops keep their own explanatory note.
Note string
Backend remote.Backend // nil = work offline
// OnProgress, when set, is called during push with upload progress. It may
// be invoked concurrently from upload workers, so it must be safe to call
@@ -210,6 +216,10 @@ func (s *Session) Cycle(ctx context.Context) (*Result, error) {
func (s *Session) scan(cache map[string]store.CachedFile, st *store.SyncState, seqBase int64, filter *Filter) ([]journal.Op, error) {
seen := make(map[string]bool, len(cache))
var ops []journal.Op
note := s.Note
if note == "" {
note = s.Store.LoadNote()
}
nextOp := func(kind, rel string) journal.Op {
st.Lamport++
seqBase++
@@ -217,7 +227,7 @@ func (s *Session) scan(cache map[string]store.CachedFile, st *store.SyncState, s
Seq: seqBase, Lamport: st.Lamport, Time: time.Now().UTC(),
Device: s.Device.ID, DeviceName: s.Device.Name, Author: s.Device.Author,
User: s.Account.Email, UserName: s.Account.Name,
Kind: kind, Path: rel,
Kind: kind, Path: rel, Note: note,
}
}
+20 -2
View File
@@ -20,7 +20,7 @@ import (
// HistoryEntry is one change as the history API reports it.
type HistoryEntry struct {
Time string `json:"time"`
Kind string `json:"kind"` // put | delete
Kind string `json:"kind"` // add | edit | delete
Path string `json:"path"`
Size int64 `json:"size,omitempty"`
Blob string `json:"blob,omitempty"` // sha256; fetch via the blob endpoint
@@ -59,6 +59,24 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
return
}
journal.Sort(all)
// A put is an "add" when the path didn't exist just before it (first
// version, or first after a delete), an "edit" otherwise. Existence is
// replayed over ALL ops in journal order, before any path/prefix filter,
// so a filtered view classifies the same as the full feed.
kinds := make([]string, len(all))
exists := make(map[string]bool, len(all))
for i, op := range all {
switch {
case op.Kind == journal.KindDelete:
kinds[i] = "delete"
exists[op.Path] = false
case exists[op.Path]:
kinds[i] = "edit"
default:
kinds[i] = "add"
exists[op.Path] = true
}
}
entries := make([]HistoryEntry, 0, n)
for i := len(all) - 1; i >= 0 && len(entries) < n; i-- { // newest first
op := all[i]
@@ -73,7 +91,7 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
dev = DeviceInfo{ID: op.Device, Name: op.DeviceName}
}
entries = append(entries, HistoryEntry{
Time: op.Time.UTC().Format("2006-01-02T15:04:05Z"), Kind: op.Kind,
Time: op.Time.UTC().Format("2006-01-02T15:04:05Z"), Kind: kinds[i],
Path: op.Path, Size: op.Size, Blob: op.Blob,
User: op.User, UserName: op.UserName, Author: op.Author,
Device: dev, Note: op.Note,
+8
View File
@@ -72,6 +72,10 @@ func TestHistoryAPI(t *testing.T) {
if newest.Size != int64(len("v2 longer")) || oldest.Size != int64(len("v1")) {
t.Fatalf("order wrong: %+v", out.Entries)
}
// puts are classified: first version = add, later versions = edit
if oldest.Kind != "add" || newest.Kind != "edit" {
t.Fatalf("kinds = %q, %q; want add, edit", oldest.Kind, newest.Kind)
}
if newest.User != "alice@x.io" || newest.UserName != "Alice" {
t.Fatalf("user = %+v", newest)
}
@@ -94,6 +98,10 @@ func TestHistoryAPI(t *testing.T) {
if out.Entries[0].Kind != "delete" || out.Entries[0].Path != "notes/other.md" {
t.Fatalf("newest notes/ entry = %+v, want the delete", out.Entries[0])
}
// the put that created other.md is an add, even in the filtered view
if out.Entries[1].Kind != "add" || out.Entries[1].Path != "notes/other.md" {
t.Fatalf("entry before the delete = %+v, want other.md's add", out.Entries[1])
}
// a device the registry never saw falls back to the op's own info
if out.Entries[0].Device.Name != "dev2" {
t.Fatalf("unknown device fallback = %+v", out.Entries[0].Device)
+205 -39
View File
@@ -3,6 +3,7 @@
const $ = (id) => document.getElementById(id);
let flatFiles = []; // [{path, name}] for wikilink resolution
let dirIndex = new Map(); // dir path → tree node, for folder listings
let currentPath = null;
let expanded = new Set(); // dir paths that are open (folders start closed)
let treeFirstLoad = true; // apply the "lone root folder opens" rule once per project
@@ -92,7 +93,7 @@ async function boot() {
initUpload();
await refreshTree();
const { path } = parseRoute();
if (path) openFile(path);
if (path) openPath(path);
}
setInterval(refreshTree, 15000); // pick up synced changes
}
@@ -161,7 +162,7 @@ function selectProject(p, path) {
initUpload();
initHistory();
updateShareButton();
refreshTree().then(() => { if (path) openFile(path); });
refreshTree().then(() => { if (path) openPath(path); });
if (!path) pushURL("/" + p.id);
}
@@ -676,6 +677,7 @@ async function refreshTree() {
root = await getJSON(apiBase + "tree");
} catch { return; } // keep the last good tree
flatFiles = [];
dirIndex = new Map();
const kids = root.children || [];
// First render of a project's tree: every folder starts closed, except a
// lone root folder — opening it spares the user a single shut folder.
@@ -688,6 +690,10 @@ async function refreshTree() {
nav.innerHTML = "";
nav.appendChild(renderChildren(kids));
markActive();
// A folder listing shows live tree data — keep it in step with the tree.
if (currentPath && dirIndex.has(currentPath) && $("content").querySelector(".dirlist")) {
renderFolderListing(currentPath);
}
}
function renderChildren(children) {
@@ -719,6 +725,7 @@ function renderNode(n) {
row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } };
li.appendChild(row);
if (n.dir) {
dirIndex.set(n.path, n);
if (serverConfig.mode === "hub") {
const hist = document.createElement("span");
hist.className = "dir-history";
@@ -731,12 +738,21 @@ function renderNode(n) {
const open = expanded.has(n.path);
if (!open) li.classList.add("collapsed");
row.setAttribute("aria-expanded", String(open));
row.onclick = () => {
const toggle = () => {
li.classList.toggle("collapsed");
const isCollapsed = li.classList.contains("collapsed");
isCollapsed ? expanded.delete(n.path) : expanded.add(n.path);
row.setAttribute("aria-expanded", String(!isCollapsed));
};
// The chevron only folds; the row selects the folder (opens it in the
// tree and lists its contents in the main pane). Clicking the folder
// whose listing is already showing folds/unfolds it, like a plain tree;
// from any other view (file, history) it brings the listing back.
chev.onclick = (e) => { e.stopPropagation(); toggle(); };
row.onclick = () => {
if (currentPath === n.path && $("content").querySelector(".dirlist")) { toggle(); return; }
openFolder(n.path);
};
} else {
flatFiles.push({ path: n.path, name: n.name });
row.onclick = () => openFile(n.path);
@@ -780,6 +796,122 @@ function revealInTree(p) {
if (row) row.scrollIntoView({ block: "center" });
}
/* ---- breadcrumb ----
Every ancestor segment is a link to that folder's listing; the last
segment is the current page. */
function setCrumb(p) {
const c = $("crumb");
c.innerHTML = "";
const parts = p.split("/");
let acc = "";
parts.forEach((seg, i) => {
acc = acc ? acc + "/" + seg : seg;
if (i) el(c, "span", "crumb-sep", "/");
const last = i === parts.length - 1;
const s = el(c, "span", last ? null : "crumb-seg", seg);
if (!last) {
const target = acc;
s.title = target;
s.onclick = () => { if (dirIndex.has(target)) openFolder(target); };
}
});
}
/* ---- folder pane ---- */
/* Open a path of either kind a route or link doesn't know which it is
until the tree has loaded. */
function openPath(p) {
if (dirIndex.has(p)) openFolder(p);
else openFile(p);
}
/* Selecting a folder: unfold it in the tree, highlight it, and list what's
inside it in the main pane. */
function openFolder(p) {
if (!dirIndex.has(p)) return;
currentPath = p;
syncURL(p);
expandTo(p);
expanded.add(p); // the selected folder itself opens, not just its ancestors
applyTreeExpansion();
markActive();
const row = document.querySelector(`#tree .row[data-path="${CSS.escape(p)}"]`);
if (row) row.scrollIntoView({ block: "nearest" });
closeSidebarOnMobile();
setCrumb(p);
$("meta").textContent = "";
$("download").hidden = true;
$("more-btn").hidden = !(serverConfig.mode === "hub" && currentProject);
updateShareButton();
renderFolderListing(p);
}
function renderFolderListing(p) {
const node = dirIndex.get(p);
if (!node) return;
const content = $("content");
content.className = "view";
content.innerHTML = "";
const wrap = el(content, "div", "dirlist");
const head = el(wrap, "h1", "dl-title");
const hicon = el(head, "span", "dl-title-icon");
hicon.innerHTML = svgIcon("folder");
el(head, "span", null, node.name);
const kids = (node.children || []).slice()
.sort((a, b) => (b.dir - a.dir) || a.name.localeCompare(b.name));
const dirs = kids.filter((c) => c.dir).length;
const files = kids.length - dirs;
const counts = [];
if (dirs) counts.push(dirs + (dirs === 1 ? " folder" : " folders"));
if (files) counts.push(files + (files === 1 ? " file" : " files"));
el(wrap, "p", "dl-sub", counts.join(" · ") || "Empty folder");
if (!kids.length) {
el(wrap, "div", "dl-empty", "Nothing in this folder yet.");
renderFolderHistory(wrap, p);
return;
}
const list = el(wrap, "div", "dl-items");
for (const c of kids) {
const row = el(list, "div", "dl-row");
row.tabIndex = 0;
row.setAttribute("role", "button");
row.title = c.path;
const icon = el(row, "span", "ticon");
icon.innerHTML = svgIcon(c.dir ? "folder" : "doc");
el(row, "span", "dl-name", c.name);
let meta = "";
if (c.dir) {
const n = (c.children || []).length;
meta = n + (n === 1 ? " item" : " items");
} else {
meta = [c.size ? humanSize(c.size) : "", c.time ? new Date(c.time).toLocaleDateString() : ""]
.filter(Boolean).join(" · ");
}
el(row, "span", "dl-meta", meta);
row.onclick = () => openPath(c.path);
row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } };
}
renderFolderHistory(wrap, p);
}
/* The folder's change feed, straight from the journals: files added, edited,
and deleted anywhere under it, newest first. Hub-only a plain-folder
viewer has no journals to read. */
function renderFolderHistory(wrap, p) {
if (!(serverConfig.mode === "hub" && currentProject)) return;
const sec = el(wrap, "div", "dl-history");
getJSON(apiBase + "history?prefix=" + encodeURIComponent(p + "/") + "&n=20").then((out) => {
const entries = out.entries || [];
if (!entries.length) return sec.remove();
el(sec, "h3", "dl-h3", "Recent changes");
const list = el(sec, "div", "history dl-hlist");
for (const e of entries) list.appendChild(historyEntryRow(e));
const more = el(sec, "button", "ai-btn dl-more", "Full history");
more.onclick = () => showHistory({ prefix: p + "/" });
}).catch(() => sec.remove());
}
/* ---- file pane ---- */
async function openFile(p) {
currentPath = p;
@@ -787,7 +919,7 @@ async function openFile(p) {
markActive();
revealInTree(p);
closeSidebarOnMobile();
$("crumb").textContent = p.split("/").join(" / ");
setCrumb(p);
updateShareButton();
const dl = $("download");
dl.href = apiBase + "download?path=" + encodeURIComponent(p);
@@ -916,7 +1048,8 @@ function join(dir, rel) {
(rendered, sandboxed), no account needed. Always the latest content. */
function updateShareButton() {
const btn = $("share-btn");
btn.hidden = !(serverConfig.mode === "hub" && currentProject && currentPath);
// Shares are per-file; a selected folder has nothing to mint.
btn.hidden = !(serverConfig.mode === "hub" && currentProject && currentPath && !dirIndex.has(currentPath));
btn.onclick = async () => {
try {
const r = await fetch(apiBase + "shares", {
@@ -941,7 +1074,11 @@ function updateShareButton() {
function initHistory() {
const btn = $("history-btn");
btn.hidden = !(serverConfig.mode === "hub" && currentProjectOrNull());
btn.onclick = () => showHistory(currentPath ? { path: currentPath } : { prefix: "" });
btn.onclick = () => {
if (!currentPath) return showHistory({ prefix: "" });
if (dirIndex.has(currentPath)) return showHistory({ prefix: currentPath + "/" });
showHistory({ path: currentPath });
};
}
function currentProjectOrNull() {
@@ -972,39 +1109,63 @@ async function showHistory(q) {
if (!out.entries || out.entries.length === 0) {
wrap.innerHTML = `<div class="empty">No history yet.</div>`;
}
for (const e of out.entries || []) {
const row = document.createElement("div");
row.className = "hentry " + e.kind;
const who = e.user_name ? `${e.user_name} <${e.user}>` : (e.user || e.author || "unknown");
const dev = [e.device.name || e.device.id, e.device.os, e.device.ip].filter(Boolean).join(" · ");
const when = new Date(e.time).toLocaleString();
row.innerHTML =
`<div class="hline"><span class="hkind"></span><span class="hpath"></span><span class="htime"></span></div>` +
`<div class="hmeta"><span class="hwho"></span><span class="hdev"></span><span class="hsize"></span><span class="hact"></span></div>`;
row.querySelector(".hkind").innerHTML = svgIcon(e.kind === "delete" ? "x" : "dot");
row.querySelector(".hpath").textContent = e.path;
row.querySelector(".htime").textContent = when;
row.querySelector(".hwho").textContent = who;
row.querySelector(".hdev").textContent = dev;
row.querySelector(".hsize").textContent = e.size ? humanSize(e.size) : "";
if (e.kind !== "delete" && e.blob) {
const view = document.createElement("a");
view.textContent = "view";
view.href = apiBase + "blob?sha=" + e.blob + "&name=" + encodeURIComponent(e.path);
view.target = "_blank";
const dl = document.createElement("a");
dl.textContent = "download";
dl.href = view.href + "&download=1";
dl.setAttribute("download", e.path.split("/").pop());
row.querySelector(".hact").append(view, " ", dl);
}
const p = e.path;
row.querySelector(".hpath").onclick = () => showHistory({ path: p });
wrap.appendChild(row);
}
for (const e of out.entries || []) wrap.appendChild(historyEntryRow(e));
content.appendChild(wrap);
}
/* One change as a row: what happened (added / edited / deleted), to which
file, by whom, from where with view/download of that exact version. */
const KIND_ICON = { add: "plus", edit: "edit", delete: "x" };
const KIND_LABEL = { add: "added", edit: "edited", delete: "deleted" };
function historyEntryRow(e) {
const kind = e.kind === "put" ? "edit" : e.kind; // older servers report raw "put" ops
const row = document.createElement("div");
row.className = "hentry " + kind;
const who = e.user_name ? `${e.user_name} <${e.user}>` : (e.user || e.author || "unknown");
const dev = [e.device.name || e.device.id, e.device.os, e.device.ip].filter(Boolean).join(" · ");
const when = new Date(e.time).toLocaleString();
row.innerHTML =
`<div class="hline"><span class="hkind"></span><span class="hpath"></span><span class="htag"></span><span class="htime"></span></div>` +
`<div class="hmeta"><span class="hwho"></span><span class="hdev"></span><span class="hsize"></span><span class="hact"></span></div>`;
row.querySelector(".hkind").innerHTML = svgIcon(KIND_ICON[kind] || "dot");
row.querySelector(".hpath").textContent = e.path;
row.querySelector(".htag").textContent = KIND_LABEL[kind] || kind;
row.querySelector(".htime").textContent = when;
row.querySelector(".hwho").textContent = who;
row.querySelector(".hdev").textContent = dev;
row.querySelector(".hsize").textContent = e.size ? humanSize(e.size) : "";
if (kind !== "delete" && e.blob) {
const view = document.createElement("a");
view.textContent = "view";
view.href = apiBase + "blob?sha=" + e.blob + "&name=" + encodeURIComponent(e.path);
view.target = "_blank";
const dl = document.createElement("a");
dl.textContent = "download";
dl.href = view.href + "&download=1";
dl.setAttribute("download", e.path.split("/").pop());
row.querySelector(".hact").append(view, " ", dl);
}
if (e.note) {
const note = document.createElement("div");
note.className = "hnote";
// Linkify http(s) URLs (e.g. a Claude session link); everything else
// stays plain text — notes are user/agent input, never markup.
for (const tok of e.note.split(/(https?:\/\/\S+)/)) {
if (/^https?:\/\//.test(tok)) {
const a = document.createElement("a");
a.href = tok; a.textContent = tok; a.target = "_blank"; a.rel = "noopener";
note.appendChild(a);
} else if (tok) {
note.append(tok);
}
}
row.appendChild(note);
}
const p = e.path;
row.querySelector(".hpath").onclick = () => showHistory({ path: p });
return row;
}
function humanSize(n) {
if (n < 1024) return n + " B";
const units = ["KB", "MB", "GB", "TB"];
@@ -1029,8 +1190,10 @@ function initUpload() {
const file = input.files[0];
input.value = "";
if (!file) return;
const dir = currentPath && currentPath.includes("/")
? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
// A selected folder receives the upload; a selected file means "next to it".
const dir = !currentPath ? ""
: dirIndex.has(currentPath) ? currentPath
: currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
const dest = dir ? dir + "/" + file.name : file.name;
const status = $("meta");
try {
@@ -1204,6 +1367,9 @@ function paletteCandidates() {
if (serverConfig.auth && serverConfig.auth.enabled) {
add("power", "Sign out", "action", () => { location.href = "/auth/logout"; });
}
for (const d of dirIndex.keys()) {
add("folder", d, "folder", () => openFolder(d));
}
for (const f of flatFiles) {
add("doc", f.path, "file", () => openFile(f.path));
}
@@ -1343,7 +1509,7 @@ window.addEventListener("popstate", () => {
const proj = projects.find((x) => x.id === project);
if (proj) { selectProject(proj, path || null); return; }
}
if (path && path !== currentPath) openFile(path);
if (path && path !== currentPath) openPath(path);
});
boot();
+1
View File
@@ -33,6 +33,7 @@
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 4v11m0 0 4-4m-4 4-4-4M5 19h14"/></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 20V9m0 0 4 4m-4-4-4 4M5 5h14"/></symbol>
<symbol id="i-dot" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.5" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M4 20l1.2-4.2L16.6 4.4a2 2 0 0 1 2.9 2.9L8.2 18.8z"/></symbol>
<symbol id="i-enter" viewBox="0 0 24 24"><path d="M9 10 4 15l5 5"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></symbol>
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
<symbol id="i-dots" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none"/></symbol>
+34 -1
View File
@@ -174,6 +174,9 @@ button, input, a.btn { font-family: inherit; }
.icon-btn { display: none; width: 34px; height: 34px; border: none; background: transparent; color: var(--text-dim); cursor: pointer; border-radius: 7px; align-items: center; justify-content: center; }
.icon-btn:hover { color: var(--text); background: var(--hover); }
#crumb { font-size: 12.5px; color: var(--text); font-weight: 500; letter-spacing: -.01em; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#crumb .crumb-seg { color: var(--text-dim); cursor: pointer; }
#crumb .crumb-seg:hover { color: var(--accent-bright); }
#crumb .crumb-sep { color: var(--text-ghost); margin: 0 5px; }
#meta { flex: 1; min-width: 0; font-size: 12px; color: var(--text-faint); text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.btn {
@@ -251,6 +254,27 @@ button, input, a.btn { font-family: inherit; }
.tg-desc { font-size: 12px; color: var(--text-faint); margin-top: 3px; line-height: 1.5; }
.admin-item.toggle input { width: 16px; height: 16px; margin-top: 2px; accent-color: var(--accent); flex: none; }
/* ---- folder listing ---- */
.dirlist { max-width: 704px; margin: 0 auto; }
.dl-title { display: flex; align-items: center; gap: 10px; font-size: 21px; font-weight: 640; letter-spacing: -.02em; margin: 0 0 4px; color: #f4f6f9; }
.dl-title-icon { display: flex; color: var(--accent); }
.dl-title-icon .ico { width: 20px; height: 20px; }
.dl-sub { color: var(--text-faint); font-size: 12.5px; margin: 0 0 18px; }
.dl-items { border: 1px solid var(--border); border-radius: var(--r-card); overflow: hidden; background: var(--bg-side); }
.dl-row { display: flex; align-items: center; gap: 11px; padding: 10px 14px; border-bottom: 1px solid var(--border); cursor: pointer; }
.dl-row:last-child { border-bottom: none; }
.dl-row:hover { background: var(--hover); }
.dl-row .ticon { flex: none; display: flex; color: var(--text-ghost); }
.dl-row .ticon .ico { width: 16px; height: 16px; }
.dl-row:hover .ticon { color: var(--text-faint); }
.dl-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: var(--text); }
.dl-meta { flex: none; font-size: 12px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
.dl-empty { padding: 24px 14px; color: var(--text-faint); font-size: 13px; border: 1px dashed var(--border); border-radius: var(--r-card); text-align: center; }
.dl-h3 { margin: 28px 0 8px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .07em; color: var(--text-ghost); font-weight: 600; }
.dl-hlist { border: 1px solid var(--border); border-radius: var(--r-card); background: var(--bg-side); overflow: hidden; max-width: none; }
.dl-hlist .hentry:last-child { border-bottom: none; }
.dl-more { margin-top: 10px; }
/* ---- history ---- */
.history { max-width: 860px; }
.hentry { padding: 11px 12px; border-bottom: 1px solid var(--border); }
@@ -258,7 +282,12 @@ button, input, a.btn { font-family: inherit; }
.hline { display: flex; gap: 10px; align-items: center; }
.hkind { display: inline-flex; color: var(--add); }
.hkind .ico { width: 13px; height: 13px; }
.hentry.edit .hkind { color: var(--accent); }
.hentry.delete .hkind { color: var(--del); }
.htag { flex: none; font-size: 10px; text-transform: uppercase; letter-spacing: .06em; font-weight: 600; padding: 1px 6px; border-radius: 4px; }
.hentry.add .htag { color: var(--add); background: rgba(76,195,138,.12); }
.hentry.edit .htag { color: var(--accent-bright); background: var(--glow); }
.hentry.delete .htag { color: #ff8b8b; background: rgba(242,109,109,.12); }
.hpath { font-weight: 500; cursor: pointer; color: var(--text); font-size: 13px; }
.hpath:hover { color: var(--accent-bright); }
.htime { margin-left: auto; color: var(--text-faint); font-size: 12px; font-variant-numeric: tabular-nums; }
@@ -268,6 +297,10 @@ button, input, a.btn { font-family: inherit; }
.hact { margin-left: auto; }
.hact a { color: var(--accent-bright); text-decoration: none; margin-left: 10px; }
.hact a:hover { text-decoration: underline; }
.hnote { margin-top: 4px; padding-left: 23px; font-size: 12px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hnote::before { content: " "; color: var(--text-ghost); }
.hnote a { color: var(--accent-bright); text-decoration: none; }
.hnote a:hover { text-decoration: underline; }
/* ---- command palette ---- */
#palette-overlay { position: fixed; inset: 0; background: rgba(6,7,9,.62); backdrop-filter: blur(3px); display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh; z-index: 100; }
@@ -330,7 +363,7 @@ button, input, a.btn { font-family: inherit; }
#invite-btn { min-height: 40px; padding: 0 14px; }
#org-name { min-height: 44px; }
.nav-add { min-width: 44px; min-height: 44px; }
.markdown, .admin, .onboard, .history { max-width: 100%; }
.markdown, .admin, .onboard, .history, .dirlist { max-width: 100%; }
.markdown table, pre.plain { display: block; overflow-x: auto; max-width: 100%; }
.ob-row { flex-direction: column; }
}
+10 -1
View File
@@ -52,7 +52,16 @@ Follow these steps:
bdrive init --name <project> --shared wiki # in a repo: only ./wiki syncs
```
5. **Verify**: run `bdrive status <folder>` and confirm the daemon is
5. **Register agent sync hooks**: run `bdrive hooks install <folder>`. It
detects the agent platforms in use (Claude Code, Codex, Gemini CLI,
Hermes — by their config dirs in the project or home) and idempotently
merges beardrive's sync hooks into each platform's own hook config, so
files pull at every turn start, push after edits, and every change is
stamped with the agent session that made it. Tell the user which
platforms got hooks; if Codex is among them, mention they must run
`/hooks` inside Codex once to trust the project's `.codex` layer.
6. **Verify**: run `bdrive status <folder>` and confirm the daemon is
running and pending is 0. Summarize: project name/id, what syncs, and
that edits propagate to every team member within seconds. Offer a
consent-gated CLAUDE.md note and tell the user how teammates connect
+19 -40
View File
@@ -61,49 +61,28 @@ every change is tracked (who, when, from which device).
the URL.
```
## 5. Register project-level sync hooks (ask first)
## 5. Register agent sync hooks
Ask: "Want me to register sync hooks in `.claude/settings.json` so files
sync automatically during Claude sessions — for every teammate, plugin or
not?" If yes, merge this into the project's `.claude/settings.json`
(create it if missing; preserve existing hooks — append to the arrays,
never overwrite them):
Run `bdrive hooks install` in the project. It detects the agent platforms
in use — Claude Code (`.claude/`), Codex (`.codex/`), Gemini CLI
(`.gemini/`), Hermes (`~/.hermes/`) — and idempotently merges beardrive's
sync hooks into each platform's own hook config, preserving any hooks
already there. Project-level files (`.claude/settings.json`,
`.codex/hooks.json`, `.gemini/settings.json`) ride the repo, so every
teammate gets them — plugin or not, whatever agent they use; Hermes hooks
are per-user (`~/.hermes/config.yaml`).
```json
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "sh -c 'cd \"${CLAUDE_PROJECT_DIR:-.}\" && [ -d .bdrive ] && command -v bdrive >/dev/null && bdrive sync . >/dev/null 2>&1 || true'",
"timeout": 30,
"statusMessage": "beardrive: pulling latest files"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "sh -c 'cd \"${CLAUDE_PROJECT_DIR:-.}\" && [ -d .bdrive ] && command -v bdrive >/dev/null && bdrive sync . >/dev/null 2>&1 || true'",
"async": true
}
]
}
]
}
}
```
The registered hooks pull before every turn (the agent always reads the
team's latest files), push right after edits (artifacts land on the server
seconds after they're created — daemon or no daemon), and stamp every
change with the agent session that made it (`bdrive sync --note "<agent>
session <id>"` — visible in `bdrive log` and the hub's history views).
They are fast no-ops in folders without `.bdrive/`.
The pull at prompt-submit means Claude always reads the team's latest
files; the async push after each Write/Edit means artifacts land on the
server seconds after Claude creates them — daemon or no daemon. Both are
fast no-ops in folders without `.bdrive/`.
Tell the user which platforms got hooks (`bdrive hooks` shows the status
table). If Codex is among them, mention they must run `/hooks` inside
Codex once to trust the project's `.codex` layer. To register a platform
that wasn't detected: `bdrive hooks install --agent claude,codex,gemini,hermes`.
## 6. Verify and summarize
+14 -1
View File
@@ -4,7 +4,20 @@
#
# Runs blocking on UserPromptSubmit (fresh files before Claude reads them)
# and async on Stop (push edits out without delaying the turn).
#
# Hooks receive JSON on stdin with a session_id; it is passed as the sync
# note so every change journaled during this session — including ones the
# background daemon commits — is stamped with the Claude Code session that
# made it, and shows up in `bdrive log` and the hub's history views.
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
[ -d .bdrive ] || exit 0
command -v bdrive >/dev/null 2>&1 || exit 0
bdrive sync . >/dev/null 2>&1 || true
sid=""
if [ ! -t 0 ]; then
sid=$(head -c 8192 | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
fi
if [ -n "$sid" ]; then
bdrive sync . --note "claude-code session $sid" >/dev/null 2>&1 || true
else
bdrive sync . >/dev/null 2>&1 || true
fi
+31 -1
View File
@@ -17,13 +17,14 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing
| Run the daemon in the foreground | `bdrive init -f` |
| Stop syncing | `bdrive stop [<folder>]` (`--forget` also unregisters) |
| One sync cycle now | `bdrive sync [<folder>]` |
| Register agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes) | `bdrive hooks install [<folder>]` — auto-detects the platforms in use and merges pull/push/session-note hooks into each one's own hook config, idempotently; bare `bdrive hooks` shows the status table |
| Mounts + daemon + pending state | `bdrive status [<folder>]` |
| Change history | `bdrive log [<folder>] [-p path] [-n N]` |
| This device's identity | `bdrive whoami` |
| Sign this device in (once per device) | `bdrive login [url]` — bare form uses the remembered server or beardrive.ai. Opens the sign-in page in a browser (sign-up available there); the terminal completes on its own and stores a per-device token. `--device` prints a code to approve from any browser (SSH/headless); `--status` shows server + account. Password reset: "Forgot password?" on the sign-in page (emailed via the server's SMTP config, or the link appears in the server log). **Switch hubs** with `bdrive login <new-url>`, then re-run `bdrive init` in each folder. |
| Sign this device out | `bdrive logout` — clears the saved token + account (folders untouched); `--forget` also drops the remembered server. The device token stays valid server-side until it expires — revoke it from the hub's device list to be sure. |
| Share a synced file publicly by URL | `bdrive share <file>` — prints a link anyone can open (HTML renders as a page, markdown rendered, PDFs inline; sandboxed; always the latest content; no account needed). `--expires 24h` for self-destructing links; `--list` / `--revoke <token-or-url>` to manage. Put generated reports in the shared folder, sync, then share. |
| Set up a project for a Claude Code team | `/beardrive:install` — installs the CLI, signs in, runs init (whole/shared folder), offers a CLAUDE.md section about the shared folder, and registers project-level hooks (blocking pull at prompt-submit, async push after Write/Edit) in `.claude/settings.json` |
| Set up a project for a Claude Code team | `/beardrive:install` — installs the CLI, signs in, runs init (whole/shared folder), offers a CLAUDE.md section about the shared folder, and registers agent sync hooks via `bdrive hooks install` (pull at turn start, push after edits, session-note stamping — for every detected platform, not just Claude) |
| Per-file / folder change history in the web UI | History button (file versions or project feed) and per-folder ⌚ — each entry: account, time, device (name/OS/IP), view/download of that exact version. API: `GET /api/p/<id>/history?path=\|prefix=`, `GET /api/p/<id>/blob?sha=` |
| Web server: viewer + multi-project sync hub (read-only unless `--upload`) | `bdrive web [<folder> \| <storage-root-url>]` (serves cwd by default, `--addr :4173`; `-c config.json` reads remote/addr/upload/projects_db/database/auth settings from a file, explicit flags win; a storage root URL makes it a hub hosting many projects at `<root>/<project-id>/`, registry in `--projects-db` file, default `$BDRIVE_HOME/projects.json`; `--upload` lets browsers add files, client devices push, and projects be created — direct to storage via expiring presigned URLs on S3/GCS, relayed through the server for `file://`; `--upload-ttl 15m`; clients never see the remote URL or credentials; hub projects are walled by org membership — invite teammates from the web UI; the viewer has a ⌘K palette for fuzzy file search, project switching, and quick actions) |
@@ -114,6 +115,35 @@ Renaming/moving a project folder is safe: the daemon notices its folder vanished
- Verify credentials and the remote end-to-end.
- Sync once even when the daemon is stopped.
**Session-linked notes**: `bdrive sync --note "<text>"` stamps the text onto
every change this cycle commits — and persists it (in the mount's volume
store, never synced) so changes the background daemon commits over the next
`--note-ttl` (default 30m) carry it too. Notes appear in `bdrive log` (as
`[note]`) and under each entry in the hub's history views. The plugin's sync
hooks pass `--note "claude-code session <session-id>"` automatically, so
every change made during a Claude Code session is traceable to the session
that made it. An explicit empty `--note ""` clears the persisted note;
conflict-copy ops keep their own `conflict copy of <path>` note.
### Agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes)
`bdrive hooks install [<folder>]` registers turn-boundary sync for every
agent platform it detects (by config dir, in the project or home):
| Platform | Config it writes | Pull / push events |
|---|---|---|
| Claude Code (& Cowork) | `<project>/.claude/settings.json` | `UserPromptSubmit` / `PostToolUse` (Write\|Edit) |
| Codex (ChatGPT) | `<project>/.codex/hooks.json` | `UserPromptSubmit` / `PostToolUse` (apply_patch) — user must `/hooks`-trust the layer once |
| Gemini CLI | `<project>/.gemini/settings.json` | `BeforeAgent` / `AfterTool` (write_file\|replace) |
| Hermes | `~/.hermes/config.yaml` (per-user) | `pre_llm_call` / `post_tool_call` (write_file\|patch) |
Every platform pipes hook JSON with a `session_id`, so one hook command
serves all four: it syncs the project (fast no-op outside bdrive folders)
and stamps changes with `<agent> session <id>`. Merging is idempotent and
preserves existing hooks; `--agent claude,codex,gemini,hermes` overrides
detection; bare `bdrive hooks` prints the detection/registration table.
Project-level configs ride the repo, so hooks reach the whole team.
### Examples to walk a user through
```sh