Skip to content

Repository files navigation

arcq

CI npm version

A fast CLI for querying ArcGIS feature services, designed to be driven by both humans and coding agents.

Why arcq

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. 0 success (an empty [] result is success, not an error), 1 error, 2 token 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 ... | jq always works. The human-readable query summary goes to stderr.
  • Loud errors. ArcGIS reports failures (a malformed where clause, 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, and arcq list expose 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 sync indexes every service in your config and turns each layer into a named shortcut.

Installation

Prerequisites

  • Node.js 18 or newer
  • fzf - only needed for the interactive arcq use picker

Install globally from npm:

npm install -g @leverstack/arcq

arcq is now on your PATH (the package is scoped; the command is still arcq).

From source

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/. The npm run build step (tsc) is wired into npm's prepare lifecycle, so it runs automatically on npm install, npm install -g ., and npm link - you normally never invoke it directly. The one exception is development (see below): after editing any .ts source 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 changes

Handy 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         # lint

Using arcq with coding agents

The 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>`).

Security

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:

  1. the --insecure flag on any command: arcq --insecure list my-service
  2. the ARCQ_INSECURE=1 environment variable
  3. "insecure": true at 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-token with file mode 600 (owner read/write only). OAuth refresh credentials (~/.arcq-oauth.json) and the token-expiry meta (~/.arcq-token-meta.json) are written 600 as well - see Authentication for the trade-off between a stored refresh token and a --command credential helper.
  • Requests are sent as HTTP POST with form-encoded bodies, so the token is never placed in a URL query string where it would land in server or proxy access logs.
  • The stored token is bound to the host it was issued for and is not sent anywhere else (see Token host binding).

Token host binding

arcq records which host an access token was issued for, and refuses to send it to any other one. Without that record, pointing arcq at a public ArcGIS Online service while a token for an internal portal is configured sends that credential to services.arcgis.com - ordinary use, no attacker required.

The host is recorded automatically where arcq can know it:

How the token was stored Where the host comes from
arcq token connect the portal you connected to
arcq token refresh the portal that minted the token
arcq token set --host <host> the flag
arcq token set (no flag) inferred, when every service in your config shares one host

On a request to any other host, arcq omits the token and continues anonymously, printing one line to stderr:

[arcq] omitting the token: it was issued for portal.example.com, not services.arcgis.com. Re-run with --allow-cross-host to send it anyway.

Failing open to an anonymous request rather than erroring is deliberate: many ArcGIS services are readable without a token, so the call usually just succeeds. Ones that genuinely need auth fail with the server's own message.

arcq token show reports the binding. A token stored by an older arcq has no recorded host; it keeps working exactly as before, with a one-time stderr hint, until you re-run token connect or token set --host.

Some deployments legitimately share one token across hosts. To opt out, in order of precedence:

  1. the --allow-cross-host flag on any command
  2. the ARCQ_ALLOW_CROSS_HOST=1 environment variable
  3. "allowCrossHost": true at the top level of your config

Each still prints a stderr warning when it sends a token cross-host, so the override is never silent.

Comparison is on hostname, ignoring the port, because ArcGIS deployments routinely front portal and server on different ports of the same machine.

State directory

All six of arcq's state files live in your home directory by default:

File Purpose
~/.arcq.json Service/layer URL config
~/.arcq-context.json Active layer selection
~/.arcq-cache.json Cached layer catalog
~/.arcq-token ArcGIS auth token
~/.arcq-oauth.json OAuth refresh credential
~/.arcq-token-meta.json Access-token expiry

Set ARCQ_HOME to keep a complete, independent set somewhere else. It is created on first write with mode 700:

export ARCQ_HOME=~/.arcq-portal-a
arcq token set
arcq services add my-service "https://portal-a.example.com/arcgis/rest/services"

A second portal gets a second directory, and neither one's token can reach the other's host.

ARCQ_CONFIG still names a single config file and takes precedence over ARCQ_HOME, so an existing setup that points it at a shared config keeps working. Note that it isolates only the config - the token and OAuth credential follow ARCQ_HOME.

Configuration

arcq reads a JSON config file from ~/.arcq.json by default (or $ARCQ_HOME/.arcq.json; see State directory). Override the path directly with the ARCQ_CONFIG environment variable, which wins over ARCQ_HOME.

{
  "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 by arcq list and arcq refresh, and by the <service>:<id> layer form
  • layers - named shortcuts used by arcq query <layer>
  • insecure - optional; set to true to disable TLS verification (see Security)
  • allowCrossHost - optional; set to true to send the token to hosts it was not issued for (see Token host binding)

services and layers both accept a raw URL in place of a name.

Authentication

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.

1. Paste a token (baseline)

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 status

This is the simplest path and always works. The catch on secured portals is that tokens are short-lived, so you end up re-pasting.

2. OAuth refresh (paste once, auto-refresh after)

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 demand

and 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 Keychain

With a command configured, the only secret arcq writes to disk is the short-lived access token in ~/.arcq-token.

3. Username/password portals (browser-console alternative)

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.)

Security note

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.

Commands

arcq services add <name> <url>

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/FeatureServer

arcq refresh

Fetches 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 refresh

arcq use [name]

Selects 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 → Parcels

With a name, selects non-interactively. The name is resolved in this order, first match wins:

  1. a config layer key (my-service-parcels)
  2. a cached service:id pair (my-service:0)
  3. 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 Parcels

An unknown name exits 1 with closest-match suggestions.

arcq active

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/0

arcq fields [layer]

Prints a layer's field metadata as a JSON array of {name, type, alias, length}. Defaults to the active layer; accepts a config layer name, <service>:<id>, or a raw URL.

arcq fields
arcq fields my-service-parcels
# [
#   { "name": "OBJECTID", "type": "esriFieldTypeOID", "alias": "OBJECTID" },
#   { "name": "STATUS", "type": "esriFieldTypeString", "alias": "Status", "length": 50 },
#   ...
# ]

arcq query

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 is resolved in three steps: a key from layers in the config, then <service>:<id> against services, then a raw URL (anything containing ://). query, fields, and use all accept the same three forms. 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.

Query flags

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.

Global flags

--insecure is valid on any command and disables TLS certificate verification for arcq's own requests (see Security).

Query summary

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 summary

arcq sync

Fetches 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 updated

After syncing you can query any layer by its generated name:

arcq query my-service-parcels "1=1"

arcq layers

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-roads

arcq list <service>

List 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/FeatureServer

arcq version

Print the arcq version (also --version / -V).

Exit codes

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 [].

Typical workflow

# 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]'

Contributing

Bug reports and pull requests are welcome - see CONTRIBUTING.md.

License

MIT

About

A fast CLI for querying ArcGIS feature services - built for humans and coding agents.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages