@reopt-ai/data-cli
v0.2.0
Published
reopt-data command line: provision organizations and projects, run analytics queries, and upload source maps — built for CI and AI agents
Downloads
311
Maintainers
Readme
@reopt-ai/data-cli
reopt-data — the command line for a reopt-data deployment. Provision organizations and projects, run analytics queries, manage the event catalogue, and upload source maps from CI. Built to be driven by scripts and AI agents as much as by people.
For the guided Korean documentation, see CLI 사용하기.
npm install -g @reopt-ai/data-cli # or: npx @reopt-ai/data-cli --help
reopt-data --helpNode 22 or newer. Runtime dependencies: @reopt-ai/data-contract, zod, and @modelcontextprotocol/server (loaded only by reopt-data mcp). Two capabilities need optional packages installed next to the CLI, and only those commands load them:
- platform authority (
org *,sourcemap upload --platform,sourcemap list-platform/delete-platform):@reopt-ai/brandapp-sdk(^4.7.0-rc.6, an optional peer) and@better-auth/core(>=1.7.7, which brandapp-sdk's machine signing needs) —npm install -g @reopt-ai/data-cli "@reopt-ai/brandapp-sdk@^4.7.0-rc.6" "@better-auth/core@>=1.7.7"(ornpx -p @reopt-ai/data-cli -p "@reopt-ai/brandapp-sdk@^4.7.0-rc.6" -p "@better-auth/core@>=1.7.7" reopt-data …). Give the ranges: until brandapp-sdk 4.7.0 is released, itslatesttag has no machine signing. Without them those commands exit 4 with this same install line; every other command works. - direct source-map uploads:
@vercel/blob.
Commands
| Group | Command | Needs | Effect |
| ----------- | -------------------------------------------------------------------------- | -------------------------- | ------------------- |
| top-level | login, status, logout | browser-approved login | session lifecycle |
| top-level | tools, mcp, completion | none | discovery / server |
| account | show | browser-approved login | read-only |
| org | create, update | machine client (platform) | writes |
| org | rotate-key | machine client (platform) | destructive --yes |
| project | list, get | org key | read-only |
| project | create, update | org key | writes |
| project | delete, rotate-secret | org key | destructive --yes |
| event | set-meta | org key | writes |
| query | timeseries, funnel, retention, traffic-overview, traffic-sources | client credential | read-only |
| segment | list, get, preview | client credential | read-only |
| sourcemap | inject, upload | key by target | writes, --dry-run |
| sourcemap | list, delete | org key | read / destructive |
| sourcemap | list-platform, delete-platform | machine client (platform) | read / destructive |
| event | init, pull, diff, verify, push, types, history | org key (init/types: none) | see below |
| top-level | pull, diff, verify, push, import, unlink (Project as Code) | org key | see below |
| config | link (also reopt-data link), show | none | local writes |
reopt-data <group> <command> --help prints every flag with its type, default and whether it is required — generated from the same schema the parser validates against. Shell completion is generated from that registry too:
reopt-data completion bash
reopt-data completion zsh
reopt-data completion fishApply it to the current shell with source <(reopt-data completion bash), source <(reopt-data completion zsh), or reopt-data completion fish | source.
Credentials
Browser user session
Use the browser flow for a personal Reopt Data session:
reopt-data login
reopt-data status
reopt-data account show
reopt-data logoutlogin prints an 8-character code, opens /cli-auth, and polls until the signed-in browser approves it. On a remote machine use --no-browser and open the printed URL elsewhere. --force replaces a still-valid saved login. The password never enters the terminal, the browser URL contains only the display code, and the separate 256-bit device secret stays in CLI memory.
The resulting session is stored atomically at ~/.reopt/data/auth.json; the directory is owner-only (0700) and the file is 0600 on POSIX. REOPT_DATA_AUTH_FILE can redirect this path for an isolated runtime. REOPT_DATA_USER_TOKEN supplies an environment-owned session for headless use; logout never revokes that value and tells you to unset it.
login --format json is the one streaming command: it writes NDJSON events (verification_uri, polling, success) so an agent can approve the flow while the process is still running. The token is never included. status validates the session with the server, silently persists an extended expiry, and reports only identity/expiry plus booleans for deployment credentials. account show uses the scoped session to list the signed-in user and only their organization/project memberships.
The status.session value is valid, missing, invalid, corrupt, or unreachable. An unreachable server leaves authenticated as null instead of claiming that a cached session is valid. Use login --force to replace an invalid or corrupt session. logout removes the local file even when the server cannot confirm revocation and reports that condition on stderr.
Personal login and deployment credentials are intentionally separate. Browser cookies and CLI bearer tokens have different server-enforced audiences; neither can be replayed as the other. The CLI token currently carries only account:read and does not replace the platform/org/client credentials required by provisioning, catalogue, query, or source-map commands.
Deployment credentials
Set them in the environment. Flags (--machine-client-id, --org-key, --client-id, --client-secret) exist for one-off use; they land in shell history. The machine private key is never a flag. A variable exported empty (an unset CI secret) counts as unset everywhere. The machine credential is read only by the commands that need it: a malformed REOPT_CLIENT_PRIVATE_KEY_JWK fails those with exit 4 and nothing else, and a token endpoint that refuses the client is exit 2 (not retried).
| Variable | Issued by | Used by |
| --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| REOPT_DATA_API_URL | you; default https://data.reopt.ai | everything |
| REOPT_DATA_USER_TOKEN | external secret injection (optional) | status, account show; never persisted or revoked by logout |
| REOPT_DATA_MACHINE_CLIENT_ID + REOPT_CLIENT_PRIVATE_KEY_JWK (+ REOPT_ID_BASE_URL) | the deployment operator — its reopt machine OAuth client (RFC-0034: private_key_jwt + DPoP, scopes svc:reopt-data:*); generate the key with npx @reopt-ai/brandapp-sdk reopt-keygen | org *, platform uploads |
| REOPT_DATA_ORG_KEY | org create (once) / org rotate-key | project *, event *, project & host uploads |
| REOPT_DATA_CLIENT_ID + REOPT_DATA_CLIENT_SECRET | project create (once) / project rotate-secret | query * |
A command that lacks its credential exits 2 before making any request and names the variable to set.
Output contract
stdout carries the result and nothing else. Default
--format jsonprints the server's response verbatim — the shape documented in@reopt-ai/data-contract.--format textgives a one-line human summary where one exists;table,csv, andyamlprovide inspectable or pipeline-friendly views.--fields project.id,project.name,clients.server.idselects comma-separated dotted fields before rendering;segments[].id,segments[].namegives one row per array item, and on a list such asproject listplain names (--fields id,name) select the rows' fields. It works with every format.Cursor-list commands accept
--page-all,--page-limit <n>, and--page-delay <ms>. JSON streams one selected item per line (NDJSON); the other formats aggregate items before rendering. Reaching--page-limitwhile another cursor exists exits 6 so automation cannot mistake a truncated list for a complete one.stderr carries progress (
--quietsilences it) and, on failure, one envelope:{ "error": "auth_error", "message": "…", "exitCode": 2, "status": 401, "apiCode": "invalid_key", "requestId": "…" }Exit codes name the caller's next move:
| Code | Name | Do | | ---- | ----------------------- | ----------------------------------------------------------- | | 0 |
success| | | 1 |api_error| server or network failed;retryAfterMsif the server said | | 2 |auth_error| set or rotate the credential named inmessage| | 3 |validation_error| fix the input;details.issueslists each field | | 4 |config_error| environment incomplete (URL, directory, peer dependency) | | 5 |internal_error| CLI bug — please report withrequestIdif present | | 6 |partial_failure| some items failed; the result on stdout lists them | | 7 |confirmation_required| destructive command; re-run with--yes| | 8 |drift_detected|event verify: file and server disagree; pull or push |
Giving input
Three equivalent ways, merged (later wins):
# flags, kebab-case, one per field
reopt-data query funnel --project-id prj_1 --start-date 2026-08-01 --end-date 2026-08-31 \
--steps view_item --steps add_to_cart --steps purchase
# one JSON object — inline, from a file, or from stdin
reopt-data query funnel --input '{"projectId":"prj_1","steps":["view_item","purchase"],…}'
reopt-data query funnel --input @funnel.json
echo '{…}' | reopt-data query funnel --input @-
# positionals for ids
reopt-data project get prj_1Arrays of scalars repeat the flag; nested objects take JSON (--property-filters '[{"key":"plan","value":"pro"}]'). null clears a nullable field (--symbol-host-app null). Booleans stand alone (--conversion, --no-conversion).
For agents
reopt-data tools --jsonreturns every command with its inputSchema (JSON Schema 2020-12), flags, which credential it needs, whether it mutates, its confirmation policy, optional cursor pagination metadata, supportsDryRun, and ready-to-run examples. Read it once; the schemas are the contract package's own, so what validates here validates at the server.
Rules an agent can rely on:
- A destructive command never runs without
--yes— it exits 7 first, before any network call. - Validation and credential checks happen before any request; exit 2 and 3 mean nothing was sent.
--dry-runonsourcemap uploadandsourcemap injecttouches nothing and needs no key. On any other command it is refused (exit 3) rather than ignored.- Creating with the same
--external-idtwice is safe: the second call answerscreated:falseand returns no secret. - stdout is always parseable when the exit code is 0 or 6.
MCP
reopt-data mcpserves the same tools over the Model Context Protocol on stdio — one entry per command, named reopt_data_<group>_<command> (reopt_data_project_create, reopt_data_query_funnel, …). A call behaves like the same CLI command run in the server's directory: deployment credentials come from the flags given to mcp (--api-url, --org-key, --client-id/--client-secret, --machine-client-id), then the environment; --timeout, --config and --env (else REOPT_DATA_ENV) apply to every call; and the repository config is read on each call, so its defaults — projectId, events.file, definition.dir, sourcemaps.dir — fill omitted arguments and a config link takes effect on the next call. Per-command flags (--yes, --dry-run, --page-all, --fields, --format, --json) are refused on mcp, whatever their value; tools take confirmed / dryRun instead. A cursor list (project list, segment list, …) returns one page per call, and when another page exists the result's second content block names the cursor to pass for it. account show can use the saved browser-approved login or REOPT_DATA_USER_TOKEN. Destructive tools take a required confirmed: true argument instead of --yes; conditionally risky tools such as event push expose it as optional and require it only when the computed plan needs confirmation. Dry-runnable tools take dryRun. Annotations (readOnlyHint, destructiveHint) are derived from the same definitions, and every nonzero result returns the CLI's error envelope as its text with isError: true.
{
"mcpServers": {
"reopt-data": {
"command": "npx",
"args": ["-y", "@reopt-ai/data-cli", "mcp"],
"env": { "REOPT_DATA_ORG_KEY": "…" }
}
}
}The same registry is importable for in-process use:
import { allTools, run, toolCatalog } from "@reopt-ai/data-cli";Segments
Segments are the saved audiences somebody defined in the console. The CLI reads them; it does not create them. Defining one means validating a condition tree against the project's catalogue and then computing it — expensive work that belongs behind a console somebody is looking at, not behind an unattended script.
reopt-data segment list --project-id prj_1 --format table
reopt-data segment list --project-id prj_1 --kind static --limit 10
reopt-data segment get --segment-id seg_1
reopt-data segment preview --project-id prj_1 --segment-id seg_1list omits the condition tree — summary carries one rendered line per
condition, which is what a listing needs — and get returns the tree in full.
A segment id that does not exist and one that belongs to another project both
answer 404 (exit 3), for get and for preview --segment-id alike, so the
answer does not tell a credential which ids exist elsewhere.
preview takes either a saved --segment-id or a definition through
--input, and answers with three numbers:
deviceCountis the segment. Actors are devices, so anonymous visitors count; a segment like "visited but never purchased" is mostly them.profileCountis how many of those devices have been identified.totalDevicesis the whole project, as a denominator.
deviceCount is null on a listed segment that has never been calculated.
That is not the same as a segment with no members, and a script that treats it
as zero will report a healthy audience as an empty one.
Event catalogue as code
Declare the project's event catalogue in reopt-data.events.json next to the code that emits the events, and let CI keep the server in step with the commit that ships:
{
"$schema": "https://data.reopt.ai/schemas/event-catalog.v1.json",
"version": 1,
"projectId": "prj_123",
"events": {
"purchase": { "displayName": "Purchase", "conversion": true, "rollupProperties": ["plan", "currency"] },
"healthcheck": { "status": "internal" },
"$pageview": {},
},
}The file is the truth: a field you leave out is the default, and an event you remove is archived (never deleted). A lock file beside it (reopt-data.events.lock.json, commit it) records the last sync so diff can tell your edits from console edits.
reopt-data event init --project-id prj_123 # or: event pull, to start from what the server has
reopt-data event diff --format text # + create ~ update - archive ! changed on the server
reopt-data event push # plan only
reopt-data event push --apply --yes # write; --yes covers metric-changing and expensive changes
reopt-data event verify # CI gate: exit 8 on drift
reopt-data event types --out src/reopt-events.d.ts # ReoptEventName / ReoptEventProperties for a typed track()Changes are graded: safe (display fields), metric-changing (conversion, status) and expensive (rollupProperties, which queues a rollup rebuild); the last two need --yes. A push sends the last sync's updatedAt with every event, so a console edit made in between is refused as a whole (409) instead of half-applied — event pull first, or --force to overwrite. event pull refuses while the file has unpushed edits — events and, in a version 2 file, properties (archived events are not edits). In a version 2 file the property half is a second request: if it fails after the events were written the push exits 6, the lock records the event half, and re-running finishes the job.
Project as Code
The same treatment for the rest of a project's console settings. Three files under reopt-data/ declare the project's settings, the error-tracking rules and the alerts the repository owns; one lock beside the directory records the last sync:
// reopt-data/project.json
{
"$schema": "https://data.reopt.ai/schemas/project-settings.v1.json",
"version": 1,
"projectId": "prj_123",
"settings": { "timezone": "Asia/Seoul", "retentionDays": 90, "repositoryBranch": "main" },
}// reopt-data/error-rules.json — array order is evaluation order
{
"$schema": "https://data.reopt.ai/schemas/error-rules.v1.json",
"version": 1,
"projectId": "prj_123",
"rules": [
{
"key": "ignore-vendor",
"name": "Ignore vendor bundles",
"action": { "kind": "suppression" },
"filters": [{ "field": "path", "operator": "startsWith", "value": "/vendor/" }],
},
{
"key": "checkout-owner",
"name": "Checkout errors",
"action": { "kind": "assignment", "assigneeUserId": "usr_9" },
},
],
}// reopt-data/alerts.json — no order; the destination is a secret, so it is a reference
{
"$schema": "https://data.reopt.ai/schemas/alerts.v1.json",
"version": 1,
"projectId": "prj_123",
"alerts": [
{
"key": "purchase-drop",
"name": "Purchases below 10/h",
"eventName": "purchase",
"condition": { "kind": "event_count", "operator": "lt", "threshold": 10, "timeWindow": "1h" },
"channel": "slack",
"destination": "${env:SLACK_ALERTS_URL}",
},
],
}pull also writes the three files' JSON Schemas to reopt-data/.schemas/ and points each file's $schema at its local copy, so an editor validates and completes them without the network (commit that directory too; the same schemas are served at https://data.reopt.ai/schemas/). reopt-data.lock.json (commit it) holds the server's updatedAt and a hash of the resolved fields for the project and every keyed rule and alert, so diff can tell your edits from console edits. The files are the truth: a setting you leave out is the default, and a keyed rule or alert you remove is deleted. Ownership is the key — an item with one belongs to the repository, an item without one belongs to the console and is never touched (it is counted, not changed).
reopt-data pull # server → files + lock; refuses over local edits (--force discards them)
reopt-data diff --format text # kind / key / operation / grade table; always exit 0
reopt-data verify # CI gate: exit 8 when either side moved
reopt-data push # plan only
reopt-data push --apply --yes # write; approve metric-changing/expensive changes
reopt-data import error-rule rule_abc --key ignore-vendor # give a console rule a key and add it to the file
reopt-data import alert alr_123 --key purchase-drop # same for an alert
reopt-data unlink alert purchase-drop # drop the key and the file entry; the alert stays on the serverGrades: retentionDays shortened, newly set or cleared is destructive (the inherited limit may purge rows); timezone and the lifecycle / power-user thresholds are metric-changing; creating, editing, removing or reordering a suppression or grouping rule is metric-changing (it changes which issues exist); alert changes and the remaining declared fields are safe; unknown fields default to destructive. --yes approves a risky grade; destructive changes additionally require --allow-destructive. --force skips compare-and-set for deliberate overwrites. --prune-imported separately permits deleting keyed items imported since the last pull. The directory defaults to ./reopt-data and can be set once with definition.dir in reopt-data.config.mjs.
${env:NAME} references
An alert's destination (a webhook URL, a Slack hook, an address) must not be committed. Write ${env:NAME} instead — upper-case, as CI secret stores spell it — and the CLI substitutes it from the environment before comparing or sending anything. The rules:
diff,verifyandpushfail with exit 3 when any referenced name is unset, listing every missing name at once (unresolved ${env:…} references in reopt-data/alerts.json: SLACK_ALERTS_URL, PAGER_URL — set them in the environment), and send nothing. The server only ever sees the resolved string.- The lock hashes the resolved value, so
${env:SLACK_ALERTS_URL}and the literal it stands for are the same alert. Rotating the secret in the environment is a change; reformatting the reference is not. - Reports show the reference, never its value, and never the server's current destination either (
"from": "<redacted>"). pullkeeps a reference only when the existingalerts.jsondeclares it for the same key and it resolves to exactly the server's value. Otherwise the server's literal is written and the key is listed undersecretsWritten— replace it with a reference before you commit. A reference the current environment cannot resolve proves nothing and is replaced too.
Environments
One repository, several deployments: name them in the config and pick one with --env <name> (or REOPT_DATA_ENV). The files are shared and carry no projectId; each environment keeps its own lock, reopt-data.<env>.lock.json.
// reopt-data.config.mjs — imports nothing, so it loads wherever the CLI runs from (global install, npx)
/** @type {import("@reopt-ai/data-cli").Config} */
export default {
environments: {
staging: { projectId: "prj_staging" },
production: { projectId: "prj_production", apiUrl: "https://data.example.com" },
},
};reopt-data pull --env staging # writes reopt-data/*.json without projectId and reopt-data.staging.lock.json
reopt-data verify --env production # the same files against the production projectPrecedence: the project is the environment's projectId; without --env it is the files' projectId, then --project-id, then config.projectId. --env also makes the environment's projectId the default for every other command (query, project update, …), not only these. A file pinned to a different project than the active environment is refused (exit 3) naming the file and both ids. The API URL is --api-url > REOPT_DATA_API_URL > environments[env].apiUrl > config.apiUrl. An --env the config does not define exits 4 listing the known names.
Project as Code concurrency
push --apply sends settings, error rules and alerts in one atomic request.
Any validation or compare-and-set conflict rolls back all submitted changes.
The returned transaction snapshot becomes the lock baseline; unrelated later
edits remain visible in the next diff. Locally detected conflicts exit 3 before
writing; HTTP 409 races also exit 3 with apiCode: "conflict".
Rule order is stored as an explicit errorRuleOrder array in lock v1, including
numeric keys. pull protects local reordering. A legacy lock with multiple rules
and no order requires reviewing local order before pull --force refreshes it.
--prune-imported includes known imported deletions in the diff and grade gate, so deleting
suppression/grouping rules requires --yes. Without a lock (a fresh CI checkout, the first run
under a new --env) a keyed item the files do not declare is reported as imported, never planned
as a deletion: --force only skips compare-and-set and cannot delete it. An item whose tracked
fields the diff shows unchanged is sent with the updatedAt this run read, so a console save that
changed nothing the files track (a rename, a mute, a reorder of other rules) does not fail the push.
diff and verify report a declaration the console would not save as a warning; push refuses
only the ones it would write. Imports arriving after that read
still cause a conflict. Malformed or nested ${env:...} references fail validation.
A push creates a private <lock-path>.pending.json before sending its UUID operationId.
If the response or local lock write fails, rerun the same push --apply command.
It recovers the original committed receipt before attempting another write; later
console edits remain drift. If no receipt exists, the original files, lock, target,
environment values and flags must match before that operation can be retried.
Do not delete the pending file while the result is unknown. The lock is replaced
atomically. Ignore pending and temporary files in version control; commit the lock.
Server receipts are retained until project deletion and require the project's org key.
The server also rejects a direct project PATCH or definition PUT that could
shorten retention without allowDestructive: true (409 destructive_blocked).
Error envelopes include classification, retryable, and resolution.action/message.
Full control API schemas, approvals and receipt recovery are available at /openapi.json.
The console displays repository keys and the last definition push with an edit warning.
Source maps in CI
# after `next build`
reopt-data sourcemap inject --dir .next/static
reopt-data sourcemap upload --dir .next/static \
--url-prefix https://shop.example.com/_next/static --project-id "$REOPT_DATA_PROJECT_ID" \
--release "$GIT_SHA" --delete-after-upload--delete-after-upload deletes every map the server now holds — uploaded by this run or already there unchanged — and keeps any map with a failed or too-large ref so the next run can retry it. A network error — including a start or finish request that gets no answer within --timeout — is retried and then fails only the refs of that request; the summary always prints, and failures exit 6. The bytes of a map are not held to --timeout, since a large map on a slow link can legitimately take longer. The target resolves flag > REOPT_DATA_PROJECT_ID / REOPT_DATA_ORGANIZATION_ID > the --env entry > the config, and a config prefix or host app is only a default for the target the flags chose.
One bundle serving many projects uploads once per host app, keyed by path:
reopt-data sourcemap upload --dir .next/static --path-prefix /_next/static \
--host-app studio-web --organization-id org_abc --release "$GIT_SHA"Inspect or retire uploaded symbol sets without leaving the CLI:
reopt-data sourcemap list --project-id prj_123
reopt-data sourcemap delete sym_123 --project-id prj_123 --yes
reopt-data sourcemap list --scope host --organization-id org_abc --host-app studio-web
reopt-data sourcemap list-platform --host-app studio-web
reopt-data sourcemap delete-platform sym_123 --yesinject-chunk-ids and upload-sourcemaps — the spellings from @reopt-ai/data-sdk-server's bin — still work, as do its --api-key (either upload key) and --silent (= --quiet) flags. Note the exit code for a partial failure is 6, where the old bin used 1.
License
MIT
