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--