pay-cli

module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 23, 2026 License: MIT

README

pay — the Payload CMS CLI

Latest release ci

Drive any Payload CMS 3.x project from the command line — discover its collections, query documents, create, update, delete, manage globals, versions, drafts, locales and uploads — through the REST API that project already exposes.

PayCLI is built for LLM coding agents first and humans second. That is not a slogan, it is the set of design constraints:

  • One envelope, always. Every command prints a single JSON object with the same shape: ok, data_kind, data, error, meta, warnings. Success and failure are the same shape. Output is never TTY-dependent.
  • Exit codes mean something. Twelve classes, from 2 (auth) to 10 (this project cannot do that). An agent branches on the exit code and error.code, never on prose.
  • It discovers the project. One cold run learns the collections, globals, field schemas, capabilities, id types and locales, then caches them. pay explain answers "what can I do here?" in a single call.
  • It fails locally, before the network. An unknown --sort field returns 200 and silently unsorted data from Payload. PayCLI rejects it with invalid_sort_field, exit 5, and the list of fields that do work. Same for unknown collections, unknown --select keys, bad enum values and uncastable ids.
  • It is never a dead end. pay raw reaches any endpoint PayCLI does not model — custom collection endpoints, POST /api/graphql, plugin routes — with auth, retry, redaction, audit and write-safety intact.
  • Secrets never reach stdout. Not in output, not in logs, not in the audit log, not in the cache, not in error messages. A Payload project with useAPIKey returns the API key in plaintext from GET /api/users/me; PayCLI redacts it on the way past.
  • It is strictly an API client. PayCLI never writes to your Payload project's source files. Not payload.config.ts, not a collection file, not package.json. Every change it makes goes through the API. This is enforced in CI.

Single static binary. No Node, no Python, no runtime. Works against a Payload project you did not write and cannot read the source of.


Install

macOS / Linux:

curl -fsSL https://raw.githubusercontent.com/KLIXPERT-io/pay-cli/main/install.sh | sh

Windows (PowerShell):

irm https://raw.githubusercontent.com/KLIXPERT-io/pay-cli/main/install.ps1 | iex

Or with Go:

go install github.com/KLIXPERT-io/pay-cli/cmd/pay@latest

The installer verifies the archive against checksums.txt and, when cosign is on your PATH, verifies the signature over checksums.txt too. See INSTALL.md for manual downloads, version pinning, air-gapped installs and the self-update model.

pay version
pay --help

Setup

1. Get an API key from your Payload project

PayCLI authenticates as a user in an auth-enabled collection — normally users. Payload's API-key auth has to be turned on for that collection:

// src/collections/Users.ts
export const Users: CollectionConfig = {
  slug: 'users',
  auth: {
    useAPIKey: true,        // <- this
  },
  // ...
}

Then, in the Payload admin panel, open the user you want the CLI to act as, tick Enable API Key, and copy the generated key.

An API key carries that user's full access-control profile. Create a dedicated user for automation rather than handing an agent your own admin account, and give it only the collection permissions it needs. pay can update pages tells you exactly what a key can do before you trust it with anything.

2. Log in
pay auth login --profile dev --base-url http://localhost:3900 --api-key-stdin <<< "$PAYLOAD_API_KEY"

--api-key-stdin reads one line and keeps the key out of your shell history and out of ps. login verifies the key against GET /api/users/me, discovers which collection the key belongs to, and stores it in ~/.config/pay/credentials.json with mode 0600.

Prefer your OS keychain:

pay auth login --profile dev --base-url http://localhost:3900 --api-key-stdin --keyring

Or skip storage entirely — PayCLI reads the environment first:

export PAY_BASE_URL=http://localhost:3900
export PAY_API_KEY=...
pay whoami

Check it worked:

pay whoami
pay auth status
3. Discover the project
pay discover          # one cold run; cached for 10 minutes, hard max 24 hours
pay explain           # what can I do here? — offline after the first discover

pay explain is the command to run first, every time, and the command to hand an agent. It is the answer to what collections exist, what can this key do with them, which ones have drafts or versions or uploads, what are the locales, what are the id types.


Multi-profile

