AGENTS.md@apps/cli · git:20261014.80042ba · 2026-10-14 · sha256 b3dcc5551d18ec99

AGENTS.md@apps/cli git:20261014.80042baA

Immutable. This exact content never changes and is served at /api/v1/blob/b3dcc5551d18ec99.

# Agent Instructions — `apps/cli`

Scope pointers first — this file's own body covers **telemetry only**:

- The agent-facing command contract (JSON envelope, posture, claim, doctor
  repair) is [`SKILL.md`](skills/zitadel-cli/SKILL.md) — keep it aligned with the command
  surface on every CLI behavior change (root `AGENTS.md` Generated Files rule).
- Scaffold posture derivation (standalone page vs widget in a pre-existing
  app) is [ADR 044](../../docs/adrs/044-scaffold-embedding-posture-defaults.md);
  the implementation lives in `src/lib/orca/patchers/posture.ts` and the
  manifest in `src/lib/scaffold-manifest.ts` (drift rules:
  [ADR 042](../../docs/adrs/042-scaffolded-file-ownership-and-drift-detection.md)).
- The claim lifecycle is
  [ADR 046](../../docs/adrs/046-claim-lifecycle-v2.md) (`src/commands/claim.ts`,
  `src/lib/claim-state.ts`, `src/commands/doctor/checks/claim.ts`).
- The journey e2e contract is
  [`apps/cli-journey-e2e/AGENTS.md`](../cli-journey-e2e/AGENTS.md).
- The test contract — the unit/spec split and one spec per command — is
  [`tests/AGENTS.md`](tests/AGENTS.md). Read it before adding or moving any
  test.
- Warnings go through `reportWarning` in `src/lib/warnings.ts`, or the
  `warnings` field of a command's result. Never call `consola.warn` directly:
  `--json` silences consola, so the warning would never reach an agent.
  `reportWarning` shows it in the terminal at once and adds it to the JSON
  envelope's `warnings`, and `emit` prints a result's `warnings` after its
  `pretty` text. A unit test fails on any other `consola.warn` call.
- Input goes through one function, `input` in `src/lib/input.ts` — a command
  calls it as `this.input(...)`. It takes the value from `given` (the flag),
  else piped stdin (`stdin: true`), else a prompt, else throws one uniform
  `E_VALIDATION` ("No <name> supplied", with a hint from `flag`, `stdin` and
  `retry`), and runs the question's `parse` on whichever source answered.
  `retry` is the command an agent can run once the input is to hand: it becomes
  the error's `nextCommands`. `optional` returns `undefined` instead of
  prompting or refusing when nothing was given or piped (a bad non-empty pipe
  still fails). A `confirm` is answered by the command's `--force`, and
  refuses without it when no one can be asked, so a command whose `--force`
  means something else (`setup`) must use its own asker with `force: false`.
  Never import clack's `text`, `password`, `select`, `multiselect`, `confirm`,
  `isCancel` or `cancel` anywhere else, and never hand-write the
  flag-or-stdin-or-prompt rule: a unit test fails on the import.
