Skip to content

feat: add resource commands to the CLI - #1210

Merged
mridang merged 26 commits into
mainfrom
feat/cli-resource-commands
Sep 21, 2026
Merged

mridang merged 26 commits into
mainfrom
feat/cli-resource-commands

Conversation

@mridang

@mridang mridang commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Closes #1221. Partially addresses #537 (the releases list half).

Implements ADR 064, which landed separately in #1222 along with the design doc, so this PR now carries the code, tests and changeset only.

Summary

  • Adds a uniform zitadel <resource> <verb> surface for the runtime resources the API already exposes: users, teams, sessions, events, grants, and projects gain list, get, create, update, and delete (delete everywhere). Configuration resources — schemas, environments, releases, flow-definitions, branding — gain list and get only: they are authored as files and shipped through a release (ADR 007, ADR 035), so a write verb here would be a second writer over the same state, while reading what the server currently holds is how you check that a deploy landed.
  • Every list shares one filter grammar. --filter field=operation:value works the same whether the endpoint takes a structured query body (POST /users/query) or flat query parameters (GET /events), and the registry declares per field which operations it accepts, its closed values, whether repeats widen, and which parameter each operation travels as — so events takes --filter created_at=greater_than_or_equal:… even though the endpoint spells that as created_after. This abstracts the transport, never the capability: an operation the endpoint cannot honour is refused locally naming the field, so nothing --help offers can fail at the server.
  • Paging is declared the same way rather than assumed: a list whose endpoint has no cursor (GET /branding) simply has no paging flags instead of advertising ones the server ignores, and a list whose partial answer reads as a complete one (schemas, a revision history) drains on a bare invocation while --limit or --page-token still return one page. The positional argument is named after what the resource is addressed by, so it is zitadel environments get <name>, not a fictional id.
  • create and update take the body two ways. Each field of the generated request schema becomes its own flag (--name, --principal-type, with closed value sets enforced at parse time and required fields marked in --help), and a schema-defined open record such as a user's attributes becomes a repeatable --attributes, where key=value is a string and key:=value parses the value as JSON so a field the user schema types as a number, boolean, array, or object keeps that type (--attributes age:=42, --attributes postcode=02139). The whole body can still be passed as --data '<json>' or --file <path>, and a field flag overrides the same key in it. A required field missing from both is reported by name (grants create is missing required fields: --principal-type, --relation) rather than as a schema issue.
  • The commands are generated, not hand-written. A platform-agnostic factory under apps/cli/src/lib/oclif/crud/ turns a registry entry into oclif command classes: one ResourceCommand base owns parse → meta → execute → emit, and each verb is a *Operation class with a static describe() for its oclif statics and an execute() for its work. Pure helpers (paging, table, body, query) are split out and unit-tested on their own. The Zitadel registry in apps/cli/src/commands/resources.ts supplies the client calls, the project-secret connector, and the environment flag; adding a backend resource is one registry entry.
  • zitadel resources reports the whole surface in one call — every resource, its verbs, its filter and sort fields, and the body fields of its writes (flag, kind, required, allowed values). It is a projection of the registry, contacts no server, and saves an agent eight --help invocations. stripe resources and kubectl api-resources play the same role.
  • --force is no longer a global flag. What it permits differs per command — overwrite a managed file on setup, delete a resource on teams delete — so each command that honours it now declares its own with wording that says what it does (Delete the team without the confirmation prompt. Required when non-interactive.). Commands that never honoured it no longer advertise it.
  • list takes --fields id,attributes.email to choose its columns, dot-paths included, with an unknown path refused and the available ones named; --json is unaffected. The default columns for users now show the person's identifier rather than the schema URL, which was identical on every row.
  • get renders one record for a person on a terminal — headed by whatever identifies it, one labelled field per line in the registry's order, omitting fields the record does not carry — and still prints the whole object when piped or with --json, the split gh view makes. --fields applies to it too.
  • --file - with nothing piped in fails immediately instead of waiting on the keyboard, which looked like a hang.
  • oclif command discovery switches from the dist/commands pattern to the explicit strategy (src/index.ts exports the command table), and the tsdown build becomes a single entry. Existing commands are unchanged.
  • Conventions (one page by default, --all to drain, --filter field=op:value validated locally against the generated Zod schema, --force on destructive verbs, --dry-run, the { items, count, next_page_token } envelope) are documented in docs/design/cli/resource-commands.md and summarised in SKILLS.md.

Drift detection

The registry is hand-maintained, so an endpoint the API gains is invisible to the CLI until someone adds an entry. tests/unit/commands/api-coverage.test.ts is what notices. Its source of truth is the generated client, not the OpenAPI document: every operation is an exported function paired with an exported URL builder, so the operations and their collections are read by calling them, and what the CLI uses is read by invoking each registry verb against a recording proxy client. The comparison is therefore between the calls the commands really make and the calls the client really offers.

Every operation the client exposes must be either called by the registry or listed with a written reason. Of 65 operations, 32 are called; the other 33 carry reasons — the login and auth protocol (10), configuration writes owned by plan/apply (6), bootstrap and claim (5), end-user self-service on the user's own credential (4), health probes (3), WebAuthn registration (2), and three genuine gaps (setUserPassword, listUserTeams, listUserPasskeys) recorded as follow-ups rather than left implicit.

One part of the classification is not a judgement: a collection with POST /<collection>/query is a resource by the API's own definition (ADR 031), so the test requires commands for it and accepts no exclusion — proved by attempting to wave teams through and getting "teams has a query endpoint, so it cannot be listed in NOT_RESOURCES". That covers five of the eleven; the other six list through GET, which is the deviation recorded as an open question, and until it is resolved a new collection without a query endpoint still needs a person to classify it. Each failure mode was verified by deliberately breaking it.

Generic factory

crud/ is generic over the platform connection and over the wire vocabulary. The cursor property names and the structured-query body shape were previously hardcoded ADR 027 / ADR 031 conventions sitting inside the platform-agnostic half; they now live in crud/wire.ts as an overridable default. A test drives the whole factory with a deliberately foreign vocabulary — size / cursor / next_cursor, and a query body nesting its filters under where — and asserts both what goes out and what comes back, so "platform-agnostic" is demonstrated rather than asserted.

Resources and operations

Resource Create List Get Update Delete
users
teams ✓ deactivates
grants
sessions ✓ reports revoked
projects
events
schemas
environments
releases
flow-definitions
branding

zitadel resources --json returns this table, plus each resource's filter fields with the operations each accepts, its paging behaviour, and the body fields of its writes, as JSON.

Most dashes are operations the API has no endpoint for. The exceptions are deliberate and worth naming: POST /projects and POST /sessions do exist, but project creation is unauthenticated bootstrap that mints secrets into .zitadel/secret, which is zitadel setup's job, and a session is minted for an end user by the SDKs and the login flow, with no browser on the other end of a terminal. For the configuration resources every write endpoint is omitted on purpose, because plan / apply already writes them from .zitadel/.

The hand-written schemas list command is replaced by the generated one, which is a breaking change for anyone scripting it — see Release notes below.

Against the CLI guidelines

The surface was checked against clig.dev. Nine findings applied to these commands; all nine are fixed here. The rest are CLI-wide and left for a follow-up.

