# Corti CLI

[![npm](https://img.shields.io/npm/v/@corti/cli)](https://www.npmjs.com/package/@corti/cli)
[![License: MIT](https://img.shields.io/npm/l/@corti/cli)](https://opensource.org/licenses/MIT)

Wire Corti as an OpenAI-compatible provider into your terminal AI coding agent.

```bash
npx @corti/cli@latest models init
```

The command prompts you to paste an `.env` block from the [Corti Console](https://console.corti.app) (Developer quickstart → Copy all as .env variables) containing `CORTI_TENANT_NAME`, `CORTI_CLIENT_ID`, and `CORTI_CLIENT_SECRET`. It constructs the Bearer token (base64 of `<tenant>:client_credentials:<client_id>:<client_secret>`) and writes a `corti` provider entry into each tool you select. Pass `-i, --interactive` to enter credentials field-by-field instead.

| Tool          | File written                                              | Format | Notes |
| ------------- | --------------------------------------------------------- | ------ | ----- |
| OpenCode      | `opencode.json` (or `~/.config/opencode/opencode.json`)   | JSON   | Full provider entry — `CORTI_BEARER` referenced via `{env:…}` |
| Pi            | `.pi/agent/models.json` (or `~/.pi/agent/models.json`)    | JSON   | `providers.corti` entry, `api: "openai-completions"`, `apiKey: "CORTI_BEARER"` (bare env name) |
| ZCode         | `~/.zcode/v2/config.json` (global only)                   | JSON   | `provider.corti` entry with the bearer embedded (ZCode has no env indirection); restart ZCode after, see below |
| Crush         | `.crush.json` (or `~/.config/crush/crush.json`)           | JSON   | Full provider entry — `$CORTI_BEARER` substitution |
| ForgeCode     | `.forge.toml` + `.forge/.credentials.json`               | TOML+JSON | `[[providers]]` entry (selected by default); bearer embedded in sidecar `.credentials.json` (no env indirection) |

It also writes a managed `# >>> corti-cli >>>` block to `.env` with:

- `CORTI_CLIENT_ID`
- `CORTI_CLIENT_SECRET`
- `CORTI_TENANT`
- `CORTI_BEARER` (base64 of `<tenant>:client_credentials:<client_id>:<client_secret>`, used by the tools)
- `CORTI_BASE_URL`

Each tool config references `CORTI_BEARER` rather than embedding the secret in the JSON/TOML file, so the credential stays out of version control. Two exceptions embed the bearer because the tool offers no env indirection: ForgeCode's `.credentials.json` sidecar and ZCode's `~/.zcode/v2/config.json` (both global/home-dir files, never in your repo).

Under global scope it also offers to add a managed block to your shell rc (`~/.zshrc`, `~/.bashrc` / `~/.bash_profile`, or fish config, resolved from `$SHELL`) so new shells load the `CORTI_*` variables from `~/.env` automatically. The block exports only `CORTI_*` lines — it never sources the whole file and never contains secret values. It shows up as its own row on the plan screen (press `s` there to skip it), and the file is backed up before its first edit. Project scope never touches your shell rc.

## Usage

```text
corti models init [options]

  --client-id <id>          Corti client ID     (else $CORTI_CLIENT_ID,     else prompted)
  --client-secret <secret>  Corti client secret (else $CORTI_CLIENT_SECRET, else prompted)
  --tenant-name <name>      Corti tenant name   (else $CORTI_TENANT,        else prompted; default base)
  --base-url <url>          Corti base URL      (else $CORTI_BASE_URL,      else prompted; default https://ai.eu.corti.app/v1)
  --region <region>         Corti region, e.g. eu (else $CORTI_ENVIRONMENT) — sets the base URL
  --model <id>              Default model       (else $CORTI_MODEL,  else picked from /models, else prompted)
  --tools <list>            Comma-separated subset: opencode,pi,zcode,crush,forgecode (else $CORTI_TOOLS)
  --scope <project|global>  Where to write configs (else $CORTI_SCOPE; default global)
  --shell-rc                Wire your shell rc to load CORTI_* into new shells (headless is opt-in; interactive default)
  --no-shell-rc             Never touch your shell rc file
  -y, --yes                 Non-interactive; fail if required values are missing
  -i, --interactive         Prompt for credentials field-by-field instead of pasting a .env block
  --fresh                   Ignore credential/region CORTI_* env vars (shell-exported or from ~/.env/./.env); use to re-provision to a different region/tenant (a harvested ~/.env block otherwise re-uses the prior region's credentials)
  --dry-run                 Print file changes without writing them
  --no-verify               Skip the /models probe (--yes skips prompts, not verification)
  --json                    Emit exactly one JSON document on stdout; all text to stderr (else $CORTI_JSON)
  --no-color                Disable color output (NO_COLOR and TERM=dumb are also honored)
  --theme <mode>            Palette for light/dark terminals (else $CORTI_THEME; default auto — probes the background)
```

Resolution order per value: flag → env var → interactive prompt. With `--yes`, missing required values fail fast (exit 2), the `/models` probe still runs (`--no-verify` opts out), and Ink/React are never loaded.

When a newer version is published, the wizard shows a one-line update row at startup; headless runs print the notice on stderr after the command finishes (never in CI, `--json` runs, or piped output; set `NO_UPDATE_NOTIFIER=1` to opt out). Prefer `npx @corti/cli@latest` — it always runs the newest release.

### Exit codes

| Code | Meaning | `E_*` codes |
| ---- | ------- | ----------- |
| 0    | Full success (including "everything already up to date" and clean `--dry-run`) | `E_NO_MODELS`, `E_MODEL_UNKNOWN` (warnings; the run continues) |
| 1    | Internal error, or plan declined | `E_DECLINED`, `E_INTERNAL`, `E_DEVICE_DENIED` |
| 2    | Usage/validation error (bad flag, missing flag under `--yes`, no TTY without `--yes`) | `E_USAGE` |
| 3    | Auth failure (HTTP 401/403 from the probe) | `E_AUTH` |
| 4    | Network/API failure (DNS, timeout, TLS, connection refused, 5xx) | `E_API`, `E_DNS`, `E_TIMEOUT`, `E_TLS`, `E_NET`, `E_DEVICE`, `E_DEVICE_EXPIRED` |
| 5    | Run failed with nothing modified (total write failure, backup gate, stale plan) | `E_PLAN`, `E_WRITE`, `E_BACKUP`, `E_STALE_PLAN` |
| 6    | Partial success (some tools configured, some failed; includes env-write failure) | `E_PARTIAL`, `E_ENV_WRITE` |
| 130  | Interrupted (SIGINT/SIGTERM) | — |

The `E_*` code is stable, printed with every failure, and carried in `--json`.

### `--json`

`--json` puts exactly one versioned JSON document on stdout — on success *and* failure — with all
human-readable progress on stderr. Combine with `--yes` (writes) or `--dry-run` (preview):

```bash
corti models init --yes --json --tools opencode --model corti-1 \
  --client-id … --client-secret …          # writes, then emits the result document
corti models init --json --dry-run --tools opencode --model corti-1 \
  --client-id … --client-secret …          # previews with would-* actions
```

The document carries `ok`, `exitCode`, `resolved` (each value with its provenance), `probe`,
per-file `results` (with diffs — secret-bearing files are diffstat-only, never a diff body),
`env`, `backupDir`, `warnings`, and `error: {code, message} | null`. Secrets never appear in it.

### `models list`

`corti models list` probes `GET {base-url}/models` and prints the available model ids:

```bash
corti models list --bearer $CORTI_BEARER --base-url https://ai.eu.corti.app/v1
```

```text
corti-s1  # ctx 524,288 · cost in 2 out 8
corti-s1-mini  # ctx 262,144 · cost in 1 out 4
corti-s1-tiny  # ctx 32,768
```

Each line is the bare model id first (so `awk`/`cut` pipelines stay intact), then `#` and any
metadata the API provides — context window and cost per million tokens, only the keys that are
present. Models with no metadata print as a bare id.

With `--json` the document adds a `modelDetails` sibling, index-for-index with `models`, carrying
each model's metadata (`capabilities`, `cost`, `effort`, `maxInputTokens`) verbatim from the API:

```bash
corti models list --json --bearer $CORTI_BEARER --base-url https://ai.eu.corti.app/v1
```

```json
{
  "$schema": "https://corti.app/schemas/cli-list-models.v1.json",
  "ok": true,
  "exitCode": 0,
  "models": ["corti-s1", "corti-s1-mini", "corti-s1-tiny"],
  "count": 3,
  "modelDetails": [
    { "id": "corti-s1", "maxInputTokens": 524288, "capabilities": { "reasoning": true, "image_input": false, "tool_call": true, "temperature": true }, "cost": { "input": 2, "output": 8, "cacheRead": 0.2 }, "effort": { "supported": true, "levels": ["high", "max"] } },
    { "id": "corti-s1-mini", "maxInputTokens": 262144, "capabilities": { "image_input": true } },
    { "id": "corti-s1-tiny" }
  ],
  "probe": { "status": "ok", "httpStatus": 200, "latencyMs": 42 },
  "error": null
}
```

`modelDetails` is always present (never omitted, even when empty), so a consumer can distinguish
"old deployment, no metadata support" from "new deployment, this tenant's models happen to have
none" by the key's presence rather than its contents. The `models` array stays `string[]` — adding
`modelDetails` is an additive change, `$schema` stays v1.

### Backups

Before modifying any existing file, the CLI copies it to
`~/.local/share/corti/backups/<timestamp>/` (mode 0700, manifest included) and verifies the file
on disk still matches what was planned — a mismatch aborts the whole run with nothing written.

## What it does

1. Builds the Bearer token as base64 of `<tenant>:client_credentials:<client_id>:<client_secret>`.
2. Probes `GET {base-url}/models` with that Bearer. The response carries per-model metadata — context window, cost, capabilities (reasoning, image input, tool calling), and reasoning-effort levels — which the CLI writes into each tool's config. A field the API omits is omitted from the config too; the tool falls back to its own default.
   - On success, lets you pick a model.
   - On failure (auth or network), surfaces the status in the side panel and falls back to a typed model id.
3. Detects which tools are installed (best-effort) and pre-selects them.
4. Asks before writing. Each writer merges into existing config rather than overwriting unrelated keys.
5. Writes are atomic (temp file + rename) and idempotent — a second run is a no-op.

## After init

If you accepted the shell rc block (global scope), `CORTI_*` loads automatically in shells you open from now on — the shell you ran `init` in is not one of them, so open a new terminal. For that shell — or if you skipped the block or used project scope — load `.env` manually so `CORTI_BEARER` is available when the coding tool starts:

```bash
set -a; . .env; set +a
```

Then run the tool:

```bash
opencode --model corti/<model>     # OpenCode
pi                                 # Pi           (select provider 'corti')
crush                              # Crush        (select provider 'corti')
forge                              # ForgeCode
```

ZCode is a desktop app: restart it after init (it loads providers at launch), then pick the
`Corti` provider in chat.

## Development

```bash
pnpm install
pnpm build
pnpm test
node ./dist/cli.js models init --dry-run \
  --client-id test-client-id \
  --client-secret test-client-secret \
  --base-url https://ai.eu.corti.app/v1 \
  --model corti-1 \
  --tools opencode,pi,zcode,crush,forgecode
```

## Releasing

Releases are automated — merging to `main` runs semantic-release, which derives the
version from the conventional commit message and publishes to npm via OIDC. Since PRs are
squash-merged, that commit message is the PR title.

- `fix:` → patch · `feat:` → minor · `feat!:` / `BREAKING CHANGE:` → major
- Prerelease: push a suffixed tag — `git tag v1.2.0-rc.1 && git push origin v1.2.0-rc.1` (publishes under the `rc` dist-tag, never `latest`)
- Don't hand-push a bare `vX.Y.Z` tag — CI rejects it; stable goes through `main`

## License

MIT
