Skip to content

feat(cli): bdrive capture — file an agent's output into the project with one pipe - #211

Open
ssowonny wants to merge 2 commits into
mainfrom
bea-214-ph-idea-file-an-agent-conversation-into-the-project-with-one
Open

feat(cli): bdrive capture — file an agent's output into the project with one pipe#211
ssowonny wants to merge 2 commits into
mainfrom
bea-214-ph-idea-file-an-agent-conversation-into-the-project-with-one

Conversation

@ssowonny

@ssowonny ssowonny commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

TL;DR

  • claude -p "..." | bdrive capture files the output as inbox/<timestamp>.md in this project and prints the path. --share syncs it and prints a link on line 2.
  • Everything after the write already shipped — the scan journals it, History attributes it, share.go mints the link. This is the verb, nothing else.
  • The one real decision: if the project's scope excludes inbox/, it refuses before writing instead of leaving a file that syncs to nobody, silently, forever.
  • Two behavior-preserving extractions land first (mintShare, acceptedRules) so this reuses code instead of copying it — read git show fb94db4 and skip ahead.
  • Known gap: the GTM half of BEA-214 (a /capture listing in awesome-claude-code / awesome-claude-skills) is not in here and needs its own issue.

Closes BEA-214.

The command is four steps, and only one of them is new

flowchart LR
    A["<b>1. stdin</b><br/>TTY -> refuse<br/>0 bytes -> refuse"]
    B["<b>2. mount</b><br/>findProject(cwd)<br/>-> root + proj"]
    C["<b>3. SCOPE GATE</b><br/>SkipUp(inbox/..)<br/>-> refuse, no file"]
    D["<b>4. write</b><br/>O_EXCL, -2, -3<br/>--share: one Cycle"]
    A --> B --> C --> D
    classDef gate fill:#f59e0b22,stroke:#f59e0b,stroke-width:2px
    class C gate
Loading

Steps 1, 2 and 4 are plumbing over helpers that already exist. Step 3 is the whole
judgement call, and it is why the command refuses rather than warns.

Why the gate refuses instead of warning

A project narrowed with bdrive init --only or bdrive scope add carries !
negation rules in the synced .bdriveignore. An inbox/ outside that scope is a
perfectly ordinary local file that simply never leaves the machine — no error, not
now, not ever. The promise of the command is "filed into the project"; a file that
quietly stays on one laptop is exactly the failure the issue describes. So it
refuses before MkdirAll, leaving not even an empty inbox/ behind, and names the
fix: run `bdrive scope add inbox` first.

The accepted-rules floor is what makes the gate correct, and it is the part
worth reading twice. syncer.LoadFilter alone gives you Skip semantics. The scan
applies SkipUp with store.SyncState.IgnoreAccepted installed
(syncer.go:456), so a teammate's pulled !inbox/ widening rule reads as
"syncs" to a bare filter while the cycle silently declines to upload it. Skipping
AcceptRules here would reintroduce the exact silent failure this command exists
to fix. TestCaptureScopeGateUsesAcceptedRules fails if you drop that one line —
I checked by dropping it.

Two decisions taken, both overridable in one line

Paused or never-enrolled projects warn and still write (--share is the
exception and refuses). Rationale: a paused project's inbox/ file syncs the
moment someone runs bdrive init again, while an out-of-scope path never syncs no
matter what anyone does. Those are different failures and get different answers.

inbox/ is hard-coded, no setting — the spec's answer, kept. Reversing it is a
folder rename, which sync already handles as ordinary journal ops. A per-project
setting is what to add when a team actually asks.

Two things that are deliberately not what you'd guess

Not store.WriteFileAtomic. Its temp-file-plus-rename is exactly what would
lose a race between two captures in the same second; the collision criterion needs
O_EXCL on the real name. The cost is a millisecond window where the daemon could
scan a 0-byte file and journal an empty version — the next scan corrects it, and
--share runs its own cycle after the write.

Not io.LimitReader. readlog.go and hooksync.go bound an untrusted hook
event; this is a transcript the user piped in, and silently truncating it is the one
unacceptable failure.

A capture holding a credential still syncs

