From 4e34d03e145e8385d35e656221984620fffe8e51 Mon Sep 17 00:00:00 2001 From: "Snow W. Lee (Sungwon)" Date: Wed, 29 Jul 2026 10:08:45 +0900 Subject: [PATCH] feat(hooks): user-scope agent sync hooks, one-command setup, --only scoping (#71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 `, 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 Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ --------- Co-authored-by: Claude Opus 5 --- .claude/skills/onboarding-e2e/SKILL.md | 221 ++++--- CLAUDE.md | 5 +- INSTALL_FOR_AGENTS.md | 202 +++++-- README.md | 58 +- architecture/cli-sync.md | 14 +- cmd/bdrive/approve.go | 89 +++ cmd/bdrive/cmds.go | 147 +++-- cmd/bdrive/helpers.go | 59 ++ cmd/bdrive/hooks.go | 46 +- cmd/bdrive/hooksync.go | 7 +- cmd/bdrive/init.go | 268 +++++++-- cmd/bdrive/init_test.go | 193 ++++++- cmd/bdrive/main.go | 1 + cmd/bdrive/migrate.go | 2 +- cmd/bdrive/readlog.go | 83 +-- cmd/bdrive/scope.go | 143 +++-- cmd/bdrive/scopefile.go | 116 ++++ cmd/bdrive/url_test.go | 2 +- internal/agenthooks/agenthooks.go | 214 ++++++- internal/agenthooks/agenthooks_test.go | 159 ++++- internal/syncer/flows_test.go | 2 +- internal/syncer/ignore.go | 6 + internal/syncer/prune_test.go | 2 +- internal/syncer/syncer.go | 2 +- internal/webapp/cli_e2e_test.go | 542 ++++++++++++++++++ internal/webapp/frontend/e2e/home.spec.ts | 2 +- internal/webapp/frontend/e2e/hub.spec.ts | 2 +- .../frontend/src/components/ConnectGuide.tsx | 21 +- .../frontend/src/components/EmptyState.tsx | 4 +- .../{index-pwEy71bM.js => index-Bs1oTv2s.js} | 38 +- internal/webapp/static/index.html | 2 +- plugin/commands/init.md | 55 +- plugin/commands/install.md | 144 +++-- plugin/hooks/hooks.json | 14 +- plugin/scripts/beardrive-approve.sh | 12 + plugin/skills/beardrive/SKILL.md | 61 +- .../src/content/docs/concepts/how-it-works.md | 3 +- .../content/docs/guides/agent-artifacts.md | 2 +- web/docs/src/content/docs/guides/scoping.md | 103 +++- .../docs/guides/shared-agent-memory.md | 2 +- .../content/docs/guides/what-agents-read.md | 6 +- web/docs/src/content/docs/index.md | 2 +- web/docs/src/content/docs/manual/install.md | 7 +- .../src/content/docs/manual/setup-by-hand.md | 36 +- .../content/docs/manual/skills-and-hooks.md | 60 +- web/docs/src/content/docs/reference/cli.md | 58 +- .../content/docs/reference/project-files.md | 21 +- .../content/docs/self-hosting/run-a-hub.md | 4 +- web/docs/src/content/docs/start/setup.md | 22 +- .../docs/use-cases/business-context.md | 11 +- .../content/docs/use-cases/company-brain.md | 5 +- .../content/docs/use-cases/multi-device.md | 12 +- 52 files changed, 2601 insertions(+), 691 deletions(-) create mode 100644 cmd/bdrive/approve.go create mode 100644 cmd/bdrive/scopefile.go create mode 100644 internal/webapp/cli_e2e_test.go rename internal/webapp/static/assets/{index-pwEy71bM.js => index-Bs1oTv2s.js} (88%) create mode 100755 plugin/scripts/beardrive-approve.sh diff --git a/.claude/skills/onboarding-e2e/SKILL.md b/.claude/skills/onboarding-e2e/SKILL.md index e5ed189..70bb950 100644 --- a/.claude/skills/onboarding-e2e/SKILL.md +++ b/.claude/skills/onboarding-e2e/SKILL.md @@ -1,80 +1,171 @@ --- name: onboarding-e2e -description: "Live end-to-end test of BearDrive's agent-first onboarding: spawn a fresh agent that role-plays a real user conversation ('keep our wiki synced…') against a running hub, following the plugin's SKILL/install instructions verbatim, and report the full transcript plus doc-vs-reality findings. Use when the plugin onboarding copy, the CLI init/login/hooks flow, or hub auth changed and you want proof the conversation still works. Args: [hub-url] [authenticated BDRIVE_HOME] [bdrive-binary]" +description: "Live end-to-end test of BearDrive's agent-first onboarding: run the real paste-prompt flow in a fresh headless Claude session (and optionally a full role-played user conversation) against a seeded local hub, asserting the scope hard-gate, hooks-via-init, and plugin-upgrade behaviors, and report transcripts plus doc-vs-reality findings. Self-bootstrapping — no inputs needed. Use when INSTALL_FOR_AGENTS.md, plugin/commands/install.md, the CLI init/login/hooks flow, or hub auth changed. Args: [hub-url] [authenticated BDRIVE_HOME] [bdrive-binary] (all optional)" --- # Agent-onboarding live E2E -Tests the thing unit tests can't: that a *conversation* driven by the plugin -instructions actually onboards a user end to end against a real hub. The -deliverable is a transcript + findings, not a pass/fail bit — instruction -drift (docs promising what the binary doesn't do) is exactly what this -catches. +Tests the thing `go test` can't: that a *conversation* driven by the +published instructions actually onboards a user. The deterministic half — +device login, init registers hooks, resume idempotency — is +`TestCLIOnboardingE2E` (`internal/webapp/cli_e2e_test.go`); run it first +and don't re-prove what it covers. The deliverable here is transcripts + +findings, not a pass/fail bit: instruction drift and judgment failures +(mounting a folder without asking) are exactly what this catches. -## Inputs (ask if not provided) +## Environment — self-bootstrapping, no inputs required -- `HUB` — a running hub URL (dev default: the local cloud hub). -- `HOME_AUTH` — a `BDRIVE_HOME` directory already signed in to that hub - (`bdrive login --status` must show an account). Browser signup can't run - headlessly; if none exists, create a synthetic user first (hub-dependent — - on a PropelAuth hub: Backend API `POST /api/backend/v1/user/` then a magic - link, or the device flow approved from an authenticated browser session). -- `BDRIVE` — path to the `bdrive` binary to test (build it from the tree - under test: `go build -o /tmp/bdrive-e2e ./cmd/bdrive`). Don't trust PATH. +If the caller gave a hub URL / authenticated `BDRIVE_HOME` / binary, use +them. Otherwise build everything from the tree under test: -## Procedure +```sh +W= # the tree whose docs/binary are under test +go build -o $W/bdrive ./cmd/bdrive +# Seeded hub on 0.0.0.0:8993 (accounts e2e@example.com / e2e-pass-1, project "wiki"). +# -count=1 or go serves a cached result instead of a server; it LIVES 2 HOURS then exits. +BDRIVE_E2E_SERVE=1 go test -count=1 -run TestE2EServe -timeout 0 ./internal/webapp & # background task +# wait for `curl -s localhost:8993` to return 200 +``` -1. **Stage a realistic repo** in a scratch dir (never inside the real repo): - ```sh - A=/agent-e2e && mkdir -p $A/repo/wiki $A/repo/src $A/plugin - # 3 markdown pages with [[wikilinks]] in wiki/, a token src/ file, - # git init + commit (the wiki MUST be git-tracked — the handoff step - # is part of the test). - ``` -2. **Materialize the instructions under test** — from the branch being - tested, not the working tree: - ```sh - git show :plugin/commands/install.md > $A/plugin/install.md - git show :plugin/skills/beardrive/SKILL.md > $A/plugin/SKILL.md - ``` -3. **Spawn a fresh agent** (Task/Agent tool, general-purpose) with a prompt - that makes it role-play a real Claude Code session. The prompt must: - - confine all writes to the scratch dir; - - name the two instruction files as its ONLY operating manual and demand - it follow them faithfully, noting friction instead of papering over it; - - require `BDRIVE_HOME=$HOME_AUTH` on every bdrive call and forbid bare - `bdrive login` (no browser available); - - fix the project name (avoid collisions with earlier runs); - - open with the exact user message - `"keep our wiki synced with the team and give me a link to it"`; - - script the simulated user: consent to the sync + synced AGENTS.md, but - DECLINE one optional step (e.g. the root pointer) so the transcript - proves the consent gates are real; - - require every command's real output in the transcript — no fabrication; - - end with cleanup: `bdrive stop ` so no daemon lingers; - - demand a two-part report: **TRANSCRIPT** (User/Claude turns with real - command output) and **TEST FINDINGS** (numbered: worked-as-written / - doc-vs-reality gaps with quoted instruction text / first-timer - confusion / whether the `bdrive url` payoff link served real content — - verify with an authenticated `curl` against the project API). -4. **Independently verify** the agent's headline claims before relaying: - the project exists on the hub (`/api/projects` with the token), the file - content round-trips, the daemon is stopped. -5. **Relay** the transcript verbatim and triage findings into: fix-now doc - patches, behavior bugs (file/branch them), and cosmetics. +Headless login (no browser needed — approve the device code over HTTP): -## Pass bar +```sh +export BDRIVE_HOME=/home +$W/bdrive login --device http://localhost:8993 > login.log & # prints "approve code: XXXX" +jar=$(mktemp) +curl -s -c $jar -d "email=e2e@example.com&password=e2e-pass-1" http://localhost:8993/auth/login -o /dev/null +curl -s -b $jar -d "code=" http://localhost:8993/auth/device -o /dev/null +$W/bdrive login --status # must show the account +# project id for the paste prompt: +curl -s -b $jar http://localhost:8993/api/projects # take the "wiki" id — RE-FETCH after any hub restart, ids change +``` -The conversation must reach the payoff (a working hub link) with no step -where the agent had to contradict the instructions silently. Any place the -agent adapted beyond the written instructions is a finding, even if the run -"worked". +## Scenario A — the teammate paste-prompt flow (always run) + +A real fresh `claude -p` session, not a role-played subagent: only a real +session has the real permission classifier and fresh context — subagents +handed the doc as their "manual" over-comply and mask judgment failures. + +```sh +mkdir /agentN && cd /agentN # fresh EMPTY folder every run +BDRIVE_HOME=/home PATH="$W:$PATH" claude -p \ + "Follow $W/INSTALL_FOR_AGENTS.md +to set up BearDrive project on http://localhost:8993. Ask me which folder to sync." \ + --output-format json --allowedTools "Read,Bash(bdrive:*),Bash(command:*)" +``` + +Point at the **local** INSTALL_FOR_AGENTS.md — the raw.githubusercontent +URL serves merged main, not the tree under test. Capture `session_id` from +the JSON; the restricted tools are deliberate (plugin-install commands +must be *offered*, and denied if attempted). + +**Assertions** (each miss is a finding, quote the instruction it violates): + +1. **Scope hard gate**: the turn ends *asking which folder syncs* — with + concrete options — and the folder is still empty (`ls -a`). Init not + run. "The folder was empty so I proceeded" is the exact regression. +2. **Answer and resume**: `claude -p --resume "sync this + whole folder"` (same env, same cwd). Now init must run and its output + must show hooks registered *inline* — no separate `bdrive hooks + install` invocation anywhere in the transcript. +3. **Plugin upgrade**: for an already-installed plugin the agent offers + `claude plugin marketplace update beardrive` + `claude plugin update + beardrive@beardrive` — offered to the user, never silently run. +4. **Payoff**: the final message hands a hub link; verify it serves real + content with an authenticated `curl -b $jar`. + +### Case matrix (each in a fresh scratch dir; assertions above apply to all) + +Allowed tools for these runs: `Read,Write,Edit,Bash(bdrive:*),Bash(command:*),Bash(git:*),Bash(mkdir:*),Bash(ls:*),Bash(cat:*)` — +enough to stage/inspect and run bdrive, still too little to install +plugins silently. Give consent one turn at a time (a case may need a +second `--resume` for the git-handoff consent — that's correct behavior, +not a failure). + +1. **Empty folder** — staging: empty dir; prompt: existing wiki id. + Turn 1 must *recommend creating a dedicated subfolder as the default* + (not whole-folder "because it's empty"). Answer: "Go with your + recommendation." Assert: the subfolder exists and is the mount + (`/.bdrive/config.json` pointing at the project), the parent has + no `.bdrive/`, and the project's files actually landed inside it. +2. **Repo with a custom-named knowledge folder** — staging: git repo, + committed `src/main.go` + `gbrain/` (3 markdown files with + `[[wikilinks]]` — name deliberately NOT wiki/docs/notes; tests + content-based detection). Turn 1 must propose `gbrain/` and never + offer the repo root. Answer: "Yes, sync gbrain — consent to the git + handoff." Assert: for a join to an existing root-layout project, + `gbrain/` is mounted as its OWN root (`gbrain/.bdrive/config.json` + pointing at the project — the mount is the folder itself, never the + repo root); the repo's `src/` never reaches the hub; + `git rm -r --cached gbrain` staged but NOT committed; `gbrain/` + + `.bdrive/` in `.gitignore`. +3. **Pure-code repo, no knowledge folder** — staging: git repo with + `src/`, `README.md`, `package.json` only. Turn 1 must recommend + creating a subfolder (never whole-folder). Answer: "Create wiki/ and + sync only that." Assert: `wiki/` exists and is the mount, root not + whole-mounted, `wiki/` in `.gitignore`. +4. **A second project beside an existing one (sibling subfolders)** — + staging: parent dir containing `a/`, script-mounted to project A + (`bdrive init --name project-a --yes`, daemon running, one file); + project B created separately on the hub with its own content. The + agent starts in the **parent** and is given B's id. Turn 1 must + notice `a/` is already mounted to a *different* project, refuse to + re-point it silently, and ask. Answer: "Leave a/ alone. Create b/ + here and sync B into it." Assert: `b/` mounted to B, `a/`'s + `.bdrive/config.json` byte-identical with its daemon still running, + the two configs differ, and each project on the hub holds only its + own files. (The CLI half is `TestCLISiblingProjectMounts` — here + assert the *conversation*: the agent detected A and never re-pointed + it.) +5. **Same project mounted twice on one device** — staging: project P + already mounted at folder X on this device; agent asked to connect P + again in folder Y. `bdrive init` refuses and names X (one device + writes one journal per project, so a second mount would overwrite the + first's ops on the hub). Assert the agent relays the refusal and its + options rather than working around it — and that folder Y has no + `.bdrive/` afterwards. Deterministic version: + `TestCLISameProjectTwoMounts`. + +## Scenario B — full user conversation (run when install.md changed) + +Stage a realistic repo: `/repo` with a git-committed `wiki/` (3 +markdown pages with `[[wikilinks]]`) plus a token `src/` file — the +git-handoff consent is part of the test. Run a fresh `claude -p` session +opening with `"keep our wiki synced with the team and give me a link to +it"`, resuming turn by turn as the scripted user: consent to the sync and +the synced AGENTS.md, but DECLINE one optional step (e.g. the root +pointer) so the transcript proves the consent gates are real. Same +assertions as A plus: never syncs the repo root bare (mounts `wiki/` +itself, or narrows the root with `--only wiki`), does the `git rm -r --cached` handoff only after consent, +and the two-file orientation is offered, not imposed. + +## Verify, clean up, report + +Independently verify headline claims before relaying (project exists via +`/api/projects`, files round-trip, hooks JSON on disk). Then: `bdrive +stop` every folder the runs mounted, kill the hub task, note that hub +state dies with it. + +Report: per scenario — **SESSION** (the resumable `session_id` + how to +resume it with the right env), **TRANSCRIPT** (real command output, no +fabrication), **FINDINGS** (numbered: worked-as-written / doc-vs-reality +gaps with quoted instruction text / judgment failures / first-timer +confusion), then a triage: fix-now doc patches, behavior bugs, cosmetics. + +**Pass bar**: every assertion holds and the conversation reaches the +payoff with no step where the agent contradicted the instructions +silently. Any adaptation beyond the written instructions is a finding, +even if the run "worked". ## Known environment quirks -- `command -v bdrive` may find a Homebrew binary that's older than the tree - under test — always pass `BDRIVE` explicitly and watch for version skew. -- On hubs with PropelAuth + "must be in at least one org": brand-new users - are gated at PropelAuth's create-org screen unless the `user.created` - webhook can reach the hub — synthetic-user setup must account for it - (deliver the webhook by hand or pre-create the org). +- The seeded hub **exits after 2 hours** and wipes state on restart — + project ids from before a restart 404; re-fetch, and expect stray + daemons from earlier runs to be pointing at dead projects (`bdrive stop` + them). +- `go test` **caches** the harness invocation — without `-count=1` you get + "ok (cached)" and no server. +- `command -v bdrive` may find a Homebrew binary older than the tree under + test — always prepend `$W` to PATH and watch for version skew. +- The agent's `bdrive skill install` writes to the real `~/.codex`, + `~/.gemini`, `~/.hermes` skill dirs (user-level); harmless but expect it. diff --git a/CLAUDE.md b/CLAUDE.md index d884e39..4cae9ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ Package roles (`internal/`): - **`config`** — global state under `$BDRIVE_HOME` (default `~/.bdrive`): device identity (`device.json`), settings (`settings.json`: default server + device token + signed-in account), and the mount registry (`mounts.json`, keyed by **stable mount id**, holding only each mount's last-known path). The per-folder `.bdrive/` directory (`project.go`) holds `config.json` with the mount id + volume/remote/include; **nothing is keyed by the folder path**, so renames/moves are free — `ResolveMount` self-heals the registry path, and the volume store lives at `~/.bdrive/volumes//`. `.bdrive/` is never synced and holds no credentials. - **`webapp`** — the `bdrive web` server, in two modes. Single-volume: `Source` is a `DirSource` (plain folder from disk) or `RemoteSource` (folds journals into a file tree with per-file provenance). Hub: `Root` + `Projects` host many projects on one storage root, each under `//` via `remote.Prefixed`; `ProjectDB` (`projects.go`) is a file-backed registry (JSON, loaded at open, rewritten atomically per change) with create-or-join-by-name semantics, name-scoped per organization. Orgs (`orgs.go`, file-backed `orgs.json`) wall projects by membership (email → owner|member): every per-project route — viewer APIs, uploads, history, shares management, the `/store/*` sync proxy — 403s for non-members, `/api/projects` lists only your orgs' projects, owners mint expiring multi-use invite links (`/join/`), and a pre-org hub migrates all projects into a "default" org (all existing accounts join, oldest owns) at startup. `QuotaProvider` (`quota.go`) is the plan-enforcement seam mirroring `AuthProvider` — CheckWrite/RecordUsage on every write path, CheckSeat on invite redemption; OSS ships only `UnlimitedQuota`, managed deployments swap the provider. Renders markdown (goldmark + Obsidian `[[wikilinks]]`). With `--upload` it accepts writes: browser uploads (`upload.go` — direct-to-storage via presigned URLs when the backend implements `remote.PutSigner`, relayed otherwise; ops journaled under the server's own device) and the per-project `/api/p//store/*` proxy (`store.go`) that whole devices sync through — the `https://` remote backend (`remote/http.go`) is its client; journals are never presigned, only immutable blobs. Frontend is a React + TypeScript app (`webapp/frontend/`, Vite + Tailwind v4 + shadcn/ui — Radix-based components copied into `src/components/ui`, themed from the BearDrive tokens in `src/tw.css`; TanStack query/table/virtual, react-hook-form + zod, cmdk, sonner, lucide-react) whose **built output is committed** at `webapp/static/` — the `go:embed static` target — so plain `go build` needs no Node; after any `frontend/src` change run `npm run build` there and commit the new `static/` (`frontend/check-dist.sh` verifies freshness; e2e suite: `npm run e2e` — Playwright against the seeded harness in `e2e_serve_test.go`, port 8993). It learns everything from `/api/config` (+ `/api/projects` in hub mode) and never sees storage info or credentials. It uses native History-API path routing (`//` in hub mode, `/` in volume mode, `/join/` for invites — no `#`, slashes stay literal). **Every user-facing page owns a URL path**: new surfaces are view routes (`//{dashboard|history|install|settings}[/]`, `VIEW_ROUTES` in `router.ts`; renamed segments live in `LEGACY_VIEWS` and are normalized away on arrival) so deep links, reload, and back/forward always work — never URL-less panel state (the org/hub admin panels are the legacy exceptions; don't add more) implemented by the in-repo synchronous router `frontend/src/nav.ts` + `router.ts` (deliberately NOT a router library: react-router v7's startTransition navigation left stale views on screen); `Server.frontend` serves `index.html` as the SPA fallback for any non-asset, non-API/auth/share route so deep links and refreshes resolve (hashed `assets/*` are cached immutable, everything else no-cache), and all client API/asset URLs are root-absolute so a deep path doesn't break relative resolution. Rendered markdown is transformed as a string before mounting and link clicks are delegated on the container — never patch the `dangerouslySetInnerHTML` subtree after commit (React re-applies the markup on unrelated updates and discards DOM patches). **Read heat** (`reads.go`): a `ReadLedger` (hub-only, nil = off, config `reads` block) aggregates read telemetry into daily per-actor buckets, debounced to 10-minute visits, folded into all-time rows past `retention_days` — viewer file/render/download = human (recorded via the project id the `proj()` resolver stashes in the request context), `/s/*` hits = share, device-reported reads (`POST /api/p//reads`) = agent; `/store/*` replication and history `/blob` views are NEVER reads. `GET /api/p//heat?prefix=&days=` returns counts/distinct-readers/last-read only — actor identities (the email/device/token in the buckets) must never appear in an API response. Recording and flushing degrade silently (log once); telemetry must never fail a request or a sync cycle. The frontend shows heat dots on folder listings and a per-project Dashboard quadrant (reads × staleness, route `//dashboard`) — both visible to every project member, since `/heat` is membership-gated and identity-free. **Hub metadata persistence** (accounts, projects, orgs+invites, shares, devices, read buckets — never blobs or journals) sits behind a pluggable `MetaStore` of typed repos (`db.go`): the service structs (`BuiltinAuth`, `OrgDB`, `ProjectDB`, `ShareDB`, `DeviceRegistry`, `ReadLedger`) keep their in-memory maps + logic and persist each change as one record through a repo (the `ReadRepo` alone is batch-oriented — one flush, one write). Two backends — `db_file.go` (the historical JSON files, still the zero-dep default, reached via the `Open*(path)` constructors) and `db_sql.go` (one `database/sql` impl over pure-Go drivers: `modernc.org/sqlite` locally, `jackc/pgx` for Postgres/Supabase, portable schema + idempotent migrations + transactional multi-row writes). `web.go`'s `database` config (`{driver:file|sqlite|postgres, dsn}`) selects it; file is default and untouched. `db_conformance_test.go` runs the same service ops against every backend. -`cmd/bdrive/` is a thin cobra CLI over these packages (`login`, `logout`, `init`, `stop`, `sync`, `status`, `log`, `export`, `import`, `url`, `web`, `whoami`, `daemon`, `version` — `mnt`/`umnt`/`remote` are gone; `init` is the front door and `stop` pauses). `export`/`import` (`migrate.go`) move a whole project between hubs with full fidelity: the archive is the remote store layout (all devices' journals + all blobs) in a tar.gz, streamed through the existing `remote.Backend` — no server-side support needed, so it works against any hub in either direction (the anti-lock-in story for cloud-hesitant users). `bdrive login` signs the device in (bare form uses the remembered server or `config.DefaultServer` = beardrive.ai; loopback-callback browser flow in `login.go`, `--device` for headless) and stores server+token+account in `settings.json`; `bdrive logout` clears the saved token+account (keeps the remembered server unless `--forget`). Switching hubs is `bdrive login ` then re-`init` — `init` is the only thing that writes a folder's remote (always a hub, `server + "/p/" + id`); there is no client command to point a folder at a raw bucket. `bdrive init` is interactive on a TTY (survey menus: create-new vs connect-existing with a project list; whole-folder vs `--shared `, which becomes the include list) with full flag bypass (`--name/--project/--shared/--yes`) and never prompts without a TTY; it runs the login flow first when there is no session, writes `.bdrive/config.json`, seeds `.bdriveignore`, and starts sync via `startSync`; re-running it resumes — including after a folder move. `bdrive web -c config.json` configures the server from a file, explicit flags winning. +`cmd/bdrive/` is a thin cobra CLI over these packages (`login`, `logout`, `init`, `stop`, `sync`, `scope`, `forget`, `status`, `log`, `share`, `export`, `import`, `url`, `hooks`, `skill`, `read-log`, `hook-approve`, `web`, `whoami`, `daemon`, `version` — `mnt`/`umnt`/`remote` are gone; `init` is the front door and `stop` pauses). `export`/`import` (`migrate.go`) move a whole project between hubs with full fidelity: the archive is the remote store layout (all devices' journals + all blobs) in a tar.gz, streamed through the existing `remote.Backend` — no server-side support needed, so it works against any hub in either direction (the anti-lock-in story for cloud-hesitant users). `bdrive login` signs the device in (bare form uses the remembered server or `config.DefaultServer` = beardrive.ai; loopback-callback browser flow in `login.go`, `--device` for headless) and stores server+token+account in `settings.json`; `bdrive logout` clears the saved token+account (keeps the remembered server unless `--forget`). Switching hubs is `bdrive login ` then re-`init` — `init` is the only thing that writes a folder's remote (always a hub, `server + "/p/" + id`); there is no client command to point a folder at a raw bucket. `bdrive init` is interactive on a TTY (survey menus: create-new vs connect-existing with a project list; whole-folder vs only-some-subfolders) with full flag bypass (`--name/--project/--only/--yes`) and never prompts without a TTY; it runs the login flow first when there is no session, writes `.bdrive/config.json`, seeds `.bdriveignore`, registers agent sync hooks in each platform's USER config (`~/.claude/settings.json` and friends — once per machine, never inside a project: platforms read hook config only from the directory a session starts in, so a per-project file covers only sessions that start there and, living in a mount, would sync to the team; `Install` also migrates away hooks older versions wrote into projects) and installs the beardrive skill, and starts sync via `startSync`; re-running it resumes — including after a folder move. **A mount is always exactly the folder named** — there is no re-rooting flag. Syncing only part of a mount is `--only wiki,docs`, which writes a bdrive-managed block of `.bdriveignore` negation rules (`cmd/bdrive/scopefile.go`; `bdrive scope add/rm` edits the same block) rather than a second scope mechanism: the old `Include` list in `config.json` is legacy — still honored, never written. Because the rules live in the synced `.bdriveignore`, scope is team-wide, which is why `sync --prune` refuses when `!` rules are present (it would strip everything outside the scope from the hub for everyone; `bdrive forget ` is the per-path tool). `init` also refuses a second folder for a project this device already syncs — one device writes one journal per project, so two mounts would overwrite each other's ops. `bdrive web -c config.json` configures the server from a file, explicit flags winning. Authentication (`webapp/auth.go`, `authlocal.go`, `mail.go`) is **mandatory in hub mode** — the config's `auth` block tunes `users_db`/`allow_signup`/`allowed_domains`/`require_verification`/`require_approval`/`admins`/`smtp`; the plain-folder viewer stays auth-free — and sits behind the `AuthProvider` interface — the OSS server ships only `BuiltinAuth` (email+password accounts and device tokens in a file-backed `auth.json`; bcrypt for passwords, SHA-256 digests for tokens, plaintext never stored; server-owned `/auth/*` pages; one-time codes for the CLI callback and device flows; SMTP reset mail with a log-link fallback). **Signup is invite-only by default** (`allow_signup` defaults false): a valid org invite bootstraps an account even when self-signup is closed — `BuiltinAuth.InviteValid` (wired to `OrgDB.ValidInvite`) lets `pageSignup`/`pageLogin` offer account creation for a `/join/` target, and `signupInvited` skips the domain/verification/approval gates and activates immediately (the invite is the vetting). `BuiltinAuth.ValidateSignupPolicy` (called at hub startup, `web.go`) refuses an ungated open hub and email-verification-without-SMTP rather than silently leaving the door open. The three postures: invite-only (default), approval-gated (`require_approval`), and domain-restricted+verified (`allowed_domains`+`require_verification`+`smtp`); `allow_signup`/`allowed_domains`/`admins` stay server-config-owned so a browser session can't widen access. A managed deployment can swap in a different provider (e.g. PropelAuth) without touching the CLI or API — keep provider-specific code out of this repo. The sync client picks up its token from `BDRIVE_TOKEN` or `settings.json` and sends `X-Bdrive-Device{,-Name,-Os}` headers (`remote/http.go`); the hub's file-backed device registry (`webapp/devices.go`) records per-device name/OS/account/server-observed IP. Journal ops carry the signed-in account (`Op.User`/`UserName` from `Session.Account`; `Author` remains the git/OS fallback). History (`webapp/history.go`): `GET /api/p//history?path=|prefix=` (newest first, device-registry join) and `GET /api/p//blob?sha=` stream any exact version — blobs are retained forever, so the future revert phase is just re-putting an old blob as a new op. Share links (`webapp/shares.go`, file-backed `shares.json`): any signed-in member mints `/s/` public URLs (`bdrive share`, or the UI's Share button) serving the file's LATEST content until revoked (optional expiry); `/s/*` responses are sandboxed (CSP `sandbox allow-scripts`, no auth cookies) so shared HTML can't attack hub sessions — keep that header on any change; `/s/*` also sits behind a per-IP token bucket (`ratelimit.go`, `share_rpm` config), and markdown share pages get a "Shared with BearDrive" footer (raw HTML is never injected into). @@ -58,6 +58,7 @@ Authentication (`webapp/auth.go`, `authlocal.go`, `mail.go`) is **mandatory in h - **Replay must stay deterministic.** Any change to `journal.Less` or `Replay` changes what every device converges to. - **Materialize never clobbers dirty files**: a file whose size/mtime differs from the state cache changed mid-cycle and is left for the next scan. - **All state files are written atomically** (temp file + rename, see `store.WriteFileAtomic`). Temp files are prefixed `.bdrive-tmp-` and ignored by the scanner. +- **The agent hook guard stays pure shell.** It runs on every session and every tool call on the machine, so it must never spawn `bdrive` (or anything else) outside a BearDrive project — a couple of `stat`s and at most one `grep` of `mounts.json`. - **`Cycle` runs under the volume flock** — the daemon and one-shot CLI commands (`bdrive sync`) coexist through it. - Errors during pull/push degrade to `Result.Offline` rather than failing the cycle; unreadable/vanished files during scan are skipped and retried next cycle. Follow this "never break sync, retry next cycle" posture. @@ -67,7 +68,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 `/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. +`plugin/` is a Claude Code plugin (skill + `/beardrive:install` + `/beardrive:init` + `/beardrive:status` commands + turn-boundary sync hooks + a `PreToolUse` hook that auto-approves beardrive's own setup subcommands — `cmd/bdrive/approve.go`, narrow by design: a shell operator anywhere in the command disqualifies it — so onboarding is not a permission gauntlet). `/beardrive:install` (`plugin/commands/install.md`) is the team onboarding flow: binary, login, init, a consent-gated two-file agent orientation (synced `/AGENTS.md` map + repo-root `AGENTS.md`/`CLAUDE.md` pointer — see SKILL.md "Teaching agents the folder"), and user-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); teammates without the plugin get the same hooks from their own `bdrive init`, 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 scripts `plugin/scripts/beardrive-{sync,pull,read,approve}.sh` (and the inline hook commands `internal/agenthooks` writes) must stay a fast no-op outside BearDrive folders — they run on every turn, and now on every machine-wide session, so the guard is pure shell and never spawns the binary outside a mount. ## Docs site (`web/docs`) diff --git a/INSTALL_FOR_AGENTS.md b/INSTALL_FOR_AGENTS.md index 201e14e..242abda 100644 --- a/INSTALL_FOR_AGENTS.md +++ b/INSTALL_FOR_AGENTS.md @@ -7,18 +7,31 @@ Steps marked **ask first** require the user's explicit answer — do not guess or skip them. BearDrive syncs a folder across a team and their agents through a hub, with -per-file history and share links. Setting it up means: install the CLI, sign -in, pick what to sync, and register turn-boundary sync hooks for your -platform. Full documentation: https://docs.beardrive.ai (agent-readable +per-file history and share links. Setup is short by design: make sure the CLI +exists, ask the user what to sync, and run **one** `bdrive init` — it signs +in, installs the skill, registers the sync hooks, starts syncing and prints +the project link. Every extra command you invent is another permission prompt +for the user. Full documentation: https://docs.beardrive.ai (agent-readable index at https://docs.beardrive.ai/llms.txt). +**How to run commands, throughout.** Each distinct command you run may cost +the user a permission prompt, so: + +- **one command per shell call** — never chain with `&&`, `;` or a pipe; a + compound command needs approval for every part; +- **no preflight** — do not check versions or status "just to be sure"; +- **never retry a denied command** — say so once, hand the user the exact + `! ` to run plus the allow-rule `"Bash(bdrive:*)"`, then continue + with the steps that do not depend on it. + > Maintainers: this is a condensed, URL-addressable version of > `plugin/commands/install.md` — that file is the source of truth. If the > two disagree, update this one. -## 1. Install the CLI +## 1. Install the CLI (only if it is missing) -Check `command -v bdrive`. If missing: +Run `command -v bdrive` — this is the one preflight worth a prompt. If it +prints a path, go straight to step 2. If missing: ```sh brew install runbear-io/tap/beardrive # macOS / Linuxbrew @@ -29,73 +42,138 @@ https://github.com/runbear-io/beardrive/releases, or with a Go toolchain: `go install github.com/runbear-io/beardrive/cmd/bdrive@latest`. If nothing works, stop and tell the user. -## 2. Teach yourself the CLI for future sessions +## 2. Do not run a login command -```sh -bdrive skill install -``` +`bdrive init` (step 3) signs the device in when there is no session, and +without a TTY it uses the device-code flow automatically: it prints a URL and +a short code for the user to approve in any browser. Pass the hub with +`--server ` and init signs in *there* — so a hub this device has +never seen still needs no separate command. -This detects your platform and installs the beardrive skill into its skills -directory, so your later sessions understand `bdrive` conversationally. +So: no `bdrive login`, no `bdrive login --status`. They are extra permission +prompts for something init does anyway. The only time to run login on its own +is when the user explicitly asks to switch hubs without connecting a folder. -## 3. Sign in +Init also installs the beardrive skill for every agent platform it detects, +so later sessions understand `bdrive` conversationally. Not a separate +command either. -Check `bdrive login --status`. If there's no valid session: +## 3. Initialize the project — **ask first** -- **Default (BearDrive Cloud):** run `bdrive login` — a browser opens - beardrive.ai where the user signs in or signs up. A new account gets a free - personal workspace automatically; a pending team invite lands them in that - team. Warn the user a browser window is coming. -- **Self-hosted hub:** if the user's team runs its own hub, ask for the URL - and run `bdrive login https://their-hub`. -- **Headless (no browser):** `bdrive login --device ` — show the - user the code and URL it prints. +Two questions: -## 4. Initialize the project — **ask first** - -Two questions (or infer from what the user already told you): - -1. **Create a new project or connect an existing one?** +1. **Create a new project or connect an existing one?** (Skip only if the + user already gave a project id or name.) `bdrive init --name ` creates-or-joins by name; `bdrive init --project ` connects by id. -2. **Whole folder, or shared subfolders?** Hard rule: **never sync a repo - root.** Inside a repo, sync scoped subfolders via `--shared`. Scan for - knowledge folders (`wiki/`, `docs/`, `notes/`, an Obsidian vault — - markdown-heavy, not source code) and propose candidates for confirmation. - Whole-folder is only for a dedicated knowledge folder that is the mount - itself. +2. **Which folder syncs?** ALWAYS ask, and always ask with a named + recommendation — never an open-ended "which folder and what scope?". + Pick your recommendation with this rule, in order: + - **You found a knowledge folder** (markdown-heavy, not source code — + `wiki/`, `docs/`, `notes/`, an Obsidian vault, or any folder that + looks like one whatever its name): recommend it. + - **You found none** (including when the folder is empty): + **recommend creating a dedicated subfolder**, and name it — + "create `wiki/` here and sync that". An empty folder is not + evidence that it is meant to be the knowledge folder, and the + folder *not* being a git repo is not a reason to sync it whole — + the recommendation is the same either way. The repo-root rule + below is a separate, harder prohibition, not the only reason to + prefer a subfolder. -If the chosen folder is git-tracked, git and BearDrive would both write it + Then list the alternatives: a different folder entirely (`bdrive init + `), or the whole current folder — an alternative the user may + choose, never your recommendation. + + Hard rule: **never mount a repo root bare.** The one sanctioned way to + sync inside a repo without picking a single subfolder is to mount the + root *narrowed*: `bdrive init . --only wiki,docs`, which syncs only + those subfolders. That is exactly how several sibling folders share + one project — do not propose moving folders around to give them a + common parent. Wait for the pick. + + The shape to aim for: "I recommend creating `wiki/` here and syncing + that. Alternatives: a different path, or this whole folder if you + mean it to be the knowledge folder itself. Which do you want?" + + **On Claude Code, ask with the AskUserQuestion tool** rather than + plain prose — one question, header "Sync folder", your recommendation + as the first option labelled "(Recommended)", then the alternatives. + The user picks instead of typing a path. Every other agent: prose. + +Executing the pick — **the mount is always exactly the folder you name.** +`bdrive init wiki --project ` makes ./wiki the project, so the +project's files land inside it. There is no flag that re-roots a mount +somewhere else. Syncing only part of a folder is `--only`, which narrows +a mount without moving it: `bdrive init . --only wiki,docs` keeps the +mount at `.` and writes `.bdriveignore` rules so only those subfolders +sync (their paths keep the `wiki/` prefix on the hub, which is what +teammates then see). + +**Hard gate: do not run `bdrive init` until the user has answered +the folder question in this conversation.** There is no exception — not for an +empty folder, not for a non-repo, not for a non-interactive session. If +you cannot ask, end your turn with the question instead of proceeding. + +Run init BEFORE the git handoff: init can refuse (e.g. this device already +syncs that project somewhere else), and a refusal after you have already +rewritten `.gitignore` and unstaged files leaves the repo half-changed. If +the chosen folder is git-tracked, git and BearDrive would both write it (silent-revert hazard). Get consent, then `git rm -r --cached ` and add `/` to `.gitignore`; stage but let the user commit. -Then run non-interactively: +Then run **one** command — init signs in if needed, installs the skill, +registers the hooks, syncs, and prints the project link. Do not precede it +with `command -v bdrive` or `bdrive --version`: every extra command is +another permission prompt, and if the binary is missing this one says so. ```sh -bdrive init --name --yes # dedicated knowledge folder -bdrive init --name --shared wiki,docs # in a repo: only these sync +bdrive init wiki --project --server --yes # ./wiki is the project +bdrive init . --name --only wiki,docs --yes # this folder, only those subfolders sync ``` +Drop `--server` when the user gave no hub URL (BearDrive Cloud is the +default), and `--project`/`--name` follow the answer to question 1. + +**Run one command per shell call.** Never chain with `&&`, `;` or a pipe: a +compound command needs approval for each part, so chaining multiplies the +prompts. + +**If a command is blocked by your harness's permissions**, say so once and do +not retry it. Hand the user both of these, then continue with every step that +does not depend on it and re-check at the end: + +- the command to run themselves — in Claude Code, `! bdrive init …` +- the allow-rule that prevents it recurring: `"Bash(bdrive:*)"` + +Approving with "don't ask again" also works, and since setup is a single +command that is the last prompt they will see. + After init: add `.bdrive/` to `.gitignore` (per-machine state, never committed). Re-running `bdrive init --yes` later is always safe. To change the scope later use `bdrive scope add/rm `, never hand-edit `.bdrive/config.json`. -## 5. Register sync hooks — don't skip +## 4. Confirm the sync hooks -```sh -bdrive hooks install -``` +`bdrive init` already did this — do not run a separate hooks command. It +registers turn-boundary hooks (pull before every turn, push right after +edits, stamp changes with the agent session) **once per machine**, in each +platform's own user config: `~/.claude/settings.json`, `~/.codex/hooks.json`, +`~/.gemini/settings.json`, `~/.hermes/config.yaml`. That covers every session +in every folder, and nothing is written inside the project — a hook file in a +synced folder would travel to the whole team. -This detects the agent platforms in use and idempotently merges BearDrive's -hooks into each one's config: pull before every turn, push right after -edits, stamp changes with the agent session. Project-level hook files ride -the repo, so teammates get them too. Run `bdrive hooks` to show the status -table and tell the user which platforms got hooks. (Codex users must run -`/hooks` once inside Codex to trust the project layer; Hermes hooks are -per-user — mention that, and skip Hermes unless the user uses it.) +Read init's output for the platforms it registered and tell the user. One +platform needs a manual step worth passing on: **Codex hooks are experimental +and off by default** — the user enables them with `[features] codex_hooks = +true` in `~/.codex/config.toml`, and Codex asks once to trust the hook. -## 6. Claude Code only: install the plugin +Only if a platform the user works with is missing from init's output: run +`bdrive hooks install --agent `. `bdrive hooks` shows the status table, +`bdrive hooks uninstall` removes them again. + +## 5. Claude Code only: install the plugin If you are Claude Code, also register the marketplace and plugin so future sessions get the skill, `/beardrive:*` commands, and hooks built in: @@ -105,18 +183,28 @@ claude plugin marketplace add runbear-io/beardrive claude plugin install beardrive@beardrive ``` +If it's already installed, upgrade it instead so this machine isn't on a +stale version: + +```sh +claude plugin marketplace update beardrive +claude plugin update beardrive@beardrive +``` + Tell the user the plugin loads on their next session (current session keeps -working via the hooks from step 5). Other platforms: step 2 already covered +working via the hooks from step 4). Other platforms: init already covered you — skip this. -## 7. Verify, then show the payoff +## 6. Verify, then show the payoff -Run `bdrive status` — confirm the daemon is running and pending is 0. -Summarize what was set up, then pick a representative synced file (the -wiki's index/README), run `bdrive url `, and hand the user the link — -seeing the folder rendered in the browser is the moment the setup clicks. -Teammate links require sign-in; `bdrive share ` exists for fully -public URLs. +Init printed the project's hub link and a sync summary — use them rather +than running more commands. Summarize what was set up and hand the user that +link: seeing the folder rendered in the browser is the moment the setup +clicks. Teammate links require sign-in; `bdrive share ` exists for +fully public URLs. + +Only if something looked wrong in init's output: `bdrive status` shows the +daemon and pending count, and `bdrive url ` links a specific file. ## Optional: teach agents about the folder — **ask first** @@ -133,7 +221,7 @@ page shows it pre-filled): ``` Follow https://raw.githubusercontent.com/runbear-io/beardrive/main/INSTALL_FOR_AGENTS.md -to connect this folder to BearDrive project on . +to set up BearDrive project on . Ask me which folder to sync. ``` On BearDrive Cloud, drop `on ` — login defaults to beardrive.ai. diff --git a/README.md b/README.md index 2e634bc..c933bb2 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,10 @@ agents actually read (and which hot-but-stale docs nobody maintains). $ bdrive login https://your-hub # once per device — self-host a hub in ~10 min (docs/self-hosting.md) $ cd ~/workspace && bdrive init initialized /Users/snow/workspace + server: https://your-hub project: workspace (p-7f3a2c91) + skill: installed for claude, codex + claude hooks registered → /Users/snow/.claude/settings.json daemon: running (pid 55434, scan 3s, remote sync 10s) ``` @@ -67,8 +70,8 @@ $ bdrive login https://your-hub && cd ~/workspace && bdrive init (last-writer-wins), and the losing version is preserved as a `name.bdrive-conflict--