A profile is one (base URL, api path, auth collection, credential) tuple. Profiles are how you keep local, staging and prod apart, and PayCLI keys its cache by the connection and the credential fingerprint, so two keys with different permissions never see each other's view of what is readable.

pay auth login --profile local --base-url http://localhost:3900     --api-key-stdin
pay auth login --profile prod  --base-url https://cms.example.com   --api-key-stdin --keyring

pay auth list                 # every profile, its base URL and where its credential lives
pay auth use local            # set the default
pay find pages --profile prod # or override per command

Profiles also live in config, which is where the non-secret settings belong:

# ~/.config/pay/config.toml
version = 1
default_profile = "local"

[defaults]
output      = "json"
depth       = 0
limit       = 20
timeout     = "30s"
max_bulk    = 100

[profiles.local]
base_url        = "http://localhost:3900"
auth_collection = "users"

[profiles.prod]
base_url        = "https://cms.example.com"
auth_collection = "users"
label           = "production — writes are audited"

A credential never goes in a config file. PayCLI refuses to load a config containing an api_key key at any nesting depth and tells you where to put it instead (config_secret_in_plaintext, exit 9). Use pay auth login, the keychain, a credential_helper, or an environment variable.

A project-local pay.toml at your repository root layers on top of the user config, so a checked-in pay.toml can pin base_url and auth_collection for the whole team without carrying a secret.

pay config paths      # where everything lives
pay config explain    # every resolved setting and exactly which layer set it

Use with coding agents

PayCLI ships an agent skill — the envelope, the exit-code table, the query DSL and the handful of Payload behaviours that produce confidently wrong answers when guessed. It lives in this repository at skills/pay/, and there are two ways to install it.

1. From the binary (offline, recommended). The skill is compiled into pay, so this needs no network and no Node:

pay skills install

2. With the skills CLI, if you would rather not install PayCLI first, or you want the skill in a tool PayCLI does not know about:

npx skills add https://github.com/KLIXPERT-io/pay-cli/skills --skill pay

Both routes copy the same files — skills/pay/ is a Go package that owns the go:embed, so the tree inside the binary is the tree in the repository, and there is only ever one copy to keep up to date. Only route 1 can also write references/PROJECT.md for the project you are standing in, and only route 1 records .pay-skill.json so pay skills status can tell you when your copy has gone stale.

pay skills install copies the skill into every agent skills directory that already exists under your project root — .claude/skills/pay/, .codex/, .cursor/, .gemini/, .antigravity/, .opencode/, .windsurf/, .continue/, .crush/, .kiro/, .qwen/, .qoder/ — and never creates one for an agent you do not use.

pay skills install --with-project-context   # + references/PROJECT.md for THIS project
pay skills install --global                 # into $HOME instead of the project
pay skills install --agent claude,codex     # only these, creating the directories
pay skills install --dir ~/.config/anything/skills
pay skills status                           # what is installed, and is it stale
pay skills update                           # refresh to this binary's version

--with-project-context runs discovery and writes references/PROJECT.md: your actual collection table, globals, auth collection, locales, and three examples written against real slugs. It never contains a credential, and base_url is redacted. Files you edit are skipped with a warning on reinstall, never silently overwritten.

Not using a skills-aware agent? pay skills print --output raw > SKILL.md gives you the same document.


Quick tour

Everything below is real output shape from a real Payload 3.x project — a website with pages, posts, media, categories, forms and a bolted-on CRM.

What is here?
pay collections
pay collections --capability upload          # media, crm-attachments
pay collections --kind auth                  # users
pay collections --grep crm --include-internal
pay describe pages                           # fields, types, required, queryable, sortable
pay describe pages --field hero.links        # one field, including block slugs
Read
pay find pages --limit 5
pay find pages --select title,slug,_status --sort -updatedAt --limit 10
pay find pages --where 'slug equals home'
pay find pages --where '_status equals published' --where 'title contains guide'
pay find pages --or 'title contains guide' --or 'title contains tutorial'
pay find posts --since 7d --date-field updatedAt
pay find crm-contacts --q 'acme' --q-fields name,email
pay get pages 16
pay get pages 16 --depth 1 --select title,hero
pay count pages

