Files
beardrive/cmd/bdrive/hooksync_test.go
Snow LeeandClaude Fable 5 644caff70e feat(hooks): every mentioned file path gets a gated hub link — formula injected each turn
Field report follow-up: an agent with a stale skill copy couldn't find
the gated URL after creating a file. Instructions rot; hook output is
computed fresh from the binary every turn. Claude Code's turn-start
pull hook now runs 'bdrive sync --hook claude-code', which:

- pulls as before, and stamps the session note from the event JSON
  (replacing the sh/sed pipeline for the pull leg)
- emits the project's gated-link formula as UserPromptSubmit
  additionalContext: whenever the agent mentions a synced file path in
  prose, it appends the hub link on an emoji — `<path>` [🔗](<url>) —
  path plain (it's the local path), hyperlink on the emoji only; code
  blocks stay plain; bdrive share stays explicit-opt-in-public

Blind-tested: an agent given only the injected context decorated every
path mention correctly, kept the code-block command plain, and checked
files were synced before linking.

- hooks install now CONVERGES marker-identified groups to the current
  shape (command/matcher/flags), so improvements reach existing
  projects on reinstall instead of being frozen by the idempotency
  marker; hermes same
- plugin: UserPromptSubmit → beardrive-pull.sh (stdout passes through);
  version 0.3.0
- SKILL 'Share what you make' generalized to 'Link what you mention'
  (URL formula documented for non-Claude platforms); install.md pointer
  template updated

Never fails the turn: every error path in --hook mode is a silent
successful exit; offline still emits (links serve online teammates).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
2026-07-16 10:25:07 -07:00

100 lines
2.9 KiB
Go

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())
}
}