Files
beardrive/cmd/bdrive/hooksync.go
T
4e34d03e14 feat(hooks): user-scope agent sync hooks, one-command setup, --only scoping (#71)
* feat(hooks): register agent sync hooks per machine, not per project

Agent platforms read hook config only from the directory a session starts
in — never a parent, never a subfolder. Project-level hooks therefore fired
only for sessions that happened to start at the mount, and, living inside a
synced folder, they replicated one machine's agent config to the whole team
(a second writer of a file bdrive already owns). Claude Code additionally
ignores project hooks until the folder is trusted, so in practice they were
often inert without any visible sign.

Hooks now go to each platform's user config, once per machine, covering
every session in every folder; the existing shell guard keeps them a no-op
outside BearDrive projects. Install migrates away blocks older versions
wrote into projects, and `bdrive hooks uninstall` removes ours while leaving
foreign hooks untouched.

Setup is also one command now. init absorbs the skill install, prints the
hub link, and takes --server, so connecting to a named hub no longer needs a
separate login; the runbook forbids preflight and command chaining, since
each distinct command costs the user a permission prompt. For plugin users a
PreToolUse hook auto-approves bdrive's own setup subcommands — narrowly: any
shell operator in the command disqualifies it.

Also drops --shared in favor of `init . --only wiki,docs`, which writes a
managed block of .bdriveignore rules instead of a second scope mechanism.
Because those rules sync, `sync --prune` now refuses on a scoped project
rather than stripping everything outside the scope from the hub for everyone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ

* docs: fix stale claims an audit found against the new CLI

An audit of every doc surface against the code turned up claims that the
user-scope hook move and the one-command init made false: project-level
hooks "riding the repo", the Claude trust prompt, Codex's //hooks project
layer, `--no-hooks` skipping the skill (it does not), prune reconciling
against a per-device scope (it now refuses on a scoped project), and
`--scan-interval`/`--remote-interval` documented as init flags when they
only exist on `bdrive daemon run`.

Also documents the surface added today — `--server`, `bdrive hooks
uninstall`, and the plugin's PreToolUse auto-approval — refreshes the two
sample `init` transcripts to the real output, and corrects hook matchers
that had drifted from agenthooks.go.

`bdrive scope` told users to narrow an existing mount with `bdrive init .
--only <dirs>`, which resume then ignored — a dead end. Init now applies
--only on resume, writing the scope block, so the advice works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:08:45 +09:00

85 lines
3.1 KiB
Go

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
// emitContext is false for every mount after the first in one hook run: the
// hook's stdout contract is a single JSON object.
func runHookSync(cmd *cobra.Command, folder, label string, emitContext bool) 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
}
if !emitContext {
return nil
}
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
}