Pagination, without writing a loop:

pay find pages --all --max 1000 --output jsonl > pages.jsonl

Only ids:

pay find pages --where '_status equals draft' --output id
Drafts, versions, globals

The draft rule, which everyone gets wrong once: a read without --draft does not filter out unpublished documents. Payload returns never-published documents from a plain read. To get only published content you must say so:

pay find pages --published-only     # actually published
pay find pages --draft-only         # only drafts
pay find pages --draft              # draft versions where they exist
pay versions list pages --id 16
pay versions get pages <versionId>
pay versions diff pages <vA> <vB>
pay versions restore pages <versionId> --yes

pay globals list
pay globals get header
pay globals update header --set 'navItems.0.link.label=Docs' --yes
Write

Writes are classified L0–L3 and the risky ones need --yes. Everything L1 and above is written to a local audit log before and after the call, so an interrupted destructive operation still leaves a trace.

pay create posts --set title='Hello world' --set slug=hello-world --draft
pay create posts --data-file ./post.json
pay update pages 16 --set title='New title' --yes
pay update pages 16 --publish --yes
pay delete pages 16 --yes                       # soft delete when trash is enabled
pay delete pages 16 --permanent --yes           # irreversible; says so
pay restore pages 16 --yes                      # un-trash
pay duplicate pages 16

Bulk writes always resolve their blast radius first and print it:

pay delete pages --where '_status equals draft' --dry-run
# -> {"ok":true,"data_kind":"op_result","data":{"would_affect":7,"sample_ids":[...],
#     "request":{"method":"DELETE","url":"...","body":null}},"meta":{"dry_run":true,...}}

pay delete pages --where '_status equals draft' --max-docs 10 --yes
pay update pages --where 'category equals 3' --set featured=true --all --yes

--max-docs caps the damage; exceeding it is bulk_limit_exceeded (exit 5) with the count and the two ways forward. --all is the explicit "yes, everything that matches".

Whole-document validation. Payload validates the entire document on every update, not just the fields you sent. A validation error can therefore name a field you never touched. PayCLI marks which is which: error.fields[].sent is true for the fields your command supplied and false for pre-existing invalid data.

Files
pay upload media ./logo.png --alt 'Company logo'
pay upload media - --filename shot.png < screenshot.png
pay upload media https://example.com/x.jpg --allow-remote
pay download media 42 -o ./out.png
pay download media --filename logo.png --size thumbnail -o -
Locales
pay get pages 16 --locale de
pay find pages --locale de --fallback-locale none

The locale rule. --locale de on an untranslated field returns the default locale's text, not an empty value — unless fallback-locale=none, which PayCLI sends by default and reports in meta.locale so you always know which you got.

Anything else
pay raw GET users/me
pay raw GET pages --query 'where[slug][equals]=home' --query limit=1
pay raw POST /api/graphql --data '{"query":"{ Pages { totalDocs } }"}'
pay raw POST media --file ./logo.png --data '{"alt":"Logo"}'
pay raw DELETE pages/42 --yes

pay raw applies the same auth, retry, redaction, audit and risk classification as every other command. DELETE still needs --yes.

Maintenance
pay doctor                       # connection, auth, cache, audit log, skill, clock
pay cache info                   # freshness of this profile's discovery
pay cache ls                     # every cached scope and why they are separate
pay cache warm                   # run discovery now, so the next command is local
pay cache clear --all
pay audit tail -n 50 --action delete
pay update-self --check

rm -rf "$(pay cache path --output raw)" is always safe. It costs one re-discovery.


The envelope

Every command, success or failure, prints one JSON object:

{
  "ok": true,
  "v": 1,
  "command": "find",
  "data_kind": "doc_list",
  "data": [
    { "id": 16, "title": "PayCLI Probe 2", "slug": "paycli-probe-2", "_status": "draft" }
  ],
  "page": {
    "limit": 20, "page": 1, "total_pages": 1, "total_docs": 11, "returned": 1,
    "has_next_page": false, "has_prev_page": false
  },
  "meta": {
    "request_id": "01K5…", "cli_version": "0.1.0", "profile": "local",
    "base_url": "http://localhost:3900", "api_path": "/api", "auth_mode": "api-key",
    "duration_ms": 41, "http_requests": 1, "retries": 0,
    "cache": { "discovery": "hit", "age_s": 92, "ttl_s": 600 }
  },
  "warnings": []
}

