Merge pull request #36 from runbear-io/web/column-md-width

One md-width column for app views; read only for rendered files
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-19 16:38:02 -07:00
committed by GitHub
24 changed files with 1192 additions and 269 deletions
+2
View File
@@ -5,3 +5,5 @@
/example/
/cloud/
.claude/worktrees/
/docs/
/.review-shots/
+13
View File
@@ -4,6 +4,19 @@ Notable changes per release. Format loosely follows
[Keep a Changelog](https://keepachangelog.com/); BearDrive is pre-1.0, so
minor versions may ship breaking changes (see [SemVer §4](https://semver.org/#spec-item-4)).
## Unreleased
- **`bdrive skill install`** — the binary now carries the `beardrive`
skill and installs it into any agent that reads `SKILL.md`
(`~/.claude|.codex|.gemini|.hermes/skills/beardrive/`), idempotently;
bare `bdrive skill` prints the detection table.
- **Hub install guide, Codex and Hermes tabs: one paste, no terminal** —
the same shape as the Claude tab. The pasted prompt has the agent install
the CLI, keep the skill, sign in (`login --device`, so it can relay the
code instead of hoping a browser opened), `bdrive init`, and
`bdrive hooks install` — the step hand-copied setups routinely skipped.
The plain commands moved into an "or run it yourself" fallback.
## v0.8.0 — 2026-07-16
- **Gated links on every mentioned file path**: Claude Code's turn-start
+33
View File
@@ -143,6 +143,7 @@ hub's own storage, never something a syncing client points at directly:
| `bdrive share <file>` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) |
| `bdrive sync [folder]` | Run one sync cycle now. `--note <text>` stamps session context (e.g. an agent session id) onto changes — shown in `bdrive log` and hub history; keeps applying to daemon-committed changes until `--note-ttl` (default 30m) expires. `--hook <label>` is agent-hook plumbing: event JSON on stdin, sync + note, gated-link formula (Claude Code hook JSON) on stdout |
| `bdrive hooks [install]` | Register turn-boundary sync hooks with detected agent platforms (Claude Code, Codex, Gemini CLI, Hermes) — pull each turn, push after edits, session-note stamping, agent-read tracking; idempotent (`--agent` overrides detection) |
| `bdrive skill [install]` | Install the `beardrive` skill into detected agent platforms (`~/.codex/skills/beardrive/SKILL.md` and friends) so the agent can do the setup itself — sign in, `bdrive init`, and register the sync hooks; idempotent (`--agent` overrides detection) |
| `bdrive read-log [folder]` | Hook plumbing: queue agent file reads from a hook event (JSON on stdin) for the hub's read heatmap — native reads, grep matches, and files named in shell commands; drained on the next sync. Registered by `bdrive hooks install` |
| `bdrive status [folder]` | Projects, daemon state, pending changes |
| `bdrive log [folder] [-p path] [-n N]` | Change history: account, device, time, file |
@@ -431,6 +432,38 @@ The plugin sets up everything at once:
selective sync, and troubleshooting. Working in a clone of this repo
picks the same skill up automatically via `.claude/skills/`.
## Other agents: Codex, Gemini CLI, Hermes
No terminal needed here either — the setup is one paste. Start the agent in
the folder you want the files and give it:
```
Set up BearDrive in this folder.
1. If `bdrive` is missing, install it: brew install runbear-io/tap/beardrive
(no Homebrew? grab the release binary for this OS/arch from
https://github.com/runbear-io/beardrive/releases)
2. bdrive skill install # so you know the CLI next time
3. bdrive login --device https://your-hub # show me the code and the URL
4. bdrive init --project <project-id>
5. bdrive hooks install # don't skip this - it's what syncs every turn
Then tell me what got set up.
```
The commands ride inside the prompt because these agents ship no BearDrive
knowledge — but the user copies one thing, and the agent handles every
deviation (already installed, no Homebrew, sign-in, wrong folder). Step 2 is
the durable part: `SKILL.md` is a cross-agent format, and `bdrive skill
install` writes the very skill the Claude plugin ships to each detected
platform's user-level skills directory (`~/.codex/skills/beardrive/SKILL.md`,
`~/.gemini/…`, `~/.hermes/…`, `~/.claude/…`), so from then on "share this
file" or "what changed?" just works. Step 5 is the one people skip when they
copy commands by hand, which is exactly why the agent runs it.
A project's home page in the web UI shows this with the hub URL and project
id already filled in (plus the plain-terminal version). `bdrive skill` and
`bdrive hooks` print what's set up on this machine; re-run either after a CLI
upgrade to refresh.
## How it works
```
+1
View File
@@ -39,6 +39,7 @@ everything keeps working offline; changes sync when the remote is reachable.`,
syncCmd(),
readLogCmd(),
hooksCmd(),
skillCmd(),
statusCmd(),
logCmd(),
webCmd(),
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/runbear-io/beardrive/internal/agentskills"
)
// bdrive skill — install the `beardrive` skill into whatever agent platforms
// the user works with (Claude Code, Codex, Gemini CLI, Hermes), so the agent
// can do the setup itself: sign in, `bdrive init`, and — the part people miss
// when they copy commands by hand — `bdrive hooks install`.
func skillCmd() *cobra.Command {
c := &cobra.Command{
Use: "skill",
Short: "Show which AI agent platforms have the beardrive skill installed",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
folder, err := absFolder(nil)
if err != nil {
return err
}
detected := map[string]bool{}
for _, a := range agentskills.Detect(folder) {
detected[a] = true
}
for _, a := range agentskills.Agents {
state := "not detected"
if detected[a] {
state = "detected, skill not installed"
if agentskills.Installed(a) {
state = "skill installed"
}
}
fmt.Printf(" %-8s %-32s %s\n", a, state, agentskills.Path(a))
}
fmt.Println("\ninstall with: bdrive skill install [--agent claude,codex,gemini,hermes]")
return nil
},
}
var agentsFlag string
install := &cobra.Command{
Use: "install [folder]",
Short: "Install the beardrive skill for detected agent platforms (or --agent list)",
Long: "Writes the beardrive skill to each agent's user-level skills directory\n" +
"(~/.codex/skills/beardrive/SKILL.md and friends), so the agent knows the\n" +
"CLI everywhere — ask it to set up a folder and it runs login, init, and\n" +
"`bdrive hooks install` for you. Re-running refreshes the copy shipped\n" +
"with this binary.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
folder, err := absFolder(args)
if err != nil {
return err
}
var agents []string
if agentsFlag != "" && agentsFlag != "auto" {
agents = strings.Split(agentsFlag, ",")
}
results, err := agentskills.Install(folder, agents)
if err != nil {
return err
}
if len(results) == 0 {
fmt.Println("no agent platforms detected (looked for .claude/, .codex/, .gemini/, .hermes/ here or in ~)")
fmt.Println("pick explicitly: bdrive skill install --agent claude,codex,gemini,hermes")
return nil
}
for _, r := range results {
state := "already current"
if r.Changed {
state = "installed"
}
fmt.Printf(" %-8s %s → %s\n", r.Agent, state, r.Path)
}
fmt.Println("\nnow ask your agent to set up the folder — it will run init and register sync hooks")
return nil
},
}
install.Flags().StringVar(&agentsFlag, "agent", "auto", "comma-separated platforms (claude,codex,gemini,hermes) or auto")
c.AddCommand(install)
return c
}
+7 -4
View File
@@ -66,9 +66,11 @@ as bearer tokens. For containers, the repo ships a `Dockerfile`
sync only that subfolder.
3. Invite a teammate: sidebar footer → **Manage****New invite**
the join link both creates their account and adds them to your org.
4. Connect agents: the project's home page in the web UI shows
copy-paste setup for Claude Code/Cowork, Hermes, and Codex; or run
`bdrive hooks install` in the folder.
4. Connect agents: the project's home page in the web UI shows one-paste
setup for Claude Code/Cowork, Hermes, and Codex — hub URL and project
id already filled in. Teammates paste it into their own agent, which
installs the CLI, keeps the beardrive skill (`bdrive skill install`),
signs in, mounts the project, and registers the sync hooks.
## Authentication reference
@@ -147,7 +149,8 @@ drivers are pure Go, so the binary stays a CGO-free static build.
`brew upgrade beardrive` (clients and hub are the same binary — keep
them roughly in step; the sync protocol is append-only journals + blobs,
which old clients read forward). After upgrading a client, re-run
`bdrive hooks install` once per project to pick up any hook improvements.
`bdrive hooks install` once per project to pick up any hook improvements,
and `bdrive skill install` once per machine to refresh the agent skill.
## Backup
+123
View File
@@ -0,0 +1,123 @@
// Package agentskills installs the `beardrive` skill into the agent
// platforms a user works with, so their agent knows how to drive the CLI —
// including running `bdrive init` and `bdrive hooks install` itself, which is
// how hooks end up registered correctly without the user hand-copying
// commands.
//
// SKILL.md is a cross-agent standard: every supported platform discovers
// skills the same way — a directory per skill under the platform's config
// dir, holding a SKILL.md with `name` + `description` frontmatter:
//
// claude ~/.claude/skills/beardrive/SKILL.md
// codex ~/.codex/skills/beardrive/SKILL.md
// gemini ~/.gemini/skills/beardrive/SKILL.md
// hermes ~/.hermes/skills/beardrive/SKILL.md
//
// Installs are user-level on purpose: the skill is about the CLI, not about
// one folder, and a synced project folder should never carry it. The skill
// is the binary's own copy (embedded at build time), so upgrading bdrive and
// re-running install refreshes it — writes are idempotent and report whether
// anything changed.
package agentskills
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/runbear-io/beardrive/internal/store"
"github.com/runbear-io/beardrive/plugin"
)
// Agents is the supported platforms, in the order they are reported.
var Agents = []string{"claude", "codex", "gemini", "hermes"}
// Result reports what Install did for one agent platform.
type Result struct {
Agent string
Path string // SKILL.md written (or already current)
Changed bool // false = the installed copy already matched
}
// Path returns where an agent reads (or would read) the beardrive skill.
// Empty if the agent is unknown or the home directory is undiscoverable.
func Path(agent string) string {
if !supported(agent) {
return ""
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return ""
}
return filepath.Join(home, "."+agent, "skills", "beardrive", "SKILL.md")
}
// Detect reports which agent platforms are in use, judged — like
// agenthooks.Detect — by their config dirs existing in the project or the
// home directory.
func Detect(folder string) []string {
home, _ := os.UserHomeDir()
var found []string
for _, a := range Agents {
dir := "." + a
if dirExists(filepath.Join(folder, dir)) || (home != "" && dirExists(filepath.Join(home, dir))) {
found = append(found, a)
}
}
return found
}
// Installed reports whether an agent already has the current skill.
func Installed(agent string) bool {
path := Path(agent)
if path == "" {
return false
}
data, err := os.ReadFile(path)
return err == nil && string(data) == plugin.SkillMD
}
// Install writes the skill for the given agents ("auto"/empty = every
// detected platform). An outdated copy is overwritten — the file is ours.
func Install(folder string, agents []string) ([]Result, error) {
if len(agents) == 0 || (len(agents) == 1 && agents[0] == "auto") {
agents = Detect(folder)
}
var out []Result
for _, a := range agents {
if !supported(a) {
return out, fmt.Errorf("unknown agent %q (supported: %s)", a, strings.Join(Agents, ", "))
}
path := Path(a)
if path == "" {
return out, fmt.Errorf("%s: cannot locate home directory", a)
}
if Installed(a) {
out = append(out, Result{Agent: a, Path: path})
continue
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return out, fmt.Errorf("%s: %w", a, err)
}
if err := store.WriteFileAtomic(path, []byte(plugin.SkillMD), 0o644); err != nil {
return out, fmt.Errorf("%s: %w", a, err)
}
out = append(out, Result{Agent: a, Path: path, Changed: true})
}
return out, nil
}
func supported(agent string) bool {
for _, a := range Agents {
if a == agent {
return true
}
}
return false
}
func dirExists(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}
+100
View File
@@ -0,0 +1,100 @@
package agentskills
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/runbear-io/beardrive/plugin"
)
func TestDetect(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
folder := t.TempDir()
if got := Detect(folder); len(got) != 0 {
t.Fatalf("nothing configured, detected %v", got)
}
os.MkdirAll(filepath.Join(folder, ".codex"), 0o755) // project-level
os.MkdirAll(filepath.Join(home, ".hermes"), 0o755) // user-level
if got := strings.Join(Detect(folder), ","); got != "codex,hermes" {
t.Fatalf("detected %q, want codex,hermes", got)
}
}
func TestInstallWritesSkillPerAgent(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
results, err := Install(t.TempDir(), []string{"codex", "hermes"})
if err != nil {
t.Fatal(err)
}
if len(results) != 2 {
t.Fatalf("got %d results, want 2", len(results))
}
for _, r := range results {
if !r.Changed {
t.Fatalf("%s: fresh install reported unchanged", r.Agent)
}
data, err := os.ReadFile(r.Path)
if err != nil {
t.Fatalf("%s: %v", r.Agent, err)
}
if string(data) != plugin.SkillMD {
t.Fatalf("%s: written skill differs from the embedded one", r.Agent)
}
// Frontmatter is what makes a SKILL.md discoverable on every platform.
if !strings.HasPrefix(string(data), "---\nname: beardrive\n") {
t.Fatalf("%s: skill lacks name frontmatter", r.Agent)
}
}
if got := results[0].Path; got != filepath.Join(home, ".codex", "skills", "beardrive", "SKILL.md") {
t.Fatalf("codex path = %s", got)
}
// Re-running is idempotent...
results, err = Install(t.TempDir(), []string{"codex"})
if err != nil {
t.Fatal(err)
}
if results[0].Changed {
t.Fatal("second install reported a change")
}
if !Installed("codex") {
t.Fatal("Installed() false right after installing")
}
// ...but a stale copy is refreshed to the binary's own.
os.WriteFile(results[0].Path, []byte("--- old skill ---\n"), 0o644)
if Installed("codex") {
t.Fatal("Installed() true for a stale copy")
}
results, _ = Install(t.TempDir(), []string{"codex"})
if !results[0].Changed {
t.Fatal("stale copy was not refreshed")
}
}
func TestInstallAutoDetects(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
os.MkdirAll(filepath.Join(home, ".claude"), 0o755)
results, err := Install(t.TempDir(), nil)
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].Agent != "claude" {
t.Fatalf("auto-detect installed %v", results)
}
}
func TestInstallUnknownAgent(t *testing.T) {
t.Setenv("HOME", t.TempDir())
if _, err := Install(t.TempDir(), []string{"cursor"}); err == nil {
t.Fatal("unknown agent accepted")
}
}
+16 -6
View File
@@ -36,15 +36,25 @@ test("claude tab: plugin flow with real hub origin and project id, no raw CLI",
await expect(page.locator(".gd-body")).toContainText("Cowork");
});
test("codex tab keeps the full CLI flow", async ({ page }) => {
test("codex tab is one paste that hands the whole setup to the agent", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.click('.gd-tab[data-key="codex"]');
const codes = await page.$$eval(".gd-code code", (els) => els.map((e) => e.textContent).join("\n"));
expect(codes).toContain("brew install runbear-io/tap/beardrive");
expect(codes).toContain("bdrive login http://localhost:8993");
expect(codes).toContain(`bdrive init --project ${pid}`);
expect(codes).toContain("bdrive hooks install --agent codex");
await expect(page.locator(".gd-step")).toHaveCount(1);
const prompt = await page.$$eval(".gd-step .gd-code code", (els) =>
els.map((e) => e.textContent).join("\n"),
);
// Everything the agent needs rides inside the pasted prompt.
expect(prompt).toContain("brew install runbear-io/tap/beardrive");
expect(prompt).toContain("bdrive skill install --agent codex");
expect(prompt).toContain("bdrive login --device http://localhost:8993");
expect(prompt).toContain(`bdrive init --project ${pid}`);
expect(prompt).toContain("bdrive hooks install");
const manual = await page.$$eval(".gd-manual .gd-code code", (els) =>
els.map((e) => e.textContent).join("\n"),
);
expect(manual).toContain("bdrive login http://localhost:8993");
expect(manual).toContain("bdrive hooks install --agent codex");
await page.click('.gd-tab[data-key="claude"]');
});
+112
View File
@@ -0,0 +1,112 @@
import { test, expect, type Page } from "@playwright/test";
import { login, wikiId } from "./helpers";
/* The column system (shell.tsx <Page>, style.css .page): every route renders
exactly one .page, and pages of the same width share the same column edges.
These used to range from 560px to unbounded half of them uncentered so
no two routes lined up. Widths live in CSS tokens; this asserts the routes
actually resolve to them. */
const WIDTHS = { read: 768, app: 768 }; // both = Tailwind md; wide is viewport-capped at 1280
async function column(page: Page) {
return page.evaluate(() => {
const pages = document.querySelectorAll("#content > .page");
if (pages.length !== 1) throw new Error(`expected 1 .page, got ${pages.length}`);
const el = pages[0] as HTMLElement;
const r = el.getBoundingClientRect();
const kind = el.classList.contains("read") ? "read" : el.classList.contains("wide") ? "wide" : "app";
return { kind, left: Math.round(r.left), width: Math.round(r.width) };
});
}
test("every view shares one column system", async ({ page }) => {
await login(page);
const pid = await wikiId(page); // the seeded project — other specs create their own
const seen: Record<string, { left: number; width: number }> = {};
const visit = async (path: string, want: "read" | "app" | "wide") => {
await page.goto(`http://localhost:8993/${pid}${path}`);
await page.waitForSelector("#content > .page");
const col = await column(page);
expect(col.kind, `${path || "/"} column kind`).toBe(want);
if (want !== "wide") {
expect(col.width, `${path || "/"} column width`).toBe(WIDTHS[want]);
}
// Same width class ⇒ identical edges, on every route.
if (seen[want]) {
expect(col.left, `${path || "/"} left edge matches other ${want} pages`).toBe(seen[want].left);
expect(col.width, `${path || "/"} width matches other ${want} pages`).toBe(seen[want].width);
} else {
seen[want] = col;
}
};
await visit("", "app"); // project home
await visit("/install", "app"); // the same guide, so the same column
await visit("/settings", "app");
await visit("/insights", "app"); // charts cap their own measure; the column is normal
await visit("/history", "app"); // structured view, not a file render
await visit("/index.md", "read"); // rendered markdown — the only read surface
await visit("/notes", "app"); // folder listing is a structured view too
});
test("the install route and the project home render the guide identically", async ({ page }) => {
// They are two sidebar items apart and show the same component; /install
// used to wrap it in the .onboard card — 320px narrower, 90px lower.
await login(page);
const pid = await wikiId(page);
const box = async (path: string) => {
await page.goto(`http://localhost:8993/${pid}${path}`);
await page.waitForSelector(".guide");
return page.evaluate(() => {
const r = (document.querySelector(".guide") as HTMLElement).getBoundingClientRect();
return { left: Math.round(r.left), width: Math.round(r.width), top: Math.round(r.top) };
});
};
expect(await box("/install")).toEqual(await box(""));
});
test("charts never scale past the size they were drawn at", async ({ page }) => {
// .in-chart SVGs are viewBox="0 0 720 …" at width:100%, so an unbounded
// column magnifies them — labels ended up larger than the page title.
await page.setViewportSize({ width: 1600, height: 900 });
await login(page);
const pid = await wikiId(page);
await page.goto(`http://localhost:8993/${pid}/insights`);
await page.waitForSelector(".in-chart");
const worst = await page.evaluate(() => {
let max = 0;
for (const el of document.querySelectorAll(".in-chart")) {
const vb = (el.getAttribute("viewBox") || "0 0 720 0").split(/\s+/);
max = Math.max(max, el.getBoundingClientRect().width / Number(vb[2]));
}
return max;
});
expect(worst, "chart scale factor").toBeLessThanOrEqual(1.06);
});
test("the gutter belongs to the scroll container, not the column", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
// A markdown page used to carry the max-width on #content itself, so its
// gutter came out of the reading measure and .md ran ~80px narrower than
// every other page. The gutter must live on #content alone.
for (const path of ["/index.md", "/notes", "/history"]) {
await page.goto(`http://localhost:8993/${pid}${path}`);
await page.waitForSelector("#content > .page");
const r = await page.evaluate(() => {
const c = document.querySelector("#content") as HTMLElement;
const p = document.querySelector("#content > .page") as HTMLElement;
return {
contentMax: getComputedStyle(c).maxWidth,
pad: getComputedStyle(c).paddingLeft,
childMax: getComputedStyle(p.firstElementChild as HTMLElement).maxWidth,
};
});
expect(r.contentMax, `${path}: #content must not constrain width`).toBe("none");
expect(r.pad, `${path}: gutter`).toBe("40px");
// Views may not re-declare a column of their own inside .page.
expect(r.childMax, `${path}: view sets its own max-width`).toBe("none");
}
});
+15 -12
View File
@@ -11,10 +11,10 @@ import type { Project, ServerConfig } from "../api/types";
import { useHeat, useTree } from "../hooks/useBrowse";
import { urlForPath, urlForView, type Route } from "../router";
import { currentNavType, navigate, useLocationPath } from "../nav";
import { copyText } from "../util";
import { HTML_EXT, copyText } from "../util";
import { toast } from "../toast";
import { onSearchRequest } from "../search";
import { AppShell, Icon, Topbar, closeSidebarOnMobile } from "../components/shell";
import { AppShell, Icon, Page, Topbar, closeSidebarOnMobile, type PageWidth } from "../components/shell";
import { FileTree, ancestorsOf } from "../components/FileTree";
import { Breadcrumbs } from "../components/Breadcrumbs";
import { FolderListing } from "../components/FolderListing";
@@ -226,13 +226,16 @@ export default function Browser(props: {
/* ---- content view ---- */
const isFolderFn = useCallback((p: string) => dirIndex.has(p), [dirIndex]);
let contentClass = "markdown";
// One column decision per route (see <Page> in shell.tsx). File views also
// carry the markdown typography class, which used to sit on #content itself
// — putting the width there made the gutter eat into the reading column, so
// .md pages ran 80px narrower than every other page.
let pageWidth: PageWidth = "app";
let pageClass: string | undefined;
let view: ReactNode;
if (panel) {
contentClass = "view";
view = panel.body;
} else if (route.view === "insights") {
contentClass = "view";
view = props.canInsights ? (
<Insights
flatFiles={flatFiles}
@@ -247,7 +250,7 @@ export default function Browser(props: {
<div className="empty">Insights is for hub admins and org owners.</div>
);
} else if (route.view === "history") {
contentClass = "view";
// structured view — default app column, like the folder listing it shares rows with
view = (
<HistoryView
apiBase={apiBase}
@@ -264,7 +267,6 @@ export default function Browser(props: {
} else if (isMissing) {
// The tree polls every few seconds, so a file that's mid-upload (or
// mid-sync from a teammate's device) appears here on its own.
contentClass = "view";
view = (
<div className="notfound">
<h1>Couldn't find that</h1>
@@ -285,8 +287,7 @@ export default function Browser(props: {
</div>
);
} else if (isDir) {
contentClass = "view";
view = (
view = ( // structured view — default app column; read is for rendered files only
<FolderListing
node={dirIndex.get(path)!}
heatMap={heatMap}
@@ -298,6 +299,8 @@ export default function Browser(props: {
/>
);
} else {
pageWidth = HTML_EXT.test(path) ? "wide" : "read";
pageClass = "markdown";
view = (
<FileView
apiBase={apiBase}
@@ -313,7 +316,6 @@ export default function Browser(props: {
} else if (isHome) {
// The project's index page: the connect-an-agent guide, with Insights
// below for admins/owners.
contentClass = "view";
view = (
<>
<ConnectGuide project={project!} />
@@ -430,11 +432,12 @@ export default function Browser(props: {
/>
}
topbar={topbar}
contentClass={contentClass}
contentRef={contentRef}
onContentScroll={onScroll}
>
{view}
<Page width={pageWidth} className={pageClass}>
{view}
</Page>
</AppShell>
{share && <ShareDialog url={share.url} copied={share.copied} onClose={() => setShare(null)} />}
<Palette open={paletteOpen} onClose={() => setPaletteOpen(false)} candidates={paletteCandidates} />
+16 -12
View File
@@ -4,7 +4,7 @@ import type { InviteAccepted, Project, ProjectCreated, ServerConfig } from "../a
import { useOrgs, usePending, useProjects, useHubRefresh } from "../hooks/useHub";
import { parseRoute, urlForView } from "../router";
import { navigate, Redirect, useLocationPath } from "../nav";
import { AppShell, Topbar, VaultHeader, closeSidebarOnMobile } from "../components/shell";
import { AppShell, Page, Topbar, VaultHeader, closeSidebarOnMobile } from "../components/shell";
import { OrgAdmin } from "../components/OrgAdmin";
import { HubSettings } from "../components/HubSettings";
import { ProjectNav } from "../components/ProjectNav";
@@ -102,7 +102,9 @@ export default function HubApp({ config }: { config: ServerConfig }) {
if (!projects || !orgs) {
return (
<AppShell vault={vault} topbar={<Topbar />}>
<div className="empty">Loading</div>
<Page>
<div className="empty">Loading</div>
</Page>
</AppShell>
);
}
@@ -114,9 +116,9 @@ export default function HubApp({ config }: { config: ServerConfig }) {
projectsNav={<ProjectNav projects={projects} />}
orgBar={accountBar}
topbar={<Topbar />}
contentClass="view"
>
<EmptyState
<Page>
<EmptyState
authEnabled={config.auth.enabled}
onCreate={async (name) => {
if (!name) {
@@ -131,8 +133,9 @@ export default function HubApp({ config }: { config: ServerConfig }) {
} catch (e) {
toast("Could not create the project: " + (e as Error).message, true);
}
}}
/>
}}
/>
</Page>
</AppShell>
);
}
@@ -160,12 +163,11 @@ export default function HubApp({ config }: { config: ServerConfig }) {
? { crumb: "Project settings", body: <ProjectSettings project={current} org={org} /> }
: route.view === "install"
? {
// The same guide the project home shows, in the same column —
// it used to sit in the .onboard card, 320px narrower and 90px
// lower than home, two sidebar items apart.
crumb: "Installation",
body: (
<div className="onboard">
<ConnectGuide project={current} />
</div>
),
body: <ConnectGuide project={current} />,
}
: null;
@@ -264,7 +266,9 @@ function JoinInvite({ token, onDone }: { token: string; onDone: (orgId: string |
}, [token]);
return (
<AppShell vault={<VaultHeader name="BearDrive" />} topbar={<Topbar />}>
<div className="empty">Joining</div>
<Page>
<div className="empty">Joining</div>
</Page>
</AppShell>
);
}
@@ -10,9 +10,9 @@ import { copyText } from "../util";
interface GuideAgent {
key: string;
label: string;
hook?: string;
note?: string;
extra?: string;
agent?: string; // --agent value for `bdrive skill install`
skillDir?: string; // where that agent reads the skill from
extra?: string; // platform-specific caveat after the last step
}
const GUIDE_AGENTS: GuideAgent[] = [
@@ -20,19 +20,17 @@ const GUIDE_AGENTS: GuideAgent[] = [
{
key: "hermes",
label: "Hermes",
hook: "hermes",
note:
"Registers BearDrive's hooks in Hermes's config: pull before every turn, push after edits " +
"with a session note, and report file reads to Insights.",
agent: "hermes",
skillDir: "~/.hermes/skills/beardrive/",
},
{
key: "codex",
label: "Codex",
hook: "codex",
note: "Registers hooks in .codex/hooks.json.",
agent: "codex",
skillDir: "~/.codex/skills/beardrive/",
extra:
"Run /hooks inside Codex once to trust the project's .codex layer — after that every turn " +
"pulls, edits push automatically, and reads are reported to Insights.",
"Codex asks once to trust the project's .codex hooks layer — answer yes (or run /hooks) and " +
"from then on every turn pulls, edits push automatically, and reads are reported to Insights.",
},
];
@@ -71,35 +69,69 @@ function guideSteps(agent: GuideAgent, project: Project): Step[] {
];
}
const slug =
(project.name || "project").toLowerCase().replace(/[^a-z0-9._-]+/g, "-") || "project";
// Every other agent: one paste, no terminal — the same shape as the Claude
// tab. The commands ride INSIDE the prompt because these agents ship no
// BearDrive knowledge (Claude's tab can be terse only because the plugin
// carries it); the user still copies one thing, and the agent handles every
// deviation — already installed, no Homebrew, sign-in, wrong folder. Step 2
// of the prompt installs the skill, so every later session is conversational.
return [
{
title: "Install the BearDrive CLI",
desc: "One static binary. Homebrew on macOS and Linux; releases and `go install` also work.",
code: "brew install runbear-io/tap/beardrive",
},
{
title: "Sign in to this hub",
title: "Paste this into " + agent.label,
desc:
"Opens the browser once and stores a device token on this machine — the synced folder itself never holds credentials.",
code: "bdrive login " + origin,
},
{
title: "Mount the project into a local folder",
desc:
"Run it where you want the files. An existing folder works too — contents merge, and re-running init later (or after moving the folder) just resumes.",
code: "mkdir -p ~/" + slug + " && cd ~/" + slug + "\nbdrive init --project " + pid,
},
{
title: "Connect " + agent.label,
desc: agent.note,
code: "bdrive hooks install --agent " + agent.hook,
extra: agent.extra,
"Start " +
agent.label +
" in the folder where you want the files (an existing folder works too — contents merge), " +
"then paste:",
code: setupPrompt(agent, project),
extra:
"Approve the shell commands when it asks. It installs the CLI, signs this machine in (it " +
"hands you a code and a URL — the folder itself never holds credentials), mounts the " +
"project, and registers the sync hooks: pull before every turn, push after edits stamped " +
"with the session that made them, file reads into Insights. It also keeps the beardrive " +
"skill in " +
agent.skillDir +
", so from here on you can just ask." + (agent.extra ? " " + agent.extra : ""),
},
];
}
// The prompt the user pastes. Numbered, exact commands: an agent with no
// BearDrive knowledge follows this reliably, and knows what to do when a step
// is already done. `login --device` on purpose — a browser-callback sign-in is
// invisible to an agent mid-turn, while the device flow gives it a code and a
// URL it can hand back in chat.
function setupPrompt(agent: GuideAgent, project: Project): string {
return [
"Set up BearDrive in this folder.",
"1. If `bdrive` is missing, install it: brew install runbear-io/tap/beardrive",
" (no Homebrew? grab the release binary for this OS/arch from",
" https://github.com/runbear-io/beardrive/releases)",
"2. bdrive skill install --agent " + agent.agent + " # so you know the CLI next time",
"3. bdrive login --device " + window.location.origin + " # show me the code and the URL",
"4. bdrive init --project " + project.id,
"5. bdrive hooks install # don't skip this - it's what syncs every turn",
"Then tell me what got set up.",
].join("\n");
}
// For anyone who would rather not hand the setup to an agent. Skipping
// `hooks install` is the one thing that silently costs you turn-boundary
// syncing, so it is spelled out here.
function manualCommands(agent: GuideAgent, project: Project): string {
return (
"brew install runbear-io/tap/beardrive" +
"\nbdrive skill install --agent " +
agent.agent +
"\nbdrive login " +
window.location.origin +
"\nbdrive init --project " +
project.id +
"\nbdrive hooks install --agent " +
agent.agent
);
}
function savedAgent(): string {
try {
return localStorage.getItem("bdrive-guide-agent") || "claude";
@@ -113,6 +145,7 @@ export function ConnectGuide({ project }: { project: Project }) {
// A stale saved key (e.g. a tab that no longer exists) falls back to the
// first tab rather than rendering nothing.
const agent = GUIDE_AGENTS.find((a) => a.key === agentKey) || GUIDE_AGENTS[0];
const steps = guideSteps(agent, project);
return (
<div className="guide">
@@ -142,10 +175,11 @@ export function ConnectGuide({ project }: { project: Project }) {
))}
</div>
<div className="gd-body">
{guideSteps(agent, project).map((s, i) => (
<div className="gd-step" key={i}>
{steps.map((s, i) => (
<div className={"gd-step" + (steps.length > 1 ? "" : " gd-solo")} key={i}>
<div className="gd-step-head">
<span className="gd-num">{i + 1}</span>
{/* A lone step is not a sequence — no "1." badge for it. */}
{steps.length > 1 && <span className="gd-num">{i + 1}</span>}
<span className="gd-step-title">{s.title}</span>
</div>
{s.desc && <p className="gd-desc">{s.desc}</p>}
@@ -153,6 +187,16 @@ export function ConnectGuide({ project }: { project: Project }) {
{s.extra && <p className="gd-desc gd-extra">{s.extra}</p>}
</div>
))}
{agent.agent && (
<details className="gd-manual">
<summary>Or run it yourself</summary>
<p className="gd-desc">
Same result, in the folder you want the files. Don't skip the last line the hooks
are what keep every turn starting from the latest state.
</p>
<GuideCode code={manualCommands(agent, project)} />
</details>
)}
<p className="gd-done">
That's it the folder now syncs on its own. Every agent turn starts from the latest
state, edits appear here (and on every teammate's mount) within seconds, and what your
@@ -81,13 +81,41 @@ export function Icon({ name }: { name: string }) {
return C ? <C className="ico" aria-hidden="true" /> : null;
}
/* The column system, in one place. `#content` owns scrolling and the page
gutter; `<Page>` owns width and centering nothing else may set either.
Three widths cover every view: `read` for rendered files only (markdown
prose), `app` for every structured view (guide, listings, history,
insights, settings, admin), `wide` for content that is itself a
page (a rendered HTML file in its frame). `read` and `app` both resolve
to Tailwind's md (768px); they stay separate classes because the file
view carries markdown typography and the widths may diverge again.
Views used to declare their own
max-width (560px to unbounded, half of them uncentered), so no two routes
shared a column.
The line: <Page> sets the COLUMN, a view may still cap its own MEASURE
(`.nf-sub`, a chart's design width). What a view must never do is declare
a page-level width that is how the tiers drifted apart the first time.
Widening a column also never means scaling content up: Insights sits at
`app` and its charts cap themselves, because at `wide` the viewBox SVGs
just zoomed (a 10.5px label painted at 21px). */
export type PageWidth = "read" | "app" | "wide";
export function Page(props: {
width?: PageWidth;
className?: string; // a view's own styling hook (e.g. markdown typography)
children: ReactNode;
}) {
const cls = ["page", props.width ?? "app", props.className].filter(Boolean).join(" ");
return <div className={cls}>{props.children}</div>;
}
export function AppShell(props: {
vault: ReactNode;
projectsNav?: ReactNode;
tree?: ReactNode;
orgBar?: ReactNode;
topbar: ReactNode;
contentClass?: string;
contentRef?: React.Ref<HTMLElement>;
onContentScroll?: () => void;
children: ReactNode;
@@ -105,7 +133,6 @@ export function AppShell(props: {
{props.topbar}
<article
id="content"
className={props.contentClass ?? "markdown"}
ref={props.contentRef}
onScroll={props.onContentScroll}
>
+37 -16
View File
@@ -30,6 +30,12 @@
--r-ctl: 7px;
--r-card: 10px;
--r-over: 14px;
/* Page columns. Three widths for every route — see .page below. */
--page-read: 768px; /* rendered files only (markdown prose) — Tailwind md */
--page-app: 768px; /* structured views: guide, listings, history, insights, settings, admin — Tailwind md */
--page-wide: 1200px; /* data-dense: treemap, coverage matrix, big tables */
--hero-top: clamp(32px, 8vh, 88px); /* one vertical start for short centered states */
}
* { box-sizing: border-box; }
@@ -268,12 +274,19 @@ button, input, a.btn { font-family: inherit; }
.more-item { display: block; width: 100%; text-align: left; min-height: 40px; padding: 0 12px; background: transparent; border: none; cursor: pointer; color: var(--text); font: inherit; font-size: 13.5px; border-radius: var(--r-ctl); }
.more-item:hover { background: var(--hover); }
/* The scroll container owns scrolling and the page gutter never a width.
Width and centering belong to .page (one per view), so every route shares
the same column edges and the gutter never eats into the reading measure. */
#content { flex: 1; overflow-y: auto; padding: 44px 40px 110px; scroll-behavior: smooth; }
.empty { color: var(--text-faint); text-align: center; margin-top: 22vh; }
.page { width: 100%; max-width: var(--page-app); margin-inline: auto; min-width: 0; }
.page.read { max-width: var(--page-read); }
.page.wide { max-width: var(--page-wide); }
.empty { color: var(--text-faint); text-align: center; margin-top: var(--hero-top); }
.empty-hint { display: block; margin-top: 6px; font-size: 12px; color: var(--text-faint); }
/* ---- onboarding ---- */
.onboard { max-width: 560px; margin: 8vh auto 0; }
/* A hero card inside .page.read, not a page column of its own. */
.onboard { max-width: 560px; margin: var(--hero-top) auto 0; }
.onboard h1 { font-size: 25px; font-weight: 640; letter-spacing: -.02em; margin: 0 0 8px; color: #f4f6f9; }
.onboard > p { color: var(--text-dim); margin: 0 0 28px; font-size: 14px; }
.ob-card { background: var(--bg-side); border: 1px solid var(--border); border-radius: var(--r-card); padding: 20px 22px; margin-bottom: 14px; }
@@ -291,7 +304,7 @@ button, input, a.btn { font-family: inherit; }
.danger-btn:hover { background: #c94336; }
/* ---- admin panels ---- */
.admin { max-width: 760px; }
/* width comes from .page (app) — see #content */
.admin h1 { font-size: 21px; font-weight: 640; letter-spacing: -.02em; margin: 0 0 6px; color: #f4f6f9; }
.admin h3 { font-size: 10.5px; text-transform: uppercase; letter-spacing: .07em; color: var(--text-faint); font-weight: 600; margin: 30px 0 10px; }
.admin-sub { color: var(--text-dim); font-size: 13.5px; margin: 0 0 6px; line-height: 1.55; }
@@ -321,7 +334,7 @@ button, input, a.btn { font-family: inherit; }
.admin-item.toggle input { width: 16px; height: 16px; margin-top: 2px; accent-color: var(--accent); flex: none; }
/* ---- folder listing ---- */
.dirlist { max-width: 704px; margin: 0 auto; }
/* .dirlist width comes from .page (app) */
.dl-title { display: flex; align-items: center; gap: 10px; font-size: 21px; font-weight: 640; letter-spacing: -.02em; margin: 0 0 4px; color: #f4f6f9; }
.dl-title-icon { display: flex; color: var(--accent); }
.dl-title-icon .ico { width: 20px; height: 20px; }
@@ -352,7 +365,7 @@ button, input, a.btn { font-family: inherit; }
/* ---- project home: the connect-an-agent guide ---- */
#vault-name.vault-link { cursor: pointer; }
#vault-name.vault-link:hover { color: var(--accent-bright); }
.guide { max-width: 760px; margin: 0 auto; }
/* .guide width comes from .page (app) */
.gd-tabs { display: flex; gap: 2px; margin: 20px 0 16px; border-bottom: 1px solid var(--border); overflow-x: auto; }
.gd-tab { font: inherit; font-size: 13px; font-weight: 600; padding: 7px 12px 9px; background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; color: var(--text-faint); cursor: pointer; white-space: nowrap; }
.gd-tab:hover { color: var(--text); }
@@ -363,21 +376,28 @@ button, input, a.btn { font-family: inherit; }
.gd-step-title { font-weight: 600; font-size: 14px; color: var(--text); }
.gd-desc { margin: 2px 0 8px 32px; color: var(--text-faint); font-size: 13px; line-height: 1.5; }
.gd-extra { font-size: 12.5px; margin-top: 6px; }
.gd-code { position: relative; margin: 6px 0 6px 32px; padding: 10px 72px 10px 12px; background: var(--bg-raise); border: 1px solid var(--border); border-radius: var(--r-card); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; line-height: 1.6; color: var(--text); overflow-x: auto; white-space: pre; }
.gd-copy { position: absolute; top: 7px; right: 7px; font: inherit; font-family: inherit; font-size: 11px; font-weight: 600; padding: 3px 9px; border-radius: 6px; border: 1px solid var(--border-2); background: var(--bg-raise); color: var(--text-faint); cursor: pointer; box-shadow: -14px 0 12px -6px var(--bg-raise); }
.gd-code { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 10px; margin: 6px 0 6px 32px; padding: 10px 12px; background: var(--bg-raise); border: 1px solid var(--border); border-radius: var(--r-card); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; line-height: 1.6; color: var(--text); }
.gd-code > code { display: block; min-width: 0; overflow-x: auto; white-space: pre; }
.gd-copy { align-self: start; font: inherit; font-family: inherit; font-size: 11px; font-weight: 600; padding: 3px 9px; border-radius: 6px; border: 1px solid var(--border-2); background: var(--bg-raise); color: var(--text-faint); cursor: pointer; }
.gd-copy:hover { color: var(--accent-bright); border-color: var(--accent-dim); }
/* A single unnumbered step has no badge to indent under. */
.gd-solo .gd-desc, .gd-solo .gd-code { margin-left: 0; }
.gd-manual { margin: 10px 0 0; }
.gd-manual > summary { display: inline-block; font-size: 12.5px; font-weight: 600; color: var(--text-faint); cursor: pointer; padding: 4px 0; }
.gd-manual > summary:hover { color: var(--text); }
.gd-manual .gd-desc, .gd-manual .gd-code { margin-left: 0; }
.gd-done { margin: 22px 0 8px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--r-card); background: var(--bg-side); color: var(--text-faint); font-size: 13px; line-height: 1.5; }
.home-insights { margin-top: 30px; padding-top: 22px; border-top: 1px solid var(--border); }
/* ---- insights (read×write matrix) ---- */
.insights { max-width: 760px; margin: 0 auto; }
/* .insights width comes from .page (app) */
.in-title { font-size: 21px; font-weight: 640; letter-spacing: -.02em; margin: 0 0 4px; color: #f4f6f9; }
.in-title .in-scope { color: var(--text-ghost); font-weight: 500; font-size: 15px; }
.in-lens { display: flex; gap: 6px; margin: 0 0 14px; }
.in-lens-btn { font: inherit; font-size: 12px; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--border); background: none; color: var(--text-faint); cursor: pointer; }
.in-lens-btn:hover { color: var(--text); }
.in-lens-btn.active { color: var(--accent); border-color: var(--accent); }
.in-chart { width: 100%; height: auto; border: 1px solid var(--border); border-radius: var(--r-card); background: var(--bg-side); margin-bottom: 6px; }
.in-chart { width: 100%; max-width: 760px; height: auto; border: 1px solid var(--border); border-radius: var(--r-card); background: var(--bg-side); margin-bottom: 6px; }
.in-axis { stroke: var(--border); stroke-width: 1; }
.in-threshold { stroke: var(--border); stroke-width: 1; stroke-dasharray: 4 4; }
.in-danger-zone { fill: rgba(242, 109, 109, .05); }
@@ -415,7 +435,7 @@ button, input, a.btn { font-family: inherit; }
.in-matrix rect:hover { opacity: .85; }
/* ---- history ---- */
.history { max-width: 860px; }
/* .history width comes from .page (app) */
.hentry { padding: 11px 12px; border-bottom: 1px solid var(--border); }
.hentry:hover { background: rgba(255,255,255,.015); }
.hline { display: flex; gap: 10px; align-items: center; }
@@ -514,7 +534,8 @@ button, input, a.btn { font-family: inherit; }
#account-btn { min-height: 44px; }
#project-select { min-height: 44px; }
.nav-add { min-width: 44px; min-height: 44px; }
.markdown, .admin, .onboard, .history, .dirlist { max-width: 100%; }
/* Columns already collapse on their own (.page is width: 100%); only
content that can't shrink needs its own escape hatch. */
.markdown table, pre.plain { display: block; overflow-x: auto; max-width: 100%; }
.ob-row { flex-direction: column; }
.ob-row input { flex: none; min-height: 44px; }
@@ -560,7 +581,7 @@ button, input, a.btn { font-family: inherit; }
}
/* ---- markdown reading view ---- */
.markdown { max-width: 704px; width: 100%; margin: 0 auto; }
/* .markdown is typography only; the column is .page.read around it */
.markdown h1, .markdown h2, .markdown h3, .markdown h4 { color: #f4f6f9; line-height: 1.25; letter-spacing: -.018em; margin: 1.5em 0 .5em; text-wrap: balance; }
.markdown h1:first-child { margin-top: 0; }
.markdown h1 { font-size: 1.85em; font-weight: 660; letter-spacing: -.024em; }
@@ -597,14 +618,14 @@ button, input, a.btn { font-family: inherit; }
.htmlview { display: block; width: 100%; height: calc(100vh - 150px); border: 1px solid var(--border); border-radius: var(--r-card); background: #fff; }
/* missing path: friendly not-found with the uploading hint */
.notfound { margin-top: 12vh; text-align: center; color: var(--text-dim); }
.notfound { margin-top: var(--hero-top); text-align: center; color: var(--text-dim); }
.notfound h1 { color: var(--text); font-size: 1.4em; margin-bottom: .5em; }
.notfound code { background: var(--hover); border: 1px solid var(--border); padding: .15em .5em; border-radius: 6px; }
.notfound .nf-sub { max-width: 440px; margin: 12px auto 20px; font-size: 13px; color: var(--text-faint); line-height: 1.6; }
/* plain file / binary views */
pre.plain { background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--r-card); padding: 14px 16px; overflow-x: auto; font: 12.5px/1.6 var(--mono); color: #c6cbd3; white-space: pre-wrap; overflow-wrap: anywhere; max-width: 900px; }
#content.markdown { min-width: 0; } /* let long unbreakable lines wrap, not blow out the column */
.filecard { margin-top: 15vh; text-align: center; color: var(--text-dim); }
pre.plain { background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--r-card); padding: 14px 16px; overflow-x: auto; font: 12.5px/1.6 var(--mono); color: #c6cbd3; white-space: pre-wrap; overflow-wrap: anywhere; }
/* long unbreakable lines wrap instead of blowing out the column: .page has min-width: 0 */
.filecard { margin-top: var(--hero-top); text-align: center; color: var(--text-dim); }
.filecard .name { font-size: 1.2em; color: var(--text); margin-bottom: .3em; }
.filecard .btn { margin-top: 14px; }
+330 -61
View File
@@ -1,14 +1,19 @@
package webapp
// Temporary manual demo harness: a seeded hub (~500 files, zipf-ish read
// data, four agent devices) for exploring the web UI by hand. Not part of
// the test suite: it only runs with BDRIVE_MANUAL_SERVE=1 and must never be
// committed.
// Manual demo harness: a seeded hub (a realistic company wiki, zipf-ish read
// data, four agent devices) for exploring the web UI by hand and for taking
// product screenshots. Not part of the test suite: it only runs with
// BDRIVE_MANUAL_SERVE=1.
//
// State lives in a STABLE directory (os.TempDir()/bdrive-demo-hub, override
// with BDRIVE_MANUAL_STATE), so restarting the harness — e.g. after a
// frontend change — keeps accounts, browser sessions, the project id, and
// all seeded/demo data. Delete the directory to reset the demo.
//
// The seed is deliberately realistic: real-looking runbooks, ADRs, dated
// meeting notes and product docs, with bodies that render as proper markdown
// (headings, tables, code, callouts). Screenshots taken here end up on the
// website, and "run-015.md" in a treemap tells a visitor nothing.
import (
"crypto/sha256"
@@ -18,6 +23,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -45,7 +51,7 @@ func TestManualServe(t *testing.T) {
if err != nil {
t.Fatal(err)
}
p, _, err := db.GetOrCreate("proj", "") // create-or-join: id is stable across restarts
p, _, err := db.GetOrCreate("acme-wiki", "") // create-or-join: id is stable across restarts
if err != nil {
t.Fatal(err)
}
@@ -62,10 +68,11 @@ func TestManualServe(t *testing.T) {
t.Fatal(err)
}
srv.Devices, _ = OpenDeviceRegistry(filepath.Join(state, "devices.json"))
srv.Devices.Observe(DeviceInfo{ID: "dev-ci", Name: "claude-ci", OS: "linux/amd64"})
srv.Devices.Observe(DeviceInfo{ID: "dev-snow", Name: "claude-snow", OS: "darwin/arm64"})
srv.Devices.Observe(DeviceInfo{ID: "codex-mia", Name: "codex-mia", OS: "darwin/arm64"})
srv.Devices.Observe(DeviceInfo{ID: "gemini-doc", Name: "gemini-doc", OS: "linux/amd64"})
// One agent per teammate, plus shared CI — which is what a real team's
// coverage matrix looks like once everyone is running their own.
for _, d := range demoDevices {
srv.Devices.Observe(d)
}
srv.Shares, _ = OpenShareDB(filepath.Join(state, "shares.json"))
@@ -82,78 +89,127 @@ func TestManualServe(t *testing.T) {
time.Sleep(8 * time.Hour)
}
// seedDemo writes ~500 files (journal + blobs) and their read buckets. Runs
// demoDevices is the agent fleet: one per teammate plus shared CI. Each has a
// bias in seedDemo so the coverage matrix shows agents specialising rather than
// eight identical rows.
var demoDevices = []DeviceInfo{
{ID: "claude-snow", Name: "claude-snow", OS: "darwin/arm64"},
{ID: "claude-priya", Name: "claude-priya", OS: "darwin/arm64"},
{ID: "claude-marco", Name: "claude-marco", OS: "darwin/arm64"},
{ID: "claude-ana", Name: "claude-ana", OS: "linux/amd64"},
{ID: "codex-mia", Name: "codex-mia", OS: "darwin/arm64"},
{ID: "codex-sam", Name: "codex-sam", OS: "linux/amd64"},
{ID: "gemini-doc", Name: "gemini-doc", OS: "linux/amd64"},
{ID: "claude-ci", Name: "claude-ci", OS: "linux/amd64"},
}
// agentBias is how much each agent over- or under-reads a given top-level
// area. Anything unlisted reads it at 1.0.
var agentBias = map[string]map[string]float64{
"claude-snow": {"wiki": 2.4, "docs": 1.6, "notes": 0.4},
"claude-priya": {"wiki": 1.8, "shared": 2.2, "notes": 0.5},
"claude-marco": {"docs": 2.6, "shared": 1.4, "wiki": 0.6},
"claude-ana": {"notes": 2.8, "wiki": 0.7, "docs": 0.5},
"codex-mia": {"wiki": 3.0, "notes": 0.3, "shared": 0.4},
"codex-sam": {"wiki": 1.5, "docs": 0.4, "shared": 1.9},
"gemini-doc": {"docs": 3.2, "notes": 1.2, "wiki": 0.5},
"claude-ci": {"wiki": 2.0, "shared": 0.3, "docs": 0.8},
}
// doc is one seeded file: a path and the markdown body behind it.
type doc struct {
path string
body string
}
// seedDemo writes the demo wiki (journal + blobs) and its read buckets. Runs
// once per state dir.
func seedDemo(t *testing.T, state, prefix, projectID string) {
t.Helper()
os.MkdirAll(filepath.Join(prefix, "journal"), 0o755)
os.MkdirAll(filepath.Join(prefix, "blobs"), 0o755)
seed := int64(42)
rnd := func() float64 {
seed = (seed*16807 + 7) % 2147483647
return float64(seed) / 2147483647
}
folders := []struct {
dir string
n int
}{
{"wiki/onboarding", 28}, {"wiki/architecture", 40}, {"wiki/runbooks", 55},
{"wiki/api", 70}, {"wiki/decisions", 45}, {"docs/product", 60},
{"docs/design", 35}, {"notes/meetings", 80}, {"notes/research", 45},
{"shared/reports", 42},
}
docs := demoDocs()
humans := []string{"alice@x.io", "bob@x.io", "carol@x.io"}
agents := []string{"dev-ci", "dev-snow", "codex-mia", "gemini-doc"}
agents := make([]string, 0, len(demoDevices))
for _, d := range demoDevices {
agents = append(agents, d.ID)
}
now := time.Now().UTC()
var ops []journal.Op
var stats []ReadStat
var lam, seq int64
fileNo := 0
for _, f := range folders {
short := filepath.Base(f.dir)[:3]
for i := 0; i < f.n; i++ {
fileNo++
path := fmt.Sprintf("%s/%s-%03d.md", f.dir, short, i+1)
content := fmt.Sprintf("# %s\n\nSeeded demo file %d in %s.\n", path, fileNo, f.dir)
sum := sha256.Sum256([]byte(content))
blob := hex.EncodeToString(sum[:])
os.WriteFile(filepath.Join(prefix, "blobs", blob), []byte(content), 0o644)
stale := int(400 * rnd() * rnd())
lam++
seq++
ops = append(ops, journal.Op{
Seq: seq, Lamport: lam, Time: now.AddDate(0, 0, -stale),
Device: "seed", DeviceName: "seed", Author: "alice@x.io",
User: "alice@x.io", UserName: "Alice",
Kind: journal.KindPut, Path: path, Blob: blob,
Size: int64(len(content)), Mode: 0o644,
})
hot := rnd() < 0.15
day := now.AddDate(0, 0, -int(rnd()*20)).Format("2006-01-02")
for _, a := range humans {
n := int64(math.Floor((map[bool]float64{true: 30, false: 3}[hot]) * rnd() * rnd()))
if n > 0 {
stats = append(stats, ReadStat{Project: projectID, Path: path, Day: day,
Kind: ReadKindHuman, Actor: a, Count: n, Last: now})
}
for _, d := range docs {
sum := sha256.Sum256([]byte(d.body))
blob := hex.EncodeToString(sum[:])
os.WriteFile(filepath.Join(prefix, "blobs", blob), []byte(d.body), 0o644)
// Most of a live wiki is current — roughly a sixth is genuinely stale.
// A warning triangle on every second file just reads as noise.
//
// Staleness is correlated with reads on purpose: a heavily-read file is
// far likelier to have rotted, because it's the one everybody trusts
// and nobody owns. That puts the red where the story is — big cells in
// the treemap, top-right in the scatter — instead of scattering it
// across a hundred files nobody opens.
hot := rnd() < 0.15
staleChance := 0.09
if hot {
staleChance = 0.5
}
stale := int(24 * rnd())
if rnd() < staleChance {
stale = 34 + int(260*rnd()*rnd())
}
lam++
seq++
ops = append(ops, journal.Op{
Seq: seq, Lamport: lam, Time: now.AddDate(0, 0, -stale),
Device: "seed", DeviceName: "seed", Author: "alice@x.io",
User: "alice@x.io", UserName: "Alice",
Kind: journal.KindPut, Path: d.path, Blob: blob,
Size: int64(len(d.body)), Mode: 0o644,
})
dir := filepath.Dir(d.path)
day := now.AddDate(0, 0, -int(rnd()*20)).Format("2006-01-02")
for _, a := range humans {
n := int64(math.Floor((map[bool]float64{true: 30, false: 3}[hot]) * rnd() * rnd()))
if n > 0 {
stats = append(stats, ReadStat{Project: projectID, Path: d.path, Day: day,
Kind: ReadKindHuman, Actor: a, Count: n, Last: now})
}
for ai, a := range agents {
boost := 1.0
if f.dir == "wiki/runbooks" && ai < 2 {
boost = 4
}
if f.dir == "notes/research" {
boost = 0.05
}
n := int64(math.Floor((map[bool]float64{true: 60, false: 5}[hot]) * rnd() * rnd() * boost))
if n > 0 {
stats = append(stats, ReadStat{Project: projectID, Path: path, Day: day,
Kind: ReadKindAgent, Actor: a, Count: n, Last: now})
}
}
top, _, _ := strings.Cut(d.path, "/")
for _, a := range agents {
// Each agent has its own areas (agentBias); on top of that,
// runbooks are what everyone's agent lives in and research notes
// are written for humans and barely read by anything.
boost := 1.0
if b, ok := agentBias[a][top]; ok {
boost = b
}
if dir == "wiki/runbooks" {
boost *= 2.2
}
if dir == "notes/research" {
boost *= 0.05
}
n := int64(math.Floor((map[bool]float64{true: 60, false: 5}[hot]) * rnd() * rnd() * boost))
if n > 0 {
stats = append(stats, ReadStat{Project: projectID, Path: d.path, Day: day,
Kind: ReadKindAgent, Actor: a, Count: n, Last: now})
}
}
}
if err := journal.Append(filepath.Join(prefix, "journal", "seed.jsonl"), ops); err != nil {
t.Fatal(err)
}
@@ -161,3 +217,216 @@ func seedDemo(t *testing.T, state, prefix, projectID string) {
t.Fatal(err)
}
}
// demoDocs builds the seeded wiki: a handful of hand-written documents that
// render well enough to screenshot, plus plausible filler so the folder
// listings and the insights treemap look like a real company's knowledge base.
func demoDocs() []doc {
var out []doc
add := func(path, body string) { out = append(out, doc{path, body}) }
add("wiki/q3-findings.md", q3Findings)
add("wiki/runbooks/incident-response.md", incidentResponse)
add("wiki/onboarding/first-week.md", firstWeek)
// Filler with real-looking names. Bodies follow a per-folder shape so a
// reader who opens one sees something plausible rather than lorem ipsum.
type group struct {
dir string
heading string
names []string
}
groups := []group{
{"wiki/onboarding", "Onboarding", []string{
"engineering-setup", "who-does-what", "glossary", "tools-and-access",
"first-pull-request", "how-we-write-docs", "meeting-culture", "expenses",
"security-basics", "support-rotation", "vacation-policy",
}},
{"wiki/architecture", "Architecture", []string{
"system-overview", "auth-and-sessions", "data-model", "event-pipeline",
"storage-layout", "caching-strategy", "multi-region", "rate-limiting",
"background-jobs", "search-indexing", "webhooks-delivery", "observability",
"secrets-management", "migration-strategy",
}},
{"wiki/runbooks", "Runbook", []string{
"deploy-and-rollback", "database-failover", "oncall-handoff",
"restore-from-backup", "rotate-credentials", "scale-up-workers",
"clear-stuck-queue", "expired-certificate", "region-evacuation",
"data-export-request", "hotfix-process", "postmortem-template",
"paging-policy", "load-shedding", "cache-flush",
}},
{"wiki/api", "API", []string{
"rest-conventions", "authentication", "errors", "pagination",
"rate-limits", "webhooks", "versioning", "idempotency",
"batch-endpoints", "sdk-guidelines", "deprecation-policy",
"changelog", "sandbox-environment",
}},
{"wiki/decisions", "Decision record", []string{
"0001-postgres-over-dynamo", "0002-monorepo", "0003-typescript-everywhere",
"0004-no-graphql", "0005-queue-choice", "0006-feature-flags",
"0007-tenant-isolation", "0008-append-only-journals", "0009-object-storage",
"0010-auth-provider", "0011-observability-stack", "0012-release-cadence",
"0013-pricing-model", "0014-support-tiers", "0015-data-retention",
"0016-mobile-strategy", "0017-i18n", "0018-schema-migrations",
}},
{"docs/product", "Product", []string{
"pricing-v2", "roadmap-h2", "personas", "activation-metrics",
"onboarding-flow", "trial-experiment", "churn-drivers", "packaging",
"competitive-positioning", "feature-requests", "beta-program",
"launch-checklist", "success-metrics", "pricing-faq",
}},
{"docs/design", "Design", []string{
"design-system", "brand-voice", "iconography", "empty-states",
"motion-principles", "accessibility", "dark-mode", "form-patterns",
"illustration-style",
}},
{"notes/research", "Research", []string{
"competitive-landscape", "user-interviews-q2", "pricing-sensitivity",
"churn-interviews", "market-sizing", "buyer-personas", "win-loss-review",
"support-ticket-themes", "nps-verbatims", "usability-round-3",
"agent-usage-patterns", "enterprise-requirements",
}},
{"shared/reports", "Report", []string{
"board-update-q3", "churn-analysis", "revenue-review", "hiring-plan",
"security-review", "uptime-report", "cost-breakdown", "growth-review",
"customer-health", "quarterly-okrs", "annual-plan", "budget-forecast",
"partner-review",
}},
}
for _, g := range groups {
for _, n := range g.names {
out = append(out, doc{
path: g.dir + "/" + n + ".md",
body: fillerDoc(g.heading, n),
})
}
}
// Dated meeting notes: the long tail every wiki has.
start := time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC)
kinds := []string{"standup", "retro", "planning", "design-review", "incident-review"}
for i := 0; i < 60; i++ {
d := start.AddDate(0, 0, i*3)
kind := kinds[i%len(kinds)]
out = append(out, doc{
path: fmt.Sprintf("notes/meetings/%s-%s.md", d.Format("2006-01-02"), kind),
body: fillerDoc("Meeting", fmt.Sprintf("%s %s", d.Format("Jan 2"), kind)),
})
}
return out
}
// fillerDoc renders a plausible short document for the long tail.
func fillerDoc(heading, slug string) string {
title := strings.ToUpper(slug[:1]) + strings.ReplaceAll(slug[1:], "-", " ")
return fmt.Sprintf(`# %s
_%s · owned by the platform team_
## Summary
Short context on %s so anyone human or agent can act without asking.
## Details
- What it covers and who it affects
- The constraint that made us choose this
- What to do when it changes
## Related
See the rest of the %s section.
`, title, heading, title, strings.ToLower(heading))
}
// --- hand-written documents (these are the ones that get screenshotted) ---
const q3Findings = `# Q3 findings
_Written by Claude on Snow's machine · reviewed by Alice_
Churn is concentrated in self-serve accounts that never reach a second
seat. Everything below follows from that one fact.
## Headline numbers
| Metric | Q2 | Q3 | Change |
| --- | --- | --- | --- |
| Net revenue retention | 104% | 97% | **7 pts** |
| Self-serve churn | 4.1% | 6.8% | +2.7 pts |
| Team-plan churn | 1.9% | 1.7% | 0.2 pts |
| Median seats at churn | 1.0 | 1.0 | |
## What we learned
1. **Single-seat accounts churn 4× faster.** Accounts that never invite a
teammate leave within 40 days on average. Accounts that add a second seat
in week one almost never leave.
2. **The aha requires two people.** Every retained account has at least one
shared folder with activity from more than one machine.
3. **Price is not the driver.** Only 6% of exit surveys mention cost;
41% say "never really got started".
## What we are doing about it
- Make the second seat reachable without a credit card
- Move the invite step into the first session, not the settings page
- Instrument time-to-second-device as the activation metric
## Open questions
- Does a 3-seat free tier cannibalize Team, or feed it?
- Can onboarding create the second seat automatically for an agent?
`
const incidentResponse = `# Incident response runbook
How we handle a production incident, start to finish. **Agents: read this
before touching anything during an active incident.**
## Severity levels
| Level | Meaning | Response |
| --- | --- | --- |
| SEV-1 | Customer-facing outage | Page on-call, all hands |
| SEV-2 | Degraded service | On-call handles, updates hourly |
| SEV-3 | Internal breakage | Ticket, fix within the week |
## First 15 minutes
1. Acknowledge the page and open an incident channel.
2. Assign an incident commander one voice, one timeline.
3. Freeze deploys:
` + "```sh\ndeployctl freeze --reason \"SEV-1 in progress\"\n```" + `
4. Post the first status update **before** debugging.
## After the incident
- Write the retro within 48 hours (blameless, timeline-first)
- File follow-ups as issues with the ` + "`incident`" + ` label
- Update this runbook if reality disagreed with it
`
const firstWeek = `# Your first week
Welcome. This page is the short version; everything else is linked from here.
## Day one
- Get access to the wiki, the repo, and the on-call rotation
- Run the setup script and open a pull request that changes one line
- Say hello in the team channel
## Day two to five
1. Pair with someone on a real ticket
2. Read the [architecture overview](../architecture/system-overview.md)
3. Shadow an on-call handoff
## How we work
We write things down. If you asked a question and the answer was not in the
wiki, the answer belongs in the wiki your agent can add it for you.
`
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#128059;</text></svg>">
<script type="module" crossorigin src="/assets/index-QXAy2YSg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-9WanC3kA.css">
<script type="module" crossorigin src="/assets/index-BLg-iNnn.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BXXJthEq.css">
</head>
<body>
<div id="root"></div>
+18
View File
@@ -133,6 +133,24 @@ Heads-up before installing: Hermes hooks are PER-USER (`~/.hermes/config.yaml`,
outside the repo) — mention that when it's among the targets, and skip it
unless the user actually uses Hermes.
If the user mentions teammates on other agents (Codex, Gemini CLI, Hermes),
tell them those teammates need no terminal either — they paste one prompt
into their own agent (the hub's project home page shows it filled in):
```
Set up BearDrive in this folder.
1. If `bdrive` is missing, install it: brew install runbear-io/tap/beardrive
2. bdrive skill install # so you know the CLI next time
3. bdrive login --device <hub-url> # show me the code and the URL
4. bdrive init --project <project-id>
5. bdrive hooks install # don't skip this - it's what syncs every turn
```
Step 2 leaves the beardrive skill in that agent's skills dir
(`~/.codex/skills/beardrive/` and friends) so their later sessions are
conversational. Handing teammates loose commands is how the hooks step gets
skipped.
## 6. Verify and summarize
Run `bdrive status` and confirm the daemon is running and pending is 0.
+14
View File
@@ -0,0 +1,14 @@
// Package plugin embeds the shipped agent assets — currently the `beardrive`
// skill — so the CLI can install them into any agent that reads SKILL.md.
// The file embedded here is the same one the Claude Code plugin ships
// (plugin/skills/beardrive/SKILL.md): one canonical copy, no drift.
package plugin
import _ "embed"
// SkillMD is the beardrive skill: YAML frontmatter (name + description) plus
// the instructions body, the cross-agent SKILL.md format Claude Code, Codex,
// Gemini CLI, and Hermes all read.
//
//go:embed skills/beardrive/SKILL.md
var SkillMD string
+35
View File
@@ -18,6 +18,7 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing
| Stop syncing | `bdrive stop [<folder>]` (`--forget` also unregisters) |
| One sync cycle now | `bdrive sync [<folder>]``--note <text>` stamps session context; `--hook <label>` is the Claude turn-start hook's plumbing (event JSON in, sync + note, gated-link formula out) |
| Register agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes) | `bdrive hooks install [<folder>]` — auto-detects the platforms in use and merges pull/push/session-note/read-tracking hooks into each one's own hook config, idempotently; bare `bdrive hooks` shows the status table |
| Install this skill on another agent (Codex, Gemini CLI, Hermes, Claude Code) | `bdrive skill install [<folder>]` — writes the binary's own copy of this skill to each detected platform's user-level skills dir (`~/.codex/skills/beardrive/SKILL.md` and friends), idempotently; bare `bdrive skill` shows the status table. Then the user asks that agent to set the folder up and it runs `init` + `hooks install` itself |
| Record agent file reads (hook plumbing) | `bdrive read-log [<folder>]` — parses a hook event JSON from stdin and queues in-project reads locally (native reads, grep matches, and files named in shell commands); drained to the hub on the next sync as agent traffic in the read heatmap. Registered automatically by `bdrive hooks install`; rarely run by hand |
| Mounts + daemon + pending state | `bdrive status [<folder>]` |
| Change history | `bdrive log [<folder>] [-p path] [-n N]` |
@@ -157,6 +158,40 @@ the binary); `--agent claude,codex,gemini,hermes` overrides
detection; bare `bdrive hooks` prints the detection/registration table.
Project-level configs ride the repo, so hooks reach the whole team.
**When a teammate is setting up on a non-Claude agent**, point them at
`bdrive skill install` (see below) rather than a list of commands: the
agent then runs `init` + `hooks install` itself, which is exactly the step
hand-copied setups miss.
### Installing this skill on other agents
`SKILL.md` is a cross-agent format, and the bdrive binary carries this very
file: `bdrive skill install [<folder>]` writes it to the user-level skills
directory of every platform it detects — `~/.claude/skills/beardrive/`,
`~/.codex/skills/beardrive/`, `~/.gemini/skills/beardrive/`,
`~/.hermes/skills/beardrive/`. `--agent claude,codex,gemini,hermes`
overrides detection; bare `bdrive skill` prints the table; re-running after
a CLI upgrade refreshes an outdated copy (idempotent otherwise). Installs
are user-level on purpose — the skill is about the CLI, not one folder, and
a synced project folder should never carry it.
The intended flow for a new machine is one paste into that agent — the
commands ride inside the prompt because it has no BearDrive knowledge yet:
```
Set up BearDrive in this folder.
1. If `bdrive` is missing, install it: brew install runbear-io/tap/beardrive
2. bdrive skill install # so you know the CLI next time
3. bdrive login --device <hub-url> # show me the code and the URL
4. bdrive init --project <project-id>
5. bdrive hooks install # don't skip this - it's what syncs every turn
```
Use `login --device` when an agent is driving: a browser-callback sign-in is
invisible to it mid-turn, while the device flow yields a code and URL it can
hand back in chat. The hub's project home page renders this with the URL and
id filled in.
### Read heat (who actually reads what)
Hubs aggregate reads per file — viewer opens and downloads count as human