Finding Change
-d is conventionally --debug --data no longer claims the short form
Piped output should be parseable a TTY gets the aligned table; a pipe (or --plain) gets one tab-separated record per line, no header or footer
Never accept a secret as a flag an attribute named like a credential (password, client_secret, userToken, …) is refused, pointing at --data / --file / stdin; passwordless and similar are unaffected
-a, --all is the convention --all gained -a
Show progress for long operations --all shows a spinner while it drains, on a terminal only
Let the user choose the output's fields --fields picks the columns, as gh --json <fields> and kubectl -o custom-columns do
Human-readable by default, machine-readable when piped get lays a record out on a terminal and emits the object when piped, as gh view does
Do not hang waiting on stdin --file - fails fast when stdin is a terminal
Suggest the next command a paged list now carries data.next_commands with a runnable next page — the cursor plus the same --limit, --sort and --filter, since a token is only valid with the query that issued it; --all suggests nothing

Deliberately out of scope, since they change behaviour for setup, doctor and the rest: -h does not work anywhere (only --help does), there is no --quiet, --non-interactive is not aliased to the conventional --no-input, our -n means non-interactive where the convention is dry-run, typo suggestions do not propose a near match, and the shared fetch runtime sets no network timeout. An ADR should record the conventions this surface commits to.

Review findings

All eleven review comments are addressed.

Finding Change
The page token is opaque but was interpolated unquoted into the suggested next command quoted through the same helper as filters and parameters
--data accepted the credentials --attributes refuses, and the hint recommended --data "$(cat body.json)", which the shell expands into argv a secret-looking property anywhere in an inline --data body is refused too, and every hint and doc now names only --file <path> and --file -
zitadel resources described one schema for both writes, so update looked as demanding as create create_fields and update_fields are reported separately
A team's DELETE deactivates and leaves it readable, but the output claimed deleted a resource can state its outcome; teams report deactivated, sessions revoked, the rest deleted
get --fields was validated only when stdout was a terminal validated before the output branch, so the same arguments are valid either way
--all copied every record once per remaining page pages accumulate in one array
A mistyped operation (contians:) was silently read as an equals value a near-miss of a real operation is refused with the suggestion; a colon-bearing value that resembles none (a schema URL, a timestamp) still passes
Column widths spread every row into Math.max, which throws on very large results folded instead
The changeset implied every resource gains all five verbs it now says each gains what its API supports, and lists them

Hardening after the review rounds

Three gaps found while re-reading the merged result, beyond what the reviews raised.

Gap Change
--all followed next_page_token with no guard, so a server returning a cursor it had already issued would fetch the same page forever behind a spinner a repeated cursor ends the drain with E_VALIDATION, naming the token and the page count
--fields was checked against the records a page returned, so the same argument failed on a full page and passed on an empty one the legal paths come from the generated response schema, so the verdict is the same either way and a typo is refused before any request; an open record such as a user's attributes is reported as a prefix and accepts any key
zitadel resources told agents a resource has a delete verb but not which property its envelope carries, though teams answer deactivated delete_outcome is reported per resource

Validation

  • corepack pnpm run test in apps/cli — 123 files, 1280 tests pass. (doctor fails only under parallel load on a machine where something already holds port 8080; it passes in isolation and does so identically on the base commit.)
  • corepack pnpm exec tsc --noEmit -p tsconfig.app.json in apps/cli — no errors in the new files (the package's typecheck task is documented as vacuous, so this was run by hand).
  • corepack pnpm exec oxlint on the new files — 0 warnings.
  • COLUMNS=80 corepack pnpm run readme — README regenerated.
  • Live run against a local server built from this commit with CGO_ENABLED=0 go build (sqlite): every one of the 22 commands was run, covering both body modes (field flags and --data / --file, including a field flag overriding --data), the generated --help for users create and grants create, cursor pagination with --limit / --page-token / --all, filters and sorting, --dry-run, the --force guard, and error mapping. The full transcript is under Sample session below. zitadel resources and the per-command --force help were checked after the change. The guideline fixes were re-checked live: cut -f1,3 over a piped list, --plain, -a, -d rejected, --data intact, a refused password= attribute, and the same value accepted through --file -.
  • The configuration resources and the unified grammar were verified live against a freshly migrated sqlite server: schemas list (draining, and narrowed with --filter object_type=human-user), environments list and environments get staging (addressed by name), flow-definitions list, and branding list returning the standard envelope from an endpoint that answers with a bare array and has no cursor. One finding, recorded in the ADR rather than worked around: GET /releases answered 500 on a project with no releases, reproduced with plain curl, so releases list surfaces that server error rather than hiding it.

Release notes / changeset

  • Changeset: .changeset/add-cli-resource-commands.md@zitadel/cli minor: resource commands for the six runtime resources, plus read-only list / get for the five configuration resources.

Breaking — schemas list. The hand-written command is replaced by the generated one, so it now behaves like every other list. Its --json envelope changes from { object_type, revisions, count } to the standard { items, count, next_page_token }; --object-type becomes an optional --filter object_type=<value> instead of a required flag; and the interactive revision picker is gone, since schemas get <id> reads one revision. A bare zitadel schemas list still returns the complete revision history, because the registry declares that this list drains — the behaviour #947 added deliberately, and the two-page drain test from that PR is carried over.

Notes

  • Draft for review of the command shape and conventions before widening. Open decisions are listed at the end of docs/design/cli/resource-commands.md: bulk delete, typed filter values, and read-only verbs for config resources.
  • The CLI credential is still the project secret; a PAT or service-user credential (ADR 036 / 037) would drop the need to carry the full secret for read-only operator use.
  • users list cannot filter by email today because the query endpoint only accepts created_at, id, schema, status, team_id, and lifecycle_owner_team_id; that is a backend ask, not a CLI gap.
  • events list is empty until a project is claimed: the API hides events for unclaimed projects (ADR 049 pre-claim gate). The sample session stands in for zitadel claim by inserting the owning-team grant a claim writes, because the real claim completes in a browser.
  • The server does not implement every filter operation the OpenAPI spec advertises. not_equals, not_contains, and less_than_or_equal return 501 on both users and teams, so they pass the CLI's local validation and fail at the server. That is a backend gap. Separately, the CLI's existing error mapping reports every 5xx, including that 501, as E_NETWORK, which is misleading and predates this PR.

Scripting with jq

With --json, stdout carries only the envelope: progress lines are silenced, and errors print an error envelope to stdout. Every command pipes straight into jq. These ran against the same local server:

$ zitadel users list --all --json | jq -r '.data.items[].id'
user_01M29HTHHJGN130BS1ZC7VRJT4
user_01M29HTH3FEF16ZPKSQJNH9QA1
$ zitadel users list --all --json | jq -r '.data.items[] | [.id, .attributes.email, .metadata.status] | @tsv'
user_01M29HTHHJGN130BS1ZC7VRJT4	grace@example.com	active
user_01M29HTH3FEF16ZPKSQJNH9QA1	ada@example.com	active
$ zitadel teams list --json | jq '.data | {count, names: [.items[].name]}'
{
  "count": 2,
  "names": [
    "platform",
    "support"
  ]
}
$ id=$(zitadel teams create --name ops --json | jq -r .data.id); echo "$id"
team_01M29HTM59DVRWZ5X9QVMSPZ43
$ zitadel teams get "$id" --json | jq -r .data.name
ops
$ zitadel teams list --filter name=contains:o --json | jq -r '.data.items[].id' | xargs -I{} zitadel teams delete {} --force --json | jq -c .data
{"id":"team_01M29HTHZG9JFBQV7X53MK47VP","deleted":true}
{"id":"team_01M29HTJDM7T7W716TM8Y7A49V","deleted":true}
{"id":"team_01M29HTM59DVRWZ5X9QVMSPZ43","deleted":true}
$ zitadel users get user_missing --json | jq -r '.status, .code, .message'
error
E_NOT_FOUND
user not found
$ set -o pipefail; zitadel users get user_missing --json | jq -r .code; echo "exit: $?"
E_NOT_FOUND
exit: 4

On an error the CLI exits non-zero (3 for validation, 4 for not-found or network) but jq exits 0, so a plain pipeline hides the failure. Scripts should use set -o pipefail, as in the last example, or branch on .status.

Sample session

Every resource command run against a local server (click to expand)

Every command below was run against a local Zitadel server built from this branch (CGO_ENABLED=0 go build, sqlite, http://localhost:8093). Output is verbatim; only ANSI colours were stripped. Human-mode runs show what a person sees; --json runs show the envelope an agent parses.

Setup

zitadel setup normally creates the project and writes .zitadel/secret. To keep the demo framework-free, the project is created with the same API call setup makes, and the secret file is written by hand. Every resource command reads its credential and project id from that file.

$ curl -X POST http://localhost:8093/projects -d '{"name":"resource-demo",…}'   # what setup does
project proj_01M2A9SGB8QTVSN0CSCN5RZY58 created; secret written to .zitadel/secret

The project's default human-user schema is https://nextgen.com/api/schemas/default-human-user.json; user bodies reference it.

projects

Projects are created by setup, so the resource exposes list, get, and update only.

$ zitadel projects list
proj_01M2A9SGB8QTVSN0CSCN5RZY58	resource-demo	2026-09-12T07:54:56Z
$ zitadel projects list --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "projects:list",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "items": [
      {
        "id": "proj_01M2A9SGB8QTVSN0CSCN5RZY58",
        "name": "resource-demo",
        "preview_origins": [
          "http://localhost:3000"
        ],
        "created_at": "2026-09-12T07:54:56Z",
        "updated_at": "2026-09-12T07:54:56Z"
      }
    ],
    "count": 1,
    "next_page_token": null
  },
  "warnings": []
}
$ zitadel projects get proj_01M2A9SGB8QTVSN0CSCN5RZY58
{
  "id": "proj_01M2A9SGB8QTVSN0CSCN5RZY58",
  "name": "resource-demo",
  "preview_origins": [
    "http://localhost:3000"
  ],
  "created_at": "2026-09-12T07:54:56Z",
  "updated_at": "2026-09-12T07:54:56Z"
}
$ zitadel projects update proj_01M2A9SGB8QTVSN0CSCN5RZY58 --name resource-demo-renamed --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "projects:update",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "id": "proj_01M2A9SGB8QTVSN0CSCN5RZY58",
    "name": "resource-demo-renamed",
    "preview_origins": [
      "http://localhost:3000"
    ],
    "created_at": "2026-09-12T07:54:56Z",
    "updated_at": "2026-09-12T07:54:57Z",
    "next_commands": [
      "npx @zitadel/cli@1.0.0-alpha.22 projects get proj_01M2A9SGB8QTVSN0CSCN5RZY58 --json"
    ]
  },
  "warnings": []
}

