feat: add sanity api command for direct HTTP API access#1561
Conversation
📦 Bundle Stats —
|
| Metric | Value | vs main (b00bd52) |
|---|---|---|
| Internal (raw) | 2.2 KB | - |
| Internal (gzip) | 838 B | - |
| Bundled (raw) | 11.20 MB | - |
| Bundled (gzip) | 2.11 MB | - |
| Import time | 875ms | -19ms, -2.1% |
bin:sanity
| Metric | Value | vs main (b00bd52) |
|---|---|---|
| Internal (raw) | 782 B | - |
| Internal (gzip) | 423 B | - |
| Bundled (raw) | 9.90 MB | - |
| Bundled (gzip) | 1.78 MB | - |
| Import time | 2.14s | -21ms, -1.0% |
🗺️ View treemap · Artifacts
Details
- Import time regressions over 10% are flagged with
⚠️ - Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.
📦 Bundle Stats — @sanity/cli-core
Compared against main (b00bd52f)
| Metric | Value | vs main (b00bd52) |
|---|---|---|
| Internal (raw) | 114.1 KB | - |
| Internal (gzip) | 29.2 KB | - |
| Bundled (raw) | 21.76 MB | - |
| Bundled (gzip) | 3.46 MB | - |
| Import time | 789ms | +16ms, +2.0% |
🗺️ View treemap · Artifacts
Details
- Import time regressions over 10% are flagged with
⚠️ - Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.
📦 Bundle Stats — @sanity/cli-build
Compared against main (b00bd52f)
@sanity/cli-build/_internal/build
| Metric | Value | vs main (b00bd52) |
|---|---|---|
| Internal (raw) | 113.8 KB | - |
| Internal (gzip) | 28.7 KB | - |
| Bundled (raw) | 17.76 MB | - |
| Bundled (gzip) | 3.56 MB | - |
| Import time | 1.12s | +0ms, +0.0% |
@sanity/cli-build/_internal/env
| Metric | Value | vs main (b00bd52) |
|---|---|---|
| Internal (raw) | 1.8 KB | - |
| Internal (gzip) | 644 B | - |
| Bundled (raw) | 1.31 MB | - |
| Bundled (gzip) | 333.8 KB | - |
| Import time | 121ms | -1ms, -1.0% |
@sanity/cli-build/_internal/extract
| Metric | Value | vs main (b00bd52) |
|---|---|---|
| Internal (raw) | 8.6 KB | - |
| Internal (gzip) | 2.7 KB | - |
| Bundled (raw) | 155.0 KB | - |
| Bundled (gzip) | 39.5 KB | - |
| Import time | 242ms | -3ms, -1.1% |
🗺️ ./_internal/env · ./_internal/extract · @sanity/cli-build:./_internal/build treemap too large to embed · Artifacts
Details
- Import time regressions over 10% are flagged with
⚠️ - Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.
📦 Bundle Stats — create-sanity
Compared against main (b00bd52f)
| Metric | Value | vs main (b00bd52) |
|---|---|---|
| Internal (raw) | 908 B | - |
| Internal (gzip) | 483 B | - |
| Bundled (raw) | 931 B | - |
| Bundled (gzip) | 491 B | - |
| Import time | ❌ ChildProcess denied: node | - |
Details
- Import time regressions over 10% are flagged with
⚠️ - Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.
Coverage Delta
Comparing 8 changed files against main @ Overall Coverage
|
|
bugbots comment
seems related to the merge commit bringing the PR up to date with Edit: rebased instead |
a4280c8 to
bd4ac50
Compare
5f0b65b to
b2f1889
Compare
jonahsnider
left a comment
There was a problem hiding this comment.
generally looks good, i have a few questions + change requests, as well as a handful of nitpicky things
| - [`sanity telemetry enable`](#sanity-telemetry-enable) | ||
| - [`sanity telemetry status`](#sanity-telemetry-status) | ||
| - [`sanity tokens create [LABEL]`](#sanity-tokens-create-label) | ||
| - [`sanity tokens add [LABEL]`](#sanity-tokens-add-label) |
There was a problem hiding this comment.
this looks like a bad rebase, if you rerun pnpm readme from this project it'll regenerate
There was a problem hiding this comment.
Regenerated in cba4ac8 (via manifest:generate + pnpm readme — the committed README had drifted because main's tokens add → tokens create rename (#1566) landed after this branch's last regen). The fresh regen syncs that along with the updated sanity api help.
Generated by Claude Code
| Authenticate with a specific token instead of the logged-in session | ||
|
|
||
| SANITY_AUTH_TOKEN=<token> sanity api users/me |
There was a problem hiding this comment.
should this command also allow --token?
There was a problem hiding this comment.
Added in 43551fc: --token/-t (exclusive with --anonymous), threaded through the CLI client factories so it takes precedence over the stored login token — same pattern as datasets import --token. Covered by a test.
Generated by Claude Code
| import {type ApiRouteEntry, type OpenApiDocument} from '../src/actions/api/types.ts' | ||
|
|
||
| const OUTPUT_PATH = join( | ||
| dirname(fileURLToPath(import.meta.url)), |
There was a problem hiding this comment.
nit: import.meta.dirname
can be used here
There was a problem hiding this comment.
| } | ||
|
|
||
| async function main(): Promise<void> { | ||
| const checkOnly = process.argv.includes('--check') |
There was a problem hiding this comment.
nit: you could use node:util parseArgs for this
There was a problem hiding this comment.
| try { | ||
| await main() | ||
| } catch (error) { | ||
| console.error(error) | ||
| process.exitCode = 1 | ||
| } |
There was a problem hiding this comment.
this script can use top-level await instead of wrapping everything in main(), for simplicity
There was a problem hiding this comment.
Done in 43551fc — straight top-level flow now. A failed fetch rejects at top level, which still exits non-zero and fails the build.
Generated by Claude Code
| if (requestBody !== undefined) { | ||
| requestHeaders['Content-Type'] = 'application/json' | ||
| } |
There was a problem hiding this comment.
is it intentional that all requests with a body are marked as JSON?
seems like current logic is:
- call fn with
bodyas a buffer Buffer.isBuffer(body)returns truerequestBody = bodyrequestBody !== undefinedis now true, so content-type is set to JSON
which is no good
There was a problem hiding this comment.
You're right — and my earlier claim on the Bugbot thread that gh does the same was wrong: gh only sets application/json when it builds the body from fields (bodyIsJSON in cli/cli's http.go); --input bodies get no default Content-Type. Fixed in 43551fc to match: field-built bodies are marked application/json, raw --input bodies send no default Content-Type (pass -H when the API requires one). Help text, README, the stdin example and tests updated accordingly.
Generated by Claude Code
| return patternSegments.length <= segments.length ? score + 1 : score | ||
| } | ||
|
|
||
| function parseQueryString(queryString: string): Record<string, string | string[]> { |
There was a problem hiding this comment.
i think this can be replaced with querystring.parsefrom node:querystring. it uses a null prototype object and uses arrays for repeated values
There was a problem hiding this comment.
Done in 43551fc — parseQueryString is now a thin wrapper over node:querystring's parse, kept as a function only for the narrowing cast from ParsedUrlQuery.
Generated by Claude Code
| response.statusCode === 401 && !flags.anonymous | ||
| ? `. You may need to login again with ${styleText('cyan', 'sanity login')}` | ||
| : '' | ||
| this.error(`${statusLine}${loginHint}`, {exit: 1}) |
There was a problem hiding this comment.
nit: use the exitCodes enum instead of hardcoding 1
There was a problem hiding this comment.
Done in 43551fc — both here (RUNTIME_ERROR) and the usage-error path (USAGE_ERROR).
Generated by Claude Code
| if (flags.input === '-') return stdin | ||
| try { | ||
| // Read raw bytes - `--input` bodies may be binary (eg asset uploads) | ||
| return readFileSync(flags.input) |
There was a problem hiding this comment.
this function is async, but we aren't using fs/promises to read the file here?
There was a problem hiding this comment.
Done in 43551fc — --input file reads now use node:fs/promises. The remaining readFileSync is only for -F key=@file, which runs inside the synchronous field parser.
Generated by Claude Code
| // Null-prototype containers keep user-supplied keys like `__proto__` or | ||
| // `toString` ordinary own properties: nothing to pollute, nothing inherited. | ||
| const result: Record<string, FieldValue> = Object.create(null) |
There was a problem hiding this comment.
nit: null prototype is safe but honestly for things like this where it's just raw user input, i like using Map and not worrying at all about any weirdness
There was a problem hiding this comment.
Kept null-prototype objects here deliberately: these containers are the JSON request body itself (nested, and passed straight to JSON.stringify), so Maps would need a recursive conversion back to plain objects before serialization. Null-prototype gives the same isolation from prototype weirdness without the extra pass. For the flat user-keyed maps (fieldsToQuery, parseHeaders) I've switched those to null-prototype too in 43551fc so the treatment is consistent everywhere user keys land.
Generated by Claude Code
Adds a gh api-style escape hatch to the CLI: `sanity api <endpoint>` makes
authenticated raw HTTP requests against any Sanity HTTP API documented in
the published OpenAPI specifications.
- {projectId}/{dataset} placeholders resolve from flags or CLI config, so
paths copy verbatim from `sanity openapi get <slug>`
- Host (api.sanity.io vs <projectId>.api.sanity.io) and default API version
are routed via a manifest generated from the published OpenAPI specs
(pnpm generate:api-routes), not hand-maintained
- gh-style typed fields (-f/-F), raw bodies (--input), custom headers (-H),
method inference (GET, or POST when a body is present)
- Response body passes through to stdout; HTTP errors exit 1, usage errors
exit 2; --include prints status and headers
Closes AIGRO-5218
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SvSx8KFcwJBduAUszLYH8c
The generate:api-routes script now runs as a prebuild hook, so every build compiles a manifest freshly distilled from the published OpenAPI specs. The script retries transient fetch failures, fetches specs concurrently, and fails the build when the spec index is unreachable, any listed spec fails to fetch, or the distilled manifest would be empty - a release can never ship with a missing or empty endpoint list. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvSx8KFcwJBduAUszLYH8c
…spec paths Two fixes from review: - --input file and stdin bodies are now read as raw bytes and passed through untouched, so binary uploads (eg assets) are no longer corrupted by UTF-8 decoding. Typed -F @file values intentionally stay UTF-8 text - they land inside a JSON body where values are strings by definition. - OpenAPI path keys are split on "?" before distillation, so a stray query marker in a spec path (eg /foo/{id}?) no longer produces an unmatchable route pattern. Also passes proxy-related env vars (HTTPS_PROXY, NODE_EXTRA_CA_CERTS, ...) through turbo, since the build's spec fetch needs them behind TLS- intercepting proxies. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvSx8KFcwJBduAUszLYH8c
pnpm readme regenerates docs for all commands; this picks up help-text changes that landed on main without a README regeneration. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvSx8KFcwJBduAUszLYH8c
…tent type The global-host preference on route score ties now only applies when it actually flips a project-host tie; same-host ties keep the first entry, so the matched spec (and its default API version) no longer depends on later entries in the manifest. Also documents that request bodies are sent as application/json unless a Content-Type header is provided with -H - the intended default for the JSON-first --input workflow, with -H as the override for binary bodies. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvSx8KFcwJBduAUszLYH8c
Full https:// URLs are the only accepted absolute endpoints - http:// is refused with a usage error so the CLI token is never sent unencrypted. The empty-path check now runs after version peeling, so an endpoint that is only an API version (eg "v2025-02-19") is rejected as a usage error instead of being sent to the API root. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvSx8KFcwJBduAUszLYH8c
Field parsing and query-string parsing store user-supplied keys in null-prototype containers and check conflicts with Object.hasOwn, so keys like __proto__ or constructor are ordinary own properties: nothing to pollute, nothing inherited to false-conflict with, and such keys serialize into the request body like any other. Same approach as vercel/vercel's CLI query-string parsing. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvSx8KFcwJBduAUszLYH8c
Route matching counts literal segment matches explicitly and rejects
patterns that match on placeholders alone, so a placeholder-only pattern
in a future OpenAPI spec (eg {resourceType}/{resourceId}) cannot capture
arbitrary paths and steal host/version routing from unrelated endpoints.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SvSx8KFcwJBduAUszLYH8c
Forcing a host with --global or --project-hosted restricts route matching to specs served on that host instead of skipping matching entirely, so the matched spec's default API version still applies and the documented version precedence holds under forced hosts. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvSx8KFcwJBduAUszLYH8c
- Field arrays now follow gh api semantics: consecutive [][key] fields fill the current array element until a key repeats, and a bare key[] declares an empty array - Raw --input bodies are sent without a default Content-Type; only bodies built from fields get "application/json" (gh api parity) - New --token/-t flag authenticates with an explicit API token - JSON string responses print as JSON instead of raw text - Query strings parse via node:querystring; fieldsToQuery and parseHeaders build null-prototype containers - Generation script uses import.meta.dirname, node:util parseArgs and straight top-level await - Exit codes use the exitCodes enum; --input reads via node:fs/promises Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvSx8KFcwJBduAUszLYH8c
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvSx8KFcwJBduAUszLYH8c
0b885fd to
cba4ac8
Compare
A request that is a mere prefix of a longer pattern (eg `data` vs
`data/query/{dataset}`) no longer inherits that route's host and default
version - it falls back to the global host instead of demanding a project
ID for a path that cannot be valid on the project host.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SvSx8KFcwJBduAUszLYH8c
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 6f86b7b. Configure here.
… overrides
- {name} fills from the dataset when it directly follows a datasets
segment (the projects spec's naming), so those paths can be copied
verbatim; {name} in other contexts stays unresolved
- Request headers are keyed lowercase so a user-provided header replaces
the Authorization/Content-Type defaults regardless of casing
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SvSx8KFcwJBduAUszLYH8c

Description
Adds a new top-level command,
sanity api <endpoint>, modeled on thegh apipattern: direct, bearer-authenticated HTTP access to every Sanity API documented in the published OpenAPI specifications. It is the fallback for humans and agents whenever a tailored command group doesn't exist yet — the CLI takes care of auth, host selection and API versioning, the user supplies the path.Closes AIGRO-5218.
How it works with the OpenAPI specs. The set of reachable APIs is driven by the published specs, not hand-maintained code: a routing manifest (
src/generated/apiRoutes.ts) is distilled fromhttps://www.sanity.io/docs/api/openapi(the same source assanity openapi list|get). Per API family it records the host (api.sanity.iovs<projectId>.api.sanity.io), the default API version, and the path patterns. Endpoint paths copied verbatim fromsanity openapi get <slug>just work:{projectId}/{dataset}placeholders are filled from flags or CLI config, and the request is routed to the right host with a sensible version. Route matching is deterministic: a pattern only matches when the request path consumes it completely, literal segment matches outrank placeholder matches, the global host wins host-ambiguous ties (it needs no project ID), and same-host ties resolve to the first manifest entry.Regeneration runs on every build (
prebuildhook →pnpm generate:api-routes), so every build — including release builds — compiles a manifest freshly distilled from whatever is published at that moment. The generator retries transient failures, fetches specs concurrently, and fails the build when the spec index is unreachable, any listed spec fails to fetch, or the distilled manifest would be empty — a release can never ship with a missing or empty endpoint list.--checkmode additionally lets CI detect drift of the committed snapshot. Proxy-related environment variables (HTTPS_PROXY,NODE_EXTRA_CA_CERTS, …) pass through turbo to the build so the spec fetch works behind TLS-intercepting proxies. Build-time generation was chosen over runtime fetching deliberately: routing info is tiny and degrades gracefully (flags and full URLs always override), runtime fetching would couple every cold CLI call to the docs site's availability plus ~24 spec fetches per cache miss, and agents/CI want deterministic per-version behavior. This matches how stripe/aws/twilio vendor specs into releases. Possible follow-up: an on-miss runtime lookup with a disk cache for APIs published between releases.Usage examples
sanity api users/me sanity api projects/{projectId} sanity api 'data/query/{dataset}' -f query='*[_type == "movie"][0..2]' sanity api projects/{projectId} -X PATCH -F displayName="My project" echo '{"mutations": []}' | sanity api 'data/mutate/{dataset}' --input - -H 'Content-Type: application/json' sanity api jobs/123 --include --api-version v2025-02-19Behavior (gh-api parity where it makes sense)
-f/--raw-field(string) and-F/--field(typed:true/false/null/numbers,@file,@-for stdin) with gh's nested-key semantics:a[b]=1builds objects,list[]=xappends to arrays, consecutivekey[][sub]=…fields fill the current array element until a sub-key repeats (so arrays of multi-key objects are expressible), and a barekey[]declares an empty array--input <file|->for raw bodies, read as raw bytes so binary uploads (eg assets) arrive byte-for-byte--inputare present; for GET/HEAD, fields become query parametersContent-Type: application/json; raw--inputbodies are sent without a default Content-Type — provide one with-Hwhen the API requires it (gh api parity)--prettycolorizes; non-JSON verbatim);--includeprints status + headerssanity loginhint on 401); usage errors — bad fields, unresolved placeholders, empty or version-only paths — exit 2, per the repo exit-code convention--global/--project-hostedforce a host; full URLs are accepted only when they arehttps://and on a Sanity API host, so the CLI token is never sent to other hosts or over unencrypted connections--token(orSANITY_AUTH_TOKEN) substitutes an explicit token and--anonymoussends no token; the request itself uses a bareget-itrequester so status, headers and non-JSON bodies pass through unfilteredWhat to review
The diff is ~2,700 additions, but only ~800 lines need close review. Where the lines are:
src/generated/apiRoutes.ts)commands/api.tsdeclarative surface (flag definitions, help text, examples)pnpm readme)Review priorities, highest first:
src/actions/api/resolveEndpoint.ts— the security- and correctness-critical piece: full-URL constraints (https-only, Sanity API host allowlist — the CLI token must never leave that boundary), placeholder substitution, deterministic host matching via segment scoring and tie-breaks, API version precedence (--api-version> version embedded in path > matched spec default > fallback constant)src/services/api.ts— token handling (--tokenwins over the stored login token; user-suppliedAuthorizationheader wins over both, case-insensitively;--anonymousdrops the token), header assembly, body serialization (field objects → JSON with JSON content-type, strings/Buffers verbatim without one)src/actions/api/parseFields.ts— gh-style field parsing/typing with gh's array-element grouping semanticssrc/commands/api.tsrun()/performRequest()— method inference, fields→query vs body, exit-code mappingsrc/actions/api/distillApiRoutes.ts+scripts/generate-api-routes.ts— what the manifest is built from; runs on every build viaprebuildand fails the build on fetch errors or an empty manifestNote for local development:
pnpm build:clirequires network access towww.sanity.io(by design — see above).Known limitations (intentional for v1, called out for follow-up): streaming endpoints (
data/listen, large exports) are buffered; no--jq/--paginate.Testing
Unit tests. 90 tests cover field parsing (typing, gh array-element grouping and empty arrays,
@file/stdin, error cases), endpoint resolution (routing, tie-breaks, full-pattern-consumption matching, placeholders includingdatasets/{name}, version precedence, https/host allowlisting, empty-path rejection), spec distillation (hosts, base paths, embedded versions, query-marker stripping, determinism), and command behavior viatestCommand+nockagainst both hosts (auth header presence/absence including--tokenand case-insensitive-Hoverrides, POST bodies and Content-Type defaults, byte-for-byte binary bodies, query fields, stdin input, JSON-string response printing, error passthrough and exit codes). Both prebuild outcomes are verified: a successful build regenerates the manifest, and a build with the spec endpoint unreachable fails with exit 1 after retries.Live smoke tests (local builds against a non-production environment): ✅ works as designed. Smoke-test passes drove the command live across all 24
sanity openapi listfamilies on test projects:-Fconversion,@file/@-expansion (with-fstaying literal and@-content not re-typed — both matching gh),--input <file>and--input ---input <file>and stdincompleted, agent-action prompt returning a real AI response — all created, read back, and deleted--anonymous); usage errors and URL-constraint rejections exit 2; feature-gated endpoints surface the API's own error body — the command does no client-side plan/permission checks, by designA follow-up smoke pass on the current head verified the review-round changes live: gh array-element grouping (one element per repeated key, bare
key[]→[], scalar appends intact),projects/{projectId}/datasets/{name}resolving from the dataset, prefix paths (data) falling back to the global host while full paths still route project-hosted, no default Content-Type on--inputwith field-built bodies still markedapplication/json, case-insensitive-Hoverrides replacing defaults without duplicates,--tokenbehavior (used verbatim, exclusive with--anonymous), repeated query keys parsed into arrays with__proto__handled safely, and an--input-driven document write lifecycle end-to-end. Notably, the mutate endpoint accepted a piped JSON body without an explicit Content-Type — so the plain--input -flow keeps working even without-H, which stays recommended only for endpoints that are strict about media types.Notes for release
Added a new
sanity api <endpoint>command: make authenticated HTTP requests to any Sanity API documented in the published OpenAPI specs, with{projectId}/{dataset}placeholder substitution, gh-style field flags,--tokensupport, and automatic host and API-version routing. Discover available APIs withsanity openapi list.🤖 Generated with Claude Code
https://claude.ai/code/session_01SvSx8KFcwJBduAUszLYH8c