data_kind tells you what data is without inspecting it: doc, doc_list, count, global, version, version_list, bulk_result, capabilities, schema, command_spec, op_result, raw, error.

On failure, ok is false, data is absent, and error is present:

{
  "ok": false,
  "v": 1,
  "command": "find",
  "data_kind": "error",
  "error": {
    "code": "invalid_sort_field",
    "exit": 5,
    "message": "\"titel\" is not a sortable field on pages.",
    "hint": "Sortable fields: createdAt, id, publishedAt, slug, title, updatedAt.",
    "retriable": false,
    "confidence": "certain",
    "did_you_mean": ["title"],
    "docs": "pay explain --section exit_codes"
  },
  "meta": { "…": "…" },
  "warnings": []
}

Branch on .ok and the exit code, never on error.message. Payload's messages are translated — the same failure reads differently under Accept-Language: de. The code and the field paths are stable; the prose is not.

Other output formats
--output For Notes
json agents — the default one pretty envelope
jsonl streaming large reads bare documents on stdout, envelope on stderr
id shell pipelines one id per line, nothing else
raw Payload's own body verbatim, after redaction unless --no-redact
csv humans, spreadsheets RFC 4180, CRLF
table humans aligned, width-truncated. Never recommended to agents

--path extracts from .data without a jq dependency. It supports exactly three forms — .a.b, .a[0] and .a[] — and nothing else:

pay find pages --path '.[].slug'
pay get pages 16 --path '.title'

This is not jq. Pipe the envelope to jq when you want jq.


Exit codes

Exit Class Means Typical codes
0 OK it worked —
1 internal a PayCLI bug, a corrupt cache, an unwritable audit log internal, cache_corrupt, audit_write_failed
2 auth the credential is missing, wrong, expired or locked auth_missing, auth_invalid, auth_required
3 throttled back off and retry rate_limited, doc_locked, server_busy
4 not found the document, route or version does not exist doc_not_found, route_not_found
5 validation your input is wrong — fix it and retry validation_failed, invalid_sort_field, unknown_field, invalid_args
6 network DNS, TLS, timeout, or the server is down timeout, network_unreachable, server_error
7 partial some items succeeded and some failed partial_failure
8 access denied authenticated, but not permitted access_denied
9 config no base URL, unknown profile, secret in plaintext config config_missing, profile_unknown
10 capability this project cannot do that collection_unknown, feature_unavailable, graphql_disabled
11 confirmation a destructive operation needs --yes confirmation_required

Exit 7 is the one to handle explicitly. A bulk write that partially succeeded is not a failure to retry wholesale — re-running it would re-apply the half that worked. Read error.failures[], which names every item that failed and why, and retry only those. The envelope's next block gives you the command.

pay explain --section exit_codes prints this table live, from the binary you are running.


Configuration reference

Every setting resolves through the same chain, highest wins:

  1. command-line flag
  2. environment variable (PAY_*, and PAY_*_<PROFILE> for per-profile overrides)
  3. project config (./pay.toml, found by walking up to the git root)
  4. user config (~/.config/pay/config.toml)
  5. built-in default

pay config explain prints every resolved value and the layer that set it, which is the fastest way to answer "why is it talking to the wrong server".

Common environment variables:

Variable Effect
PAY_PROFILE profile to use
PAY_BASE_URL Payload origin
PAY_API_KEY, PAY_API_KEY_<PROFILE> the credential
PAY_JWT, PAY_JWT_<PROFILE> a bearer token instead of an API key
PAY_AUTH_COLLECTION auth collection slug (default users, auto discovers it)
PAY_CONFIG_DIR, PAY_CACHE_DIR, PAY_STATE_DIR, PAY_HOME directory layout
PAY_KEYRING auto | off | force
PAY_NO_AUDIT disable the write audit log
PAY_NO_UPDATE disable every self-update network call
PAY_UPDATE_STRICT refuse an unverifiable release
PAY_YES assume --yes (use with care)

Full list: INSTALL.md and pay explain --section connection.


Security

  • Credentials are never written to a config file. PayCLI refuses to load one that contains an api_key key at any depth, and tells you the three places it belongs.
  • credentials.json is 0600, and PayCLI refuses to read it when the permissions are wider (auth_insecure_permissions, exit 2; pay auth fix-perms repairs it). On Windows this check does not apply: NTFS has no Unix mode bits, os.Chmod only toggles the read-only attribute, and Go reports 0666 regardless. Confidentiality there rests on the file living under your %AppData% profile directory. If you need a stronger guarantee on Windows, use --keyring (Windows Credential Manager) or a credential_helper.
  • The OS keychain is opt-in, never the default, and never blocks: a keychain that is unreachable is a warning, not a failure.
  • Redaction is structural, not textual. Secret-shaped keys (apiKey, hash, salt, password, sessions, resetPasswordToken, anything matching token or secret), JWT-shaped values, URL userinfo and secret query parameters are masked on every path out of the program: stdout, stderr, logs, the cache, the audit log, --dry-run previews and error messages. --no-redact opts out, deliberately and per invocation.
  • The audit log never contains a credential, an Authorization header in any form, or a document body.
  • Releases are signed. Archives are checksummed, checksums.txt is cosign-signed and the artefacts carry a GitHub build-provenance attestation. pay update-self verifies both and always aborts on a mismatch — PAY_UPDATE_STRICT cannot relax that.
  • PayCLI never writes to your project's source. Enforced by scripts/arch-lint.sh in CI, not by convention.

Compatibility

PayCLI targets Payload 3.0 and later, best-effort. There is no hard version floor: it discovers what the project supports and adapts, and every capability it could not establish stays null — unknown, which means "attempt the operation and classify the answer" rather than "refuse". When a project reports a version older than 3.0 it is a warning, never a refusal.

What that buys you: PayCLI works against a Payload project with a custom routes.api, a non-users auth collection, GraphQL disabled, introspection disabled, endpoints: false on a collection, plugin routes that shadow built-in ones, Postgres or MongoDB or SQLite, localisation on or off. Where it cannot learn something, it says so in manifest.limitations[] and falls back to trying.

pay doctor is the one command that tells you what is and is not working, in order, with the fix for each.


Contributing

make check        # exactly what CI runs: fmt, vet, staticcheck, arch-lint, tests
make test         # offline unit tests, -race -shuffle=on
make test-live    # integration tests; needs PAY_TEST_BASE_URL + PAY_TEST_API_KEY
make build        # ./pay
make snapshot     # full release artefacts, locally, without publishing

make lint runs scripts/arch-lint.sh, which enforces the architectural rules that cannot be expressed as types — process-surface confinement, single-point HTTP request construction, the redaction boundary, atomic writes, and the "PayCLI never writes project source" invariant. A violation is a build failure.

Releases: bump VERSION on main. That is the whole process — CI creates the tag and runs the release.

The design is documented in docs/ARCHITECTURE.md, and the Payload behaviours it is built around — all verified against a live instance — are in docs/GROUNDING.md.


License

MIT. See LICENSE.

Directories

