A fast, single-binary CLI that gives AI agents, scripts and support engineers structured, rate-limit-aware access to the Zendesk Support API — OAuth-first, because Zendesk stops issuing API tokens on 27 October 2026 and switches the remaining ones off on 30 April 2027.
Every command emits plain JSON when piped, uses deterministic exit codes, keeps stdout clean, and never stores a credential in plaintext. Not a chatbot. A tool that agents wield.
Status: v0.1.0 is the first release: OAuth login, credential stores, the rate governor, cursor pagination, the
apiescape hatch and curatedtickets/comments/users/orgs/searchcommands. Everything under Roadmap does not exist yet.
Support teams and the agents that help them need to read, triage, update and export tickets from scripts, pipelines and LLM tool calls. The existing options either assume a browser, depend on the API tokens Zendesk is retiring, ignore the per-endpoint rate limits that make bulk work fail halfway, or return output no program can parse.
zdk is a headless, machine-readable interface to Zendesk that any agent can call as a subprocess: OAuth with PKCE by default, client credentials for CI, a rate governor that learns the account's real quota from response headers, cursor pagination that resumes, and an api escape hatch backed by an operation registry generated from Zendesk's own OpenAPI specs (887 operations: Support 645, Help Center 182, Talk 60).
stdout is data. When stdout is not a TTY (or with -o json), a command prints the plain JSON payload — an array for lists, an object for single resources — with no envelope, so jq works directly:
zdk tickets list --status open --limit 50 | jq '.[].id'
zdk tickets get 1234 | jq '.status'
zdk tickets list -o ndjson | while IFS= read -r t; do …; done # one object per line, streamed
zdk tickets list -o raw # untouched Zendesk response body
zdk tickets list --fields id,subject,status --jq '.[] | .id' # projection and an embedded jq filterFormats: table (default on a terminal), json (default when piped), ndjson, csv, tsv, yaml, raw. Selection order: -o → ZENDESK_OUTPUT → [default].output in the config file → TTY detection.
stderr is everything else: progress, warnings, prompts, rate-limit notices, logs. In machine output modes an error is a single JSON line on stderr, and the process exits with a stable code. This is exactly what zdk tickets get 1234 -o json prints before logging in:
{"error":{"code":"AUTH_NOT_LOGGED_IN","message":"not authenticated for profile 'default'","help":"Run `zdk auth login` (browser), `zdk auth login --no-browser` (SSH), or `zdk auth login --client-credentials` (CI).","exit_code":3,"request_id":null}}help and request_id are null when there is nothing to say; request_id is Zendesk's correlation id whenever the failure came from an HTTP response. In table mode (a TTY) the same error is a readable miette report. The code strings are stable (crates/zdk-core/src/error.rs):
code |
Exit | When |
|---|---|---|
USAGE |
2 | Bad flag or value; a destructive command without --yes on a non-TTY (nothing was sent) |
AUTH_NOT_LOGGED_IN, AUTH_EXPIRED, AUTH_REVOKED, AUTH_CLIENT_SECRET_REQUIRED, AUTH_INVALID_SCOPE, AUTH_TOKEN_ENDPOINT, AUTH_CODE_EXPIRED, AUTH_FLOW_ABORTED |
3 | No usable credential; refresh failed; the OAuth client rejected a scope; the login flow failed |
SCOPE_MISSING |
4 | The token's granted scopes are known and lack the one the command needs (checked locally, before any HTTP) |
FORBIDDEN |
4 | Zendesk answered 403 (or grants are unknown, e.g. a static or API token) |
NOT_FOUND |
5 | 404, or an email that matches no user |
VALIDATION |
6 | Zendesk rejected the request (400 / 409 / 422); field details are in message |
RATE_LIMITED |
7 | A budget is exhausted and --rate-limit-strategy fail is set |
PARTIAL_FAILURE |
8 | Reserved for bulk commands (none ship yet) |
SERVER_ERROR |
9 | 5xx after every retry |
CONFIG, CREDENTIAL_STORE |
10 | Unparsable config, bad ZENDESK_* value, keyring / encrypted-file failure, refresh-token rotation that could not be saved |
NETWORK |
11 | Transport or TLS failure |
PAGINATION_LIMIT |
12 | An offset walk would cross Zendesk's 100-page / 10,000-record wall; help names the alternatives |
JOB_TIMEOUT |
13 | Reserved for job orchestration (none ships yet) |
INTERRUPTED |
130 | Ctrl-C (nothing is printed; a --checkpoint file, if given, holds the last completed page) |
IO, ERROR |
1 | Anything else (including a failed zdk doctor run) |
DRY_RUN exists as a code but maps to exit 0: --dry-run prints the request instead of sending it.
Exit codes (the contract agents branch on), from zdk --help-json:
| Code | Name | Meaning |
|---|---|---|
| 0 | OK |
success (also --dry-run) |
| 1 | ERROR |
unexpected error |
| 2 | USAGE |
usage / argument error |
| 3 | AUTH |
authentication failed or missing |
| 4 | FORBIDDEN |
forbidden or missing scope |
| 5 | NOT_FOUND |
resource not found |
| 6 | VALIDATION |
Zendesk rejected the request (400/409/422) |
| 7 | RATE_LIMITED |
rate limit exhausted (or --rate-limit-strategy fail) |
| 8 | PARTIAL_FAILURE |
bulk operation completed with failures |
| 9 | SERVER_ERROR |
Zendesk 5xx after retries |
| 10 | CONFIG |
configuration or credential-store error |
| 11 | NETWORK |
network / TLS error |
| 12 | PAGINATION_LIMIT |
offset pagination cap reached |
| 13 | JOB_TIMEOUT |
job did not finish in time |
| 130 | INTERRUPTED |
interrupted (ctrl-c) |
zdk --help-json prints the whole command tree — every command's path, summary, usage and flags, plus global_flags and exit_codes once at the root — as JSON for tool-definition generation. A closed stdout pipe (| head) is normal termination (exit 0).
# Homebrew (macOS/Linux)
brew install osodevops/tap/zendesk-cli
# Scoop (Windows)
scoop bucket add osodevops https://github.com/osodevops/scoop-bucket
scoop install zdk
# Pre-built binaries — https://github.com/osodevops/zendesk-cli/releases
# Every archive is attested with GitHub build provenance and listed in checksums-sha256.txt:
gh attestation verify zdk-v0.1.0-aarch64-apple-darwin.tar.gz --owner osodevops
sha256sum -c --ignore-missing checksums-sha256.txt
# From source (Rust 1.88+)
cargo install --git https://github.com/osodevops/zendesk-cli zendesk-cliRelease archives (zdk-<tag>-<target>.tar.gz, .zip on Windows) contain bin/zdk, share/man/man1/*.1, share/completions/* and share/doc/zendesk-cli/. Targets: macOS (Apple Silicon, Intel), Linux (x86_64 and aarch64 static musl, x86_64 glibc), Windows (x64, ARM64).
zdk --help,zdk <command> --help,zdk --help-json, and the man pages (zdk man --to DIR)- docs/oauth-migration.md — the API-token deadlines, creating an OAuth client, and how to move
- docs/recipes.md — short, copy-pasteable agent and shell recipes
- docs/release-process.md — how releases are cut and rolled back
- docs/zendesk-cli-prd.md — the product requirements document (full roadmap)
- CHANGELOG.md
You need an OAuth client from Admin Center → Apps and integrations → APIs → OAuth clients (a Public client with redirect URL http://127.0.0.1/callback for interactive use; docs/oauth-migration.md walks through it).
# 1. Write a config file for your instance (prompts for anything missing; --non-interactive in scripts)
zdk config init --subdomain acme --client-id zdk_local
# 2. Sign in (opens a browser; PKCE, no client secret needed for a Public client)
zdk auth login
# 3. Confirm who you are
zdk auth whoami
# 4. Read something
zdk tickets list --limit 3 # table on a TTY
zdk tickets list --limit 3 | jq . # JSON when pipedconfig init writes a profile named default whose scopes are tickets:read, tickets:write, users:read, organizations:read; auth login requests the profile's scopes (falling back to the agent preset when a profile has none).
OAuth is the default and the documented path. API-token auth exists only as a migration ramp and warns on every use.
# 1. Authorization code + PKCE (interactive default). A single-use loopback listener on
# http://127.0.0.1:<ephemeral port>/callback; `state` is verified; the code is exchanged at once.
zdk auth login --client-id zdk_local --scopes tickets:read,tickets:write,users:read
zdk auth login --preset agent # a named scope bundle (repeatable; combines with --scopes)
zdk auth login --port 9876 # fixed loopback port for clients registered with one
zdk auth login --no-browser # print the URL; paste the code or the full redirect URL back
# (redirect http://127.0.0.1:8484/callback unless --port / auth.callback_port)
zdk auth login --redirect-uri https://localhost # paste-from-address-bar flow with a registered redirect
zdk auth login --expires-in 3600 # requested access-token lifetime (300–172800 s)
# 2. Client credentials (CI / automation). Confidential clients only; no refresh token — zdk re-mints on expiry.
ZENDESK_CLIENT_SECRET=… zdk auth login --client-credentials --client-id zdk_ci --scopes tickets:read,users:read
zdk auth login --client-credentials --client-secret "$SECRET" --store-secret # keep the secret in the credential store
# 3. API token (legacy, deprecated).
zdk auth login --api-token --email me@acme.com --token "$ZENDESK_API_TOKEN" # defaults: ZENDESK_EMAIL, ZENDESK_API_TOKEN--subdomain and --profile are global flags (zdk --profile ci auth login …). Authorization codes are valid for 120 seconds; a late exchange is AUTH_CODE_EXPIRED. Requesting a scope outside the client's Allowed scopes is AUTH_INVALID_SCOPE and the help text names the Admin Center screen to fix it. An empty scope list is refused (Zendesk would grant full read+write).
Every API-token call prints one line on stderr, once per process, with the live countdown (today it reads: warning: API token auth is deprecated. New tokens cannot be created after 27 Oct 2026 (46 days); all tokens stop working on 30 Apr 2027 (231 days). Run zdk auth login to switch to OAuth.). After 27 October 2026 the wording becomes Zendesk no longer issues new API tokens; all tokens stop working on 30 Apr 2027 (N days), and after 30 April 2027 all API tokens stopped working on 30 Apr 2027 (N days ago). --quiet or auth.suppress_deprecation = true silences it.
After login:
zdk auth status # grant, scopes, expiry countdown, store backend (exit 3 when not logged in)
zdk auth whoami # GET /api/v2/users/me
zdk auth test # one cheap call per product API the credential can reach
zdk auth refresh --force # refresh (authorization code) or re-mint (client credentials) now
zdk auth token --format bearer # raw | bearer | json — the only command that prints a secret
zdk auth logout --no-revoke # remove the local credential; without the flag the token is revoked server-side firstzdk ships Zendesk's 52-scope catalogue and four presets:
| Preset | Scopes |
|---|---|
agent |
read, tickets:read, tickets:write, users:read, organizations:read, hc:read |
admin |
agent plus triggers:write, automations:write, macros:write, webhooks:write |
readonly |
every granular *:read scope (25 of them; never the legacy blanket read) |
exporter |
tickets:read, users:read, organizations:read, auditlogs:read |
zdk auth scopes list # the catalogue: scope, family, access, description
zdk auth scopes preset agent # a preset's scopes; --login re-authenticates with exactly that preset
zdk auth scopes show --preset admin # granted scopes versus what a preset needs
zdk auth scopes check tickets update # {"required":["tickets:write"],"granted":[…],"satisfied":…,"missing":[…]}Every curated command maps to the scope it needs (tickets update → tickets:write, orgs users → users:read, search → the legacy read because unified search has no granular scope). When the token's granted scopes are known (OAuth), a missing one fails locally with exit 4 / SCOPE_MISSING before any request. With a static ZENDESK_ACCESS_TOKEN or an API token the grants are unknown, so the server decides and a 403 is FORBIDDEN. zdk api skips the check unless the registry knows the operation's scope; --no-preflight disables it.
Resolution order for every command: ZENDESK_ACCESS_TOKEN (used as-is, never refreshed) → ZENDESK_EMAIL + ZENDESK_API_TOKEN → the credential store entry for the active profile → exit 3.
The store is chosen by --credential-store, ZENDESK_CREDENTIAL_STORE or [auth].credential_store:
auto(default): probe the OS keyring once (servicezendesk-cli, one entry per profile); if it is unusable — Docker, CI, SSH sessions, a headless Linux with no Secret Service — fall back to the encrypted file. The decision is cached instore-decision.jsonunder the state directory;zdk auth loginandzdk doctorre-probe.keyring,file,env(read-only: the environment variables above),none(in-memory; what the tests and CI use).
The encrypted file is credentials.enc next to config.toml, XChaCha20-Poly1305, mode 0600. Its key comes from ZENDESK_CREDENTIALS_PASSPHRASE (stretched with Argon2id, 64 MiB / 3 passes / 1 lane) when that variable is set, otherwise from a machine key file credentials.key (32 random bytes, 0600) created beside it on first use. Headless hosts therefore either export ZENDESK_CREDENTIALS_PASSPHRASE in the job environment, or keep credentials.key alongside credentials.enc on the host that logged in. A file written in one mode refuses to open in the other and says which it needs.
Access tokens are refreshed pre-emptively once 80 % of their lifetime has elapsed ([auth].refresh_at_percent); Zendesk rotates refresh tokens, so the new pair is persisted before it is used and the old refresh token is never retried. If the new pair cannot be saved the running command completes and then exits 10 with run zdk auth login.
# Read → decide → act
zdk tickets list --status new --unassigned --limit 20 | my-agent-triage | \
jq -r '.[] | "\(.id) \(.assignee)"' | while read -r id who; do zdk tickets assign "$id" --to "$who"; done
# Preview every write before it happens (consumes no quota)
zdk tickets update 1234 --status solved --dry-run
# Stream a large export without buffering; resume after an interruption
zdk tickets list --all --updated-after 24h -o ndjson --checkpoint /tmp/tickets.ckpt > tickets.ndjson
# Branch on exit codes
zdk tickets reply 1234 --body "On it."
case $? in
0) echo sent ;;
3) zdk auth login && echo "re-auth needed" ;;
4) echo "missing scope: run zdk auth scopes check tickets reply" ;;
7) sleep 60 ;; # rate limited with --rate-limit-strategy fail
esac
# Anything the curated commands do not cover
zdk api GET /api/v2/views --paginate | jq '.[].title'--dry-run skips the lookups that turn me or an email into a user id (a stderr note says so); the real request carries the id.
Commands below ship in v0.1.0. Everything else is roadmap.
zdk auth login [--client-credentials | --api-token] [--no-browser] [--port N] [--redirect-uri URI] [--scopes A,B] [--preset NAME]
zdk auth status ; zdk auth whoami ; zdk auth refresh [--force] ; zdk auth logout [--no-revoke] ; zdk auth test ; zdk auth token [--format raw|bearer|json]
zdk auth scopes list ; zdk auth scopes show [--preset NAME] ; zdk auth scopes check <command…> ; zdk auth scopes preset <name> [--login]zdk tickets list --status open,pending --priority urgent --assignee me --updated-after 2h
zdk tickets list --unassigned --older-than 7d --tag vip --custom-field 360001234=eu
zdk tickets get 1234 --with-comments # or: zdk tickets show 1234 (transcript on a TTY, ticket+comments as JSON)
zdk tickets create --subject "Printer on fire" --comment "It is." --requester jane@acme.com --priority high --tag hardware
zdk tickets create --file ticket.json --field priority=low --field custom_fields:='[{"id":360001234,"value":"eu"}]' # --file/--from-stdin < typed flags < --field
zdk tickets update 1234 --status pending --add-tag waiting --custom-field 360001234=eu
zdk tickets update 1234 --status solved --safe-update --updated-stamp "$STAMP" # 409 → exit 6 if it changed
zdk tickets reply 1234 --body "Fixed in v2.3" ; zdk tickets note 1234 --body-file notes.md
zdk tickets solve 1234 --body "Closing." ; zdk tickets close 1234 ; zdk tickets reopen 1234 ; zdk tickets assign 1234 --to me --group-id 501
zdk tickets count --status open ; zdk tickets recent
zdk tickets delete 1234 --yes ; zdk tickets restore 1234 ; zdk tickets permanently-delete 1234 --yes--file accepts a bare object or {"ticket": {…}}; typed flags merge on top and --field k=v|k:=json (dots nest) is applied last, with keys relative to the resource object (--field priority=low, not ticket.priority). An unfiltered tickets list walks GET /api/v2/tickets with cursor pages. Any filter compiles to a Zendesk search query (zdk search explain --status open --unassigned prints it) and runs through GET /api/v2/search, which is offset-paginated and capped at 1,000 results; --all switches to GET /api/v2/search/export (cursor, uncapped). Time filters accept RFC 3339, YYYY-MM-DD, 24h, "2 hours ago" and yesterday. Tag updates go through the dedicated PUT …/tags endpoint so other tags are untouched.
zdk comments list 1234 --public-only # authors joined from the users sideload
zdk comments get 1234 --comment 98765 ; zdk comments count 1234
zdk comments make-private 1234 --comment 98765
zdk comments redact 1234 --comment 98765 --text "4111 1111 1111 1111" --yeszdk users list --role agent --role admin ; zdk users list --organization-id 890 ; zdk users me
zdk users get jane@acme.com ; zdk users get me ; zdk users get 4567
zdk users search "jane" ; zdk users search --external-id crm-42 ; zdk users autocomplete jan ; zdk users related 4567
zdk users create --name "Jane Doe" --email jane@acme.com --role end-user --verified
zdk users create-or-update --file user.json ; zdk users update 4567 --field phone=+441234 ; zdk users update 4567 --suspended
zdk users delete 4567 --yeszdk orgs list ; zdk orgs get 890 ; zdk orgs count ; zdk orgs search --name "Acme" ; zdk orgs autocomplete acm
zdk orgs create --name "Acme Corp" --domain acme.com --tag enterprise
zdk orgs update 890 --add-tag renewal-q4 --custom-field tier=gold ; zdk orgs delete 890 --yes
zdk orgs tickets 890 ; zdk orgs users 890 ; zdk orgs related 890zdk search 'type:ticket status:open priority:urgent' --sort-by updated_at --sort-order desc
zdk search count 'type:user role:agent'
zdk search export 'type:ticket created>2026-01-01' --to tickets.ndjson # cursor pagination, no 1,000 cap
zdk search export 'status:open' --filter-type ticket -o ndjson # type inferred from a type: term when absent
zdk search explain --status open --assignee me --older-than 24h # {"query":"type:ticket status:open assignee:me created<…","endpoint":"/api/v2/search"}Any Zendesk endpoint, with auth, rate limiting, retries, pagination and scope preflight applied. Never refuses an unknown path; absolute URLs on the profile's host (or https://status.zendesk.com, unauthenticated) are accepted.
zdk api GET /api/v2/tickets/1234/audits --paginate --limit 500 -o ndjson
zdk api /api/v2/users/me # a single argument is a GET path
zdk api POST /api/v2/tickets --data @ticket.json ; zdk api PUT /api/v2/users/4567 --field user.name="Jane"
zdk api GET /api/v2/tickets --query external_id=ORDER-1 --header X-On-Behalf-Of:agent@acme.com --items-key tickets
zdk api ops --grep views --method GET ; zdk api ops --spec help_center --tag Articles
zdk api describe ListTickets --schema ; zdk api describe GET /api/v2/viewsOperation ids collide across the three specs, so describe also accepts support.ListTickets / help_center.ListLocales.
zdk config init [--non-interactive] [--grant-type authorization_code|client_credentials|api_token] [--scopes A,B] [--email E]
zdk config show [--effective [--reveal-secrets]] ; zdk config get default.page_size ; zdk config set default.page_size 50
zdk config edit ; zdk config validate ; zdk config path
zdk config profiles list ; zdk config profiles add sandbox --subdomain acme1234 --client-id zdk_sandbox --switch
zdk config profiles remove sandbox ; zdk config profiles rename old new ; zdk config profiles switch production
zdk completions bash|zsh|fish|powershell|elvish
zdk man --to ./man
zdk doctor [--json] # config, profile, credential_store, credentials, api_token_deadline, auth, clock_skew, rate_limit, registry
zdk version # "zdk 0.1.0" on a TTY; version, target and the committed spec snapshots when pipedzdk doctor exits 1 when any check fails; each check carries name, status (ok / warn / fail), detail and, on failure, the error code.
None of these commands exist in v0.1.0; the PRD in docs/ describes them. Anything read-only among them is already reachable through zdk api.
- views, groups, macros, triggers, automations, SLA policies, ticket fields / forms, custom statuses, tags, suspended tickets, requests, CSAT
- job orchestration and
bulk-*commands (exit 8 / 13 are reserved for them), attachments, side conversations, a schema resolver (--resolve-names, name-based--group/--custom-field "Title=…") - an incremental sync engine and account backup, Help Center and community content
- configuration as code (
rules export / plan / apply / diff / promote) - Talk, Chat, omnichannel routing, custom objects, webhooks, ZIS
- GDPR / redaction workflows, audit logs, an interactive API-token → OAuth migration assistant
- generated coverage for every remaining resource group, and an MCP server mode
Zendesk enforces an account-wide budget (200–2,500 requests/min by plan) and per-endpoint limits that are easy to hit in bulk work. zdk handles both:
-
Governor: a token bucket per family (Support; Help Center paths under
/api/v2/help_center,/guide,/communityhave their own) that starts at 200/min and rewrites its limit from the firstX-Rate-Limit/ratelimit-limitheader it sees, persisting it per profile under the state directory.reserve_percent(default 10) keeps headroom;warn_threshold(50) prints a stderr notice when a budget runs low;high_volume_addon = trueraises the rules Zendesk raises with the High Volume API add-on.--rate-limit-strategy wait(default) sleeps until the bucket refills,failexits 7 immediately without sleeping,burstskips every local bucket (only--max-concurrency, default 4, still applies) and relies on 429 handling. -
Per-endpoint rules, matched by method and path on every request and keyed the way Zendesk keys them:
Rule Endpoint Limit ticket_updatePUT /api/v2/tickets/{id}100/min per account (300 with the add-on) ticket_update_per_ticketPUT /api/v2/tickets/{id}30 per 10 min per ticket incrementalGET /api/v2/incremental/…10/min (30 with the add-on) view_executeGET /api/v2/views/{id}/execute5/min per view user_updatePUT /api/v2/users/{id}5/min per user user_create_or_updatePOST /api/v2/users/create_or_update5/min per email org_updatePUT /api/v2/organizations/{id}5/min per organization search_exportGET /api/v2/search/export100/min side_conversationsPOST …/side_conversations[/{id}/reply]300 per 10 min side_conversation_eventsGET /api/v2/tickets/side_conversations/events600 per 10 min agent_availabilitiesGET /api/v2/agent_availabilities300/min tickets_index_deepGET /api/v2/tickets?page=N, N > 50050/min (applied by the offset paginator) -
Retries:
Retry-After(seconds or an HTTP date) is honoured on 429; 5xx and transport errors back off exponentially with full jitter — 6 attempts, 1 s base, 60 s cap by default.--retries Nand--timeout SECSoverride per call;[retry]in the config file setsmax_attempts,base_ms,max_ms,jitter = "full" | "equal" | "none"andretry_on = [429, 500, 502, 503, 504]. A first 401 invalidates the cached credential and retries once. -
Idempotency: every POST/PUT/PATCH carries an
Idempotency-Key(UUID v4) that is fixed for the lifetime of the request, so a retry after a timeout cannot double-create; set your own with--idempotency-key. -
Dry runs:
--dry-runprints the exact request —{"method","url","headers","body"}in machine formats, acurlline in table mode,Authorizationredacted — exits 0 and sends nothing. Commands that make several requests (tickets updatewith tags) print one object per request. -
Pagination: cursor pagination by default (
page[size], ≤ 100) with--all(alias--paginate),--limit N(truncates mid-page),--page-size Nand--checkpoint FILE, which records progress after every page and resumes from it. Offset-only endpoints are guarded before the request that would cross Zendesk's 100-page / 10,000-record wall — and before the first page when--allis set and the response'scountalready says the walk cannot finish — with exit 12 /PAGINATION_LIMITand the cursor or export alternative inhelp. Plain search warns on stderr once 1,000 results have been fetched and treats a 422 onnext_pageas the end. -
Audit:
--audit-log FILEappends one NDJSON record per API call (method, URL, status, timing, rate-limit headers; secrets redacted).
zdk --profile production tickets list
zdk -p sandbox tickets create --subject "test" --comment "…" --requester me
zdk config profiles listConfig file: ~/.config/zendesk-cli/config.toml (Linux), ~/Library/Application Support/zendesk-cli/config.toml (macOS), %APPDATA%\zendesk-cli\config.toml (Windows); XDG_CONFIG_HOME is honoured on every OS; override the file with --config or ZENDESK_CONFIG. Unknown keys are warned about, not rejected. No secret is ever a config field.
[default]
active_profile = "production"
output = "table" # table | json | ndjson | csv | tsv | yaml | raw
page_size = 100
confirm_destructive = true
[auth]
method = "authorization_code" # authorization_code | client_credentials | api_token (profiles can override)
# callback_port = 8080 # fixed loopback port for clients registered with an explicit redirect URI
auto_refresh = true
refresh_at_percent = 80
credential_store = "auto" # auto | keyring | file | env | none
suppress_deprecation = false
[rate_limit]
strategy = "wait" # wait | fail | burst
max_concurrency = 4
reserve_percent = 10
warn_threshold = 50
respect_retry_after = true
high_volume_addon = false
[retry]
max_attempts = 6
base_ms = 1000
max_ms = 60000
jitter = "full" # full | equal | none
retry_on = [429, 500, 502, 503, 504]
[profiles.production]
subdomain = "acme"
client_id = "zdk_production"
grant_type = "authorization_code"
scopes = ["tickets:read", "tickets:write", "users:read", "organizations:read"]
[profiles.ci]
subdomain = "acme"
client_id = "zdk_ci"
grant_type = "client_credentials"
scopes = ["tickets:read", "users:read"]A profile may also set email (API-token auth), callback_port, credential_store and an informational plan. [cache], [sync], [jobs], [rules] and [audit] sections are parsed and validated but only [audit] (enabled, path) has any effect in v0.1.0.
Destructive commands (tickets delete / permanently-delete, users delete, orgs delete, comments redact) print the active profile and subdomain in their confirmation prompt so a production instance is never mistaken for a sandbox; --yes skips the prompt, and on a non-TTY without --yes they exit 2 before any request.
Precedence: CLI flag → environment variable → profile → [default] → built-in default. The environment is read exactly once at startup (crates/zdk-core/src/config/env.rs); this is the complete list.
| Variable | Description |
|---|---|
ZENDESK_SUBDOMAIN |
Instance subdomain (acme for acme.zendesk.com) |
ZENDESK_CLIENT_ID |
OAuth client identifier |
ZENDESK_CLIENT_SECRET |
OAuth client secret (confidential clients / client credentials) |
ZENDESK_ACCESS_TOKEN |
Direct access token; bypasses the credential store and is never refreshed |
ZENDESK_REFRESH_TOKEN |
Read and masked like the other secrets; not consumed by any v0.1.0 code path |
ZENDESK_SCOPES |
Comma- or space-separated granular scopes for auth login |
ZENDESK_EMAIL |
Legacy API-token auth: agent email |
ZENDESK_API_TOKEN |
Legacy API-token auth: the token (deprecated; dead after 30 Apr 2027) |
ZENDESK_PROFILE |
Active profile (overridden by --profile) |
ZENDESK_CONFIG |
Config file path (overridden by --config) |
ZENDESK_OUTPUT |
Default output format (a bad value is a CONFIG error, exit 10) |
ZENDESK_PAGE_SIZE |
Cursor page size (1–100) |
ZENDESK_MAX_CONCURRENCY |
In-flight request ceiling |
ZENDESK_RATE_LIMIT_STRATEGY |
wait | fail | burst |
ZENDESK_NO_CACHE |
Truthy value (anything but empty, 0, false) disables the local cache — reserved for the v0.3 name/schema cache |
ZENDESK_CREDENTIAL_STORE |
auto | keyring | file | env | none (none is what tests and CI use) |
ZENDESK_CREDENTIALS_PASSPHRASE |
Passphrase for the encrypted file store (otherwise credentials.key is used) |
ZENDESK_LOG |
Tracing filter, e.g. zdk=debug,reqwest=info (ignored when -v is given) |
ZENDESK_BASE_URL |
Base URL override for tests and proxies (also the hidden --base-url flag) |
NO_COLOR |
Present and non-empty disables colour (also --no-color; colour is off whenever the stream is not a TTY) |
VISUAL, EDITOR |
Editor for zdk config edit and --editor (VISUAL wins) |
XDG_CONFIG_HOME, XDG_STATE_HOME, XDG_CACHE_HOME |
Directory roots, honoured on every OS when set |
zdk config show --effective lists which secret-bearing variables are set (values masked as *** unless --reveal-secrets).
zdk tickets list # warnings only (stderr)
zdk -v tickets list # info
zdk -vv tickets list # debug (log targets shown)
zdk -vvv tickets list # trace: full request/response with secrets redacted
zdk -q tickets list # suppress non-error stderr (also silences the API-token warning); errors still print
zdk --audit-log audit.ndjson tickets list # append a structured record of every API callLogs never go to stdout. At -vvv the authorization, proxy-authorization, cookie and set-cookie headers and any access_token, refresh_token, client_secret, api_token, token or password value in a body are redacted.
zdk completions bash > /etc/bash_completion.d/zdk # or: >> ~/.bashrc
zdk completions zsh > ~/.zfunc/_zdk # fpath+=(~/.zfunc); autoload -Uz compinit
zdk completions fish > ~/.config/fish/completions/zdk.fish
zdk completions powershell > zdk.ps1
zdk completions elvish > zdk.elvRelease archives ship all five under share/completions/ (zdk.bash, _zdk, zdk.fish, _zdk.ps1, zdk.elv); the Homebrew formula installs bash, zsh and fish.
Man pages are generated from the clap definitions with clap_mangen, so they never drift from --help:
zdk man --to ./man && man ./man/zdk.1 # zdk.1 plus one page per subcommand (85 files)Release archives ship them under share/man/man1/, and the Homebrew formula installs them.
v0.1.0 (above) is the OAuth-before-the-deadline milestone. Later milestones from the PRD, in order — none of these exist yet:
| Version | Scope |
|---|---|
| v0.2 | views, groups, macros, triggers, automations, SLA, fields, forms, statuses, tags, suspended tickets, requests, CSAT; crates.io and ghcr.io publishing |
| v0.3 | job orchestration, bulk-*, attachments, side conversations, schema resolver (--resolve-names, name-based --group/--custom-field "Title=…") |
| v0.4 | incremental sync engine, account backup, Help Center and community |
| v0.5 | configuration-as-code: rules export/plan/apply/diff/promote |
| v0.6 | Talk, Chat, omnichannel routing, custom objects, webhooks, ZIS |
| v0.7 | GDPR/redaction workflows, audit logs, an interactive API-token → OAuth migration assistant |
| v0.8 | generated coverage for every remaining resource group |
| v0.9 | MCP server mode |
| v1.0 | stable command surface |
export PATH="$HOME/.cargo/bin:$PATH" # rustup's cargo first: Homebrew's cargo shadows it on macOS and breaks `cross` / `--target` builds
export ZENDESK_CREDENTIAL_STORE=none # never touch the OS keyring from local runs or tests
cargo build # debug build of the workspace
cargo run -p zendesk-cli -- --help # run the zdk binary (target/debug/zdk)
cargo test --workspace --all-targets --locked # unit + wiremock integration + black-box CLI tests
cargo insta test --accept && cargo insta review # refresh snapshots after changing help text, tables or error reports
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
rustup run 1.88.0 cargo check --workspace --all-targets --locked # MSRV (`cargo +1.88.0` fails when Homebrew's cargo is first on PATH)
cargo xtask codegen --check # generated registry is fresh (byte-identical)
cargo xtask spec-refresh && cargo xtask spec-diff # pull new Zendesk OpenAPI snapshots and report drift
cargo deny check bans licenses sources advisories
typos
cross build --target x86_64-unknown-linux-musl # optional: prove the static musl build still linksWorkspace: crates/zdk-core (library: config, auth, credential stores, HTTP core, governor, pagination, generated operation registry, curated API, models, output), crates/zdk-cli (the zdk binary; package zendesk-cli), xtask (codegen, spec refresh/diff). See CONTRIBUTING.md and AGENTS.md.
We welcome issues and PRs. Please see CONTRIBUTING.md for guidelines.
See SECURITY.md for our security policy and how to report vulnerabilities. Credentials never touch disk in plaintext, secrets are redacted from logs and --audit-log, TLS 1.2+ (rustls, ring) is enforced, and the binary talks to your Zendesk instance and status.zendesk.com and nothing else (no telemetry).
MIT — see LICENSE.