- The flag surface deliberately tracks the [WebCLI spec](https://webcli.com/) so
  an agent can drive the CLI from conventions it already knows. That is the
  rationale; the canonical flag list, output-channel rules and the two
  intentional deviations are the invocation rules in [`SKILL.md`](skills/zitadel-cli/SKILL.md).
  Keep that alignment in mind when adding a global flag, and record it there —
  not in a second copy here.

# Analytics Tracking — Mixpanel

This package (`@zitadel/cli`) uses **Mixpanel** for anonymous product analytics.
Mixpanel is the single source of truth for usage events. Do not introduce any
other analytics tool, SDK, or tracking library without explicit instruction.

---

## Before You Add or Modify Any Tracking

⛔ **Read this file before writing any Mixpanel tracking code.**

This is a **CLI**, not a web app, so the usual identity model does not apply:
there is no logged-in user, no `identify()`/`reset()`, and no PII. Events are
attributed to an anonymous, randomly-generated install id. Treat that as a hard
invariant — never attach a user, email, project id, URL, or file path to an
event.

### Mandatory checklist

- [ ] The Node server-side SDK (`mixpanel`) is the correct SDK — do not add the browser SDK.
- [ ] No CDP is involved; events go straight to Mixpanel.
- [ ] Consent is opt-out and resolved centrally in `src/lib/telemetry/consent.ts` — never fire an event that bypasses it.
- [ ] The new event only carries allow-listed dimensions (enums, booleans, counts, versions).

---

## Tech Stack

| Detail | Value |
|---|---|
| **Platform** | Node.js CLI (oclif), ESM, Node ≥ 24 |
| **Mixpanel SDK** | `mixpanel` (server-side) |
| **SDK version** | `^0.18.1` |
| **Tracking method** | server-side, fire-and-forget |
| **CDP (if any)** | none |
| **Consent required** | yes — opt-out model (`DO_NOT_TRACK`, `ZITADEL_TELEMETRY=0`, `--no-telemetry`) |
| **Project token location** | `src/lib/telemetry/config.ts` (write-only dev + prod tokens baked in) |
| **Data region** | `ZITADEL_TELEMETRY_REGION` = `eu` (default, Zitadel projects' region) \| `us` |

The ingestion token is **write-only** (cannot read data back), so embedding it
in the published CLI is safe and intentional — this is how dev-tool telemetry
works. Dev and prod are separate Mixpanel projects. The CLI ships as a **single
generic build** with no channel stamp, so events **default to the production
project** and the published CLI needs no per-user env. Development, CI, and test
runs stay out of prod by opting out entirely (`DO_NOT_TRACK` /
`ZITADEL_TELEMETRY=0` — CI sets `DO_NOT_TRACK=1` for every job) or by selecting
the dev project with `ZITADEL_TELEMETRY_ENV=development`. `ZITADEL_TELEMETRY_TOKEN`
overrides the token outright. Ambient `NODE_ENV` is intentionally not consulted.

---

## Initialization

Telemetry is constructed **once per command invocation** by `BaseCommand`
(`src/lib/oclif/base.ts`) via `Telemetry.create({ env, flag, debug })` in
`toMeta()`. The generic client lives in `src/lib/telemetry`; all CLI-specific
property building (event names, `server_kind`, base/profile dimensions) lives in
`src/lib/oclif/command-telemetry.ts`. The
Mixpanel client is built lazily — only when consent is granted *and* a token is
configured. Do not construct a `Mixpanel` client anywhere else.

**File:** `src/lib/telemetry/index.ts`

**Do not:**
- Initialize Mixpanel outside `BaseCommand`.
- Write to **stdout** from telemetry — the JSON envelope is the machine
  contract; the one-time first-run notice goes to stderr and only in
  interactive mode.
- Let telemetry throw or block — every path fails open; the network is bounded
  by `Telemetry.shutdown()`'s timeout in `BaseCommand.finally()`.

---

## Identity

There is **no user identity**. Each install gets a stable, anonymous
`distinct_id` (a random UUID) persisted under the platform config dir
(`src/lib/telemetry/identity.ts`). It is tied to no account and contains no
fingerprint. On first run, `isFirstRun` drives the one-time consent notice.

---

## Tracking Plan

Three **lifecycle events** cover every command (one instrumentation point in
`BaseCommand`, so new commands are covered automatically):

| Event | Trigger | Key Properties | File |
|---|---|---|---|
| `cli_command_started` | a command begins (`toMeta`) | base dimensions | `src/lib/oclif/base.ts` |
| `cli_command_completed` | a command succeeds or skips (`emit`) | `status`, `duration_ms`, command extras | `src/lib/oclif/base.ts` |
| `cli_command_failed` | a command throws (`catch`) | `reason`, `exit_code`, `step` | `src/lib/oclif/base.ts` |

**Value Moment:** `cli_command_completed` for `command=setup` — Zitadel auth is
wired into the user's app.

### Base dimensions (every event)

`command`, `cli_version`, `os`, `arch`, `node_version`, `invocation_id`,
`non_interactive`, `is_tty`, `is_ci`, `ci_provider`, `host_agent`,
`invocation_channel`, `dry_run`, `force`, `server_kind` (bucketed
`cloud`/`local`/`self_hosted` — **never the URL**), plus the reserved
event properties `$os`/`mp_country_code` (country derived from the timezone, not
the IP — `ip: 0` keeps IP geolocation off). First-run user profiles use the
People API's `$country_code` profile property for the same derived country. Built
in `src/lib/oclif/command-telemetry.ts` (using the generic env/geo helpers in
`src/lib/telemetry/`).

`ci_provider` and `host_agent` also leave the machine outside Mixpanel, as the
`ci/` and `host/` tokens of the HTTP `User-Agent` (`src/lib/user-agent.ts`). They
share the telemetry opt-out: `--no-telemetry`, `DO_NOT_TRACK` and
`ZITADEL_TELEMETRY=0` drop them.

### Event shape

Each event is one Mixpanel `track` call. We set the properties below;
Mixpanel injects the transport fields (`time`, `$lib_version`,
`$mp_api_endpoint`, `$user_id`, `mp_lib`, …) on ingestion — we do not send those.

```json
{
  "event": "cli_command_completed",
  "properties": {
    "distinct_id": "09233195-cd34-468c-bb7b-151554e19fbc",
    "$insert_id": "2398bc39-3cb6-4b05-835f-bb03908794b8",
    "ip": 0,
    "invocation_id": "6d919718-6168-42b4-9482-e656776445f0",
    "command": "status",
    "cli_version": "0.1.0-alpha.11",
    "$os": "Mac OS X",
    "mp_country_code": "AU",
    "os": "darwin",
    "arch": "arm64",
    "node_version": "24.12.0",
    "non_interactive": true,
    "is_tty": false,
    "is_ci": false,
    "host_agent": "unknown",
    "invocation_channel": "unknown",
    "dry_run": false,
    "force": false,
    "server_kind": "cloud",
    "status": "ok",
    "duration_ms": 25
  }
}
```

### Command-specific dimensions

Commands add dimensions via `this.recordTelemetry({ … })` (merged immutably onto
each lifecycle event emitted *after* recording — typically `completed`/`failed`,
since `started` fires before the command body runs):

- **setup** — `framework`, `renderer`, `package_manager`, `scaffolded_skeleton`, `skip_install`, `dev_port_explicit`, `preset`, `use_case`, `sso` (an `IDP_PROVIDERS` slug, `custom` for a developer-described connection — never its free-text slug —, `none`, or `multiple` when setup enabled several), `design` (a `BRANDING_DESIGNS` value or `built-in`), `files_written_count`, `step` (`framework_resolved` → `project_created` → `files_patched` → `dependencies_installed`).
- **plan / apply** — `creates`, `updates`, `deletes`, `revisions`, `total` (diff *counts* only).
- **doctor** — `runtime`, `checks_total`, `checks_failed`, `checks_warn`, `failed_checks` (failing check **names**, never messages).
- **start** — `runtime` (`binary` / `docker`).
- **variables** — `is_secret`, `variable_count`. Deliberately *not* recorded: the variable's name or value — a name is free text, and a value is the credential itself. There is no owner dimension: the project level is the only owner the commands can address.
- **claim** — `claim_outcome` (`completed` / `already_claimed` / `expired` / `window_expired` / `timeout` / `dry_run`), `poll_count`, `browser_opened`. Deliberately *not* recorded: `challenge_id`, `team_id`, `claim_url`, `dashboard_url` — every one of them is an id or a URL.

### Naming conventions

- Event names: `snake_case`, past tense (`cli_command_completed`).
- Property names: `snake_case`; booleans use `is_`/`has_`/`dry_`/`non_` style flags.
- Omit empty properties — `compact()` drops `null`/`undefined`/`""`; never send them.

---

## How to Add a New Event or Property

1. Prefer enriching `this.telemetryProps` in the command over adding a new
   event — the lifecycle events already cover command outcomes.
2. Only add properties available at the moment the event fires; never fetch data
   just for telemetry.
3. Keep it allow-listed: enums, booleans, counts, versions. **No** URLs, ids,
   paths, emails, secrets, or free-text.
4. Add the property to the table above and cover it in
   `tests/unit/lib/telemetry/`.
5. Verify it lands in Mixpanel **Live View** (set `ZITADEL_TELEMETRY_REGION=eu`
   first if the project is in the EU region — events to the wrong host are
   silently dropped).

---

## What Not to Do

- **Do not** introduce another analytics tool.
- **Do not** track PII — no emails, names, IPs, URLs, project ids, file paths, or secrets.
- **Do not** write to stdout from telemetry.
- **Do not** let a telemetry failure surface to the user — fail open, always.
- **Do not** fire events that bypass `resolveConsent`.