users

Create three users, then page through them.

Bodies go in two ways. One flag per schema field, with a repeatable key=value flag for the schema-defined attributes:

$ zitadel users create --schema https://nextgen.com/api/schemas/default-human-user.json --attributes email=ada@example.com --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "users:create",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "id": "user_01M2A9SJE7ZK9TMVHK22G139KW",
    "schema": "https://nextgen.com/api/schemas/default-human-user.json",
    "attributes": {
      "email": "ada@example.com"
    },
    "metadata": {
      "created_at": "2026-09-12T07:54:58Z",
      "updated_at": "2026-09-12T07:54:58Z",
      "status": "active",
      "lifecycle_owner_team_id": null
    },
    "identifier": "ada@example.com",
    "identifier_property": "email",
    "next_commands": [
      "npx @zitadel/cli@1.0.0-alpha.22 users get user_01M2A9SJE7ZK9TMVHK22G139KW --json"
    ]
  },
  "warnings": []
}
$ zitadel users create --schema https://nextgen.com/api/schemas/default-human-user.json --attributes email=grace@example.com --attributes givenName=Grace --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "users:create",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "id": "user_01M2A9SJVGDT1AXS3H57J3KVHC",
    "schema": "https://nextgen.com/api/schemas/default-human-user.json",
    "attributes": {
      "givenName": "Grace",
      "email": "grace@example.com"
    },
    "metadata": {
      "created_at": "2026-09-12T07:54:58Z",
      "updated_at": "2026-09-12T07:54:58Z",
      "status": "active",
      "lifecycle_owner_team_id": null
    },
    "identifier": "grace@example.com",
    "identifier_property": "email",
    "next_commands": [
      "npx @zitadel/cli@1.0.0-alpha.22 users get user_01M2A9SJVGDT1AXS3H57J3KVHC --json"
    ]
  },
  "warnings": []
}

Or the whole body as JSON, which still works for anything the flags cannot express:

$ zitadel users create --data '{"schema":"https://nextgen.com/api/schemas/default-human-user.json","attributes":{"email":"linus@example.com"}}' --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "users:create",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "id": "user_01M2A9SK8JMRYKC3VJKND2KZ3P",
    "schema": "https://nextgen.com/api/schemas/default-human-user.json",
    "attributes": {
      "email": "linus@example.com"
    },
    "metadata": {
      "created_at": "2026-09-12T07:54:59Z",
      "updated_at": "2026-09-12T07:54:59Z",
      "status": "active",
      "lifecycle_owner_team_id": null
    },
    "identifier": "linus@example.com",
    "identifier_property": "email",
    "next_commands": [
      "npx @zitadel/cli@1.0.0-alpha.22 users get user_01M2A9SK8JMRYKC3VJKND2KZ3P --json"
    ]
  },
  "warnings": []
}

--help lists the fields, marks the required ones, and groups them:

$ zitadel users create --help
Create a user.

REQUIRED FIELD FLAGS
  --attributes=<value>...  (required) Repeatable attributes entry: key=value for
                           a string, key:=value for JSON. The user's
                           schema-defined content.
  --schema=<value>         (required) The schema that defines the content of
                           `attributes`.

RAW BODY FLAGS
  --data=<value>  Whole body as a JSON object, instead of the field flags.
  --file=<value>  Read the body from a JSON file; `-` reads stdin.

GLOBAL FLAGS
  --json  Format output as json.

Listing and pagination

One page by default. --limit sets the page size; next_page_token is non-null while more remain.

This transcript is captured through a pipe, so lists appear in their machine form: one tab-separated record per line, no header and no footer. On a terminal the same command prints an aligned table with a row count. --plain forces the machine form anywhere.

