Design: The cairn Command-Line Interface
Context
The CLI is the third of Cairn's three surfaces (web, MCP, CLI). Per ADR-0003
(triple-surface parity), it is the one surface that is a network REST client of the
core /v1 API rather than an in-process adapter: web handlers and the MCP server call
the core package directly in the server binary, while the CLI ships as a separate
static Go binary and reaches the same operations over HTTP. This realizes the "same
auth as your agent" promise literally — the CLI presents the same OAuth 2.1
authorization-code + PKCE credentials to the same /authorize and /token endpoints
the agent uses (ADR-0004, formalized in SPEC-0007); only the server-assigned channel
differs (via CLI vs via MCP).
This design realizes SPEC-0008 and depends on the API shape, error contract, and
pagination model of ADR-0012, the identifier/URL scheme of ADR-0005
(cairn.stump.wtf/<id>), and the provenance/access/expiry policy of ADR-0007 (which the
CLI displays but never decides). It is not web-facing and renders no HTML/UI in a
browser, so it carries no Security-headers or WCAG obligations; instead it must be an
excellent, scriptable, correctly-failing UNIX citizen, which is why this design leans
hard on exit codes, --json, TTY detection, and disciplined error/concurrency handling.
Goals / Non-Goals
Goals
- Be pbcopy for cairn:
cat file | cairn→ acairn.stump.wtf/<id>link on stdout and on the clipboard, with the terminal/dev-minimal aesthetic. - Push many files at once as one bundle (
cairn add) with per-file progress and a summary line, uploaded concurrently but reported atomically. - Browse the Bin as a keyboard-driven Bubble Tea TUI (
cairn ls). - Share exactly the agent's OAuth flow, storing tokens securely and refreshing silently.
- Compose in pipelines and scripts: deterministic exit codes, a
--jsonmode, and clean non-TTY behavior. - Carry zero domain rules — the server is authoritative for type, provenance, access, and expiry.
Non-Goals
- No local domain logic (no client-side expiry math, access decisions, or anchor validation).
- No offline mode or local artifact cache — the CLI requires a reachable, authenticated server (an accepted cost in ADR-0003).
- No sharing-management or delete scopes for the token (agents and the CLI create with
the default policy only; changing sharing/expiry is a deliberate act via
share). - No second auth mechanism (no API keys) — one flow, per ADR-0004.
- Not a web/UI surface: no browser-rendered pages, so no Security/Accessibility spec sections.
Decisions
Separate binary, pure REST client
Choice: Ship the CLI as its own static Go binary that speaks only the documented
/v1 REST/JSON contract; put no domain logic in it.
Rationale: ADR-0003 makes the CLI the "first consumer of the public contract," keeping the boundary honest. A drifted rule cannot hide in the CLI because the CLI has no rules to drift. It also means the CLI works against any conformant deployment.
Alternatives considered:
- Link the core package into the CLI: rejected — it would let domain logic live in a surface adapter, the exact drift ADR-0003 forbids, and couple the CLI to server internals and the database.
- Thin wrapper over
curl: rejected — no TUI, no secure token handling, no typed error mapping.
Same OAuth flow as the agent; loopback + PKCE
Choice: cairn login runs OAuth 2.1 authorization-code + PKCE as a public client
with a loopback (127.0.0.1) redirect, hitting the same endpoints as the MCP agent
(SPEC-0007). Store a short-lived access token plus a rotating refresh token.
Rationale: One authorization server, one flow, two client profiles (ADR-0004). PKCE is mandatory for a public client with no client secret; the loopback redirect captures the code without a hosted callback.
Alternatives considered:
- Personal access tokens pasted into config: rejected by ADR-0004 (no interactive consent, copy-pasted secrets leak, coarse scopes).
- Device-code flow: reasonable for headless boxes and a possible future addition, but loopback matches the agent's client profile and is the primary path.
Secure token storage with a permissioned fallback
Choice: Prefer the OS secret store (macOS Keychain, libsecret/Secret Service);
otherwise a 0600 file under the user config dir. Never log or print tokens.
Rationale: Tokens are bearer credentials to the human's workspace. The OS keychain is the safest at-rest location; the permissioned-file fallback keeps headless Linux and CI usable without weakening the default.
Implementation note (cairn#21, a scoped takeover of upstream cairn#37): cairn login/logout/whoami first ship against the token seam described above ("Personal
access tokens pasted into config") rather than the OAuth 2.1 + PKCE browser flow — the
"rejected" alternative in this decision is, for v0.0.2, the interim path: the human
mints a personal access token (issue #74) or uses a CAIRN_API_TOKENS entry, cairn login verifies it with a GET /v1/whoami round trip and stores it exactly as described
here (OS keyring preferred, 0600 file fallback, never both). The loopback + PKCE flow
this section otherwise describes follows in cairn#22 and on (SPEC-0007), layering onto
the same login/logout/whoami command surface without changing storage semantics.
Structured error envelope → stable exit codes
Choice: Parse {"error":{"code",...}} and map each stable code (plus
transport-only failures) to a fixed exit-code taxonomy (0 success … 130
interrupted), reprinting the server message and request_id.
Rationale: ADR-0012 gives us a machine-stable code enum precisely so clients branch
on codes, not prose. A fixed exit-code table makes the CLI scriptable and lets --json
emit the raw envelope for programmatic callers. Transport failures (unreachable server)
are deliberately a distinct exit code (8) from application errors, because "the
network is down" and "the artifact is gone" are different operational facts.
Concurrent, bounded, atomic bundle upload
Choice: cairn add uploads files through a bounded worker pool sharing one
context.Context; the bundle is reported successful only when the server confirms the
complete set; Ctrl-C cancels the context and aborts cleanly (exit 130).
Rationale: Bundles are "mixed media, many files"; concurrency is the difference between snappy and slow. Boundedness protects the server and the local network. Atomic reporting prevents a half-uploaded bundle from being handed onward as if complete — partial-artifact prevention ultimately rests with the core (ADR-0007/0008), and the CLI must not paper over a partial with a success link.
Architecture
The CLI is a thin, typed HTTP client with a small command layer, an auth/token manager, a bounded uploader, and a Bubble Tea TUI. All commands funnel through one REST client that owns retry-on-401, error-envelope decoding, and code→exit mapping.
flowchart TD
subgraph CLI["cairn CLI (separate static Go binary)"]
cmd["Command layer\n(cairn · add · ls · login · logout · whoami)"]
io["I/O layer\n(TTY detect · stdin stream · clipboard · --json)"]
tui["Bubble Tea TUI\n(cairn ls: keymap ↑/k ↓/j / enter s q)"]
up["Bounded uploader\n(worker pool · context · progress)"]
auth["Auth/token manager\n(PKCE loopback · refresh · secure store)"]
rc["REST client\n(/v1 · error envelope → exit code · 401 retry)"]
end
store["OS secret store\n(Keychain / libsecret)\nor 0600 file"]
api["Cairn core API\n/v1 REST + SSE (ADR-0012)"]
cmd --> io
cmd --> tui
cmd --> up
cmd --> auth
io --> rc
tui --> rc
up --> rc
auth <--> store
auth --> rc
rc -->|"Bearer token, HTTPS"| api
cairn add — concurrent upload with transparent refresh
The interesting flow is a multi-file bundle where the access token has expired: the client refreshes once, retries, and only reports success when the whole bundle lands.
sequenceDiagram
autonumber
participant U as User
participant C as cairn add
participant P as Worker pool (bounded, shared ctx)
participant A as Auth manager
participant API as /v1 API
U->>C: cairn add a.png b.log c.sql
C->>C: de-dup paths, stat files (fail fast on obvious oversize)
C->>P: enqueue uploads (ctx propagated)
P->>API: POST /v1/artifacts (bundle, streamed bodies)
API-->>P: 401 unauthorized
P->>A: refresh needed
A->>API: refresh_token grant
API-->>A: new access + rotated refresh
A->>A: persist rotated refresh, discard old
P->>API: retry uploads with new token
API-->>P: 201 created (complete bundle)
P-->>C: all files confirmed
C->>U: summary line + cairn.stump.wtf/<id> (copied), exit 0
Note over U,C: Ctrl-C at any point → cancel ctx,<br/>abort in-flight, no success link, exit 130
Data the CLI shows but never decides
Provenance channel (via CLI), access policy (🔒 you + anyone with link), expiry
(⧗ expires 7d), share type, and the cairn.stump.wtf/<id> identifier all originate
server-side (ADR-0005, ADR-0007). The CLI's job is faithful display and correct
exit-code mapping — never recomputation.
Risks / Trade-offs
- Requires a reachable, authenticated server (no offline mode) → accepted per
ADR-0003; mitigated by a clear, distinct exit code (8) for transport failures and a
helpful "run
cairn login" nudge on auth expiry. - Loopback OAuth is awkward on headless/SSH hosts (no local browser) → mitigated by best-effort clipboard/URL printing today and a possible device-code flow later; the loopback path stays primary to mirror the agent client profile.
- Concurrency bugs (races, leaked goroutines, orphaned connections) → mitigated by a
single shared
context.Context, bounded worker pool, graceful cancellation on SIGINT, and a mandatory race-detector gate in CI. - Partial-bundle-reported-as-success would hand a broken artifact onward → mitigated by reporting success only on full server confirmation; the core enforces real atomicity (ADR-0007/0008).
- Terminal control sequences leaking into pipes corrupt scripted output → mitigated
by strict TTY detection and
--json/plain fallbacks. - Token leakage in logs → mitigated by redaction in all verbose/debug output and by never printing tokens.
Open Questions
- Should headless environments get a first-class device-code login (
cairn login --device) in v1, or is best-effort loopback + manual URL paste sufficient? - What is the default bundle-upload concurrency limit, and should it be adaptive to the server's advertised limits or a fixed small constant?
- Should the CLI pre-check file sizes against a server-advertised max (fail fast) or
always let the server return
413— and how is that limit discovered (a/v1capabilities endpoint vs. a static default)? - Does
cairn lsopen an item by launching the web viewer in a browser, rendering a terminal preview, or both (configurable)? - Should
--jsonstream NDJSON progress events forcairn addso wrapping tools can show progress, or is a single terminal JSON object enough?