# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is **BearDrive** is the product name; **`bdrive`** is its CLI binary (file conventions: the `.bdrive/` settings directory and `.bdriveignore` at the project root, `~/.bdrive` home, `BDRIVE_HOME`). BearDrive is a Go CLI that mounts any folder as a synced volume: contents sync across devices and teammates through a **`bdrive serve` hub**, with accounts, per-file change history, and offline support. Clients are storage-blind — they sync through the hub over `https://` and never hold storage credentials; the hub owns the object store (S3, GCS, S3-compatible, or a plain directory) and devices converge through append-only journals in it. (Direct client-to-bucket sync without a hub is no longer supported; the object-storage backends exist only as the hub's own storage.) The repo ships one binary: `cmd/bdrive` — the CLI, the sync daemon, and the web server (`bdrive serve`: viewer, uploads, multi-project sync hub). ## Commands ```sh go build ./... # build everything go test ./... # run all tests go test ./internal/syncer -run TestConflict -v # run a single test go vet ./... # vet go build -o bdrive ./cmd/bdrive # build the binary (gitignored at repo root) # web frontend (internal/webapp/frontend — only needed when changing frontend/src): npm run build # rebuild the committed assets in internal/webapp/static (run from frontend/) npm run e2e # Playwright suite against the seeded e2e hub (starts itself on :8993) ./check-dist.sh # verify committed static/ matches frontend/src (run before releases) ``` There is no Makefile or linter config in-repo. CI is three GitHub Actions workflows: `ci.yml` (build/vet/test on Linux and macOS), `bump-cloud.yml` (pins the new OSS commit in the private cloud repo), and `docs.yml` (builds `web/docs` on PRs, deploys it to the `beardrive-docs` Cloudflare Pages project — docs.beardrive.ai — on pushes to main; see `web/docs/README.md`). Releases run `goreleaser release` on a tagged commit (see `.goreleaser.yaml`); the version is injected via `-ldflags "-X main.version=..."` into `cmd/bdrive/main.go`. The frontend's built assets are **committed** (go:embed needs them in the module), so `go build` and `go install` never require Node — but a release tag must not ship a stale `internal/webapp/static`: run `frontend/check-dist.sh` first. When testing the CLI manually, set `BDRIVE_HOME=/some/tmp/dir` to relocate all beardrive state (device identity, mount registry, volume stores) away from the real `~/.bdrive`. ## Architecture Data flows in two hops; the local volume store is the pivot: ``` working folder ←scan/materialize→ volume store (~/.bdrive/volumes/) ←push/pull→ object store (real files) blobs/ + journal/ + state + sync s3:// gs:// file:// ``` Package roles (`internal/`): - **`journal`** — the core data model. Every change is an `Op` (`put`/`delete`) in a per-device append-only JSONL log. `Less` defines the total order `(lamport, time, device, seq)`; `Replay` folds all ops into the volume state, last-writer-wins per path. Everything else is machinery around this. - **`store`** — a volume's local on-disk state: content-addressed blob store (`blobs//`), per-device journal copies, the per-mount materialization cache (`state-.json`, size+mtime fingerprints for cheap change detection), sync state (lamport clock + push cursor), and the exclusive flock that serializes cycles. - **`remote`** — the `Backend` interface (Put/Get/List/Exists) with `file://`, `s3://`, `gs://`, and `https://` implementations (`https://` syncs through a `bdrive serve` server's `/api/store` API — the client device holds no storage credentials). `PutSigner` is the optional presign capability (S3/GCS). Remote layout: `blobs/` + `journal/.jsonl` under the URL prefix. The `https://` wire is gzipped (`compress.go`) — **transport only**: hashes, storage and the journal format are always over the uncompressed bytes, `Compressible` skips already-compressed content, and the presigned direct-to-storage leg stays raw. Pull needs no negotiation (net/http sends `Accept-Encoding: gzip` and inflates transparently, so `do` must never set that header); push compresses only when `sign()` answered `accept_encoding`, which an older hub does not. - **`syncer`** — the heart: `Session.Cycle()` runs one pass: scan → commit local ops → pull peer journals → preserve conflict copies → materialize merged state → push blobs + own journal. Read the package doc comment in `syncer.go` first. `ignore.go` holds the path filter (`.bdriveignore` rules + the `.bdrive` include list), applied symmetrically in scan and materialize; a newly filtered path is dropped from the cache *without* a delete op so opting out locally never deletes remotely. - **`daemon`** — per-mount background loop (detached process, `daemon.pid`/`daemon.log`/`daemon.lock` in the mount's volume dir). Scans every `--scan-interval` (3s), talks to the remote every `--remote-interval` (10s) or immediately after local edits. Re-reads `.bdrive/config.json` each tick; if it vanishes (folder moved/renamed/deleted) the daemon **exits cleanly without propagating deletes** — the next bdrive command at the new location resumes it (self-heal on next touch). **Liveness is the flock on `daemon.lock`, never the pidfile**: the pidfile outlives its process (it lives in `$BDRIVE_HOME`, which survives reboots), so a recycled pid used to read as a live daemon — making `status` lie and `Start` a silent no-op, which broke the one documented recovery. The kernel drops the flock when the holder dies, including at reboot and on a crash, and holding the lock also makes two daemons on one mount impossible (two writers of one journal). **The pid `stop` signals is written inside the lock file and cleared with it** — `daemon.pid` is display-only, since nothing binds its contents to the lock holder and a `kill -9`'d daemon leaves it behind for a recycled pid to inherit. A mid-run change to the folder's `remote` is not followed: the daemon exits cleanly and the next bdrive command in the folder starts one for whatever the config then says. - **`autostart`** — the login registration that undoes a reboot: one unit per machine, user-level, running `bdrive resume` — macOS `~/Library/LaunchAgents/ai.beardrive.daemon.plist` (`RunAtLoad`, deliberately no `KeepAlive` since the job exits); Linux `$XDG_CONFIG_HOME/systemd/user/beardrive.service` (`Type=oneshot`, no `Restart=`) plus the `default.target.wants` symlink that `systemctl --user enable` would create, since systemd ignores a unit nothing wants. Linux also requires systemd to be booted (`/run/systemd/system`, i.e. `sd_booted`) — otherwise `Install` returns `ErrUnsupported` rather than writing a file nothing would ever read (Alpine/runit, WSL1, slim containers), which starts a daemon for every enrolled, unpaused mount — so mounts added later need no re-registration and `bdrive stop` still means stay stopped. `init` installs it (`--no-autostart` skips). Writing the file is the whole job: no `launchctl` shell-out, so a test or a packaging script can't register a real login item as a side effect, and launchd loads it at the next login anyway. Windows uses a per-user `HKCU\...\CurrentVersion\Run` value (`golang.org/x/sys/windows/registry`) — no admin, no COM (a Startup `.lnk` would need it), no `schtasks`, and it shows up in Task Manager's Startup tab where a user can disable it. Its tests exist but have **never been executed** (written and compile-checked from macOS); they run the first time the suite runs on Windows. **`GOOS=windows go build ./...` does not pass yet** and this package is not the reason: `internal/store`'s `Lock` uses `syscall.Flock` and `internal/daemon` uses `syscall.Kill`/`Setsid`, all unix-only — a Windows port means `LockFileEx` and a kill story for a platform with no SIGTERM, which is a separate change against the sync invariants. `autostart_other.go` (`!darwin && !linux && !windows`) covers the BSDs with `ErrUnsupported`; shared bits (`writeIfDifferent`, `selfPath`) live in the tag-free `autostart.go`. - **`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 serve` 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. That proxy gzips what it answers and inflates what it is sent — the inflate sits ABOVE `spool` (the sha, the op count and the billed size are all properties of the plaintext) and is bounded by `maxInflatedPut`, since a compressed body severs the one-wire-byte-one-disk-byte relationship that made `spool` safe unbounded; `RecordUsage` charges uncompressed, `RecordEgress` compressed. 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; the shell also carries `nosniff` + `frame-ancestors 'none'`, and `immutable` is set only after an asset is found (a miss under `assets/` must not pin the shell in a shared cache for a year). 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, with one stated exception: `?by=device` reports agent **device** ids, which History already shows every project member. That exception holds only because ingest validates the id — `POST /api/p//reads` records a read as an agent actor only when the reported `X-Bdrive-Device` is shaped like a device id at all and no OTHER account has been seen syncing under it (`devices.go:ownsDevice` → `DeviceRegistry.MayActAs`); otherwise the report is accepted and counted for nobody. That route never registers a device: **only `/store/*` traffic does**, and it registers per `(account, id)`, so naming someone else's id claims nothing and cannot lock its real owner out. Emails and share tokens never leave the server in any shape. 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`, `scope`, `forget`, `status`, `log`, `share`, `export`, `import`, `url`, `hooks`, `resume`, `autostart`, `read-log`, `serve`, `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` revokes this device's token on the hub (`DELETE /api/auth/token`, authenticated by the token itself) and then clears the saved token+account, keeping the remembered server unless `--forget`; a revocation it could not reach the hub for is reported, never swallowed. 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 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 serve -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. **Any provider must call the binder handed to `AuthProvider.UseDeviceBinder` at every point it mints a CLI token, and refuse the login when it errors**: `store.go:ownJournal` refuses a journal write unless the device id is bound to the caller's account, for every provider, and `DeviceRegistry.Bind` is the only thing that binds. That hook was a field on `BuiltinAuth` wired behind `if a, ok := s.Auth.(*BuiltinAuth); ok`, so a hub running a managed provider bound nothing and refused *every* push from *every* device forever while login, permissions and blob uploads all read healthy — it is on the interface now so a provider that ignores it does not compile. The hub cannot bind on the provider's behalf: binding must be reachable only from a completed authentication (a device token that could reach a bind would let a stolen credential squat a teammate's id), and `Authenticate` reports who a request is, never which credential class it presented. 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). ## Invariants — do not break these - **Each device writes only its own journal.** This is the whole concurrency story: no locking service is needed because no object ever has two writers. Never write to another device's journal file or remote key. - **Blobs are pushed before the journal** (`syncer.push`), so a peer never sees an op whose content is missing. Preserve this ordering. - **Scan happens before pull** in `Cycle`, so local edits are journaled (and content captured) before remote state can overwrite the working folder. - **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`. - **A daemon's liveness is its `daemon.lock` flock, never its pidfile.** Anything asking "is the daemon running" goes through `daemon.Running`; a pid is for display and for signalling only. - **`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. ## Testing conventions The real coverage is the integration tests in `internal/syncer/syncer_test.go`: each test builds multiple simulated devices (`newDevice`) syncing through a shared `file://` remote (`sharedRemote`), then drives explicit `cycle()` calls to test convergence, offline operation, and concurrent-edit conflicts. Extend these when touching sync behavior — a new sync feature without a multi-device test is untested where it matters. `sandbox/` is a disposable Linux container to run a scenario in **when it needs one** — an environment, not a test suite (`./sandbox/run.sh`, which cross-builds the binary and takes `BDRIVE_SRC=` to test a branch without disturbing your tree). It provides a hub, a seeded account, browserless sign-in (`bdrive-signin`), Claude Code, and a `$HOME` that is thrown away, so `bdrive init` writes its device identity and its agent hooks somewhere other than yours. Reach for it only when a Go test cannot do the job: a real `claude` session with the real permission classifier, Linux-only paths like the systemd user unit, or a reboot simulated by killing processes while the filesystem survives. Everything deterministic and machine-local belongs in `internal/webapp/cli_e2e_test.go` (which already isolates `HOME` and drives the real binary) or `internal/syncer` — **if it doesn't need a conversation or an OS, it's a Go test.** The two scripts it ships (`onboarding.sh`, `daemon-linux.sh`) are the scenarios that cannot live anywhere else; don't grow a suite in here. ## Agent integration There is no Claude Code plugin and no bundled skill: the integration is `internal/agenthooks` alone, and `INSTALL_FOR_AGENTS.md` (repo root) is the onboarding runbook every agent follows. `bdrive init` registers the hooks in each platform's USER config (`~/.claude/settings.json` and friends), once per machine: a 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. One run can cover several mounts (`syncTargets`) and the hook's stdout contract is a single JSON object, so the formula carries **every** mount as a `prefix → URL` pair — the prefix being the mount's path as the agent sees it from the session's folder, or an empty prefix with the session's own subpath baked into the URL when the session runs inside the mount; emitting only the first mount hung one project's paths on another project's base URL. The same context also names what teammates changed since the last turn ("re-read before editing"), drained from the **inbound spool** (`internal/store/inbound.go`, a near-copy of the read spool): `materialize` appends every path it writes or removes, and the hook drains it *after* its own cycle — a `Result` field would report nothing, because the daemon has usually materialized the peer's change seconds earlier. Advisory only: nothing blocks a write. Then an async push on PostToolUse Write/Edit, and `bdrive read-log` on Read/Grep/Bash for the read heatmap. The inline hook commands `internal/agenthooks` writes must stay a fast no-op outside BearDrive folders — they run on every turn of every session on the machine, so the guard is pure shell (a couple of `stat`s, at most one `grep` of `mounts.json`) and never spawns the binary outside a mount. ## Docs site (`web/docs`) `web/docs/` is the public documentation at docs.beardrive.ai — Astro 7 + [Starlight](https://starlight.astro.build) (Starlight requires Astro ^7; the cloud landing is still on Astro 5 — separate projects, so they upgrade independently), static output, Pagefind search, `llms.txt` via `starlight-llms-txt`. Unlike the hub frontend (`internal/webapp/static`) and the cloud landing page (`cloud/internal/landing/dist`) it is **not** `go:embed`ed: docs change far more often than the binary, and a search index has no business shipping in every self-hoster's install. It deploys on its own from `dist/`, so `go build` never touches it. Sidebar order is explicit in `astro.config.mjs` — a new page under `src/content/docs/` is invisible until listed there, and every page needs a `description` (meta description, search snippet, and `llms.txt` line). **The sidebar order is the recommended path, and that path is agent-first**: `Start here` (what it is → set up with your agent → your first hour) never mentions installing a binary; the CLI route lives in `Manual setup (optional)` (install the CLI, set up by hand, hooks in detail) — same destination, one click away, never on the critical path. New onboarding content belongs in `Start here` and should say what to ask an agent, not what to type. Job-shaped "use case" pages — the ones aimed at someone deciding rather than someone building — belong on the marketing site (beardrive.ai/use-cases), not here; the docs sidebar ends with a `More` group of off-site links (use cases, blog, GitHub) and the header logo points at beardrive.ai via the `SiteTitle` override in `src/components/`, since Starlight has no config for that link. Moved URLs keep `redirects` entries in `astro.config.mjs` (static builds emit meta-refresh; real 301s live in the host config, see `web/docs/README.md`). **Guides are agent-workflow docs, not CLI tutorials** (`Working with agents`: shared agent memory, artifacts/links, read heat, scoping); command-by-command detail belongs in `reference/cli.md`. Design tokens are **generated, never copied**: `scripts/tokens.mjs` reads the `@theme` block in `internal/webapp/frontend/src/tw.css` and emits the gitignored `src/styles/tokens.gen.css`, which `src/styles/custom.css` maps onto Starlight's `--sl-color-*` — so the palette cannot drift and there is no checker to maintain (contrast the cloud landing, which keeps a copy policed by its own `check-tokens.mjs`). Because the build reads that file *outside* `web/docs`, a deploy host must check out the whole repo, not the subdirectory. Note `llms.txt` convention wants the root domain, so `beardrive.ai/llms.txt` should point at the docs subdomain — that redirect lives in the cloud landing and is the one cross-repo coordination point. ## PR descriptions Every PR body starts with a `## TL;DR` section: at most 5 bullet points, one short informal sentence each — fewer is better. Lead with the user-visible symptom/outcome, not the mechanism; include a known-gap bullet if the PR knowingly leaves one. Detail sections follow after. ## Architecture diagrams in PRs `architecture/` holds mermaid diagrams covering every application package in the repo: `overview.md` (system diagram — the package map and how the pieces connect), `cli-sync.md` (the CLI and sync engine — `cmd/bdrive` + `internal/{syncer,store,journal,config,daemon,agenthooks,autostart}`), `webapp-server.md` (the `bdrive serve` server — `internal/webapp` + `internal/remote`), and `webapp-frontend.md` (the hub's React SPA — `internal/webapp/frontend/src`). Every code change lands in exactly one detail diagram's scope; `overview.md` changes only when packages appear/disappear or the cross-piece wiring changes (`web/docs` and the private `cloud/` repo are deliberately out of scope). Before `gh pr create`: if the branch changes types or relationships drawn in any of them (new/removed types, new seams, changed fields/implements/ownership in those packages), update the affected diagram and commit it on the branch, then add an "Architecture changes" section to the PR description with, per changed diagram: (1) a sentence naming exactly which types/relationships changed and how, (2) **one** consolidated diff diagram per changed file — invoke the `mermaid-diff-diagram` skill, which marks additions (✅) and removals (❌, struck through) in place on a single flowchart, instead of pasting a Before block and an After block. Feed it the merge-base diagram as *before* and the branch's as *after*, and keep it an *excerpt* containing only the affected classes and their immediate relationships — never the full diagram. The committed diagram file stays the full current state and stays `classDiagram`; the diff excerpt lives only in the PR description. No structural change → no section, and append `# skip-diagram-check` to the `gh pr create` command to satisfy the pre-PR hook. ## Docs to keep in sync - `README.md` documents CLI behavior, flags, output formats, and the on-disk layout. When changing CLI commands, flags, output, or layout, update it. - `INSTALL_FOR_AGENTS.md` (repo root) is the URL-addressable agent onboarding runbook and the source of truth for that flow — the canonical two-line paste prompt (README, docs, and the hub's `ConnectGuide.tsx` all point at its raw URL) tells any agent to fetch and follow it. When the CLI's init/login/hooks steps change, update it too. - `web/docs/src/content/docs/` is the third surface: it restates the same CLI reference, hub config, and self-hosting knobs for end users. A CLI or config change lands there too — `reference/cli.md`, `reference/hub-config.md`, `reference/project-files.md`, and the `self-hosting/` pages are the ones that go stale.