$ zitadel users list
user_01M2A9SK8JMRYKC3VJKND2KZ3P	linus@example.com	active	2026-09-12T07:54:59Z
user_01M2A9SJVGDT1AXS3H57J3KVHC	grace@example.com	active	2026-09-12T07:54:58Z
user_01M2A9SJE7ZK9TMVHK22G139KW	ada@example.com	active	2026-09-12T07:54:58Z
$ zitadel users list --limit 2 --sort created_at:asc --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "users:list",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "items": [
      {
        "id": "user_01M2A9SJE7ZK9TMVHK22G139KW",
        "schema": "https://nextgen.com/api/schemas/default-human-user.json",
        "attributes": {
          "email": "ada@example.com"
        },
        "metadata": {
          "created_at": "2026-09-12T07:54:58Z",
          "updated_at": "2026-09-12T07:54:58Z",
          "status": "active",
          "lifecycle_owner_team_id": null
        },
        "identifier": "ada@example.com",
        "identifier_property": "email"
      },
      {
        "id": "user_01M2A9SJVGDT1AXS3H57J3KVHC",
        "schema": "https://nextgen.com/api/schemas/default-human-user.json",
        "attributes": {
          "email": "grace@example.com",
          "givenName": "Grace"
        },
        "metadata": {
          "created_at": "2026-09-12T07:54:58Z",
          "updated_at": "2026-09-12T07:54:58Z",
          "status": "active",
          "lifecycle_owner_team_id": null
        },
        "identifier": "grace@example.com",
        "identifier_property": "email"
      }
    ],
    "count": 2,
    "next_page_token": "eyJjb2x1bW5zIjpbNiwyXSwiZGlyZWN0aW9uIjowLCJ2YWx1ZXMiOlsiMjAyNi0wOS0xMlQwNzo1NDo1OC44MDAxMzFaIiwidXNlcl8wMU0yQTlTSlZHRFQxQVhTM0g1N0ozS1ZIQyJdfQ",
    "next_commands": [
      "npx @zitadel/cli@1.0.0-alpha.22 users list --limit 2 --sort created_at:asc --page-token eyJjb2x1bW5zIjpbNiwyXSwiZGlyZWN0aW9uIjowLCJ2YWx1ZXMiOlsiMjAyNi0wOS0xMlQwNzo1NDo1OC44MDAxMzFaIiwidXNlcl8wMU0yQTlTSlZHRFQxQVhTM0g1N0ozS1ZIQyJdfQ --json"
    ]
  },
  "warnings": []
}
$ zitadel users list --limit 2 --sort created_at:asc --page-token eyJjb2x1bW5zIjpbNiwyXSwiZGlyZWN0aW9uIjowLCJ2YWx1ZXMiOlsiMjAyNi0wOS0xMlQwNzo1NDo1OC44MDAxMzFaIiwidXNlcl8wMU0yQTlTSlZHRFQxQVhTM0g1N0ozS1ZIQyJdfQ --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "users:list",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "items": [
      {
        "id": "user_01M2A9SK8JMRYKC3VJKND2KZ3P",
        "schema": "https://nextgen.com/api/schemas/default-human-user.json",
        "attributes": {
          "email": "linus@example.com"
        },
        "metadata": {
          "created_at": "2026-09-12T07:54:59Z",
          "updated_at": "2026-09-12T07:54:59Z",
          "status": "active",
          "lifecycle_owner_team_id": null
        },
        "identifier": "linus@example.com",
        "identifier_property": "email"
      }
    ],
    "count": 1,
    "next_page_token": null
  },
  "warnings": []
}

--all drains every page and returns next_page_token: null.

$ zitadel users list --all --limit 1
user_01M2A9SK8JMRYKC3VJKND2KZ3P	linus@example.com	active	2026-09-12T07:54:59Z
user_01M2A9SJVGDT1AXS3H57J3KVHC	grace@example.com	active	2026-09-12T07:54:58Z
user_01M2A9SJE7ZK9TMVHK22G139KW	ada@example.com	active	2026-09-12T07:54:58Z

Choosing columns

--fields overrides the resource's own columns; --json is unaffected.

$ zitadel users list --fields id,attributes.email,metadata.status
user_01M2A9SK8JMRYKC3VJKND2KZ3P	linus@example.com	active
user_01M2A9SJVGDT1AXS3H57J3KVHC	grace@example.com	active
user_01M2A9SJE7ZK9TMVHK22G139KW	ada@example.com	active
$ zitadel users list --fields id,emial --json
{
  "status": "error",
  "cli_version": "1.0.0-alpha.22",
  "command": "users:list",
  "source": "http://localhost:8093",
  "code": "E_VALIDATION",
  "message": "--fields has no such column: emial",
  "hint": "Available: attributes, attributes.email, attributes.givenName, id, identifier, identifier_property, metadata, metadata.created_at, metadata.lifecycle_owner_team_id, metadata.status, metadata.updated_at, schema.",
  "details": {
    "missing": [
      "emial"
    ],
    "available": [
      "attributes",
      "attributes.email",
      "attributes.givenName",
      "id",
      "identifier",
      "identifier_property",
      "metadata",
      "metadata.created_at",
      "metadata.lifecycle_owner_team_id",
      "metadata.status",
      "metadata.updated_at",
      "schema"
    ]
  }
}
# exit code 3

Filtering and sorting

$ zitadel users list --filter status=active --sort created_at:desc --limit 2
user_01M2A9SK8JMRYKC3VJKND2KZ3P	linus@example.com	active	2026-09-12T07:54:59Z
user_01M2A9SJVGDT1AXS3H57J3KVHC	grace@example.com	active	2026-09-12T07:54:58Z
$ zitadel users list --filter id=user_01M2A9SJVGDT1AXS3H57J3KVHC --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "users:list",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "items": [
      {
        "id": "user_01M2A9SJVGDT1AXS3H57J3KVHC",
        "schema": "https://nextgen.com/api/schemas/default-human-user.json",
        "attributes": {
          "givenName": "Grace",
          "email": "grace@example.com"
        },
        "metadata": {
          "created_at": "2026-09-12T07:54:58Z",
          "updated_at": "2026-09-12T07:54:58Z",
          "status": "active",
          "lifecycle_owner_team_id": null
        },
        "identifier": "grace@example.com",
        "identifier_property": "email"
      }
    ],
    "count": 1,
    "next_page_token": null
  },
  "warnings": []
}
$ zitadel users list --filter created_at=greater_than_or_equal:2026-01-01T00:00:00Z --filter status=active
user_01M2A9SK8JMRYKC3VJKND2KZ3P	linus@example.com	active	2026-09-12T07:54:59Z
user_01M2A9SJVGDT1AXS3H57J3KVHC	grace@example.com	active	2026-09-12T07:54:58Z
user_01M2A9SJE7ZK9TMVHK22G139KW	ada@example.com	active	2026-09-12T07:54:58Z

Get, update, delete

