thinkwork-cli
v2.0.0
Published
thinkwork — operator CLI over the Brain Operator API (also installed as `brain`)
Maintainers
Readme
brain
The operator CLI over the Brain Operator API (platform/ops_api). One binary,
no AWS credentials, no repo checkout — everything a person or an agent needs to
land a source, map it, project it, and check that it is actually right.
It lives next to the platform because it is version-matched to
ops_api: the CLI refuses to send a request it cannot be sure the connected API
understands (see Version handshake).
Install
From npm (public package thinkwork-cli; installs both thinkwork and brain):
npm install -g thinkwork-cli
# or
brew install thinkwork-ai/tap/thinkworkOr the standalone binary, one command (needs the gh CLI,
authenticated — brew install gh && gh auth login once):
gh api -H "Accept: application/vnd.github.raw" \
repos/thinkwork-ai/thinkwork/contents/apps/cli/install.sh | bashThat runs apps/cli/install.sh, which:
- downloads the newest
cli-v*release binary for your platform and verifies it againstSHA256SUMS; - installs it as
braininto/usr/local/bin(if writable) or~/.local/bin; - syncs the Build-a-Brain skill from the same tag into
~/.claude/skills/build-a-brain(and~/.codex/skillswhen a~/.codexexists), so the agent playbook ships with the tool; - migrates an old
~/.brainctlconfig dir to~/.brain.
Re-running it upgrades everything — binary and skill together.
BRAIN_INSTALL_DIR overrides the install location. Assets exist for
darwin-arm64/x64 and linux-x64/arm64.
Why gh and not plain curl: the repo is private, so unauthenticated
release URLs 404 — and gh downloads carry no macOS quarantine xattr, so
Gatekeeper never fires (the binaries are ad-hoc signed, not notarized —
THINK-574). If a browser downloaded a binary, rescue it with
xattr -d com.apple.quarantine ./brain-darwin-arm64.
brain version prints both the CLI version and the connected API's surface
version, and the CLI refuses to talk to an API newer than itself, so a stale
binary tells you loudly rather than misbehaving.
From a checkout (development):
cd cli
pnpm install
pnpm build # → dist/cli.js
pnpm link --global # then: brain --helpDuring development, pnpm dev -- <args> runs it from source via tsx.
Cutting a release (maintainers): bump version in cli/package.json (and
src/version.ts), merge, then tag the merge commit cli-v<version> and
push the tag — .github/workflows/cli-release.yml compiles all four targets
with bun build --compile, smoke-tests the Linux binary, and attaches the
binaries plus SHA256SUMS to a GitHub Release. The tag must match the package
version or the workflow refuses.
Auth
The operator key comes from the environment or the config file. Never a flag —
flags land in shell history and in ps output, so --key, --operator-key,
--token, --api-key and --bearer are all refused by name with an explanation.
Resolution order:
$BRAIN_OPERATOR_KEY- the profile's
keyEnvvariable (e.g.keyEnv: "BRAIN_OPERATOR_KEY_TEI") - the profile's stored
key(last resort — for containers with nowhere else to put it)
export BRAIN_OPERATOR_KEY=bk_…The key goes out as Authorization: Bearer <key>. That is the entire contract with
the operator-keys work (THINK-511); the CLI never parses or interprets it, so a
Cognito JWT works today wherever an operator key will work tomorrow.
The Cognito M2M fallback (THINK-573)
When no static key resolves, a profile may carry an auth block — the token
endpoint, the app client id, and the name of the env var holding the client
secret (the secret itself never touches the config or the command line):
brain config set-profile tei --api-url https://… \
--token-url https://….amazoncognito.com/oauth2/token \
--client-id 3abc… --client-secret-env TEI_M2M_SECRET
export TEI_M2M_SECRET=… # from the account's Cognito app client
brain auth login # verifies the wiring, caches the tokenAfter that, every invocation checks the cached token's own exp and silently
re-runs the client-credentials grant when it is within two minutes of expiry
(plus one forced re-grant + retry after any 401) — an hourly token expiry never
kills a scripted loop. brain auth status says which credential the next
command would send without printing a byte of it; brain auth logout drops
the cache (~/.brain/tokens.json, mode 0600). When a refresh is impossible
the failure is exit 4 saying "token expired mid-run", not a bare 401 one call
later. A long-lived bk_ key is still the recommended credential.
Profiles
Picking the wrong account is the expensive mistake, so brain never guesses
one, and the active profile appears in every output — a banner line in human
mode, a "profile" key in the JSON envelope.
brain config set-profile dev --api-url https://… --description "thinkwork dev" --default
brain config set-profile tei --api-url https://… --key-env BRAIN_OPERATOR_KEY_TEI
brain config set-profile mcpherson --api-url https://…
brain config list
brain config current # which account this invocation would hit, and whyConfig lives at ~/.brain/config.json (override with $BRAIN_CONFIG).
Selection order: --profile → $BRAIN_PROFILE → defaultProfile →
$BRAIN_API_URL (an explicit ad-hoc profile, reported as env) → error.
Output
--json works on every command. Machine-readable is the default posture; the
pretty renderer is the extra.
Data on stdout. Everything else on stderr — warnings, banners, and all errors. On failure stdout is empty, so
| jqnever sees half a document.Success envelope:
{"ok": true, "command": "pipeline validate", "profile": "tei", "dryRun": true, "data": { … }}Failure envelope (on stderr):
{"ok": false, "command": "pipeline save", "profile": "tei", "error": {"code": "validation_failed", "message": "extract.cursor_field is required in incremental mode", "field": "extract.cursor_field", "hint": "set extract.mode to \"snapshot\" if this source has no cursor", "retryable": false, "status": 400}, "exitCode": 3}
Adding keys to data is fine; renaming envelope keys is a breaking change for
every agent parsing us, and is pinned by tests.
Human mode is a bounded view
--json is the whole document. Human mode is a reading of it, and it is allowed
to be shorter: nested structures are shown two levels deep, lists are capped,
long strings are truncated, and naturally large result sets have a --limit.
Whenever anything was left out, a note says so on stderr — human mode never
silently shows you part of an answer, and it never prints raw JSON as if it
were a rendering. Commands with a table (profile column, iceberg tables,
ontology get, duplicates list, sql, lake batches, run list, …) render
one; everything else gets the bounded summary.
An empty cell prints —. A blank cell used to mean "the renderer read a field
the API does not send" — that is how profile column printed eight unlabelled
rows — so blank is never a legitimate rendering now.
Dry run by default
Every write is a dry run unless you pass --apply. This deliberately inverts
the API, where dry-run is opt-in (?dry_run=true). Different callers, different
failure modes: an HTTP caller who forgets a query param should not silently no-op,
and a human at a terminal who forgets a flag should not silently mutate a live
account.
brain pipeline save dtn_prices --spec ./dtn.json # shows the diff, writes nothing
brain pipeline save dtn_prices --spec ./dtn.json --apply # commitsConsequence worth knowing: against an API that does not implement ?dry_run=
(surface version 0 — that is ops_api today), a dry-run write is not sent at
all and exits 8. Sending it and hoping the unknown query param were ignored would
mean a bare pipeline save writes to production, which is precisely what this
default exists to prevent.
Destructive verbs (connection delete, pipeline delete, derived delete,
run backfill --force) additionally require --yes or an interactive
confirmation. On --apply the CLI also mints the server-side confirmation token
operator keys require (POST /operator-keys/confirm → X-Brain-Confirm, bound
to your key and that exact path, five-minute TTL) and sends it with the DELETE.
A Cognito session (403 "does not need one") and an API predating operator keys
(404) proceed without one; a role that may not run destructive verbs fails
there, rather than as a confusing 403 one call later.
collection ingest's dry run is entirely local — it computes the manifest,
checksums included, and makes no HTTP request at all — so it works on every API
version.
pipeline validate has no --apply form. It cannot mutate, whatever you type.
Exit codes
| Code | Name | Meaning | Retry? |
|---|---|---|---|
| 0 | ok | Success. A dry run that produced a clean diff exits 0 too. | — |
| 1 | error | Unexpected / unclassified failure. | investigate |
| 2 | usage | You invoked the CLI wrong: bad flag, missing argument, unknown command, destructive verb without --yes. | no |
| 3 | validation_failed | HTTP 400/422. The payload is wrong; error.field says where. | no — fix the input |
| 4 | auth_failed | No key, bad key, or the key's role may not do this (401/403). | no |
| 5 | not_found | The named thing does not exist (404). | no |
| 6 | conflict | Blocked by state — a reference guard, a running batch (409). | after fixing the blocker |
| 7 | transient | Timeout, throttle, 502/503/504, network. | yes |
| 8 | unsupported | The connected API does not implement this yet, or this CLI is stale. | no — upgrade one side |
| 9 | config_error | No profile selected, unknown profile, unreadable config. | no |
brain exit-codes prints the table. The numbers are pinned by
__tests__/exit-codes.test.ts — never renumber one, add a new code instead.
A retryable field in the API's error body overrides the status mapping, so an
agent can branch on one field instead of parsing prose.
A bare 500 is exit 1, not exit 7. "5xx is transient" used to be wholesale,
and a deterministic 500 told an operator to retry three times in a row. Only the
statuses that describe infrastructure — 408, 425, 429, 502, 503, 504 — are retry
evidence on their own; 500 is an unclassified server failure and 501 is exit 8
(unsupported), which is what the API is actually saying. The body's
retryable: true still promotes any of them to exit 7, and retryable: false
still demotes.
Version handshake
The API reports its operator surface version on GET /health, either as the
x-brain-surface-version header or as a surface_version body field. An API that
reports neither is version 0 — today's ops_api, before operator keys and the
/operator/* capabilities land. Version 0 is fully supported: every command whose
endpoint already exists works against it.
The CLI declares which version it speaks (SURFACE_VERSION_SUPPORTED in
src/version.ts) and which version each capability needs:
| Capability | Needs |
|---|---|
| ?dry_run=true on writes | 1 |
| profile column (POST /operator/profile) | 1 |
| sql (POST /operator/sql) | 1 |
| duplicates list (GET /operator/duplicates) | 1 |
| ontology get (GET /operator/ontology) | 1 |
| diagnostics * | 1 |
| mapspec draft | 1 |
| mapspec get / mapspec save (GET/PUT /operator/mapspec/{name}) | 2 |
| ontology create-type (POST /operator/ontology/entity-types) | 2 |
| facet-drift (GET /operator/facet-drift) | 2 |
| derived history (GET /lake/history) | 3 |
| derived state-at (GET /lake/history/{name}) | 3 |
Two failure directions, both exit 8, both before a byte is sent:
- API older than the command needs → "
operator.sqlis not available on the connected API (it reports operator surface version 0; needs version 1)" - API newer than the CLI speaks → "this brain speaks operator surface version 1, but the connected API reports version 2 — refusing to send a request it may not understand"
brain version prints both sides. The handshake result is cached per API URL
for five minutes ($BRAIN_SURFACE_TTL seconds; 0 disables), so a shell loop
does not pay for it per iteration.
Command inventory
brain connection list | get | test | save | delete
brain pipeline list | get | schema | validate | save | preview | trigger | enable | disable | delete
brain mapspec validate | preview | draft* | get** | save**
brain derived list | get | preview | history*** | state-at*** | save | delete
brain projection list | get | run | shadows
brain run list | get | retry | backfill
brain collection list | documents | ingest | sync | settings
brain catalog list | get
brain lake batches | files | preview
brain iceberg tables | table | files
brain graph search | counts | ontology | cypher
brain identity canonicals | resolve | duplicates | census | crosswalks | conflicts
brain profile column*
brain sql*
brain duplicates list*
brain ontology get* | create-type**
brain facet-drift**
brain diagnostics freshness | failures | crosswalks*
brain evals runs (list | show | results | launch | cancel | watch)
cases (list | show | pin) | seed | profiles (list | create | set-default | archive)
schedules list | baseline pin | compare | candidates (list | promote)
ab (launch | list | show)
brain manifest resolve | latest
brain config list | current | set-profile | use
brain health | version | exit-codes* = needs operator surface version 1 (the WP3 endpoints).
** = needs version 2 (the build half).
*** = needs version 3 (THINK-547 bi-temporal history). All fail with an
instructive message — exit 8, nothing sent — against an API that is older.
derived history lists the datasets whose spec declares a temporal block,
with each one's version census; derived state-at <name> reads a dataset's
rows as of a moment on either clock — --at is recorded time (what did we
believe then; omit it for the current belief) and --valid-at is valid
time (what was in effect then, on the source's clock). The two compose:
both together is "what did we then believe was then in effect".
The build half exists because a credential-free dogfood on 2026-08-02 could
author a mapping and not save one, read an ontology and not extend it, and see
none of the drift between the two. mapspec save is the verb that closes the
authoring loop; facet-drift is the one that keeps it honest, because "add the
build verbs without it and you have only let agents ship wrong things faster."
Two things mapspec save is deliberate about. It writes targets.neptune and
nothing else in the spec, so a mapping edit cannot resend a stale extract
block — and it writes only that language: the retired mapspec
(facet/product_load) block is refused by name rather than dropped silently.
ontology create-type needs an admin key on purpose; a builder gets a 403
that tells it to stop and ask a human, which is what the skill already says.
evals runs watch <id> is the CI gate: it polls a run to a terminal status and
exits 0 only when the run completed and cleared --min-pass-rate (when given).
The failure codes are distinct so a script can branch — exit 3 when the run
completed below the gate, exit 1 when the run failed or was cancelled, exit 7
when it was still not terminal at --timeout-seconds (the run keeps going
server-side; re-running the watch is safe). Every /evals route is gated,
reads included, so the configured credential either works everywhere or 403s
everywhere.
manifest resolve <id> turns a snapshot manifest — the pinned data version a
run report or projection pass recorded (THINK-798) — into the per-table
SELECT * FROM "db"."table" FOR VERSION AS OF <snapshot> queries that reproduce
what that claim read. It is emit mode: the query text is printed, never run,
so raw-data access rides your own Athena entitlements and no field mask is
applied. Pins carry no existence check, so the command cannot know what expired;
pins older than the 90-day ice_* retention window may be gone, and an expired
pin still prints in full because the manifest's identity survives its snapshots.
See docs/runbooks/snapshot-manifest.md.
Three spellings of help all resolve to the same page:
brain help <group> <sub>, brain <group> help <sub> and
brain <group> <sub> --help. A path that does not exist is a usage error
(exit 2), never the parent's help page.
sql runs on Athena over the lake — the Glue/Iceberg tables, not a source
system and not the Aurora platform database. The engine and database it hit are
in the JSON and in the human header.
pipeline preview, mapspec preview and mapspec validate share one endpoint
and differ in what is under test: the pipeline, the MapSpec, or the MapSpec
with a pass/fail verdict (the only one of the three that can exit 3). See
brain pipeline preview --help.
Note the two similarly-named nouns: config manages account profiles;
profile column is column profiling. Likewise identity duplicates covers
duplicate canonicals in the Register while duplicates list covers duplicate nodes
in the projected graph.
Worked example — onboarding a source as a shell loop
The design bias is composability: building twenty pipelines is a loop, not twenty conversational turns.
#!/usr/bin/env bash
set -uo pipefail
export BRAIN_OPERATOR_KEY=bk_…
PROFILE=tei
# 1. Point at the account once, then never think about it again.
brain --profile "$PROFILE" config current --json | jq -r '.data.apiUrl'
# 2. Does the connection work? (Shape only — no credential is ever returned.)
brain --profile "$PROFILE" connection test dtn --json || exit $?
# 3. Validate every generated spec. Nothing is written; failures are per-spec.
failed=()
for spec in specs/*.json; do
name=$(basename "$spec" .json)
if out=$(brain --profile "$PROFILE" --json pipeline validate "$name" --spec "$spec" 2>err.json); then
echo "ok $name $(jq -c '.data.would_change // []' <<<"$out")"
else
case $? in
3) echo "bad $name $(jq -r '.error.field' err.json): $(jq -r '.error.message' err.json)"
failed+=("$name") ;;
7) echo "retry later: $name" ;;
*) echo "stop: $(jq -r '.error.message' err.json)"; exit 1 ;;
esac
fi
done
[[ ${#failed[@]} -eq 0 ]] || { echo "${#failed[@]} spec(s) need fixing"; exit 3; }
# 4. Commit them, one apply each.
for spec in specs/*.json; do
brain --profile "$PROFILE" --json pipeline save "$(basename "$spec" .json)" \
--spec "$spec" --apply >/dev/null || exit $?
done
# 5. Land one, then verify it before mapping anything.
brain --profile "$PROFILE" --json pipeline trigger dtn_prices --apply
brain --profile "$PROFILE" --json run list --source dtn --dataset prices --limit 1 \
| jq -r '.data.runs[0].status'
# 6. Profile BEFORE mapping a column — this is the check that catches a field
# that is present in the schema, extracted, mapped, and populated on 0% of rows.
brain --profile "$PROFILE" --json profile column \
--source dtn --dataset prices --columns item_id,fuel_terminal_id \
| jq -r '.data.columns[] | "\(.column) fill=\(.fill_rate) blanks=\(.blank_count)"'
# 7. Map: draft against the EXISTING ontology, validate, then SAVE it. The save
# is the step that used to have nowhere to go.
brain --profile "$PROFILE" --json mapspec draft dtn_prices \
--entity-type Terminal --connection brain-neptune | jq '.data.draft' > map.json
brain --profile "$PROFILE" --json mapspec validate dtn_prices --mapspec ./map.json
brain --profile "$PROFILE" --json mapspec save dtn_prices --mapspec ./map.json --apply
# 8. Project, then check BOTH failure shapes: duplicate nodes the projection may
# have minted, and declarations the graph does not actually carry.
brain --profile "$PROFILE" --json projection run terminal_price --apply
brain --profile "$PROFILE" --json duplicates list --entity-type Terminal \
| jq -r '.data.groups | length'
brain --profile "$PROFILE" --json facet-drift Terminal \
| jq -r '.data.entity_types[].findings[]'Step 8's facet-drift is the one that answers "is the thing I just shipped
actually there?" — a mapping can validate, save, project and report loaded
while every node it claims to write is missing the property. If it prints
declared_never_projected for a property you just mapped, stop: the lane is
green and the data is not.
Steps 6–8 need surface version 1 (profile, duplicates) or 2 (mapspec
get/save, facet-drift); until the API ships them they exit 8 and say so,
which is the loop's cue to stop rather than to proceed on unverified data.
Development
pnpm typecheck
pnpm test # 174 tests; the HTTP layer is mocked and no live API is touched
pnpm buildTests are the deliverable as much as the code. They pin: the exit-code table, stdout/stderr separation (including one genuinely out-of-process run against a local HTTP server), dry-run-by-default on every write, profile selection and the refusal to guess, missing-key handling, the JSON envelope shape, the version handshake in both failure directions, the confirmation-token flow, the command inventory, the shell-loop composability case, and the human renderers — a table that prints a blank column is a failing test, because it is not something a reader notices.
