A fast CLI for querying ArcGIS feature services, designed to be driven by both humans and coding agents.
ArcGIS REST is awkward to drive from a terminal or a script: query strings are fiddly, failures hide inside HTTP-200 response bodies, and discovering what layers and fields exist usually means clicking through a services directory. arcq is built to be scripted and, in particular, to be safe for a coding agent to run unattended:
- Strict exit-code contract.
0success (an empty[]result is success, not an error),1error,2token invalid or expired. An agent can branch on the exit code instead of parsing prose. - JSON-only stdout. Query results are the only thing on stdout, so
arcq query ... | jqalways works. The human-readable query summary goes to stderr. - Loud errors. ArcGIS reports failures (a malformed
whereclause, an expired token) inside HTTP-200 bodies; arcq inspects every response and exits non-zero, so a[]you see is trustworthy. - Non-interactive discovery.
arcq layers --names,arcq active,arcq fields, andarcq listexpose the catalog and schema without the fzf picker, so an agent can find its way around a server on its own. - One-command setup for a whole server.
arcq refresh && arcq syncindexes every service in your config and turns each layer into a named shortcut.
Prerequisites
Install globally from npm:
npm install -g @leverstack/arcqarcq is now on your PATH (the package is scoped; the command is still arcq).
git clone https://github.com/jameseaster/arcq.git
cd arcq
npm install # installs deps AND compiles TypeScript -> dist/ (via the "prepare" script)
npm install -g . # put `arcq` on your PATH (also recompiles dist/ automatically)You do not need to create any symlink by hand; npm install -g . (or npm link below) does that for you.
arcq is written in TypeScript and runs from compiled output in
dist/. Thenpm run buildstep (tsc) is wired into npm'spreparelifecycle, so it runs automatically onnpm install,npm install -g ., andnpm link- you normally never invoke it directly. The one exception is development (see below): after editing any.tssource you must rebuild for the global command to pick it up.
Local development
Link the working tree instead of installing a copy:
npm link # also runs the build via "prepare"Because the linked arcq runs the compiled dist/, rebuild after editing source:
npm run build # recompile dist/ so the global `arcq` reflects your changesHandy scripts:
npm run build # compile TypeScript -> dist/
npm run typecheck # type-check source + tests without emitting
npm test # run the test suite
npm run lint # lintThe package ships a ready-made Agent Skill covering discovery, the quoting rule, output shaping, the exit-code contract, and TLS behavior. For Claude Code, install it with:
# from an npm install
cp -r "$(npm root -g)/@leverstack/arcq/skills/arcq" ~/.claude/skills/
# or from a clone
cp -r skills/arcq ~/.claude/skills/(Any agent that reads markdown instructions can use the same file.)
Prefer something lighter? Drop this into your agent's instructions (e.g. a CLAUDE.md) to teach it the workflow:
## Querying ArcGIS with arcq
- Discover configured layers: `arcq layers --names`
- Inspect a layer's schema: `arcq fields <layer>`
- Query data: `arcq query <layer> "<where>" --quiet`
- `where` is ArcGIS SQL; string literals need SINGLE quotes, so double-quote
the shell argument: `arcq query parcels "STATUS = 'ACTIVE'" --quiet`
- Keep output small: `--limit <N>` caps rows, `--count` returns `{"count":N}`,
`--out-fields a,b,c` returns only those fields.
- stdout is pure JSON (pipe to `jq`); the summary goes to stderr.
- Exit codes: 0 = success (an empty `[]` is a valid answer), 1 = error,
2 = token invalid/expired (fix with `arcq token refresh`, or
`arcq token set <token>`).arcq verifies TLS certificates by default. Requests to a server with an untrusted or invalid certificate fail with a clear error naming the host.
Some ArcGIS deployments (typically local or on-premises servers) use self-signed certificates. For those trusted hosts you can disable certificate verification, in order of precedence:
- the
--insecureflag on any command:arcq --insecure list my-service - the
ARCQ_INSECURE=1environment variable "insecure": trueat the top level of~/.arcq.json
When insecure mode is active, arcq prints a single warning to stderr on every invocation:
[arcq] WARNING: TLS certificate verification is disabled
Verification is relaxed only for arcq's own requests (via a scoped https.Agent); it never sets NODE_TLS_REJECT_UNAUTHORIZED process-wide. Only use insecure mode against trusted hosts on trusted networks.
Two more hardening details:
- The auth token is stored at
~/.arcq-tokenwith file mode600(owner read/write only). OAuth refresh credentials (~/.arcq-oauth.json) and the token-expiry meta (~/.arcq-token-meta.json) are written600as well - see Authentication for the trade-off between a stored refresh token and a--commandcredential helper. - Requests are sent as HTTP
POSTwith form-encoded bodies, so the token is never placed in a URL query string where it would land in server or proxy access logs.
arcq reads a JSON config file from ~/.arcq.json by default. Override the path with the ARCQ_CONFIG environment variable.
{
"services": {
"my-service": "https://example.com/arcgis/rest/services/MyService/FeatureServer"
},
"layers": {
"parcels": "https://example.com/arcgis/rest/services/MyService/FeatureServer/0"
}
}services- named shortcuts used byarcq listandarcq refreshlayers- named shortcuts used byarcq query <layer>insecure- optional; set totrueto disable TLS verification (see Security)
services and layers both accept a raw URL in place of a name.
If a token is stored it is sent with every request. Unauthenticated services work without one. The token is sent in the request body, not the URL.
arcq token set # save a token to ~/.arcq-token (mode 600); prompts for it
arcq token set <token> # same, taking the token as an argument (lands in shell history)
arcq token show # print the stored token, its expiry, and refresh statusThis is the simplest path and always works. The catch on secured portals is that tokens are short-lived, so you end up re-pasting.
arcq token connect stores an OAuth refresh credential so arcq can mint fresh access tokens on its own - no re-pasting until the refresh token itself expires (portal-configured, ~2 weeks by default).
Every ArcGIS JS API web app stores its OAuth credential in the browser - in session storage or local storage depending on how the app signed in, so copy whichever is there. On a page of any ArcGIS web app you're signed into, open the DevTools console and run:
copy(
sessionStorage.getItem('esriJSAPIOAuth') ??
localStorage.getItem('esriJSAPIOAuth')
);Then paste it into arcq:
arcq token connect
# Paste esriJSAPIOAuth JSON (or a bare refresh token): <paste>
# Connected. Access token saved (expires ...); refresh credential good until ~...This works for IWA/SAML/PKI portals too - the web app already performed the interactive sign-in, and arcq only reuses the resulting refresh token. arcq never performs sign-in itself.
After connecting:
arcq token refresh # mint a fresh access token on demandand arcq query/list/fields automatically refresh once and retry if they hit an expired token, so day-to-day use is interaction-free.
Recommended: a credential helper (secret stays in your secret manager). Instead of storing the refresh token in an arcq file, point arcq at a command that prints it. arcq stays secret-manager-agnostic - the same pattern as git credential helpers:
arcq token connect --command 'op read op://Vault/arcgis/refresh-token' # 1Password
arcq token connect --command 'pass show arcgis/refresh-token' # pass
arcq token connect --command 'security find-generic-password -s arcgis -w' # macOS KeychainWith a command configured, the only secret arcq writes to disk is the short-lived access token in ~/.arcq-token.
If your portal uses built-in accounts (not IWA/SAML/PKI), you can mint a token straight from the browser console against <portal>/sharing/rest/generateToken with an expiration up to the portal's maxTokenExpirationMinutes, then arcq token set it. (This does not work on web-tier portals, which reject generateToken for anything but built-in accounts - use token connect there.)
A refresh token stored directly in ~/.arcq-oauth.json is a live credential; arcq keeps it 600 (owner-only), but treat the machine account as the security boundary. Prefer the --command credential-helper form so the secret lives in your secret manager instead. To disconnect, delete ~/.arcq-oauth.json. The refresh token is never printed by any command.
Add a named service to the config. Creates ~/.arcq.json if it doesn't exist yet.
arcq services add my-service https://example.com/arcgis/rest/services/MyService/FeatureServerFetches the layer/table catalog for every service in the config and writes it to ~/.arcq-cache.json. Run this once after updating the config, and again whenever services change.
arcq refreshSelects the active layer (saved to ~/.arcq-context.json), used by subsequent arcq query and arcq fields calls.
With no argument, opens an interactive fzf picker over the cached catalog (requires fzf on your PATH):
arcq use
# [arcq] active layer set → ParcelsWith a name, selects non-interactively. The name is resolved in this order, first match wins:
- a config layer key (
my-service-parcels) - a cached
service:idpair (my-service:0) - an exact cached layer name (
Parcels) - if the same name exists in multiple services, arcq errors and lists the qualified candidates
arcq use my-service-parcels
arcq use my-service:0
arcq use ParcelsAn unknown name exits 1 with closest-match suggestions.
Prints the active layer and its URL, or a no active layer line if none is set (exit 0 either way - absence is a valid answer).
arcq active
# my-service:0 → Parcels
# https://example.com/arcgis/rest/services/MyService/FeatureServer/0Prints a layer's field metadata as a JSON array of {name, type, alias, length}. Defaults to the active layer; accepts a config layer name or raw URL.
arcq fields
arcq fields my-service-parcels
# [
# { "name": "OBJECTID", "type": "esriFieldTypeOID", "alias": "OBJECTID" },
# { "name": "STATUS", "type": "esriFieldTypeString", "alias": "Status", "length": 50 },
# ...
# ]Query a layer and print feature attributes as a JSON array. Results are automatically paginated (1000 records per page).
# Query the active layer
arcq query "1=1"
arcq query "AREA > 1000"
# Query a named layer from config
arcq query parcels "1=1"
# Query a raw layer URL
arcq query https://example.com/.../FeatureServer/0 "STATUS = 'ACTIVE'"
# Shorthand - layer name or URL as the first argument
arcq parcels "1=1"Output is a JSON array of attribute objects:
[
{ "OBJECTID": 1, "AREA": 2048, "STATUS": "ACTIVE" },
...
]A layer argument must be a config layer name or contain :// to be treated as a raw URL - an unknown name exits 1 with closest-match suggestions instead of being sent as a URL. ArcGIS string literals use single quotes (double-quote the shell argument): "STATUS = 'ACTIVE'" works, "STATUS = \"ACTIVE\"" is an ArcGIS syntax error and exits 1.
arcq query --out-fields OBJECTID,STATUS "1=1" # return only the listed fields
arcq query --limit 50 "1=1" # stop after 50 rows
arcq query --count "1=1" # print {"count":N} only
arcq query --order-by "NAME DESC" "1=1" # sort server-side--count makes a single returnCountOnly request; --out-fields and --limit are ignored when combined with it.
--insecure is valid on any command and disables TLS certificate verification for arcq's own requests (see Security).
By default arcq query prints a short summary of what it queried to stderr - the resolved layer, the where clause, and the exact endpoint:
────────────────────────────────────────────────────────
layer: my-service:0 → Parcels
where: STATUS = 'ACTIVE'
endpoint: https://example.com/.../FeatureServer/0/query
────────────────────────────────────────────────────────
Because it goes to stderr, it never contaminates the JSON on stdout - arcq query "1=1" | jq works unchanged. Pass -q / --quiet to suppress it (useful in scripts):
arcq query "1=1" --quiet # JSON only, no summaryFetches the layer/table catalog for every service in the config and rewrites the layers section of ~/.arcq.json with auto-generated keys in the form servicename-layername (lowercase, hyphenated). Existing layers entries are overwritten.
arcq sync
# [arcq] syncing my-service...
# [arcq] config layers updatedAfter syncing you can query any layer by its generated name:
arcq query my-service-parcels "1=1"List all named layers currently in the config.
arcq layers
# my-service-parcels → https://example.com/.../FeatureServer/0
# my-service-roads → https://example.com/.../FeatureServer/1
arcq layers --names
# my-service-parcels
# my-service-roadsList all layers and tables in a service, showing their IDs and names.
arcq list my-service
# 0 → Parcels
# 1 → Addresses
# 2 → Boundaries
# Or use a raw URL
arcq list https://example.com/arcgis/rest/services/MyService/FeatureServerPrint the arcq version (also --version / -V).
| Code | Meaning |
|---|---|
| 0 | success - an empty [] result is a real answer, not an error |
| 1 | error: bad where clause, unknown layer, no active layer, server or request failure |
| 2 | token invalid or expired - run arcq token refresh (or arcq token set) |
Errors print error: <message> to stderr. ArcGIS reports failures inside HTTP-200 response bodies; arcq surfaces those as errors instead of printing [].
# 1. Add services to ~/.arcq.json
# 2. Populate the fzf cache and named layer shortcuts
arcq refresh && arcq sync
# 3a. Pick a layer interactively
arcq use
# 3b. Or query a named layer directly
arcq query my-service-parcels "STATUS = 'ACTIVE'" | jq '.[0]'Bug reports and pull requests are welcome - see CONTRIBUTING.md.