$ zitadel users get user_01M2A9SJE7ZK9TMVHK22G139KW
{
  "id": "user_01M2A9SJE7ZK9TMVHK22G139KW",
  "schema": "https://nextgen.com/api/schemas/default-human-user.json",
  "attributes": {
    "email": "ada@example.com"
  },
  "metadata": {
    "created_at": "2026-09-12T07:54:58Z",
    "updated_at": "2026-09-12T07:54:58Z",
    "status": "active",
    "lifecycle_owner_team_id": null
  },
  "identifier": "ada@example.com",
  "identifier_property": "email"
}
$ zitadel users update user_01M2A9SJE7ZK9TMVHK22G139KW --data '{"attributes":{"email":"ada@example.com","givenName":"Ada"}}' --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "users:update",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "id": "user_01M2A9SJE7ZK9TMVHK22G139KW",
    "schema": "https://nextgen.com/api/schemas/default-human-user.json",
    "attributes": {
      "email": "ada@example.com",
      "givenName": "Ada"
    },
    "metadata": {
      "created_at": "2026-09-12T07:54:58Z",
      "updated_at": "2026-09-12T07:55:04Z",
      "status": "active",
      "lifecycle_owner_team_id": null
    },
    "identifier": "ada@example.com",
    "identifier_property": "email",
    "next_commands": [
      "npx @zitadel/cli@1.0.0-alpha.22 users get user_01M2A9SJE7ZK9TMVHK22G139KW --json"
    ]
  },
  "warnings": []
}
$ zitadel users delete user_01M2A9SK8JMRYKC3VJKND2KZ3P --force
Deleted user user_01M2A9SK8JMRYKC3VJKND2KZ3P
$ zitadel users delete user_01M2A9SJVGDT1AXS3H57J3KVHC --force --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "users:delete",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "id": "user_01M2A9SJVGDT1AXS3H57J3KVHC",
    "deleted": true
  },
  "warnings": []
}
$ zitadel users list
user_01M2A9SJE7ZK9TMVHK22G139KW	ada@example.com	active	2026-09-12T07:54:58Z

teams

$ zitadel teams create --name platform --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "teams:create",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "id": "team_01M2A9SSRXAXWGY2RGWQ56RH1J",
    "name": "platform",
    "status": "active",
    "created_at": "2026-09-12T07:55:05Z",
    "updated_at": "2026-09-12T07:55:05Z",
    "next_commands": [
      "npx @zitadel/cli@1.0.0-alpha.22 teams get team_01M2A9SSRXAXWGY2RGWQ56RH1J --json"
    ]
  },
  "warnings": []
}
$ zitadel teams create --name support --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "teams:create",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "id": "team_01M2A9ST5SC8XC7CHSYGC6RSV7",
    "name": "support",
    "status": "active",
    "created_at": "2026-09-12T07:55:06Z",
    "updated_at": "2026-09-12T07:55:06Z",
    "next_commands": [
      "npx @zitadel/cli@1.0.0-alpha.22 teams get team_01M2A9ST5SC8XC7CHSYGC6RSV7 --json"
    ]
  },
  "warnings": []
}
$ zitadel teams list
team_01M2A9SSRXAXWGY2RGWQ56RH1J	platform	active	2026-09-12T07:55:05Z
team_01M2A9ST5SC8XC7CHSYGC6RSV7	support	active	2026-09-12T07:55:06Z
$ zitadel teams get team_01M2A9SSRXAXWGY2RGWQ56RH1J
{
  "id": "team_01M2A9SSRXAXWGY2RGWQ56RH1J",
  "name": "platform",
  "status": "active",
  "created_at": "2026-09-12T07:55:05Z",
  "updated_at": "2026-09-12T07:55:05Z"
}
$ zitadel teams update team_01M2A9ST5SC8XC7CHSYGC6RSV7 --name support-tier-2 --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "teams:update",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "id": "team_01M2A9ST5SC8XC7CHSYGC6RSV7",
    "name": "support-tier-2",
    "status": "active",
    "created_at": "2026-09-12T07:55:06Z",
    "updated_at": "2026-09-12T07:55:07Z",
    "next_commands": [
      "npx @zitadel/cli@1.0.0-alpha.22 teams get team_01M2A9ST5SC8XC7CHSYGC6RSV7 --json"
    ]
  },
  "warnings": []
}
$ zitadel teams list --filter name=contains:tier --sort name:asc
team_01M2A9ST5SC8XC7CHSYGC6RSV7	support-tier-2	active	2026-09-12T07:55:06Z
$ zitadel teams delete team_01M2A9ST5SC8XC7CHSYGC6RSV7 --force
Deactivated team team_01M2A9ST5SC8XC7CHSYGC6RSV7

A team delete deactivates rather than removes the row (ADR 024), so it still reads back:

$ zitadel teams list --filter status=deactivated
team_01M2A9ST5SC8XC7CHSYGC6RSV7	support-tier-2	deactivated	2026-09-12T07:55:06Z

grants

A grant gives a user or team a role on the project.

$ zitadel grants create --help
Create a grant.

RAW BODY FLAGS
  --data=<value>  Whole body as a JSON object, instead of the field flags.
  --file=<value>  Read the body from a JSON file; `-` reads stdin.

OPTIONAL FIELD FLAGS
  --expires-at=<value>  Optional expiry.

GLOBAL FLAGS
  --json  Format output as json.

REQUIRED FIELD FLAGS
  --principal-id=<value>     (required) Principal id (`user_<opaque>` or
                             `team_<opaque>`).
  --principal-type=<option>  (required) Kind of principal to bind.
                             <options: user|team>
  --relation=<option>        (required) Catalog relation on `object_type`
                             `project`.
                             <options: viewer|editor|admin>
$ zitadel grants create --principal-type user --principal-id user_01M2A9SJE7ZK9TMVHK22G139KW --relation viewer --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "grants:create",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "id": "asgn_01M2A9SXDXPM5X5JHP2MHX7M4S",
    "project_id": "proj_01M2A9SGB8QTVSN0CSCN5RZY58",
    "principal_type": "user",
    "principal_id": "user_01M2A9SJE7ZK9TMVHK22G139KW",
    "object_type": "project",
    "relation": "viewer",
    "created_at": "2026-09-12T07:55:09Z",
    "user": {
      "user_id": "user_01M2A9SJE7ZK9TMVHK22G139KW",
      "identifier": "ada@example.com",
      "identifier_property": "email"
    },
    "next_commands": [
      "npx @zitadel/cli@1.0.0-alpha.22 grants get asgn_01M2A9SXDXPM5X5JHP2MHX7M4S --json"
    ]
  },
  "warnings": []
}
$ zitadel grants create --principal-type team --principal-id team_01M2A9SSRXAXWGY2RGWQ56RH1J --relation editor --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "grants:create",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "id": "asgn_01M2A9SXV4RJVDJTW0PX3NFSNC",
    "project_id": "proj_01M2A9SGB8QTVSN0CSCN5RZY58",
    "principal_type": "team",
    "principal_id": "team_01M2A9SSRXAXWGY2RGWQ56RH1J",
    "object_type": "project",
    "relation": "editor",
    "created_at": "2026-09-12T07:55:10Z",
    "team": {
      "team_id": "team_01M2A9SSRXAXWGY2RGWQ56RH1J",
      "name": "platform"
    },
    "next_commands": [
      "npx @zitadel/cli@1.0.0-alpha.22 grants get asgn_01M2A9SXV4RJVDJTW0PX3NFSNC --json"
    ]
  },
  "warnings": []
}

A value outside a field's allowed set is rejected at parse time, before any request:

$ zitadel grants create --principal-type robot --principal-id user_01M2A9SJE7ZK9TMVHK22G139KW --relation viewer --json
{
  "status": "error",
  "cli_version": "1.0.0-alpha.22",
  "command": "grants:create",
  "source": "",
  "code": "E_VALIDATION",
  "message": "Expected --principal-type=robot to be one of: user, team\nSee more help with --help",
  "details": {
    "original": {
      "name": "Error",
      "message": "Expected --principal-type=robot to be one of: user, team\nSee more help with --help"
    }
  }
}
# exit code 3
$ zitadel grants list
asgn_01M2A9SXDXPM5X5JHP2MHX7M4S	user	user_01M2A9SJE7ZK9TMVHK22G139KW	viewer	2026-09-12T07:55:09Z	
asgn_01M2A9SXV4RJVDJTW0PX3NFSNC	team	team_01M2A9SSRXAXWGY2RGWQ56RH1J	editor	2026-09-12T07:55:10Z
$ zitadel grants list --filter principal_type=team --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "grants:list",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "items": [
      {
        "id": "asgn_01M2A9SXV4RJVDJTW0PX3NFSNC",
        "project_id": "proj_01M2A9SGB8QTVSN0CSCN5RZY58",
        "principal_type": "team",
        "principal_id": "team_01M2A9SSRXAXWGY2RGWQ56RH1J",
        "object_type": "project",
        "relation": "editor",
        "created_at": "2026-09-12T07:55:10Z",
        "team": {
          "team_id": "team_01M2A9SSRXAXWGY2RGWQ56RH1J",
          "name": "platform"
        }
      }
    ],
    "count": 1,
    "next_page_token": null
  },
  "warnings": []
}
$ zitadel grants get asgn_01M2A9SXDXPM5X5JHP2MHX7M4S
{
  "id": "asgn_01M2A9SXDXPM5X5JHP2MHX7M4S",
  "project_id": "proj_01M2A9SGB8QTVSN0CSCN5RZY58",
  "principal_type": "user",
  "principal_id": "user_01M2A9SJE7ZK9TMVHK22G139KW",
  "object_type": "project",
  "relation": "viewer",
  "created_at": "2026-09-12T07:55:09Z",
  "user": {
    "user_id": "user_01M2A9SJE7ZK9TMVHK22G139KW",
    "identifier": "ada@example.com",
    "identifier_property": "email"
  }
}
$ zitadel grants delete asgn_01M2A9SXDXPM5X5JHP2MHX7M4S --force
Deleted grant asgn_01M2A9SXDXPM5X5JHP2MHX7M4S
$ zitadel grants list
asgn_01M2A9SXV4RJVDJTW0PX3NFSNC	team	team_01M2A9SSRXAXWGY2RGWQ56RH1J	editor	2026-09-12T07:55:10Z

sessions

Sessions are minted by the login flow, not by the CLI, so the resource has no create. Two are minted here with the same API call the SDK middleware makes.

$ curl -X POST http://localhost:8093/sessions -d '{"project_id":"proj_01M2A9SGB8QTVSN0CSCN5RZY58"}'   # ×2, what the SDK does
sess_01M2A9T09VA2JC6WNHT86ZP76J
sess_01M2A9T0AQSEYNRKM8JET3PT04
$ zitadel sessions list
sess_01M2A9T0AQSEYNRKM8JET3PT04	building		2026-09-12T07:55:12Z	2026-09-12T08:05:12Z
sess_01M2A9T09VA2JC6WNHT86ZP76J	building		2026-09-12T07:55:12Z	2026-09-12T08:05:12Z
$ zitadel sessions get sess_01M2A9T09VA2JC6WNHT86ZP76J --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "sessions:get",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "session_id": "sess_01M2A9T09VA2JC6WNHT86ZP76J",
    "project_id": "proj_01M2A9SGB8QTVSN0CSCN5RZY58",
    "state": "building",
    "factors": [],
    "created_at": "2026-09-12T07:55:12Z",
    "expires_at": "2026-09-12T08:05:12Z"
  },
  "warnings": []
}
$ zitadel sessions delete sess_01M2A9T09VA2JC6WNHT86ZP76J --force
Revoked session sess_01M2A9T09VA2JC6WNHT86ZP76J
$ zitadel sessions list
sess_01M2A9T0AQSEYNRKM8JET3PT04	building		2026-09-12T07:55:12Z	2026-09-12T08:05:12Z
$ zitadel sessions list --filter state=building --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "sessions:list",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "items": [
      {
        "session_id": "sess_01M2A9T0AQSEYNRKM8JET3PT04",
        "project_id": "proj_01M2A9SGB8QTVSN0CSCN5RZY58",
        "state": "building",
        "factors": [],
        "created_at": "2026-09-12T07:55:12Z",
        "expires_at": "2026-09-12T08:05:12Z"
      }
    ],
    "count": 1,
    "next_page_token": null
  },
  "warnings": []
}

events

Events are read-only. The API hides a project's events until the project is claimed by a team (ADR 049 pre-claim gate), so an unclaimed project always lists nothing:

$ zitadel events list --limit 3

zitadel claim completes in a browser with a signed-in platform user, which this headless run cannot do. As a stand-in, the demo writes the one row a completed claim writes — an active owning-team grant (relation = 'team') — directly into the local sqlite database:

$ sqlite3 zitadel.db "INSERT INTO authz_assignments (… principal_id='team_01M2A9SSRXAXWGY2RGWQ56RH1J', object_type='project', relation='team' …)"
$ zitadel events list --limit 5
evt_01M2A9T2Y5MA65RQJE39ZDTR8Z	request.api	request		2026-09-12T07:55:15Z
evt_01M2A9T2Y5MA65RQJE38KXDAA1	request.api	request		2026-09-12T07:55:14Z
evt_01M2A9T2Y4W0FW4NWFVM1509H4	request.api	request		2026-09-12T07:55:14Z
evt_01M2A9T1FS7Y5VPHTZM1C3NPWS	request.api	request		2026-09-12T07:55:13Z
evt_01M2A9T1FS7Y5VPHTZKZF5E0RX	request.api	request		2026-09-12T07:55:13Z
$ zitadel events list --category entity --limit 3 --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "events:list",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "items": [
      {
        "event_type": "user.deleted",
        "id": "evt_01M2A9SRY8ZG46701ES5PNNJ15",
        "project_id": "proj_01M2A9SGB8QTVSN0CSCN5RZY58",
        "category": "entity",
        "occurred_at": "2026-09-12T07:55:05Z",
        "created_at": "2026-09-12T07:55:05Z",
        "actor_type": "service",
        "entity_type": "user",
        "entity_id": "user_01M2A9SJVGDT1AXS3H57J3KVHC",
        "client_id": "",
        "token_id": "tkn_01M2A9SGBHNRN69ZPKA8800R4H",
        "delegation_type": "direct",
        "request_id": "req_01M2A9SRY18MXH9HFBPSVKP46F",
        "payload": {}
      },
      {
        "event_type": "user.deleted",
        "id": "evt_01M2A9SRGT49WQ43NH5WGZRW94",
        "project_id": "proj_01M2A9SGB8QTVSN0CSCN5RZY58",
        "category": "entity",
        "occurred_at": "2026-09-12T07:55:04Z",
        "created_at": "2026-09-12T07:55:04Z",
        "actor_type": "service",
        "entity_type": "user",
        "entity_id": "user_01M2A9SK8JMRYKC3VJKND2KZ3P",
        "client_id": "",
        "token_id": "tkn_01M2A9SGBHNRN69ZPKA8800R4H",
        "delegation_type": "direct",
        "request_id": "req_01M2A9SRGHCXQM3CETSWD80CHW",
        "payload": {}
      },
      {
        "event_type": "user.created",
        "id": "evt_01M2A9SK8JMRYKC3VJKNGJ4YPG",
        "project_id": "proj_01M2A9SGB8QTVSN0CSCN5RZY58",
        "category": "entity",
        "occurred_at": "2026-09-12T07:54:59Z",
        "created_at": "2026-09-12T07:54:59Z",
        "actor_type": "service",
        "entity_type": "user",
        "entity_id": "user_01M2A9SK8JMRYKC3VJKND2KZ3P",
        "client_id": "",
        "token_id": "tkn_01M2A9SGBHNRN69ZPKA8800R4H",
        "delegation_type": "direct",
        "request_id": "req_01M2A9SK8CQYF1G5FEACBQTQYW",
        "payload": {
          "schema_id": "https://nextgen.com/api/schemas/default-human-user.json",
          "attribute_keys": [
            "email"
          ]
        }
      }
    ],
    "count": 3,
    "next_page_token": "eyJjb2x1bW5zIjpbNiwyXSwiZGlyZWN0aW9uIjowLCJ2YWx1ZXMiOlsiMjAyNi0wOS0xMlQwNzo1NDo1OS4yMTg5OTZaIiwiZXZ0XzAxTTJBOVNLOEpNUllLQzNWSktOR0o0WVBHIl19",
    "next_commands": [
      "npx @zitadel/cli@1.0.0-alpha.22 events list --limit 3 --category entity --page-token eyJjb2x1bW5zIjpbNiwyXSwiZGlyZWN0aW9uIjowLCJ2YWx1ZXMiOlsiMjAyNi0wOS0xMlQwNzo1NDo1OS4yMTg5OTZaIiwiZXZ0XzAxTTJBOVNLOEpNUllLQzNWSktOR0o0WVBHIl19 --json"
    ]
  },
  "warnings": []
}
$ zitadel events list --event-type team.created --event-type user.created
evt_01M2A9ST5SC8XC7CHSYH470M2H	team.created	admin		2026-09-12T07:55:06Z
evt_01M2A9SSRXAXWGY2RGWRE7Y0C5	team.created	admin		2026-09-12T07:55:05Z
evt_01M2A9SK8JMRYKC3VJKNGJ4YPG	user.created	entity		2026-09-12T07:54:59Z
evt_01M2A9SJVGDT1AXS3H57XJG6MW	user.created	entity		2026-09-12T07:54:58Z
evt_01M2A9SJE8376Y2B9HK4Q81HTD	user.created	entity		2026-09-12T07:54:58Z
$ zitadel events get evt_01M2A9SRY8ZG46701ES5PNNJ15
{
  "event_type": "user.deleted",
  "id": "evt_01M2A9SRY8ZG46701ES5PNNJ15",
  "project_id": "proj_01M2A9SGB8QTVSN0CSCN5RZY58",
  "category": "entity",
  "occurred_at": "2026-09-12T07:55:05Z",
  "created_at": "2026-09-12T07:55:05Z",
  "actor_type": "service",
  "entity_type": "user",
  "entity_id": "user_01M2A9SJVGDT1AXS3H57J3KVHC",
  "client_id": "",
  "token_id": "tkn_01M2A9SGBHNRN69ZPKA8800R4H",
  "delegation_type": "direct",
  "request_id": "req_01M2A9SRY18MXH9HFBPSVKP46F",
  "payload": {}
}

Safety rails and errors

Destructive verbs refuse to run unattended without --force, and say how to retry:

$ zitadel teams delete team_01M2A9SSRXAXWGY2RGWQ56RH1J --json
{
  "status": "error",
  "cli_version": "1.0.0-alpha.22",
  "command": "teams:delete",
  "source": "http://localhost:8093",
  "code": "E_VALIDATION",
  "message": "Delete requires --force in non-interactive mode",
  "hint": "Pass --force to delete team team_01M2A9SSRXAXWGY2RGWQ56RH1J.",
  "next_commands": [
    "npx @zitadel/cli@1.0.0-alpha.22 teams delete team_01M2A9SSRXAXWGY2RGWQ56RH1J --force"
  ]
}
# exit code 3

--dry-run shows what would happen and makes no request:

$ zitadel teams delete team_01M2A9SSRXAXWGY2RGWQ56RH1J --dry-run --force --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "teams:delete",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "dry_run": true,
    "verb": "delete",
    "topic": "teams",
    "id": "team_01M2A9SSRXAXWGY2RGWQ56RH1J"
  },
  "warnings": []
}
$ zitadel users create --dry-run --schema https://nextgen.com/api/schemas/default-human-user.json --attributes email=dry@example.com
Dry run: would create users
{
  "schema": "https://nextgen.com/api/schemas/default-human-user.json",
  "attributes": {
    "email": "dry@example.com"
  }
}

A credential is refused anywhere on the command line — as a field flag and inside an inline --data body alike, since argv is visible to other processes:

$ zitadel users create --schema https://nextgen.com/api/schemas/default-human-user.json --attributes email=z@example.com --attributes password=hunter2 --json
{
  "status": "error",
  "cli_version": "1.0.0-alpha.22",
  "command": "users:create",
  "source": "http://localhost:8093",
  "code": "E_VALIDATION",
  "message": "Refusing to read \"password\" from --attributes: the command line is visible to other processes and is kept in shell history",
  "hint": "Pass the body with --file <path>, or --file - to read the body from stdin.",
  "details": {
    "field": "password"
  }
}
# exit code 3
$ zitadel users create --data '{"schema":"https://nextgen.com/api/schemas/default-human-user.json","attributes":{"email":"z@example.com","password":"hunter2"}}' --json
{
  "status": "error",
  "cli_version": "1.0.0-alpha.22",
  "command": "users:create",
  "source": "http://localhost:8093",
  "code": "E_VALIDATION",
  "message": "Refusing to read \"password\" from --data: the command line is visible to other processes and is kept in shell history",
  "hint": "Pass the body with --file <path>, or --file - to read the body from stdin.",
  "details": {
    "field": "password"
  }
}
# exit code 3

Filters and bodies are validated locally against the API schema, before any request:

$ zitadel users list --filter email=ada@example.com --json
{
  "status": "error",
  "cli_version": "1.0.0-alpha.22",
  "command": "users:list",
  "source": "http://localhost:8093",
  "code": "E_VALIDATION",
  "message": "Invalid list query",
  "hint": "Filter fields: created_at, id, schema, status, team_id, lifecycle_owner_team_id. Sort fields: created_at, id, schema, status, lifecycle_owner_team_id.",
  "details": {
    "issues": [
      {
        "code": "invalid_value",
        "values": [
          "created_at",
          "id",
          "schema",
          "status",
          "team_id",
          "lifecycle_owner_team_id"
        ],
        "path": [
          "filter",
          0,
          "field"
        ],
        "message": "Invalid option: expected one of \"created_at\"|\"id\"|\"schema\"|\"status\"|\"team_id\"|\"lifecycle_owner_team_id\""
      }
    ]
  }
}
# exit code 3
$ zitadel teams create --data '{"name":42}' --json
{
  "status": "error",
  "cli_version": "1.0.0-alpha.22",
  "command": "teams:create",
  "source": "http://localhost:8093",
  "code": "E_VALIDATION",
  "message": "Body does not match the API schema",
  "details": {
    "issues": [
      {
        "expected": "string",
        "code": "invalid_type",
        "path": [
          "name"
        ],
        "message": "Invalid input: expected string, received number"
      }
    ]
  }
}
# exit code 3

