This repository is a Rust workspace that builds zdk, a Zendesk CLI for AI agents, scripts and support engineers. It is OAuth-first because Zendesk stops issuing API tokens on 27 October 2026 and disables them on 30 April 2027. It is designed for deterministic subprocess use: plain JSON on stdout, one-line JSON errors on stderr, stable exit codes, and a rate governor that keeps bulk work inside Zendesk's account-wide and per-endpoint limits.
v0.1.0 is complete: auth, config, doctor, tickets, comments, users, orgs, search, api, completions, man, version. The binary is the source of truth — target/debug/zdk --help-json dumps the whole tree — and README.md must match it.
The core contract:
- Commands emit plain JSON (array or object, no envelope) when stdout is not a TTY or
-o jsonis given;-o rawis the untouched Zendesk body;ndjson,csv,tsv,yaml,tableare the other formats. - Errors never go to stdout. In machine modes they are one stderr line
{"error":{"code","message","help","exit_code","request_id"}}; in table mode a miette report. - Exit codes and
error_code()strings are stable and map toZdkErrorvariants incrates/zdk-core/src/error.rs. - Logs, progress, prompts and the API-token deprecation warning go to stderr.
export PATH="$HOME/.cargo/bin:$PATH" # rustup's cargo, not Homebrew's
export ZENDESK_CREDENTIAL_STORE=none
cargo build
cargo run -p zendesk-cli -- --help
ZENDESK_ACCESS_TOKEN=x ZENDESK_SUBDOMAIN=example target/debug/zdk tickets list --limit 3 --dry-run -o jsonThree workspace members (Cargo.toml, resolver = "3", [workspace.package] version is shared and is the release trigger):
crates/zdk-core(libzdk_core): everything except argv parsing.config/(file + env + precedence;env.rsis the only reader of the process environment),auth/(hand-rolled OAuth: PKCE authorization code with a loopback listener, client credentials, rotating refresh, revocation; API-token ramp with the countdown warning; the 52-scope catalogue, presets andrequired_for),store/(keyring / encrypted file / env / memory;autoprobes and caches its decision),http/(ZendeskClient::executepipeline, retry, redaction, dry-run, audit observer,rate_limit/governor with header parsing, the staticRULEStable and per-profile state),pagination/(cursor, offset with the early guard, Link header;incremental/auditsare typed stubs until v0.4),api/(generated/registry.rs+detail.json.gzfrom the OpenAPI specs;curated/hand-written request builders and theTicketQueryfilter compiler),models/,output/,util/,error.rs.crates/zdk-cli(packagezendesk-cli, binzdk):cli.rsclap tree and global flags,cmd/*.rsone handler module per top-level command,args/shared parsers (--field k=v|k:=json,--data @file|-,--file/--from-stdin,--body/--body-file/--editor,TicketFilters,me|id|email),help_json.rs,context.rs(AppContext: settings, lazy provider/client,emit, confirmations),main.rs(tracing, panic hook, ctrl-c → 130, exit code).build.rsreserves an 8 MiB Windows stack.xtask:cargo xtask codegen [--check](registry fromspecs/),spec-refresh [--spec],spec-diff [--json] [--summary]. Not published. Inference overrides live inxtask/overrides.tomlandxtask/scope_map.toml.
Other places you will touch: specs/ (committed Zendesk OpenAPI snapshots + SPEC_VERSIONS.toml), tests/fixtures/{errors,headers,organizations,search,tickets,users}/, scripts/ (packaging and the release version guard), .github/workflows/, docs/ (PRD, release process, OAuth migration, recipes).
See CLAUDE.md for the file-by-file map and invariants.
OutputFormat::detect in crates/zdk-core/src/output/mod.rs: -o flag → ZENDESK_OUTPUT → [default].output → table if TTY else json. Colour only when the stream is a TTY and not NO_COLOR/--no-color. write_stdout maps BrokenPipe to exit 0.
Exit codes (ZdkError::exit_code()) and codes (error_code()):
0success (also--dry-run, codeDRY_RUN) ·1generic (ERROR,IO) ·2usage (USAGE, before any HTTP) ·3auth (AUTH_NOT_LOGGED_IN,AUTH_EXPIRED,AUTH_REVOKED,AUTH_CLIENT_SECRET_REQUIRED,AUTH_INVALID_SCOPE,AUTH_TOKEN_ENDPOINT,AUTH_CODE_EXPIRED,AUTH_FLOW_ABORTED) ·4SCOPE_MISSING(grants known, checked locally) /FORBIDDEN(403 or grants unknown) ·5NOT_FOUND·6VALIDATION(400/409/422) ·7RATE_LIMITED(with--rate-limit-strategy fail) ·8PARTIAL_FAILURE(reserved) ·9SERVER_ERROR·10CONFIG/CREDENTIAL_STORE·11NETWORK·12PAGINATION_LIMIT·13JOB_TIMEOUT(reserved) ·130INTERRUPTED(prints nothing).
When adding an error variant, update exit_code(), error_code(), help_text(), help_json::EXIT_CODES and the tests together; error_code() strings are part of the public contract.
Credential resolution (auth::resolve_provider) is predictable: ZENDESK_ACCESS_TOKEN (static bearer, never refreshed) → ZENDESK_EMAIL + ZENDESK_API_TOKEN → credential store entry for the active profile → NotLoggedIn (exit 3). ZENDESK_REFRESH_TOKEN is read into EnvOverrides but nothing consumes it yet.
Settings precedence: CLI flag → environment (ZENDESK_*, read once in config/env.rs — clippy forbids std::env::var elsewhere) → profile → [default] → built-in. Config lives at zendesk-cli/config.toml under the platform config dir (XDG honoured on every OS), overridable by --config / ZENDESK_CONFIG. No secret is ever a config field. Unknown keys warn on stderr; config validate reports them.
Grants:
- Authorization code + PKCE (default, public clients,
client_secretoptional): loopback on127.0.0.1(ephemeral port;--port/auth.callback_portfor fixed redirects), single request, state verified, code exchanged immediately (codes live 120 s).--no-browserprints the URL and reads the code or full redirect URL from stdin, with redirecthttp://127.0.0.1:8484/callbackunless a port is configured;--redirect-uriuses a registered URL instead. - Client credentials (confidential, CI): no refresh token; re-minted on expiry from
ZENDESK_CLIENT_SECRET/--client-secret/ the secret kept with--store-secret. - API token (legacy): Basic
{email}/token:{token}; one stderr warning per process with the countdown (api_token::deprecation_warning); never suggest it in docs as the primary path. - Refresh rotates: persist the new pair, then swap; save failure is reported as exit 10 after the command completes, never retry with the old refresh token. Pre-emptive refresh at 80 % of TTL;
expires_at == None→ refresh only on 401.
Store: auto uses the OS keyring (service zendesk-cli) when it works and falls back to the encrypted file credentials.enc in the config dir (XChaCha20-Poly1305; key from ZENDESK_CREDENTIALS_PASSPHRASE via Argon2id, else credentials.key); the decision is cached in state/store-decision.json. ZENDESK_CREDENTIAL_STORE=none (memory) is the test switch and is set in every CI job.
Never commit or log real tokens, client secrets, subdomains, or ticket contents.
All Zendesk calls go through ZendeskClient (crates/zdk-core/src/http/). Per attempt: scope preflight (once, when grants are known) → --dry-run short-circuit (prints the request, exit 0, zero quota; me/email lookups skipped) → AuthProvider::authorization() → RateGovernor::acquire() (fail strategy → exit 7 without sleeping; burst skips the buckets) → send with a stable Idempotency-Key on POST/PUT/PATCH → governor.observe(headers) + observers (--audit-log) → classify: 401 → one invalidate() + retry; 429 → Retry-After (seconds or HTTP date) else full-jitter backoff; 5xx/transport → backoff while attempts remain; otherwise a typed ZdkError carrying request_id. retry::decide() is pure and table-tested.
The governor starts at 200/min and rewrites its account bucket from the first X-Rate-Limit / ratelimit-limit header (ArcSwap; persisted per profile under state/rate_limit/<profile>.json). Help Center paths use a separate budget. Static RateRules in rate_limit/endpoint_rules.rs cover the documented per-endpoint limits (ticket update 100/min + 30/10 min per ticket, incremental 10/min, view execute 5/min per view, user update 5/min per user, create_or_update 5/min per email, org update 5/min per org, search export 100/min, side conversations, agent availabilities, tickets index beyond page 500).
Pagination: PageDialect per operation (from the registry or match_path); cursor by default; offset stops before page 101 / 10,000 records (and before the first page when --all and count say it cannot finish) with exit 12 and the alternatives; search warns at 1,000 and treats 422-on-next_page as end of results; --limit truncates mid-page; --checkpoint resumes.
Do not call reqwest directly outside http/ and auth/.
Two layers: the generated operation registry (api/generated/registry.rs, 887 ops, reached via zdk api <METHOD> <path>, zdk api ops, zdk api describe) and curated commands (tickets, comments, users, orgs, search) with typed flags, filters, table presets and sideload joins.
A new curated command touches:
crates/zdk-core/src/api/curated/<resource>.rs— request builder against the registry operation (spec(Method, path, "OperationId")), returningRequestSpecs; helpers for envelopes.crates/zdk-cli/src/cmd/<resource>.rs— clap subcommand + handler that builds the request fromAppContext, reusingargs/helpers.crates/zdk-core/src/models/— only if a new resource shape is needed (Option-heavy,#[serde(flatten)] extra).crates/zdk-core/src/auth/scopes.rs—COMMAND_SCOPESentry (a test asserts every entry names catalogue scopes).crates/zdk-core/src/output/table.rs— a column preset if the command lists.crates/zdk-cli/tests/resources_cli.rs— happy path against wiremock, one error path,--helpsnapshot (cargo insta test --accept, then review the.snap).README.mdCapabilities,CHANGELOG.md[Unreleased].
Mutating commands must honour --dry-run, --idempotency-key, and, if destructive, --yes with a confirmation naming the active profile and subdomain. List commands take the global pagination flags and stream in json/ndjson. Unimplemented PRD flags are not defined so they fail with exit 2 rather than silently doing nothing.
Generated code: after editing specs/, xtask/src/openapi/*, xtask/scope_map.toml or xtask/overrides.toml, run cargo xtask codegen and commit the result. CI's codegen-fresh job diffs it.
export PATH="$HOME/.cargo/bin:$PATH"
export ZENDESK_CREDENTIAL_STORE=none
cargo test --workspace --all-targets --locked # everything (unit, wiremock integration, black-box CLI); ~385 tests
cargo test -p zdk-core --test oauth --test client # one integration suite
cargo test -p zendesk-cli --test resources_cli # black-box curated-command suite (also: cli, auth_cli)
cargo insta test --accept && cargo insta review # snapshots (tables, --help, transcript) after wording changes
cargo test -p zendesk-cli --test e2e -- --test-threads 1 # live; needs ZDK_E2E_SUBDOMAIN/CLIENT_ID/CLIENT_SECRET
cargo xtask codegen --check # registry freshness
cargo llvm-cov --workspace --ignore-filename-regex '(api/generated/|^xtask/)' --fail-under-lines 70- Unit tests beside code; wiremock integration tests in
crates/zdk-core/tests/{client,governor,oauth,pagination,store}.rs; the CLI harness (crates/zdk-cli/tests/common/mod.rs) sets per-testHOME/XDG_*,ZENDESK_CREDENTIAL_STORE=none,ZENDESK_SUBDOMAIN=test,NO_COLOR=1,COLUMNS=100, strips otherZENDESK_*, andzdk_api()addsZENDESK_BASE_URL=<mock>,ZENDESK_ACCESS_TOKEN=test-tokenand a zero-backoff,burstconfig. wiremockis the preferred way to test HTTP behaviour; never require live credentials in the default suite.- Fixtures in
tests/fixtures/{errors,headers,organizations,search,tickets,users}/; no real subdomains or data. proptestcovers cursor sequences, projection idempotence, the filter compiler round trip and thek:=jsonparser.
CI (ci.yml): check, fmt, clippy -D warnings, tests on Ubuntu/macOS/Windows, MSRV 1.88, cargo-deny, cargo-audit, typos, actionlint, generated-code freshness, man/completions smoke, coverage ≥ 70 %, release version guard, and a CI Complete gate.
RUSTFLAGS=-D warningsand--lockedin CI: dead code, unused imports and a staleCargo.lockfail the build.- Homebrew's
cargoshadows rustup's on the maintainer's Mac:cargo +1.88.0fails,crossand--targetbuilds pick the wrong toolchain.export PATH="$HOME/.cargo/bin:$PATH"first; userustup run 1.88.0 cargo check --workspace --all-targets --lockedfor MSRV. - macOS binds keychain grants to the exact binary signature: every unsigned local build prompts (or is denied) when it touches stored tokens. Use
ZENDESK_CREDENTIAL_STORE=noneorfilefor local development; the test harness already does. - Keep stdout clean. Only
output::write_stdout*prints to stdout; clippy'sprint_stdout/print_stderrlints enforce it.write_stdoutmapsBrokenPipeto exit 0 (| headmust not error). - Preserve exit codes and
error_code()strings; agents branch on them. std::env::varonly inconfig/env.rs(clippydisallowed_methods); tests may use it intests/only.- Snapshots (
crates/zdk-cli/tests/snapshots/*.snap) cover--helptext, tables and the transcript; any wording change needscargo insta test --acceptor CI fails. typosis a CI gate (config in.typos.toml; British spellings in help text are accepted, generated code and fixtures are excluded).- Generated registry freshness:
cargo xtask codegen --checkmust pass; it is byte-deterministic. xtask has noman/completionscommands — usezdk man --to DIR/zdk completions <shell>. operationIds collide across the three specs; always address an operation as(spec, id).- Zendesk YAML quirks mean
openapiv3cannot parse the specs; the xtask walker works onserde_yaml_ng::Valueandoverrides.tomlpatches inference.spec-refreshrefuses to overwrite a snapshot it cannot parse. - Search is offset-only and capped at 1,000 results;
search export(cursor) is the escape, andtickets list --allwith filters takes it automatically. - The API-token warning must be one line, once per process, stderr only, and suppressible by
--quiet/auth.suppress_deprecation. It does not fire under--dry-run(the provider is never asked for a header), and dry-run output always showsBearer [redacted]. - Windows: the main thread has an 8 MiB stack via
build.rs; completions/man render on a 16 MiB thread. Keeppanic = "unwind"in the release profile. - Global flag names are reserved (
-o/--outputis the format selector,--all/--paginateis auto-paginate; never reuse them for file paths or "all profiles"). - Keep
README.md,--helptext and the CLI tests in sync: a documented flag form gets a regression test, and everyzdk …in the README must resolve against--help-json.
Version-bump driven: bump [workspace.package] version, run cargo update --workspace, add ## [X.Y.Z] to CHANGELOG.md, merge; auto-tag.yml pushes the tag and release.yml builds 7 targets, attests, publishes, and updates the Homebrew tap and Scoop bucket. Full runbook, secrets and rollback table: docs/release-process.md. Never create a release by running release.yml manually; -f tag= only re-publishes an existing tag.
For most changes:
cargo fmt --all -- --check
cargo test --workspace --all-targets --lockedFor command, model, API, error or shared-core changes, also:
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo check --workspace --all-targets --locked
cargo insta test --accept && cargo insta review
typosFor anything under specs/ or xtask/: cargo xtask codegen --check. For workflow changes: actionlint -color .github/workflows/*.yml.
If you change CLI help text, command names, output shapes, error codes or exit codes, update README.md, CHANGELOG.md, CLAUDE.md, the insta snapshots and the tests under crates/zdk-cli/tests/.