@enfinitos/cli
EnfinitOS operator-facing terminal CLI — rights, offers, challenges, proof packs, consent, drills, audit export, and pilot programmes, all driven from a single binary.
The CLI is the operator-side counterpart to the platform's HTTP API. Where the operator-web package gives a reviewer a point-and-click console, this package gives an SRE, a compliance officer, or a backend pipeline a scriptable terminal command. The two packages share the same Transport + error hierarchy under the hood — the CLI just renders results to stdout/stderr instead of to React state.
Why a CLI
Operators reach for a CLI in three situations the web UI cannot serve as efficiently:
- Pipelines.
enfinitos rights ls --status ACTIVE --format json | jqplugs straight into shell or CI scripts. No headless browser. - Reproducible incident response. When something is wrong at 3am, you want a flat, copy-pasteable command history. The CLI prints the structured request, the exit code, and the response on every line.
- Bulk ops. Issuing 200 rights from a CSV is one shell loop away once you have an idempotent
enfinitos rights issuethat takes a--scopeflag — much harder via a web form.
Auth shape. Today the CLI consumes a bearer JWT (set viaenfinitos login --token …orENFINITOS_API_KEY). Theenfinitos logincommand will grow an OAuth 2.0 Device Authorisation Grant flow in a follow-up release; the shape is documented insrc/commands/misc/login.tsso the eventual rollout is a drop-in change for operators.
Install
The CLI is a workspace-internal package today. From a fresh clone:
pnpm install
pnpm --filter @enfinitos/cli build
pnpm --filter @enfinitos/cli exec enfinitos --help
A standalone publish is on the roadmap (npx @enfinitos/cli …, plus Homebrew + apt). Once published, install with:
# globally
npm install -g @enfinitos/cli
enfinitos --help
# or ad-hoc
npx @enfinitos/cli rights ls
Configuration
Persistent settings live in ~/.enfinitos/config.json. The file is created on first enfinitos config set …; it stores at most:
{
"version": 1,
"apiUrl": "https://api.enfinitos.example.com",
"apiKey": "ey…",
"orgId": "org_…",
"defaultFormat": "table",
"refreshToken": "rt_…",
"tokenExpiresAt": "2026-06-01T12:00:00.000Z"
}
Every value can be overridden by an environment variable for a single invocation (handy in CI):
| Setting | Env var | Flag |
|---|---|---|
apiUrl | ENFINITOS_API_URL | --api-url <url> |
apiKey | ENFINITOS_API_KEY | (no flag — secrets) |
orgId | ENFINITOS_ORG | --org <id> |
defaultFormat | ENFINITOS_FORMAT | --format <fmt> |
Three precedence rules apply: command-line flag > env var > config file > built-in default. The CLI never writes secrets to logs. Token-shaped fields are partially masked (abcd…wxyz) by enfinitos config show.
Command surface
| Command | What it does | |
|---|---|---|
enfinitos login [--token JWT] | Establish a session. Device-flow OAuth coming soon. | |
enfinitos logout | Clear local credentials. | |
enfinitos whoami | Print the calling identity. | |
enfinitos config set <key> <value> | Persist a config field. | |
enfinitos config show | Show the merged config (secrets masked). | |
enfinitos version / enfinitos --version | Print CLI + protocol version (version-info is a legacy alias). | |
enfinitos doctor | Diagnose local env (network, file perms, version skew). | |
enfinitos rights ls [--status …] | List rights with cursor pagination. | |
enfinitos rights get <rightId> | Show a single right with provenance. | |
enfinitos rights issue --basis … --scope … | Issue a root right from a verified basis. | |
enfinitos rights suspend <rightId> --reason … | Suspend a right (reversible). | |
enfinitos rights revoke <rightId> --reason … | Revoke a right (terminal). | |
enfinitos offers propose --right … --target … | Propose an offer to another org. | |
enfinitos offers list [--status …] | List offers (inbound/outbound/all). | |
enfinitos offers accept <offerId> | Accept an offer (derives a new right). | |
enfinitos offers reject <offerId> --reason … | Reject an offer. | |
enfinitos offers withdraw <offerId> | Withdraw a proposed offer. | |
enfinitos offers counter <offerId> --terms … | Counter-propose new terms. | |
enfinitos challenges open <rightId> --reason … | Open a challenge against a right. | |
enfinitos challenges resolve <id> --outcome … | Resolve a challenge (upheld\ | dismissed). |
enfinitos challenges withdraw <id> | Withdraw an open challenge. | |
enfinitos proof get <campaignId> | Fetch the proof pack for a campaign. | |
enfinitos proof verify <pack.json> | Locally verify a proof pack (offline). | |
enfinitos proof export <campaignId> --out … | Export a campaign's proof pack to disk. | |
enfinitos consent issue --holder … --kind … | Record a fresh consent grant. | |
enfinitos consent revoke <consentId> | Revoke a consent record. | |
enfinitos consent check --holder … --kind … | Probe whether a consent is currently active. | |
enfinitos drill run <gateId> | Run a regulatory fire-drill scenario. | |
enfinitos drill list | List all available drills. | |
enfinitos audit export --from … --to … | Export the audit ledger over a date range. | |
enfinitos audit verify <audit.json> | Verify a Merkle-included audit bundle. | |
enfinitos pilot status | Show pilot-programme enrolment. | |
enfinitos pilot enrol <orgId> | Enrol an org in the pilot programme. |
Every command supports --format table|json|yaml and --yes for unattended runs.
Output formats
The CLI picks a default format based on whether stdout is a TTY:
- On a terminal: table (human-readable, ANSI-coloured when the terminal supports it; honours
NO_COLORandFORCE_COLOR). - Piped or redirected: json (so
enfinitos rights ls | jq …works without flags).
Force a format with --format json, --format yaml, or --format table. Set ENFINITOS_FORMAT=json to make every CLI call in a session emit JSON without sprinkling flags.
The structured-error envelope is the same in all formats — a JSON object with ok: false, code, message, optional hint, and optional reasons. Table mode renders this prose-style; json/yaml modes emit it verbatim.
Exit codes
The CLI uses small, stable exit codes so shell pipelines can branch on them. They are exported from @enfinitos/cli's programmatic entry point as the EXIT constant:
| Code | Name | Meaning |
|---|---|---|
| 0 | OK | Success. |
| 1 | GENERIC | Unclassified error. |
| 2 | USAGE | Bad command-line invocation. |
| 3 | AUTH | Authentication / authorisation failure. |
| 4 | NETWORK | Couldn't reach the platform. |
| 5 | SERVER | The platform returned a 5xx envelope. |
| 6 | VERIFICATION | A proof verify or audit verify failed. |
| 7 | NOT_FOUND | The target resource doesn't exist. |
| 8 | CONFLICT | The operation conflicts with current state. |
Architecture
+--------------------------+
| bin/cli.ts (shebang) |
+-----------+--------------+
|
v
+-----------+---------------------------+
| runner.ts |
| - commander tree (one cmd / file) |
| - context builder |
| - error classifier + formatter |
+-----+----------+-------------------+--+
| | |
v v v
+----------+ +----------+ +-----------------+
| commands/| | output.ts| | transport.ts |
| rights | | - table | | - HTTP client |
| offers | | - json | | - retries |
| proof | | - yaml | | - idempotency |
| consent | +----------+ | - envelope |
| ... | | parser |
+----------+ +-----------------+
| |
v v
+---------------+ +-----------------+
| confirm.ts | | config.ts |
| (enquirer) | | (~/.enfinitos/ |
+---------------+ | config.json) |
+-----------------+
Module boundaries
cli.tsis the bin entry — shebang,process.argv,process.exit. Nothing else.runner.tsis the testable inside: it takes argv + env + streams + a transport factory and runs to completion. Tests exercise it end-to-end without touching the real network.commands/<group>/<verb>.tsis one file per command. Each exports ahandle<Group><Verb>(ctx, args)function — the runner is the only caller. The command file owns its input validation, HTTP call (viactx.transport()), and output formatting.transport.tsis a thin REST client with retry-with-backoff, idempotency-key generation, structured error mapping, and JSON envelope parsing. It does not depend on any single command.config.tsowns~/.enfinitos/config.json— JSON Schema-style validation, atomic writes, and the env-merge precedence rules.output.tsis the table/json/yaml dispatcher plus the small ANSI-aware table renderer. Lives outside any command file so all groups render the same way.errors.tsis the typed error hierarchy + theclassify()function that turns any thrown value into a structured envelope plus an exit code.
Programmatic API
The CLI exports a small programmatic surface from @enfinitos/cli so other packages (notably operator-web) can re-use the same Transport, config loader, and proof-pack verifier without spawning a subprocess:
import {
loadConfig,
effectiveConfig,
transportFromConfig,
verifyProofPack,
EXIT,
} from "@enfinitos/cli";
const config = effectiveConfig(await loadConfig(), process.env);
const transport = transportFromConfig(config);
const res = await transport.request({ method: "GET", path: "/v1/rights/123" });
const verification = verifyProofPack(JSON.parse(await fs.readFile("pack.json")));
if (!verification.ok) process.exit(EXIT.VERIFICATION);
The run() entry point is also exported, so embedding the CLI in a different binary is one function call:
import { run } from "@enfinitos/cli";
const code = await run({ argv: process.argv.slice(2), returnExitCode: true });
process.exit(code);
Getting started
# 1. point the CLI at your platform
enfinitos config set apiUrl https://api.enfinitos.example.com
# 2. authenticate (interactive device flow coming soon)
enfinitos login --token "$ENFINITOS_API_KEY"
# 3. sanity-check the local env
enfinitos doctor
# 4. real work
enfinitos rights ls --status ACTIVE --substrate DOOH
enfinitos rights get rgt_abc123
enfinitos rights issue --basis bas_xyz --scope ./scope.json
# 5. proof export + offline verification
enfinitos proof get cmp_2026q1 --out cmp_2026q1.pack.json
enfinitos proof verify cmp_2026q1.pack.json
Testing
Tests run under Vitest and live in src/__tests__/. The CLI is tested end-to-end at the runner boundary — every test passes argv + env + an injected fake Transport into run() and asserts on captured stdout/stderr.
pnpm --filter @enfinitos/cli test
pnpm --filter @enfinitos/cli typecheck
The fake Transport (__tests__/_fakeTransport.ts) supports scripted request/response sequences with per-call assertions on the outgoing path, method, body, and idempotency key. The capture helpers (__tests__/_capture.ts) snapshot stdout/stderr so each test asserts on the exact bytes the operator would have seen.
Example workflows
Bread-and-butter operator recipes — each one shells out to a single pipeline. All emit --format json so they slot into automated jobs.
Find all open challenges and resolve them
# 1) list every open challenge (cursor-paginated)
enfinitos challenges list --status open --format json \
| jq -r '.data.items[].id' \
| while read -r cid; do
# 2) inspect, then resolve. Substitute upheld/dismissed as
# your judgement requires.
enfinitos challenges resolve "$cid" --outcome dismissed --yes
done
Export the last 30 days of audit
FROM=$(date -u -d '30 days ago' +%Y-%m-%d)
TO=$(date -u +%Y-%m-%d)
enfinitos audit export --from "$FROM" --to "$TO" --out audit.json
# Optional: verify the bundle locally before archiving.
enfinitos audit verify audit.json
Run all CRITICAL drills and write evidence
enfinitos drill list --format json \
| jq -r '.data.items[] | select(.priority=="CRITICAL") | .gateId' \
| while read -r gate; do
enfinitos drill run "$gate" \
--evidence-out "evidence/${gate}-$(date -u +%Y%m%dT%H%M%SZ).json" \
--yes
done
Bulk issue rights from a CSV
# columns: basis_id,scope_json_path
while IFS=, read -r basis scope; do
enfinitos rights issue --basis "$basis" --scope "$scope" --format json \
| jq -r '.data.id' >> issued-rights.txt
done < rights-to-issue.csv
Watch an inbox for new offers
while sleep 60; do
enfinitos offers list --status open --direction inbound --format json \
| jq -c '.data.items[] | {id, terms, target}'
done
Roadmap
- OAuth 2.0 Device Authorisation Grant for
enfinitos login(RFC 8628). Today the command accepts a token via--token; the fully-fledged flow lands in the next milestone. - Standalone publish. The
bin/entry is wired today; we still need release tooling to ship per-platform binaries (npm, Homebrew, Linux package archives). enfinitos audit verifyfor differential bundles. Today the command verifies a self-contained bundle; the differential bundle format lands when the audit module's snapshot tooling does.- Tab completion. Commander supports completion generation for bash/zsh/fish; we ship the generator + an install hook.