The issue body claimed internal/syncer/secrets.go refuses it. It doesn't — that
path only warns, by design (holding an op back would break the cycle's "degrade to
offline, never fail" posture). The refusal is hub-side at share time. So a capture
with a secret in it reaches the team; only --share is blocked, and because the
path is already on stdout by then, nothing is lost. capture grows no --force of
its own — the error names bdrive share <path> --force.

What was run

go build ./..., go vet ./... pass
go test ./... pass — full suite, internal/webapp included
cmd/bdrive/capture_test.go (new, 8 tests) happy path, subdir → root's inbox, TTY refusal, empty stdin, -2 collision, scope refusal (no empty dir left), accepted-rules floor, outside-a-project
internal/webapp TestCLICaptureShare (new) real binary, real hub: --share prints path + URL, the URL serves the captured bytes; an AWS-shaped key exits non-zero after the write, still prints the path, prints no /s/, names bdrive share <path> --force
TestCLIShare* (existing) pass — the mintShare extraction's review contract
Mermaid parse check both blocks in architecture/cli-sync.md parse

No frontend change, so no npm run build, no Playwright suite, no screenshots.
No syncer change either, so no new multi-device test — the sync path this exercises
is the ordinary one, covered end to end by TestCLICaptureShare.

Deviations from the reviewed plan

Two, both small:

  1. secretsFound returns a typed secretsError rather than a plain error. The
    plan had capture reuse mintShare's 409 message as-is, but that message ends
    "Re-run with --force" and capture has no --force — so it would have named a
    flag that doesn't exist. errors.As lets capture append the real way past it.
  2. printIfPrivate came out of shareCmd too, alongside mintShare. The
    private-address note is part of "here is your link", and capture --share prints
    a link.

Neither changes bdrive share's behavior; TestCLIShareSecretGate and
TestCLIShareFolder still pin all four status paths.

Architecture changes

architecture/cli-sync.mdcapture joins the Commands class list, and one note
states the gate. Nothing else in the package map moved: no new types, no new seams,
and the two extracted helpers stay inside cmd/bdrive.

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

flowchart TB
    Commands["<div style='text-align:left'><b>Commands</b><br/>init login logout<br/>sync stop scope grep stale forget status log<br/>restore url share <span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ capture</span> export import<br/>web desktop daemon hooks read-log<br/>resume autostart</div>"]
    CobraNote["cmd/bdrive — thin cobra layer;<br/>init is the front door, stop pauses"]
    GrepNote["grep / stale — LoadProject not ResolveMount,<br/>no session, no flock, no network"]
    CaptureNote["✅ capture: stdin → the mount root's inbox/&lt;timestamp&gt;.md,<br/>prints the path; --share runs one Cycle then mintShare<br/>(extracted out of share, which now only prints what it returns).<br/>The scope gate is the whole command: Filter.SkipUp with<br/>acceptedRules(proj.ID) installed, REFUSES BEFORE WRITING when<br/>inbox/ is excluded — a bare LoadFilter reads a teammate's pulled<br/>!inbox/ widening as &quot;syncs&quot; while the cycle silently declines<br/>to upload it. Paused/unenrolled only warns; --share refuses."]
    Commands -.- CobraNote
    Commands -.- GrepNote
    Commands -.- CaptureNote
    classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2
    classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px
    class CobraNote,GrepNote noteBox
    class CaptureNote added
    linkStyle 2 stroke:#22c55e,stroke-width:2px
Loading

Build session

cd $(git worktree list | grep wizardly-wozniak-47eed8 | awk '{print $1}') && claude --resume 80bfe185-5e3d-439e-9672-b7f8827d38cc

(Only works on the machine this ran on.)

ssowonny and others added 2 commits September 8, 2026 09:05
Two behavior-preserving lifts so `bdrive capture` reuses code instead of
copying it: the share POST out of shareCmd's RunE, and the accepted-rules
volume read out of grep.go (it was about to become a fourth copy).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An agent finishes a piece of work and its output goes wherever the human
remembers to put it, or nowhere. `claude -p "..." | bdrive capture` writes
stdin verbatim to <mount>/inbox/<timestamp>.md and prints that path;
--share syncs it now and prints a public link on the next line.

Everything after the write already shipped: the scan journals it, History
attributes it, share.go mints the link. The one new piece of judgement is
the scope gate — a project narrowed with `init --only` or `scope add`
excludes inbox/, and a file written there would sit on one laptop forever
with no error ever. It refuses before writing and names `bdrive scope add
inbox`, applying Filter.SkipUp with this device's ACCEPTED rules installed,
because a bare LoadFilter reads a teammate's pulled `!inbox/` widening as
"syncs" while the cycle silently declines to upload it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant