basalt-cli
v0.6.0
Published
CLI for Basalt (app.basalt.cofoundy.ai) — login + workspace CRUD + member management, plus pull / publish / list / status / whoami over HTTP.
Maintainers
Readme
basalt CLI (thin v1)
Thin-over-HTTP CLI for Basalt. The internal authoring loop for any harness with a working copy (Claude Code, Cursor, CI):
basalt pull <project> <slug> → edit locally → basalt publish <file>The server compiles. This binary never compiles MDX and never enumerates a
vault tree — it reads a file (and, for the space binding, the enclosing
vault.yaml), computes content shas, and speaks the publisher API. (The old
local-compile CLI drifted into a second render world and was deleted; a
lint-invariant, cli-no-local-mdx, keeps this one honest.)
Addressing — frontmatter v2 (metadata only, #289)
Frontmatter is metadata, never infrastructure. The CLI derives the address from the filesystem:
- slug = the file path, relative to the enclosing
vault.yaml, slashes preserved and each segment normalized (decisions/Channel Stack.md→decisions/channel-stack). Novault.yamlabove it → the file is a depth-0 loose doc and the slug is its basename. - project = the space binding:
--projectwins, elsevault.yaml:name, else the vault-root folder name.
Allowed frontmatter keys: title (optional — defaults to the first body H1),
tags, author, created, chrome. The infra keys slug: / project: /
role: / version: are IGNORED — publishing one emits an
infra_key_ignored warning and it is dropped. pull strips them so a
pull → edit → publish round-trip never re-injects them. Migrate an existing vault
with scripts/strip-infra-frontmatter.ts <dir> (in the basalt repo; dry-run by
default, --apply to write) — a textual strip that never touches the body, so a
republish is sha_match (basalt status stays unchanged).
Install / build
Standalone package (the repo is not a pnpm workspace):
cd cli
pnpm install
pnpm build # → dist/, bin `basalt`Auth
Set a Basalt publisher key (minted in Workspace settings → Publishers). The key is always Basalt-issued — never a raw IdP token.
export BASALT_API_KEY=ct_...
# optional: point at a non-prod host
export BASALT_BASE_URL=https://app.basalt.cofoundy.ai~/.basalt/credentials (JSON {"api_key":"…"} or a raw token line) is read when
BASALT_API_KEY is unset. It also seats the basalt login device-flow session
(session_token), which the workspace commands use — publishing still needs a
publisher key.
basalt login from an AI agent
login adapts to its caller. With a terminal it blocks until you approve in
the browser. Without one (an agent harness, CI) it would be a deadlock — the
harness could never surface the URL — so it does not block:
basalt login # no TTY → prints URL + code, exits 0
# agent shows the URL to the human, then:
basalt login --wait # run as a BACKGROUND job; exits the moment they approve--wait resumes the in-flight request (~/.basalt/pending-login.json, 0600) —
it never mints a second code, so the code the human is looking at stays the code
the CLI polls for. Its exit is the approval notification; no cron, no polling
loop in the agent. Where background jobs aren't available, basalt login --check
polls once: exit 0 signed in, 3 still pending. Use one or the other — a
concurrent --wait and --check race for the server's one-shot token mint.
--wait / --no-wait force either mode explicitly.
Commands
| Command | What it does |
|---------|--------------|
| basalt login [--wait\|--no-wait\|--check] | Sign in via browser (device flow). |
| basalt whoami | Identify the current key (name, tenant, scope, expiry). |
| basalt list [--project <p>] | List docs the key can see, with content sha. |
| basalt status <files…> [--project <p>] | Show what publish would do — no write. |
| basalt pull <project> <slug> [--out <file>] | Fetch canonical source MDX (- = stdout). |
| basalt publish <files…> [--project <p>] [--force] [--dry-run] | Publish (server-compiled). |
Add --json to any command for machine-readable output.
Workspaces & members (require basalt login)
These speak the session-gated /api/tenant/* routes with the cf_docs_session
JWT that basalt login stores — not the publisher key. Two credentials, two
surfaces: a publisher key publishes docs, a login session administers workspaces.
| Command | What it does |
|---------|--------------|
| basalt workspace list | List your workspaces (* = active). |
| basalt workspace create <name> [--slug <s>] | Create a workspace (you become owner). |
| basalt workspace switch <slug> | Set the active workspace. |
| basalt workspace edit [--name <n>] [--avatar <f>] | Rename / re-avatar a workspace. |
| basalt workspace delete <slug> --yes | Delete a workspace (owner only). |
| basalt workspace members [--tenant <slug>] | List a workspace's members. |
| basalt workspace invite <email> [--role owner\|admin\|member] [--tenant <slug>] | Invite a member (owner/admin only; --role defaults to member). |
--tenant targets any workspace you belong to, so you can administer one
workspace while another is active. Omit it to act on the active workspace.
Re-inviting the same email at the same role is idempotent.
--tenantis verified before any write. Server-side,cf_active_tenantis a preference hint: for a caller who is not a member of the named workspace it is silently ignored and the request falls back to their personal/host workspace. So the CLI resolves--tenantagainst your live membership first and fails loudly (not a member of workspace "…") rather than let an invite land in the wrong workspace. A plainmemberinviting gets a clear message, not a raw 403.
Content-addressed skip. publish fetches the doc list once, then per file
compares the local content sha (sha256 of the CRLF-normalized body — the exact
digest the server stores) against the server's current_sha. An unchanged doc is
a no-op; a frontmatter-only change publishes (cheap metadata upsert); --force
always publishes.
Asset staging. Author a local image path —  — and
commit the image bytes next to the doc. On publish, each LOCAL image ref is
content-hashed and uploaded to a shared, per-project namespace
files/{project}/_assets/{sha16}-{name}, and the source POSTed to the server is
rewritten to the resolved /files/… URL. The git .md is never mutated — its
local path + committed bytes stay the source of truth; only what the server stores
carries the resolved URLs.
- Content-addressed → the same image referenced by many docs uploads once, and moving a doc to a new slug never breaks its images.
- Bytes are uploaded verbatim (no transcoding); the
sha16prefix is a 64-bit content hash, collision-safe well past any vault's image count. - Re-publishing re-uses assets already in R2 (a
HEADprobe skips the upload). --dry-runvalidates that every local image ref resolves on disk and is a supported, in-size image — a missing/oversize/unsupported ref fails the run (exit1), making it a real pre-merge check. Nothing is uploaded on--dry-run.
Supported: png · jpg/jpeg · gif · webp · svg, ≤ 10 MB each.
Exit codes
0 ok · 1 error · 2 usage · 22 brand violation.
Test
pnpm test # vitest
pnpm test --coverage # ≥85% gateThe content-sha vectors in test/fixtures/sha-vectors.json are cross-asserted
server-side (tests/sha-parity.test.ts) so CLI and server can never drift.