A required field left out of both the flags and the body is reported as the flags that are missing:

$ zitadel grants create --principal-id user_01M2A9SJE7ZK9TMVHK22G139KW --json
{
  "status": "error",
  "cli_version": "1.0.0-alpha.22",
  "command": "grants:create",
  "source": "http://localhost:8093",
  "code": "E_VALIDATION",
  "message": "grants create is missing required fields: --principal-type, --relation",
  "hint": "Pass --principal-type and --relation, or include them in --data / --file. See `grants create --help`.",
  "details": {
    "missing": [
      "principal_type",
      "relation"
    ]
  }
}
# exit code 3

A field flag overrides the same key in --data, so a template can be loaded and one value changed:

$ zitadel teams create --data '{"name":"from-data"}' --name from-flag --dry-run --json
{
  "cli_version": "1.0.0-alpha.22",
  "command": "teams:create",
  "source": "http://localhost:8093",
  "status": "ok",
  "data": {
    "dry_run": true,
    "verb": "create",
    "topic": "teams",
    "body": {
      "name": "from-flag"
    }
  },
  "warnings": []
}

Some operations the API spec advertises are not implemented by the server yet. They pass local validation and the server answers 501; the CLI's existing error mapping reports every 5xx as E_NETWORK:

$ zitadel users list --filter id=not_equals:user_01M2A9SJE7ZK9TMVHK22G139KW --json
{
  "status": "error",
  "cli_version": "1.0.0-alpha.22",
  "command": "users:list",
  "source": "http://localhost:8093",
  "code": "E_NETWORK",
  "message": "This feature is not implemented yet.: operation \"not_equals\" is not supported",
  "details": {
    "status": 501,
    "url": "http://localhost:8093/users/query",
    "body": {
      "code": "not_implemented",
      "message": "This feature is not implemented yet.",
      "details": {
        "details": "operation \"not_equals\" is not supported"
      }
    }
  }
}
# exit code 4

Server errors map to the CLI's error codes:

$ zitadel users get user_01M2A9SJVGDT1AXS3H57J3KVHC --json
{
  "status": "error",
  "cli_version": "1.0.0-alpha.22",
  "command": "users:get",
  "source": "http://localhost:8093",
  "code": "E_NOT_FOUND",
  "message": "user not found",
  "details": {
    "status": 404,
    "url": "http://localhost:8093/users/user_01M2A9SJVGDT1AXS3H57J3KVHC",
    "body": {
      "code": "user.not_found",
      "message": "user not found"
    }
  }
}
# exit code 4

@vercel

vercel Bot commented Sep 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
nextgen Ready Ready Preview Sep 21, 2026 9:42am UTC
nextgen-docs Ready Ready Preview Sep 21, 2026 9:42am UTC
nextgen-mock-zitadel Ready Ready Preview Sep 21, 2026 9:42am UTC

Request Review

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

🦋 Changeset detected

Latest commit: d400e72

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
@zitadel/cli Minor
@zitadel/testing Minor
@zitadel/server Minor
@zitadel/server-linux-x64 Minor
@zitadel/server-linux-arm64 Minor
@zitadel/server-darwin-x64 Minor
@zitadel/server-darwin-arm64 Minor
@zitadel/server-win32-x64 Minor
@zitadel/api Minor
@zitadel/config Minor
@zitadel/components Minor
@zitadel/sdk-core Minor
@zitadel/sdk-next Minor
@zitadel/sdk-nuxt Minor
@zitadel/sdk-react Minor
@zitadel/sdk-vue Minor
@zitadel/sdk-angular Minor
@zitadel/sdk-solid Minor
@zitadel/sdk-svelte Minor
@zitadel/sdk-qwik Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@mridang mridang self-assigned this Sep 11, 2026
@mridang mridang moved this from Inbox to In progress in Engineering Kanban Sep 11, 2026
@mridang
mridang force-pushed the feat/cli-resource-commands branch from b42d9be to b45d654 Compare September 12, 2026 00:56
@mridang
mridang force-pushed the feat/cli-resource-commands branch from b45d654 to 8f21a69 Compare September 12, 2026 01:05
@mridang
mridang force-pushed the feat/cli-resource-commands branch from 8f21a69 to bc39c60 Compare September 12, 2026 01:11
@mridang
mridang force-pushed the feat/cli-resource-commands branch from bc39c60 to 2d2488b Compare September 12, 2026 01:56
@mridang
mridang force-pushed the feat/cli-resource-commands branch from 2d2488b to 4749c1d Compare September 12, 2026 01:58
@mridang
mridang force-pushed the feat/cli-resource-commands branch from 4749c1d to 3c347e8 Compare September 12, 2026 02:13
// The revision routes are not modelled here — the CLI has no grammar for a
// sub-resource listing yet, so `idps revisions` is deliberately absent.
idps: {
group: CommandGroups.resources,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IDPs are in between Resources and Configuration. At the moment, they are configured as code under .zitadel, but for b2b cases, they will probably also be managed in runtime. For now, I lean towards CommandGroups.configuration.

Add list/get/create/update/delete verbs for users, teams, sessions,
events, grants, and projects, built from one registry by a generic
oclif factory. Switch command discovery to the explicit strategy.
Adds the command grammar, flag conventions, discovery, output and safety rules.
…tory

Also corrects ADR 061 on how credential fields are detected.
Adds read-only commands for the configuration resources and declares paging,
draining and the id argument per resource.
Reads the generated client and records what the registry calls, so an
unexposed operation must carry a written reason.
Cursor property names and the query body shape move behind an overridable
default, so the factory carries no platform's spelling.
Sessions used a revoke verb while teams kept delete and reported the outcome.
The verb is now always delete; the outcome stays in the result.
ADR 061 is taken by the egress policy ADR, merged in #1207.
#1121 removed update and delete; the coverage guard caught the stale entries.
…ulary

Addresses the review on the ADR PR: the envelope carries cli_version, command
and source; six lists read through GET, not three; deploy replaces plan/apply;
and the environments naming conflict with ADR 035 is recorded.
delete means gone; a session is revoked and a team deactivated, following gh's
rule that a lifecycle change gets its own verb.
No sentence now runs past 35 words and none stacks dashes. Command
registration moves to the design doc.
A filter field can declare its default, so schemas list shows the current
revisions. Resolution by object type or flow name lives in the registry.
#1249 requires every command to declare a group. Configuration resources join
the configuration group; runtime resources get their own.
#1246 added revisions to GET /flow_definitions, so flows match schemas.
The coverage test caught idps (#1217), which has a query endpoint and so
cannot be excluded. Variables are configuration and carry a reason.
They are a management resource, not file-authored. The reason is the shape:
no id, a map-keyed list and a merge write.

This branch was successfully deployed

3 active deployments
Preview – nextgen-docs d400e720 Deployed Sep 21, 2026 by vercel[bot]
Preview – nextgen-mock-zitadel d400e720 Deployed Sep 21, 2026 by vercel[bot]
Preview – nextgen d400e720 Deployed Sep 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

CLI - CRUD operations on resources

4 participants