Path Synopsis
cmd
pay command
Command pay drives any Payload CMS 3.x project through its REST API.
Command pay drives any Payload CMS 3.x project through its REST API.
internal
apierr
Package apierr is PayCLI's error vocabulary (§11): a closed set of stable machine-readable codes, a total code-to-exit-status map, and the normaliser that folds Payload's six different error body shapes into one.
Package apierr is PayCLI's error vocabulary (§11): a closed set of stable machine-readable codes, a total code-to-exit-status map, and the normaliser that folds Payload's six different error body shapes into one.
audit
Package audit implements §12.7: the JSONL audit log of every write PayCLI performs.
Package audit implements §12.7: the JSONL audit log of every write PayCLI performs.
buildinfo
Package buildinfo carries the values stamped into the binary at link time (§16.4) and degrades gracefully when they are absent.
Package buildinfo carries the values stamped into the binary at link time (§16.4) and degrades gracefully when they are absent.
cache
Package cache implements PayCLI's on-disk discovery cache (§8) and the in-process memo layer that sits in front of it (§8.6).
Package cache implements PayCLI's on-disk discovery cache (§8) and the in-process memo layer that sits in front of it (§8.6).
cli
Package cli is PayCLI's command tree and the only place in the program that is allowed to see the process itself.
Package cli is PayCLI's command tree and the only place in the program that is allowed to see the process itself.
config
Package config owns everything PayCLI knows before it talks to a server: where its files live (§4.1), what is in them (§4.2, §4.3), how the layers combine (§4.5) and what can be learned about the surrounding Payload project from the local filesystem alone (§7.10, §7.11).
Package config owns everything PayCLI knows before it talks to a server: where its files live (§4.1), what is in them (§4.2, §4.3), how the layers combine (§4.5) and what can be learned about the surrounding Payload project from the local filesystem alone (§7.10, §7.11).
discovery
Package discovery builds PayCLI's adaptive capability manifest (§7).
Package discovery builds PayCLI's adaptive capability manifest (§7).
fetch
Package fetch retrieves an arbitrary URL that is NOT the Payload server.
Package fetch retrieves an arbitrary URL that is NOT the Payload server.
fsatomic
Package fsatomic implements the single durable-write primitive required by §3.1: every durable write in PayCLI goes through Write.
Package fsatomic implements the single durable-write primitive required by §3.1: every durable write in PayCLI goes through Write.
logging
Package logging builds PayCLI's slog logger.
Package logging builds PayCLI's slog logger.
output
Package output owns PayCLI's single response envelope (§10) and every rendering of it.
Package output owns PayCLI's single response envelope (§10) and every rendering of it.
payload
Package payload is PayCLI's Payload CMS REST/GraphQL client.
Package payload is PayCLI's Payload CMS REST/GraphQL client.
payload/query
Package query builds Payload REST query strings.
Package query builds Payload REST query strings.
payloadtest
Package payloadtest is PayCLI's hermetic test harness: a fixture-backed Payload server, a golden-file comparator and a deterministic clock.
Package payloadtest is PayCLI's hermetic test harness: a fixture-backed Payload server, a golden-file comparator and a deterministic clock.
redact
Package redact removes credentials from everything PayCLI emits: stdout, stderr, log lines, audit records, cache files and the manifest (§5.3).
Package redact removes credentials from everything PayCLI emits: stdout, stderr, log lines, audit records, cache files and the manifest (§5.3).
rows
Package rows is PayCLI's local editor for an array of objects: the rows of a Payload `blocks` field, or of a plain `array` field.
Package rows is PayCLI's local editor for an array of objects: the rows of a Payload `blocks` field, or of a plain `array` field.
safety
Package safety implements §12 "Write safety": the four risk levels, the confirmation policy, --dry-run and the single blast-radius cap (--max-docs).
Package safety implements §12 "Write safety": the four risk levels, the confirmation policy, --dry-run and the single blast-radius cap (--max-docs).
secret
Package secret resolves, stores and fingerprints PayCLI's credentials (§5.1, §5.2, §4.4).
Package secret resolves, stores and fingerprints PayCLI's credentials (§5.1, §5.2, §4.4).
skills
Package skills installs the agent-facing documentation that ships inside the binary (§14) into an agent's skill directory.
Package skills installs the agent-facing documentation that ships inside the binary (§14) into an agent's skill directory.
update
Package update implements §15: `pay update-self`, the three-state verification outcome, managed-install detection and the detached apply.
Package update implements §15: `pay update-self`, the three-state verification outcome, managed-install detection and the detached apply.
Package skills is the repository-root home of PayCLI's agent skill and the single owner of its //go:embed directive (§14).
Package skills is the repository-root home of PayCLI's agent skill and the single owner of its //go:embed directive (§14).
tools
recordfixtures command
Command recordfixtures re-records testdata/fixtures from a live Payload instance.
Command recordfixtures re-records testdata/fixtures from a live Payload instance.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL