Skip to content

CLI JSON

easylogic edited this page Oct 15, 2026 · 11 revisions

CLI JSON output (schema 1, Issue #37)

--json anywhere on the barocss-hub command line switches the output to machine-readable JSON. It is the interface for tools, starting with the VS Code extension (#38). Code: crates/hub-cli/src/json.rs. Golden documents: crates/hub-cli/tests/golden/json/ (fixed replica id …abc); contract tests: crates/hub-cli/tests/json.rs, editor.rs.

Status: [decision-proposal] (#37). Human output is unchanged by this page.

1. Contract

  • stdout: exactly one JSON document, one line, ending in \n (watch --json: one event per line, JSON Lines). Nothing else is ever written to stdout under --json.

  • Document shape: an object whose first keys are "schema": 1 and "command": "<verb>", and whose last key is "warnings": an array of strings (the text of each warning: line the command produced, e.g. torn-tail recovery, skipped symlinks, an ignored bad snapshot). Key order is fixed (golden tests compare bytes).

  • Errors: stdout is empty; stderr holds one document and the exit status is 1: {"schema":1,"program":"barocss-hub","error":{"code":"<code>","message":"<human text>"},"warnings":[...]}. An error the remote end of a sync reported in a wire Error frame adds "remote": true after message (#43, Remote-Sync §5.2.6). code is chosen where the error is raised (hub_cli::Code, #37 repair), never derived from the message; message is human text and may change. One golden per code: crates/hub-cli/tests/golden/json/code-<code>.json.

    code meaning / what a tool does
    usage bad arguments, unknown command
    not-a-repository no .hub/ here or above (also a remote path that is not a repository)
    already-exists init in a repository; mkdir/touch/mv onto an existing path
    invalid-path .., empty or invalid name, moving/removing the root
    no-such-path no node at the path; missing directory argument
    not-a-file / not-a-directory wrong node kind
    bad-version version id not 4-64 hex digits
    not-found well-formed version id, checkpoint or peer that matches nothing
    ambiguous a prefix or checkpoint message matching several
    no-such-blob / bad-blob-id blob not stored / not 64 hex
    locked another watcher (or lock holder) is running
    behind the working directory is behind head: run checkout
    pinned the working directory is pinned to an old version: checkout to return (see the pinned way out)
    interrupted a checkout was interrupted: run checkout to finish it
    blocked paths collide with unmaterialized case-clashing paths (D-019)
    refused the working-directory update would clobber unrecorded or ignored data
    duplicate-replica sync aborted: a copied .hub/ (D-022): run reid
    corrupt a damaged .hub/ file, blob or unsupported format; also a .hub/remotes whose name, url or exec fails the #45 safety rules (message refusing …: remote X is unsafe: …; nothing was run)
    io i/o failure (also: the current directory cannot be read, exit 2; a transport failure: ssh exited, connection closed, with the ssh stderr tail)
    protocol wire protocol violation: no BHUBWIRE preamble (not barocss-hub, or a login banner), malformed or unexpected frame (#43)
    version-mismatch no common wire protocol version with the remote (both ranges in the message)
    limit a wire limit was exceeded (frame, chunk, list, blob size, or an existing record larger than the remote accepts; the message names the op); also (#54) an op this replica authors whose encoded record exceeds TEXT_OP_MAX (192 KiB): refused before anything is written (Text-CRDT 7.4.1 chained saves)
    timeout the remote sent nothing within the step timeout (60 s, Hello 15 s) or the server session wall time (1 h) ran out
    unsupported a scheme or feature that is not implemented or not negotiated (https:// remotes, RefMoves)
    forbidden refused by the server: path confinement (--root, .., ~user, symlink escape), a bad forced command, or a write to a serve --read-only server
    bare record, checkout, mkdir, touch, mv, rm, watch in a bare repository (D-042)
    untrusted-remote first contact with a remote (ssh or file://) from .hub/remotes that this user never added or trusted from this repository (the key includes the local repository; migrated peers start untrusted), with no terminal to ask (or --json) and no --yes (#45, Remote-Sync §5.9.7); fix: check it, then remote trust <name> or sync --yes <name>
    upgrade-required (#54, D-053 E-T6) sync stopped: the peer lacks wire feature text and the ops it is missing (pull) or would receive (push, mixed remotes) include text edits; fatal for the session, nothing persisted; fix: upgrade barocss-hub on the side that lacks it (Remote-Sync 5.2.3, Text-CRDT 7.9)
    error anything else (internal)
  • Exit status (human and JSON): 0 success; 1 any command error; 2 the current directory cannot be read; 3 sync finished but the remote rejected some of this repository's ops that weren't reported before (#50) (D-045, #45): stdout carries the normal document (with rejected non-empty) and stderr no error document. watch never exits 3: it emits rejected events. A closed stdout (EPIPE) exits 0 quietly.

  • Human mode errors are barocss-hub: <message> on stderr (was hub: ).

  • Encoding: RFC 8259. Strings escape " \ and every control character (U+0000–U+001F, U+007F) plus U+2028/U+2029; non-ASCII is raw UTF-8. Paths are UTF-8 (the scanner skips non-UTF-8 names with a warning). No dependency (hand-rolled writer, tested for control characters and non-ASCII).

  • Stability: schema 1 may gain keys (and error codes); existing keys keep their names and meaning. Consumers must ignore unknown keys. A breaking change bumps schema.

  • Caveat: the flag is the exact argument --json, removed wherever it appears; a commit message that is literally --json cannot be given.

Value forms

value form
node id / op id "<counter>.<32 hex replica>", e.g. "2.00000000000000000000000000000abc" (root: "0.000…0")
version {"id": "<64 hex>", "short": "<shortest unique prefix, ≥ 8>"}; short is what show, cat, checkout accept
blob id "<64 hex>" (SHA-256 of the bytes; the empty file's blob is e3b0…b855)
replica / peer / author "<32 hex>"
path root-relative, /-separated, root = ""

Shared objects

  • entry: {"path", "kind": "file"|"dir", "node", "blob": <blob id>|null} (blob null for dirs).
  • change: {"kind": "added"|"modified"|"deleted"|"renamed", "path", "from": <old path>|null, "node": <id>|null, "dir": bool}. For renamed, path is the new path and from the old one. node is null only for an added path not yet recorded (status).
  • checkpoint: {"version", "message", "published": bool, "author"}.
  • conflict (one of, by kind; path is null when the node is trashed):
    • name-collision: node, path (projected, suffixed), name (original), winner (node that kept the name);
    • content: node, path, winner, loser (op ids), winner_blob, loser_blob (fetch both with cat-blob);
    • cycle, same-node-move: node, path, winner, loser;
    • revived: node, path, delete, move;
    • edit-revived: node, path, edit, delete;
    • invalid-op: op.
  • sync warning: {"reason": "equivocation"|"lamport-violation"|"corrupt-blob"|"duplicate-replica", "peer", "author"|null, "op"|null, "detail", "kept"|null, "received"|null, "action", "stuck": [op ids], "first_seen", "text"} (text = the human line). #45: text is a sentence saying who (author replica, peer), which op, what differs and what to do, never Rust Debug text; kept/received are human descriptions of the two payloads of an equivocation (e.g. create file "a" in 0.000…), null for other reasons and for warnings stored before #45; action is what to do; detail is the stored detail (#45 equivocations: kept: …\nreceived: …; Lamport: dep <id> is not older than the op <id> (Lamport rule)).
  • rejected op (#45): {"op", "reason": "equivocation"|"lamport-violation", "message"} (message = what it means and what to do).

2. Documents

command keys after schema, command (then warnings)
status [--ignored] root; head {version, ops, checkpoints, published}; workdir {state, version, behind_ops, pinned, interrupted_checkout, clean}; watch {running, pid}; conflicts; sync_warnings; pending_ops; remotes ([{name, url, default}], #43; replaced peers, D-041); changes; blocked ([{path, with: [{path, node}]}], D-019); unmaterialized (paths); tracked_but_ignored (paths); ignored ([{path, dir, default}], top-most of each ignored subtree, default = hidden by a built-in default; always present, --ignored changes nothing); hidden_by_defaults (count)
status in a bare repository root; bare: true; head; conflicts; sync_warnings; pending_ops; remotes (no workdir, watch, changes or ignore keys)
sync [--yes] [<remote|url>] remote (the path or URL synced with), received {ops, blobs, checkpoints}, sent {…}, read_only (bool: the server announced read-only, pulled only), head (version), pending_ops, rejected ([rejected op]: our pushed ops the remote rejected, #45), rejected_reported (bool: the remote speaks rejected-ops; false for a pre-#45 server, which cannot report), output (the human lines) — #43. Under --json an untrusted remote (ssh or file://, D-047) is never asked about: untrusted-remote unless --yes. Each rejected entry also carries already_reported (bool, #50/D-050). Exit 3 when rejected has an entry with already_reported: false (D-045). If all of them are already reported, it exits 0.
ls [-l] [path] path (the listed dir), entries (every entry below it, recursively, full paths, sorted)
show [-l] <version> version, messages (checkpoint messages on it), entries
log checkpoints (newest first; local then published-only, as the human log)
log --path <path> path, node, kind, blob (head content), checkpoints (each + path and blob of that node in that version; only versions where the node is live), ops (every op on the node, newest first: `{op, kind: "create"
warnings sync_warnings, pending ([{op, peer}]), reported_rejections ([{op, reason, payload_sha256, remote, exec}]: rejections already reported once, not warned again, #50)
warnings --clear-reported [<remote>] cleared_reported (count forgotten; they're reported again on the next sync, #50)
cat <version> <path> version, path, node, blob, size, encoding: "base64", content
cat-blob <blob id> blob, size, encoding, content
record [-m <msg>] recorded (bool), changes (with the new nodes of added paths), head, checkpoint (null without -m)
checkout [--force] [<version>] version, written, removed, at_head
commit -m <msg> checkpoint
reid --rescue [--dry-run] [--offline] dry_run (bool), old_replica, new_replica (null in a dry run); a no-op is {dry_run, noop: true, rescue: [], next: null}, rescue ([op]: the removed ops), files ([{path, change: "modified"|"added"|"deleted"}], the working-tree files that differ from the server-side version), backup (path of .hub/rescue/<ts>/files; in a dry run of a fresh plan the parent .hub/rescue, since the directory does not exist yet; null if nothing is backed up), recorded (changes as record; absent in a dry run), checkpoints ([{message, author, published}]: checkpoint records naming the removed ops, re-created privately, #49 R1), resumed (bool), noop (bool), sync (null when not run; else {ok, exit, rejected, message}), checkout (null when no sync ran; else {done, reason} or {done: true, version, written, removed}: the automatic checkout after an online rescue, only if the working tree was unchanged), restored_checkpoints ([{message, short, was_published}]), next (a human hint, e.g. "run barocss-hub sync") (#49)
help / --version text / version (command is "help" / "version")
any other (init, mkdir, touch, mv, rm, publish, remote, plain reid) output: the human lines, as an array (content not stable)

workdir.state: "at-head" (the working directory is head), "behind" (head has behind_ops ops the working directory lacks; after a sync; checkout fixes it), "pinned" (checkout <old id>; pinned holds that version; the watcher pauses), "unknown" (the index base does not resolve). interrupted_checkout true means run checkout.

Without --json, cat and cat-blob write the raw bytes to stdout (the easiest way for a tool to read a file version); errors are then human text.

3. watch --json events

One object per line: {"schema":1,"event":"<name>",...}. stderr stays empty unless the watcher fails to start (then the error document, exit 1).

event keys
started root, pid, remotes (count; was peers before #43, D-041), sync (bool; false under watch --no-sync, #45)
recorded changes, head
checked-out version, written, removed, at_head, reason: "incoming" (ops from a peer applied) or "resume-interrupted"
synced peer (the remote's name), received {ops, blobs, checkpoints}, sent {…}, read_only (only syncs that moved something)
deferred paths: tracked files missing while their editor sidecar exists (save in progress, Repository-Semantics §1.6.1)
paused pinned (version) — once per pause
resumed —
warning message
untrusted-remote peer, url, message — an ssh remote this user never trusted is skipped (not contacted); once per remote per run; not an error, no backoff (#45)
rejected peer, ops ([rejected op]) — the remote rejected pushed ops (#45); only rejections not reported before (#50)
error stage (auto-record, apply, sync), message, peer and code (sync; the remote's name and the error code) — the watcher keeps running; a failing remote backs off (up to 32 sync intervals)
stopped — (SIGINT/SIGTERM, exit 0)

4. For the VS Code extension (#38)

  • Poll status --json for the status bar: workdir.state / behind_ops, watch.running / watch.pid, conflicts (with winner_blob / loser_blob for a diff), blocked, unmaterialized, warnings.
  • Timeline for a file: log --json --path <p> (checkpoints + every set-content op with its blob), then cat-blob <blob> (raw) for a version's bytes, or cat <version> <p>.
  • Run the watcher as a child with watch --json and read events.
  • Every error is error.code on stderr with exit 1.

Clone this wiki locally