diff --git a/.changeset/ai-cli-initial.md b/.changeset/ai-cli-initial.md
new file mode 100644
index 000000000..52c8ca765
--- /dev/null
+++ b/.changeset/ai-cli-initial.md
@@ -0,0 +1,33 @@
+---
+'@tanstack/ai-cli': minor
+---
+
+Add `@tanstack/ai-cli` — a type-safe CLI over TanStack AI exposing the core
+activities as the `ts-ai` binary (`chat`, `image`, `video`, `audio`, `speech`,
+`transcribe`, `summarize`), plus `introspect` (machine-readable manifest),
+`mcp` (expose commands as MCP tools), and `update`.
+
+Designed machine-first for agent harnesses: every command is a stateless
+single-shot subprocess with `--json` buffered output, `--stream` AG-UI event
+output, strict stdout-is-payload discipline, typed exit codes, and structured
+error objects. Providers resolve from a `provider/model` slug (openai,
+anthropic, gemini, openrouter, and fal bundled for zero-install) with keys from
+`--apiKey`, a conventional `.env`, or environment variables, and all options are
+expressible via `--config` (file or inline JSON).
+
+For humans there's a lazily-loaded, TanStack-branded Ink layer: running `ts-ai`
+with no command on a TTY opens a full-width welcome screen (island logo on
+graphics-capable terminals + a two-color `TANSTACK` / pink `AI` wordmark) and
+menu, `ts-ai chat` with no prompt drops into an interactive REPL, and image
+results preview inline. `chat` supports tools via `--mcp` servers, sandboxed
+`--code-mode` execution, and `--schema` structured output, plus
+`ts-ai introspect` (machine-readable manifest), `ts-ai mcp` (expose commands as
+MCP tools — prints a ready-to-paste client config to stderr), and `ts-ai update`.
+
+Generations (`image`, `video`, `audio`, `speech`) write to the current directory
+by default; `--output-dir
` sets the target directory (created if missing,
+cross-platform) and `-o/--output ` sets an exact path.
+
+Ships a TanStack Intent agent skill (`ai-cli`) so coding agents learn how to
+drive `ts-ai` correctly (machine-mode contract, exit codes, `--config`,
+`--output-dir`, `introspect`, `mcp`).
diff --git a/AGENTS.md b/AGENTS.md
index 13d918e3d..b276add68 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,6 +9,15 @@ apply to every coding agent regardless of tool.
Run `pnpm install` before starting any task and again after every merge with
`main`.
+## Adding a New Library
+
+When you add a new library under `packages/`, add a `workspace:*` override for
+it in `pnpm-workspace.yaml` under `overrides:` (e.g.
+`'@tanstack/ai-foo': workspace:*`) and run `pnpm install`. Every `packages/`
+library must have an entry — this forces any transitive or example dependency
+that references a published version onto the local workspace copy. Use
+`workspace:*` for internal deps in `package.json` as usual.
+
## Pre-PR Quality Gate (MANDATORY)
**Before committing, run the narrowest meaningful quality checks for your
diff --git a/CLAUDE.md b/CLAUDE.md
index f1fa41281..89f65e194 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -269,6 +269,12 @@ pnpm dev # start dev server
- Use `workspace:*` protocol for internal package dependencies in `package.json`
- Example: `"@tanstack/ai": "workspace:*"`
+- **When adding a new library under `packages/`, add a `workspace:*` override for
+ it in `pnpm-workspace.yaml` under `overrides:`** (e.g.
+ `'@tanstack/ai-foo': workspace:*`), then run `pnpm install`. Every `packages/`
+ library must have an entry. This forces any transitive or example dependency
+ that references a published version onto the local workspace copy, so the
+ monorepo never accidentally builds/tests against an npm-published version.
### Tree-Shakeable Exports
diff --git a/docs/cli/overview.md b/docs/cli/overview.md
new file mode 100644
index 000000000..80c0b5ba0
--- /dev/null
+++ b/docs/cli/overview.md
@@ -0,0 +1,209 @@
+---
+title: ts-ai CLI
+id: cli-overview
+order: 1
+description: "Run TanStack AI from the terminal or any agent harness with the ts-ai CLI — chat, image, video, audio, speech, transcribe, and summarize, with JSON output, AG-UI streaming, and a self-describing manifest."
+keywords:
+ - tanstack ai
+ - cli
+ - ts-ai
+ - agent harness
+ - json output
+---
+
+`@tanstack/ai-cli` installs the `ts-ai` binary — a thin, type-safe CLI over the
+core TanStack AI activities. It is built **machine-first**: every command is a
+stateless, single-shot subprocess with structured I/O, so it drops cleanly into
+an agent harness — while still giving humans a pretty, interactive experience on
+a TTY.
+
+## Install
+
+```bash
+# Zero-install one-off
+npx @tanstack/ai-cli image "a watercolor fox" -o fox.png
+
+# Or install globally
+pnpm add -g @tanstack/ai-cli
+ts-ai --version
+```
+
+OpenAI, Anthropic, Gemini, OpenRouter, and Fal are bundled, so those providers
+work with no extra install. Other providers (Ollama, Grok, Groq, ElevenLabs)
+are loaded on demand — if one isn't installed, `ts-ai` exits with code `4` and
+tells you which package to add.
+
+## Choosing a model and key
+
+Pick a model with a `provider/model` slug. The API key comes from `--apiKey`
+or, by default, the conventional environment variable for that provider
+(`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `OPENROUTER_API_KEY`,
+`FAL_KEY`).
+
+```bash
+ts-ai chat "Explain MCP in one sentence" --model openai/gpt-5.5
+ts-ai chat "Summarize this PR" --model anthropic/claude-sonnet-4-6 --api-key sk-...
+```
+
+`ts-ai` also loads a `.env` from the current directory automatically, so dropping
+`OPENAI_API_KEY=...` in a project `.env` is enough. Real environment variables
+and `--apiKey` always take precedence over `.env`.
+
+## Interactive mode
+
+Run `ts-ai` with no command on a terminal and you get a full-width TanStack AI
+welcome screen — the island logo (on graphics-capable terminals) above the
+wordmark — and a menu (Chat, Image, Video, …). Pick **Chat** to drop into a live
+REPL (`/clear` to reset, `/exit` to quit); pick a generation command to type a
+prompt and run it inline.
+
+```bash
+ts-ai # animated menu → pick an action
+ts-ai chat # no prompt on a TTY → interactive chat REPL
+```
+
+## Commands
+
+| Command | What it does |
+| --- | --- |
+| `ts-ai chat ` | Chat / agentic text generation |
+| `ts-ai image ` | Generate an image |
+| `ts-ai video ` | Generate a video (async job; blocks until done) |
+| `ts-ai audio ` | Generate audio (music / sfx) |
+| `ts-ai speech ` | Text-to-speech (alias: `tts`) |
+| `ts-ai transcribe ` | Speech-to-text (alias: `stt`) |
+| `ts-ai summarize ` | Summarize text |
+| `ts-ai introspect` | Print the machine-readable CLI manifest |
+| `ts-ai mcp` | Expose every command as an MCP tool over stdio |
+| `ts-ai update` | Update `ts-ai` to the latest version |
+
+The prompt is everything after the command that isn't a flag, so multi-word and
+multi-line prompts work without quoting gymnastics. When no prompt is given,
+input is read from stdin — handy for pipes:
+
+```bash
+cat article.txt | ts-ai summarize --model openai/gpt-5.5
+```
+
+Attach files with the repeatable `--attachment` flag:
+
+```bash
+ts-ai chat "What's in this diagram?" --model openai/gpt-5.5 --attachment diagram.png
+```
+
+## Output: humans vs harnesses
+
+`ts-ai` decides how to render based on whether stdout is an interactive TTY:
+
+- **On a TTY** it renders a pretty, [Ink](https://github.com/vadimdemedes/ink)-based
+ view: streamed chat text, inline image previews, and saved-file callouts.
+- **In `--json` mode, or when stdout is piped/redirected**, it never renders
+ anything human-facing. stdout carries only the payload; all progress, warnings,
+ and logs go to stderr.
+
+### Buffered JSON
+
+`--json` returns a single JSON object you can parse directly:
+
+```bash
+ts-ai image "a red bicycle" --model openai/gpt-image-1 --json
+# {"id":"...","model":"gpt-image-1","images":[{"path":"./ts-ai-image-.png","mimeType":"image/png"}],"usage":{...}}
+```
+
+Media commands (`image`, `video`, `audio`, `speech`) always write the artifact
+to a file and report the path in the JSON. By default the file lands in the
+**current directory** with an auto-generated name. Control where it goes:
+
+- `--output-dir ` — write the auto-named file into `` (created if
+ missing). Works the same on Windows and macOS.
+- `-o/--output ` — set the exact file path (wins over `--output-dir`).
+- `-o -` — stream the raw bytes to stdout (for piping).
+
+```bash
+ts-ai image "a red bicycle" --model openai/gpt-image-1 # ./ts-ai-image-.png
+ts-ai image "a red bicycle" --model openai/gpt-image-1 --output-dir ./out
+ts-ai image "a red bicycle" --model openai/gpt-image-1 -o ./pics/bike.png
+```
+
+### Streaming the AG-UI event stream
+
+`--stream` emits the TanStack AI / AG-UI event stream as newline-delimited JSON,
+one event per line, so a harness can reconstruct state incrementally:
+
+```bash
+ts-ai chat "Write a haiku" --model openai/gpt-5.5 --stream
+```
+
+## Stateless multi-turn
+
+`chat` keeps no state of its own. Pass the full conversation in with `--messages`
+(a JSON array) and thread the returned messages back yourself:
+
+```bash
+ts-ai chat --model openai/gpt-5.5 --json \
+ --messages '[{"role":"user","content":"hi"},{"role":"assistant","content":"hello!"},{"role":"user","content":"what did I just say?"}]'
+```
+
+`--threadId` is accepted purely as a correlation id (for telemetry / AG-UI) and
+never causes anything to be persisted.
+
+## Structured output
+
+Constrain `chat` to a JSON Schema and get a validated object back under `.data`:
+
+```bash
+ts-ai chat "Classify: 'the app crashes on launch'" \
+ --model openai/gpt-5.5 \
+ --schema ./ticket.schema.json \
+ --json
+# {"data":{"severity":"high","area":"startup"},"model":"gpt-5.5"}
+```
+
+## Configuration
+
+Every option is settable as a flag. For nested, provider-specific options use
+`--config`, which accepts a JSON file path **or** an inline JSON string whose
+shape mirrors the command's options. Precedence is **flags > `--config` > env >
+defaults**:
+
+```bash
+ts-ai image "a logo" --model openai/gpt-image-1 \
+ --config '{"size":"1024x1024","modelOptions":{"background":"transparent"}}'
+```
+
+Provider-specific options live only under `modelOptions`.
+
+## Using ts-ai inside an agent harness
+
+Two patterns make `ts-ai` easy to drive programmatically:
+
+1. **`ts-ai introspect`** prints a versioned JSON manifest of every command,
+ flag, type, and exit code — read it once and auto-generate tool definitions.
+2. **`ts-ai mcp`** starts an MCP server (stdio) that exposes each command as a
+ tool, so any MCP-capable agent can register `ts-ai` directly. On startup it
+ prints the connection details to **stderr** (stdout is the JSON-RPC channel)
+ — a ready-to-paste client config, the transport, and the tool list:
+
+ ```jsonc
+ // add to Claude Desktop / Cursor
+ "mcpServers": {
+ "tanstack-ai": { "command": "ts-ai", "args": ["mcp"] }
+ }
+ ```
+
+### Exit codes
+
+| Code | Meaning |
+| --- | --- |
+| `0` | Success |
+| `1` | Generic runtime error |
+| `2` | Usage / validation error |
+| `3` | Provider/API error or output-schema validation failure |
+| `4` | Required provider package not installed |
+
+In `--json` mode a non-zero exit also prints a structured error object on stdout
+so the failure stays parseable:
+
+```json
+{ "error": { "code": "USAGE", "message": "Missing --model (e.g. openai/gpt-5.5).", "provider": "openai" } }
+```
diff --git a/docs/config.json b/docs/config.json
index f5552703f..6aba85cd1 100644
--- a/docs/config.json
+++ b/docs/config.json
@@ -47,7 +47,8 @@
{
"label": "Agent Skills (TanStack Intent)",
"to": "getting-started/agent-skills",
- "addedAt": "2026-04-17"
+ "addedAt": "2026-04-17",
+ "updatedAt": "2026-06-07"
}
]
},
@@ -216,6 +217,17 @@
}
]
},
+ {
+ "label": "CLI",
+ "children": [
+ {
+ "label": "ts-ai CLI",
+ "to": "cli/overview",
+ "addedAt": "2026-06-07",
+ "updatedAt": "2026-06-07"
+ }
+ ]
+ },
{
"label": "Media",
"children": [
diff --git a/docs/getting-started/agent-skills.md b/docs/getting-started/agent-skills.md
index a3441133d..b3e98eb45 100644
--- a/docs/getting-started/agent-skills.md
+++ b/docs/getting-started/agent-skills.md
@@ -31,6 +31,7 @@ TanStack AI publishes skills inside its packages so the guidance travels with `n
|---------|-------|-----------------|
| `@tanstack/ai` | `ai-core` | Chat experience, tool calling, adapters, middleware, structured outputs, media generation, AG-UI protocol, custom backends |
| `@tanstack/ai-code-mode` | `ai-code-mode` | Setting up Code Mode with a sandbox driver and registering server tools |
+| `@tanstack/ai-cli` | `ai-cli` | Driving the `ts-ai` CLI from a terminal or agent harness — JSON/stream output, exit codes, `--config`, `--output-dir`, `introspect`, and `mcp` |
Each skill lives under `node_modules//skills//SKILL.md` once the package is installed.
diff --git a/knip.json b/knip.json
index a5e8a03e1..dbd58f420 100644
--- a/knip.json
+++ b/knip.json
@@ -27,6 +27,15 @@
"packages/ai": {
"ignoreDependencies": ["@opentelemetry/api"]
},
+ "packages/ai-cli": {
+ "ignoreDependencies": [
+ "@tanstack/ai-openai",
+ "@tanstack/ai-anthropic",
+ "@tanstack/ai-gemini",
+ "@tanstack/ai-openrouter",
+ "@tanstack/ai-fal"
+ ]
+ },
"packages/ai-anthropic": {
"ignore": ["src/tools/**"]
},
diff --git a/packages/ai-cli/assets/logo.png b/packages/ai-cli/assets/logo.png
new file mode 100644
index 000000000..9db3e67ba
Binary files /dev/null and b/packages/ai-cli/assets/logo.png differ
diff --git a/packages/ai-cli/package.json b/packages/ai-cli/package.json
new file mode 100644
index 000000000..da11277f9
--- /dev/null
+++ b/packages/ai-cli/package.json
@@ -0,0 +1,85 @@
+{
+ "name": "@tanstack/ai-cli",
+ "version": "0.1.0",
+ "description": "ts-ai — a type-safe CLI over TanStack AI: chat, image, video, audio, speech, transcribe, and summarize from the terminal or any agent harness.",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/TanStack/ai.git",
+ "directory": "packages/ai-cli"
+ },
+ "keywords": [
+ "ai",
+ "ai-sdk",
+ "cli",
+ "tanstack",
+ "agent",
+ "agent-harness",
+ "llm",
+ "chat",
+ "image-generation",
+ "tts",
+ "transcription",
+ "tanstack-intent"
+ ],
+ "type": "module",
+ "module": "./dist/esm/index.js",
+ "types": "./dist/esm/index.d.ts",
+ "bin": {
+ "ts-ai": "./dist/bin/bin.js"
+ },
+ "exports": {
+ ".": {
+ "types": "./dist/esm/index.d.ts",
+ "import": "./dist/esm/index.js"
+ }
+ },
+ "sideEffects": false,
+ "engines": {
+ "node": ">=18"
+ },
+ "files": [
+ "assets",
+ "dist",
+ "skills",
+ "src"
+ ],
+ "scripts": {
+ "build": "vite build && tsup --config tsup.bin.config.ts",
+ "clean": "premove ./build ./dist",
+ "lint:fix": "eslint ./src --fix",
+ "test:build": "publint --strict",
+ "test:eslint": "eslint ./src",
+ "test:lib": "vitest",
+ "test:lib:dev": "pnpm test:lib --watch",
+ "test:types": "tsc"
+ },
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.29.0",
+ "@tanstack/ai": "workspace:*",
+ "@tanstack/ai-anthropic": "workspace:*",
+ "@tanstack/ai-code-mode": "workspace:*",
+ "@tanstack/ai-fal": "workspace:*",
+ "@tanstack/ai-gemini": "workspace:*",
+ "@tanstack/ai-isolate-node": "workspace:*",
+ "@tanstack/ai-mcp": "workspace:*",
+ "@tanstack/ai-openai": "workspace:*",
+ "@tanstack/ai-openrouter": "workspace:*",
+ "commander": "^13.1.0",
+ "ink": "^7.0.5",
+ "react": "^19.2.3",
+ "react-devtools-core": "^6.1.5",
+ "terminal-image": "^4.3.0"
+ },
+ "peerDependencies": {
+ "zod": "^3.0.0 || ^4.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^24.10.1",
+ "@types/react": "^19.2.7",
+ "@vitest/coverage-v8": "4.0.14",
+ "tsup": "^8.5.1",
+ "vite": "^7.3.3",
+ "zod": "^4.2.0"
+ }
+}
diff --git a/packages/ai-cli/skills/ai-cli/SKILL.md b/packages/ai-cli/skills/ai-cli/SKILL.md
new file mode 100644
index 000000000..44f18aa74
--- /dev/null
+++ b/packages/ai-cli/skills/ai-cli/SKILL.md
@@ -0,0 +1,238 @@
+---
+name: ai-cli
+description: >
+ Drive TanStack AI from the terminal or an agent harness with the `ts-ai`
+ binary: chat, image, video, audio, speech, transcribe, summarize, plus
+ introspect (machine-readable manifest), mcp (expose commands as MCP tools),
+ and update. Machine-first design — `--json` buffered output, `--stream` AG-UI
+ events, strict stdout-is-payload, typed exit codes, structured error objects.
+ Providers resolve from a `provider/model` slug; options via flags or `--config`;
+ generations write to the cwd or `--output-dir`.
+type: core
+library: tanstack-ai
+library_version: '0.1.0'
+sources:
+ - 'TanStack/ai:docs/cli/overview.md'
+ - 'TanStack/ai:packages/ai-cli/src/manifest/manifest.ts'
+---
+
+# `@tanstack/ai-cli` (`ts-ai`)
+
+`ts-ai` is a thin, type-safe CLI over the core TanStack AI activities, built so
+the **same binary** serves one-off human use and agent harnesses. For
+programmatic use, always drive the machine path: pass `--json`, parse stdout,
+branch on the exit code.
+
+## Install / invoke
+
+```bash
+# zero-install one-off
+npx @tanstack/ai-cli image "a watercolor fox" --output-dir ./out
+
+# or globally
+pnpm add -g @tanstack/ai-cli
+ts-ai --version
+```
+
+OpenAI, Anthropic, Gemini, OpenRouter, and Fal are bundled (zero-install). Other
+providers (ollama, grok, groq, elevenlabs) are loaded on demand; if one isn't
+installed, `ts-ai` exits with code `4` telling you which package to add.
+
+## Model + key
+
+Select a model with a `provider/model` slug. The key comes from `--api-key`, a
+conventional `.env` in the working directory, or the provider's env var
+(`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `OPENROUTER_API_KEY`,
+`FAL_KEY`).
+
+```bash
+ts-ai chat "Explain MCP in one sentence" --model openai/gpt-5.5 --json
+ts-ai chat "Summarize this" --model anthropic/claude-haiku-4-5 --api-key sk-...
+```
+
+The model after the first `/` may itself contain slashes (e.g.
+`openrouter/openai/gpt-oss-120b`, `fal/fal-ai/ltx-video`).
+
+## The machine-mode contract
+
+This is what makes `ts-ai` safe to script:
+
+- **`--json`** prints a single buffered JSON object to stdout, nothing else.
+- **`--stream`** prints the AG-UI event stream as newline-delimited JSON (one
+ event per line) for incremental consumption.
+- **stdout carries only the payload.** All progress, warnings, and logs go to
+ **stderr**. Capture stdout and `JSON.parse` it directly.
+- **Exit codes:** `0` success · `1` runtime · `2` usage/validation · `3`
+ provider/API or output-schema validation · `4` provider package not installed.
+- On any non-zero exit in `--json` mode, a structured error object is printed to
+ **stdout**: `{ "error": { "code": "...", "message": "...", "provider": "..." } }`.
+
+```bash
+result=$(ts-ai chat "classify: 'app crashes on launch'" \
+ --model openai/gpt-5.5 --schema ./ticket.schema.json --json)
+echo "$result" | jq '.data'
+```
+
+## Commands
+
+| Command | Purpose |
+| ------------------------- | --------------------------------------------------------- |
+| `ts-ai chat ` | Chat / agentic text (tools, code-mode, structured output) |
+| `ts-ai image ` | Generate an image |
+| `ts-ai video ` | Generate a video (async job; blocks until done) |
+| `ts-ai audio ` | Generate audio (music / sfx) |
+| `ts-ai speech ` | Text-to-speech (alias `tts`) |
+| `ts-ai transcribe ` | Speech-to-text (alias `stt`) |
+| `ts-ai summarize ` | Summarize text |
+| `ts-ai introspect` | Print the full CLI manifest as JSON |
+| `ts-ai mcp` | Expose every command as an MCP tool over stdio |
+| `ts-ai update` | Update to the latest version |
+
+The prompt is every non-flag argument after the command. With no positional
+prompt, input is read from stdin (`cat doc.txt | ts-ai summarize ...`). Media
+inputs use the repeatable `--attachment ` flag (`-` reads stdin).
+
+## chat specifics
+
+`chat` is 100% stateless — pass the full history in and thread the returned
+messages back yourself:
+
+```bash
+ts-ai chat --model openai/gpt-5.5 --json \
+ --messages '[{"role":"user","content":"hi"},{"role":"assistant","content":"hello!"},{"role":"user","content":"what did I say?"}]'
+```
+
+- `--system ` — system prompt.
+- `--max-steps ` — bound the agent loop (tool-calling iterations).
+- `--mcp ` (repeatable) — give the chat tools from an MCP server. A
+ spec is an HTTP(S) URL or a stdio command (`--mcp "npx -y @scope/server /tmp"`).
+- `--code-mode` — wrap the MCP tools in a sandboxed `execute_typescript` tool
+ (requires at least one `--mcp` server to orchestrate).
+- `--schema ` — JSON Schema for structured output; the validated
+ object is returned under `.data` in the JSON envelope.
+- `--thread-id ` — passthrough correlation id (telemetry / AG-UI); never
+ causes persistence.
+
+On a TTY with no prompt, `ts-ai chat` opens an interactive REPL instead.
+
+## Output of generations
+
+`image`, `video`, `audio`, `speech` always write a file and report the path in
+the JSON. Default: the **current directory** with an auto-generated name.
+
+- `--output-dir ` — auto-named file into `` (created if missing;
+ cross-platform).
+- `-o/--output ` — exact file path (wins over `--output-dir`).
+- `-o -` — stream raw bytes to stdout (for piping).
+
+```bash
+ts-ai image "a red bicycle" --model openai/gpt-image-1 --output-dir ./assets --json
+ts-ai speech "hello there" --model openai/gpt-4o-mini-tts -o ./hi.mp3 --json
+ts-ai transcribe ./talk.mp3 --model openai/gpt-4o-mini-transcribe --json
+```
+
+## Options & --config
+
+Every option is a flag, but nested, provider-specific options live under
+`--config`, which accepts a JSON file path **or** an inline JSON string mirroring
+the command's options. Precedence: **flags > `--config` > env > defaults**.
+
+```bash
+ts-ai image "a logo" --model openai/gpt-image-1 \
+ --config '{"size":"1024x1024","modelOptions":{"background":"transparent"}}'
+```
+
+`modelOptions` (the unbounded provider-specific bag) is only settable via
+`--config`.
+
+## Self-description for harnesses
+
+- `ts-ai introspect` prints a versioned JSON manifest of every command, flag
+ (with its exact CLI spelling), type, default, and exit code — read it once to
+ auto-generate tool/function definitions.
+- `ts-ai mcp` starts an MCP server (stdio) exposing each command as a tool, so an
+ MCP-capable agent can register `ts-ai` directly. On startup it prints a
+ ready-to-paste client config to **stderr** (stdout is the JSON-RPC channel).
+
+## Common Mistakes
+
+### CRITICAL: Parsing stdout without `--json`
+
+On a TTY, `ts-ai` renders a pretty Ink UI (colors, image previews, spinners) to
+stdout. A harness that scrapes that output gets ANSI/terminal garbage. Always
+pass `--json` (or `--stream`) in programmatic use; then stdout is exactly one
+JSON object (or one event per line) and nothing else.
+
+Wrong:
+
+```bash
+answer=$(ts-ai chat "hi" --model openai/gpt-5.5) # pretty UI if stdout is a TTY
+```
+
+Right:
+
+```bash
+answer=$(ts-ai chat "hi" --model openai/gpt-5.5 --json | jq -r '.text')
+```
+
+Source: docs/cli/overview.md
+
+### HIGH: Ignoring the exit code / not reading the error envelope
+
+Failures are reported by exit code AND, in `--json` mode, a structured error
+object on stdout. Branch on the exit code; on non-zero, parse `.error.code`
+(`USAGE`, `PROVIDER`, `PROVIDER_NOT_INSTALLED`, `OUTPUT_VALIDATION`, `RUNTIME`).
+
+```bash
+out=$(ts-ai image "x" --model openai/gpt-image-1 --json) || {
+ code=$(echo "$out" | jq -r '.error.code')
+ echo "ts-ai failed: $code" >&2
+ exit 1
+}
+```
+
+Source: packages/ai-cli/src/core/exit-codes.ts
+
+### HIGH: Passing provider-specific options as flags
+
+Only the bounded, documented options are flags (`--size`, `--voice`, etc.).
+Anything provider-specific (reasoning effort, background, moderation, …) must go
+under `--config`'s `modelOptions` — there is no generic `--model-options` flag.
+
+Wrong:
+
+```bash
+ts-ai image "x" --model openai/gpt-image-1 --background transparent # unknown flag
+```
+
+Right:
+
+```bash
+ts-ai image "x" --model openai/gpt-image-1 --config '{"modelOptions":{"background":"transparent"}}'
+```
+
+Source: packages/ai-cli/src/manifest/manifest.ts
+
+### MEDIUM: Expecting `ts-ai mcp` to stream, or printing info to stdout
+
+MCP tool calls are request/response — they return the command's buffered `--json`
+result, never the AG-UI stream. And `ts-ai mcp`'s human-facing connection info is
+written to **stderr** on purpose; stdout is reserved for JSON-RPC. Don't grep the
+server's stdout for the config — read stderr (MCP clients surface it in logs).
+
+Source: docs/cli/overview.md
+
+### MEDIUM: Assuming every provider supports every command
+
+Resolution maps `provider/model` + activity to an adapter factory. A provider
+that lacks a factory for an activity exits `2` (`USAGE`); a non-bundled provider
+that isn't installed exits `4` (`PROVIDER_NOT_INSTALLED`). Only openai,
+anthropic, gemini, openrouter, and fal are bundled.
+
+Source: packages/ai-cli/src/core/providers.ts
+
+## Cross-References
+
+- See also: ai-core/SKILL.md — the underlying `chat`/`generateImage`/… activities the CLI wraps.
+- See also: ai-mcp/SKILL.md — the MCP client that powers `--mcp`.
+- See also: ai-code-mode/SKILL.md — the sandbox behind `--code-mode`.
diff --git a/packages/ai-cli/src/cli/activities/audio.ts b/packages/ai-cli/src/cli/activities/audio.ts
new file mode 100644
index 000000000..c918626c4
--- /dev/null
+++ b/packages/ai-cli/src/cli/activities/audio.ts
@@ -0,0 +1,92 @@
+import { generateAudio } from '@tanstack/ai'
+import { instantiateAdapter } from '../../core/providers'
+import { emitJson } from '../../core/emit'
+import { mediaSourceToBytes, writeArtifact } from '../artifact'
+import { resolveAdapterContext, withSpinner } from '../context'
+import { renderArtifactPath } from '../../render/lazy'
+import type { RunContext } from '../context'
+
+interface AudioResultLike {
+ id: string
+ model: string
+ audio: {
+ url?: string
+ b64Json?: string
+ contentType?: string
+ duration?: number
+ }
+ usage?: unknown
+}
+
+const EXT_BY_CONTENT_TYPE: Record = {
+ 'audio/mpeg': 'mp3',
+ 'audio/mp3': 'mp3',
+ 'audio/wav': 'wav',
+ 'audio/ogg': 'ogg',
+ 'audio/flac': 'flac',
+}
+
+/** `ts-ai audio` (music / sfx) handler. */
+export async function runAudio(ctx: RunContext, prompt: string): Promise {
+ const { resolved, apiKey, adapterConfig, modelOptions } =
+ resolveAdapterContext(ctx.options)
+ const adapter = await instantiateAdapter({
+ resolved,
+ activity: 'audio',
+ apiKey,
+ config: adapterConfig,
+ })
+
+ const duration =
+ typeof ctx.options.duration === 'number'
+ ? ctx.options.duration
+ : typeof ctx.options.duration === 'string'
+ ? Number(ctx.options.duration)
+ : undefined
+
+ const result = (await withSpinner(
+ ctx,
+ `Generating audio with ${resolved.provider}/${resolved.model}…`,
+ () =>
+ generateAudio({
+ adapter: adapter as never,
+ prompt,
+ duration,
+ modelOptions: modelOptions as never,
+ debug: false,
+ }),
+ )) as AudioResultLike
+
+ const bytes = await mediaSourceToBytes(result.audio)
+ const ext = EXT_BY_CONTENT_TYPE[result.audio.contentType ?? ''] ?? 'mp3'
+ const output =
+ typeof ctx.options.output === 'string' ? ctx.options.output : undefined
+ const outputDir =
+ typeof ctx.options.outputDir === 'string'
+ ? ctx.options.outputDir
+ : undefined
+ const path = await writeArtifact(
+ 'audio',
+ { bytes, ext, mimeType: result.audio.contentType ?? `audio/${ext}` },
+ { output, outputDir },
+ ctx.now,
+ )
+
+ if (ctx.mode === 'pretty') {
+ await renderArtifactPath({
+ label: `Audio generated with ${result.model}`,
+ path: path ?? '(stdout)',
+ meta: result.audio.duration
+ ? { duration: `${result.audio.duration}s` }
+ : undefined,
+ })
+ return
+ }
+ emitJson({
+ id: result.id,
+ model: result.model,
+ path,
+ mimeType: result.audio.contentType ?? `audio/${ext}`,
+ usage: result.usage,
+ })
+}
diff --git a/packages/ai-cli/src/cli/activities/chat.ts b/packages/ai-cli/src/cli/activities/chat.ts
new file mode 100644
index 000000000..6bc2c08d6
--- /dev/null
+++ b/packages/ai-cli/src/cli/activities/chat.ts
@@ -0,0 +1,208 @@
+import {
+ StreamProcessor,
+ chat,
+ maxIterations,
+ modelMessagesToUIMessages,
+} from '@tanstack/ai'
+import { instantiateAdapter } from '../../core/providers'
+import { emitEvent, emitJson } from '../../core/emit'
+import { CliError } from '../../core/exit-codes'
+import { loadJsonInput } from '../../core/config'
+import { loadAttachments } from '../../core/io'
+import { resolveAdapterContext, withSpinner } from '../context'
+import { renderText } from '../../render/lazy'
+import { buildMcpClients } from '../mcp-clients'
+import { buildCodeMode } from '../code-mode'
+import type { McpClientLike } from '../mcp-clients'
+import type { RunContext } from '../context'
+
+interface ModelMessageLike {
+ role: string
+ content: unknown
+}
+
+/**
+ * `ts-ai chat` handler — stateless one-shot or `--stream`.
+ *
+ * History is supplied via `--messages` (a JSON array); nothing is persisted.
+ * `--thread-id` is accepted purely as a passthrough correlation id. Tools come
+ * from `--mcp` servers; `--code-mode` wraps those tools in a sandboxed
+ * `execute_typescript` tool.
+ */
+export async function runChat(ctx: RunContext, prompt: string): Promise {
+ const { resolved, apiKey, adapterConfig, modelOptions } =
+ resolveAdapterContext(ctx.options)
+ const adapter = await instantiateAdapter({
+ resolved,
+ activity: 'chat',
+ apiKey,
+ config: adapterConfig,
+ })
+
+ const messages = await buildMessages(ctx, prompt)
+ const systemPrompts = resolveSystem(ctx)
+ // --schema accepts a file path / inline JSON string (CLI flag) OR an already
+ // parsed object (supplied via --config or the MCP `options` bag).
+ const schemaInput = ctx.options.schema
+ const schema =
+ typeof schemaInput === 'string' && schemaInput
+ ? await loadJsonInput(schemaInput, '--schema')
+ : typeof schemaInput === 'object' && schemaInput !== null
+ ? (schemaInput as Record)
+ : undefined
+ const maxSteps =
+ typeof ctx.options.maxSteps === 'number'
+ ? ctx.options.maxSteps
+ : typeof ctx.options.maxSteps === 'string'
+ ? Number(ctx.options.maxSteps)
+ : undefined
+ if (maxSteps !== undefined && (!Number.isInteger(maxSteps) || maxSteps < 1)) {
+ throw new CliError('USAGE', '--max-steps must be a positive integer.')
+ }
+
+ // Resolve tools from MCP servers, optionally wrapped in Code Mode.
+ const mcpSpecs = Array.isArray(ctx.options.mcp)
+ ? (ctx.options.mcp as Array)
+ : []
+ const useCodeMode = Boolean(ctx.options.codeMode)
+ const clients = await buildMcpClients(mcpSpecs)
+
+ let tools: Array | undefined
+ let mcp: { clients: Array } | undefined
+ const extraSystem: Array = []
+
+ try {
+ if (useCodeMode) {
+ const discovered = (
+ await Promise.all(clients.map((c) => c.tools()))
+ ).flat()
+ const wiring = await buildCodeMode(discovered)
+ tools = [wiring.tool]
+ extraSystem.push(wiring.systemPrompt)
+ } else if (clients.length > 0) {
+ mcp = { clients }
+ }
+
+ const base = {
+ adapter: adapter as never,
+ messages: messages as never,
+ systemPrompts: [...(systemPrompts ?? []), ...extraSystem] as never,
+ modelOptions: modelOptions as never,
+ // The CLI owns all output; silence the library's internal logger so it
+ // never writes to stdout/stderr behind our back.
+ debug: false as never,
+ ...(tools ? { tools: tools as never } : {}),
+ ...(mcp ? { mcp: mcp as never } : {}),
+ ...(maxSteps !== undefined
+ ? { agentLoopStrategy: maxIterations(maxSteps) }
+ : {}),
+ }
+
+ const thinking = `Thinking with ${resolved.provider}/${resolved.model}…`
+
+ // Structured output: schema-bearing call resolves to the validated object.
+ if (schema !== undefined) {
+ const data = await withSpinner(
+ ctx,
+ thinking,
+ () =>
+ chat({ ...base, outputSchema: schema as never }) as Promise,
+ )
+ if (ctx.mode === 'pretty') {
+ await renderText(JSON.stringify(data, null, 2))
+ return
+ }
+ emitJson({ data, model: resolved.model })
+ return
+ }
+
+ if (ctx.mode === 'stream') {
+ const stream = chat({ ...base, stream: true }) as AsyncIterable
+ for await (const event of stream) {
+ emitEvent(event)
+ }
+ return
+ }
+
+ // Buffered: accumulate the stream into a rich envelope via StreamProcessor.
+ const processor = new StreamProcessor()
+ processor.setMessages(modelMessagesToUIMessages(messages as never))
+ const result = await withSpinner(ctx, thinking, () =>
+ processor.process(
+ chat({ ...base, stream: true }) as AsyncIterable,
+ ),
+ )
+
+ if (ctx.mode === 'pretty') {
+ await renderText(result.content || '(no text response)')
+ return
+ }
+ emitJson({
+ text: result.content,
+ ...(result.thinking ? { thinking: result.thinking } : {}),
+ ...(result.toolCalls ? { toolCalls: result.toolCalls } : {}),
+ finishReason: result.finishReason ?? null,
+ messages: processor.toModelMessages(),
+ model: resolved.model,
+ })
+ } finally {
+ // When Code Mode owns the tools we passed (mcp not handed to chat), close
+ // the MCP connections ourselves; otherwise chat() closes them.
+ if (useCodeMode) {
+ await Promise.all(clients.map((c) => c.close().catch(() => undefined)))
+ }
+ }
+}
+
+async function buildMessages(
+ ctx: RunContext,
+ prompt: string,
+): Promise> {
+ const history = parseMessages(ctx.options.messages)
+ const attachmentPaths = Array.isArray(ctx.options.attachment)
+ ? (ctx.options.attachment as Array)
+ : []
+
+ if (!prompt && history.length === 0) {
+ throw new CliError('USAGE', 'Provide a prompt or --messages.')
+ }
+ if (!prompt) return history
+
+ if (attachmentPaths.length === 0) {
+ return [...history, { role: 'user', content: prompt }]
+ }
+
+ const attachments = await loadAttachments(attachmentPaths)
+ const parts: Array> = [{ type: 'text', text: prompt }]
+ for (const att of attachments) {
+ const kind = att.mimeType.startsWith('image/') ? 'image' : 'file'
+ parts.push({ type: kind, mimeType: att.mimeType, data: att.data })
+ }
+ return [...history, { role: 'user', content: parts }]
+}
+
+function parseMessages(value: unknown): Array {
+ if (value === undefined) return []
+ if (!Array.isArray(value)) {
+ throw new CliError('USAGE', '--messages must be a JSON array of messages.')
+ }
+ for (const m of value) {
+ if (
+ typeof m !== 'object' ||
+ m === null ||
+ typeof (m as { role?: unknown }).role !== 'string' ||
+ !('content' in (m as Record))
+ ) {
+ throw new CliError(
+ 'USAGE',
+ '--messages entries must be objects with a string "role" and a "content" field.',
+ )
+ }
+ }
+ return value as Array
+}
+
+function resolveSystem(ctx: RunContext): Array | undefined {
+ const system = ctx.options.system
+ return typeof system === 'string' && system ? [system] : undefined
+}
diff --git a/packages/ai-cli/src/cli/activities/image.ts b/packages/ai-cli/src/cli/activities/image.ts
new file mode 100644
index 000000000..ac1270215
--- /dev/null
+++ b/packages/ai-cli/src/cli/activities/image.ts
@@ -0,0 +1,137 @@
+import { detectImageMimeType, generateImage } from '@tanstack/ai'
+import { instantiateAdapter } from '../../core/providers'
+import { emitJson } from '../../core/emit'
+import { CliError } from '../../core/exit-codes'
+import { mediaSourceToBytes, writeArtifact } from '../artifact'
+import { resolveAdapterContext, withSpinner } from '../context'
+import { renderImageResult } from '../../render/lazy'
+import type { RunContext } from '../context'
+
+interface GeneratedImageLike {
+ url?: string
+ b64Json?: string
+ revisedPrompt?: string
+}
+interface ImageResultLike {
+ id: string
+ model: string
+ images: Array
+ usage?: unknown
+}
+
+const EXT_BY_MIME: Record = {
+ 'image/png': 'png',
+ 'image/jpeg': 'jpg',
+ 'image/webp': 'webp',
+ 'image/gif': 'gif',
+}
+
+/** `ts-ai image` handler. */
+export async function runImage(ctx: RunContext, prompt: string): Promise {
+ const { resolved, apiKey, adapterConfig, modelOptions } =
+ resolveAdapterContext(ctx.options)
+ const adapter = await instantiateAdapter({
+ resolved,
+ activity: 'image',
+ apiKey,
+ config: adapterConfig,
+ })
+
+ const result = (await withSpinner(
+ ctx,
+ `Generating image with ${resolved.provider}/${resolved.model}…`,
+ () =>
+ generateImage({
+ // The CLI resolves adapters at runtime; the static generic is erased.
+ adapter: adapter as never,
+ prompt,
+ numberOfImages: numberValue(ctx.options.count) ?? 1,
+ size: stringValue(ctx.options.size) as never,
+ modelOptions: modelOptions as never,
+ debug: false,
+ }),
+ )) as ImageResultLike
+
+ const output = stringValue(ctx.options.output)
+ const outputDir = stringValue(ctx.options.outputDir)
+ // Streaming bytes to stdout only makes sense for a single image.
+ if (output === '-' && result.images.length > 1) {
+ throw new CliError(
+ 'USAGE',
+ `Cannot stream ${result.images.length} images to stdout with "-o -". Use --output-dir or --count 1.`,
+ )
+ }
+ const written: Array<{
+ path: string | null
+ mimeType: string
+ revisedPrompt?: string
+ }> = []
+
+ for (const [index, image] of result.images.entries()) {
+ const bytes = await mediaSourceToBytes(image)
+ // Detect the real format from the magic bytes rather than assuming PNG.
+ // detectImageMimeType reads only the leading base64 chars, so encoding a
+ // small prefix is enough.
+ const b64Prefix =
+ image.b64Json ?? Buffer.from(bytes.subarray(0, 16)).toString('base64')
+ const mimeType = detectImageMimeType(b64Prefix) ?? 'image/png'
+ const ext = EXT_BY_MIME[mimeType] ?? 'png'
+ // Only the first image honors an explicit -o; subsequent ones get a suffix.
+ const target =
+ index === 0
+ ? output
+ : output && output !== '-'
+ ? suffixPath(output, index)
+ : undefined
+ const path = await writeArtifact(
+ 'image',
+ { bytes, ext, mimeType },
+ { output: target, outputDir },
+ ctx.now + index,
+ )
+ written.push({ path, mimeType, revisedPrompt: image.revisedPrompt })
+ }
+
+ if (ctx.mode === 'pretty') {
+ const previewable: Array<{ path: string; revisedPrompt?: string }> = []
+ for (const w of written) {
+ if (w.path)
+ previewable.push({ path: w.path, revisedPrompt: w.revisedPrompt })
+ }
+ await renderImageResult({
+ model: result.model,
+ images: previewable,
+ preview: ctx.options.preview !== false,
+ })
+ return
+ }
+
+ emitJson({
+ id: result.id,
+ model: result.model,
+ images: written.map((w) => ({
+ path: w.path,
+ mimeType: w.mimeType,
+ ...(w.revisedPrompt ? { revisedPrompt: w.revisedPrompt } : {}),
+ })),
+ usage: result.usage,
+ })
+}
+
+function suffixPath(path: string, index: number): string {
+ const dot = path.lastIndexOf('.')
+ if (dot <= 0) return `${path}-${index}`
+ return `${path.slice(0, dot)}-${index}${path.slice(dot)}`
+}
+
+function stringValue(value: unknown): string | undefined {
+ return typeof value === 'string' && value ? value : undefined
+}
+function numberValue(value: unknown): number | undefined {
+ if (typeof value === 'number') return value
+ if (typeof value === 'string' && value.trim() !== '') {
+ const n = Number(value)
+ return Number.isNaN(n) ? undefined : n
+ }
+ return undefined
+}
diff --git a/packages/ai-cli/src/cli/activities/speech.ts b/packages/ai-cli/src/cli/activities/speech.ts
new file mode 100644
index 000000000..1f160e528
--- /dev/null
+++ b/packages/ai-cli/src/cli/activities/speech.ts
@@ -0,0 +1,81 @@
+import { generateSpeech } from '@tanstack/ai'
+import { instantiateAdapter } from '../../core/providers'
+import { emitJson } from '../../core/emit'
+import { writeArtifact } from '../artifact'
+import { resolveAdapterContext, withSpinner } from '../context'
+import { renderArtifactPath } from '../../render/lazy'
+import type { RunContext } from '../context'
+
+interface TTSResultLike {
+ id: string
+ model: string
+ audio: string
+ format: string
+ duration?: number
+}
+
+type SpeechFormat = 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
+
+/** `ts-ai speech` (text-to-speech) handler. */
+export async function runSpeech(ctx: RunContext, text: string): Promise {
+ const { resolved, apiKey, adapterConfig, modelOptions } =
+ resolveAdapterContext(ctx.options)
+ const adapter = await instantiateAdapter({
+ resolved,
+ activity: 'speech',
+ apiKey,
+ config: adapterConfig,
+ })
+
+ const result = (await withSpinner(
+ ctx,
+ `Synthesizing speech with ${resolved.provider}/${resolved.model}…`,
+ () =>
+ generateSpeech({
+ adapter: adapter as never,
+ text,
+ voice: str(ctx.options.voice),
+ format: str(ctx.options.format) as SpeechFormat | undefined,
+ speed: num(ctx.options.speed),
+ modelOptions: modelOptions as never,
+ debug: false,
+ }),
+ )) as TTSResultLike
+
+ const bytes = new Uint8Array(Buffer.from(result.audio, 'base64'))
+ const ext = result.format || 'mp3'
+ const path = await writeArtifact(
+ 'speech',
+ { bytes, ext, mimeType: `audio/${ext}` },
+ { output: str(ctx.options.output), outputDir: str(ctx.options.outputDir) },
+ ctx.now,
+ )
+
+ if (ctx.mode === 'pretty') {
+ await renderArtifactPath({
+ label: `Speech generated with ${result.model}`,
+ path: path ?? '(stdout)',
+ meta: result.duration ? { duration: `${result.duration}s` } : undefined,
+ })
+ return
+ }
+ emitJson({
+ id: result.id,
+ model: result.model,
+ path,
+ format: ext,
+ ...(result.duration ? { duration: result.duration } : {}),
+ })
+}
+
+function str(v: unknown): string | undefined {
+ return typeof v === 'string' && v ? v : undefined
+}
+function num(v: unknown): number | undefined {
+ if (typeof v === 'number') return v
+ if (typeof v === 'string' && v.trim() !== '') {
+ const n = Number(v)
+ return Number.isNaN(n) ? undefined : n
+ }
+ return undefined
+}
diff --git a/packages/ai-cli/src/cli/activities/summarize.ts b/packages/ai-cli/src/cli/activities/summarize.ts
new file mode 100644
index 000000000..a375cec65
--- /dev/null
+++ b/packages/ai-cli/src/cli/activities/summarize.ts
@@ -0,0 +1,73 @@
+import { summarize } from '@tanstack/ai'
+import { instantiateAdapter } from '../../core/providers'
+import { emitJson } from '../../core/emit'
+import { CliError } from '../../core/exit-codes'
+import { resolveAdapterContext, withSpinner } from '../context'
+import { renderText } from '../../render/lazy'
+import type { RunContext } from '../context'
+
+interface SummaryResultLike {
+ id: string
+ model: string
+ summary: string
+ usage?: unknown
+}
+
+type SummaryStyle = 'bullet-points' | 'paragraph' | 'concise'
+
+/** `ts-ai summarize` handler. */
+export async function runSummarize(
+ ctx: RunContext,
+ text: string,
+): Promise {
+ const { resolved, apiKey, adapterConfig, modelOptions } =
+ resolveAdapterContext(ctx.options)
+ const adapter = await instantiateAdapter({
+ resolved,
+ activity: 'summarize',
+ apiKey,
+ config: adapterConfig,
+ })
+
+ const focus = Array.isArray(ctx.options.focus)
+ ? (ctx.options.focus as Array)
+ : undefined
+ const maxLength =
+ typeof ctx.options.maxLength === 'number'
+ ? ctx.options.maxLength
+ : typeof ctx.options.maxLength === 'string'
+ ? Number(ctx.options.maxLength)
+ : undefined
+ if (
+ maxLength !== undefined &&
+ (!Number.isInteger(maxLength) || maxLength < 1)
+ ) {
+ throw new CliError('USAGE', '--max-length must be a positive integer.')
+ }
+
+ const result = (await withSpinner(
+ ctx,
+ `Summarizing with ${resolved.provider}/${resolved.model}…`,
+ () =>
+ summarize({
+ adapter: adapter as never,
+ text,
+ maxLength,
+ style: ctx.options.style as SummaryStyle | undefined,
+ focus,
+ modelOptions: modelOptions as never,
+ debug: false,
+ }),
+ )) as SummaryResultLike
+
+ if (ctx.mode === 'pretty') {
+ await renderText(result.summary)
+ return
+ }
+ emitJson({
+ id: result.id,
+ model: result.model,
+ summary: result.summary,
+ usage: result.usage,
+ })
+}
diff --git a/packages/ai-cli/src/cli/activities/transcribe.ts b/packages/ai-cli/src/cli/activities/transcribe.ts
new file mode 100644
index 000000000..c1945e37e
--- /dev/null
+++ b/packages/ai-cli/src/cli/activities/transcribe.ts
@@ -0,0 +1,84 @@
+import { readFile } from 'node:fs/promises'
+import { generateTranscription } from '@tanstack/ai'
+import { instantiateAdapter } from '../../core/providers'
+import { emitJson } from '../../core/emit'
+import { CliError } from '../../core/exit-codes'
+import { resolveAdapterContext, withSpinner } from '../context'
+import { renderText } from '../../render/lazy'
+import type { RunContext } from '../context'
+
+interface TranscriptionResultLike {
+ id: string
+ model: string
+ text: string
+ language?: string
+ duration?: number
+}
+
+/**
+ * `ts-ai transcribe` (speech-to-text) handler. The audio file is the positional
+ * argument (`ts-ai transcribe ./talk.mp3`) or the first `--attachment`.
+ */
+export async function runTranscribe(
+ ctx: RunContext,
+ positional: Array,
+): Promise {
+ const attachment = Array.isArray(ctx.options.attachment)
+ ? (ctx.options.attachment as Array)[0]
+ : undefined
+ const audioPath = positional[0] ?? attachment
+ if (!audioPath) {
+ throw new CliError(
+ 'USAGE',
+ 'Provide an audio file: ts-ai transcribe ./audio.mp3',
+ )
+ }
+
+ const { resolved, apiKey, adapterConfig, modelOptions } =
+ resolveAdapterContext(ctx.options)
+ const adapter = await instantiateAdapter({
+ resolved,
+ activity: 'transcription',
+ apiKey,
+ config: adapterConfig,
+ })
+
+ let audio: string
+ try {
+ // Adapters accept a base64 string / File / Blob / ArrayBuffer — NOT a Node
+ // Buffer — so hand over base64.
+ audio = (await readFile(audioPath)).toString('base64')
+ } catch (cause) {
+ throw new CliError('USAGE', `Cannot read audio file "${audioPath}".`, {
+ cause,
+ })
+ }
+
+ const result = (await withSpinner(
+ ctx,
+ `Transcribing with ${resolved.provider}/${resolved.model}…`,
+ () =>
+ generateTranscription({
+ adapter: adapter as never,
+ audio,
+ language:
+ typeof ctx.options.language === 'string'
+ ? ctx.options.language
+ : undefined,
+ modelOptions: modelOptions as never,
+ debug: false,
+ }),
+ )) as TranscriptionResultLike
+
+ if (ctx.mode === 'pretty') {
+ await renderText(result.text)
+ return
+ }
+ emitJson({
+ id: result.id,
+ model: result.model,
+ text: result.text,
+ ...(result.language ? { language: result.language } : {}),
+ ...(result.duration ? { duration: result.duration } : {}),
+ })
+}
diff --git a/packages/ai-cli/src/cli/activities/video.ts b/packages/ai-cli/src/cli/activities/video.ts
new file mode 100644
index 000000000..69485b48d
--- /dev/null
+++ b/packages/ai-cli/src/cli/activities/video.ts
@@ -0,0 +1,150 @@
+import { generateVideo, getVideoJobStatus } from '@tanstack/ai'
+import { instantiateAdapter } from '../../core/providers'
+import { emitJson } from '../../core/emit'
+import { CliError } from '../../core/exit-codes'
+import { fetchBytes, writeArtifact } from '../artifact'
+import { resolveAdapterContext, withSpinner } from '../context'
+import { renderArtifactPath } from '../../render/lazy'
+import type { RunContext } from '../context'
+
+const POLL_INTERVAL_MS = 3000
+/** Stop polling after this long so a stuck job can't hang the CLI forever. */
+const MAX_POLL_MS = 15 * 60 * 1000
+
+/**
+ * `ts-ai video` handler (experimental). Creates a job and, by default, blocks
+ * until completion (polling, progress to stderr) then downloads the result.
+ * `--no-wait` returns the job id immediately.
+ */
+export async function runVideo(ctx: RunContext, prompt: string): Promise {
+ const { resolved, apiKey, adapterConfig, modelOptions } =
+ resolveAdapterContext(ctx.options)
+ const adapter = await instantiateAdapter({
+ resolved,
+ activity: 'video',
+ apiKey,
+ config: adapterConfig,
+ })
+
+ const job = await withSpinner(
+ ctx,
+ `Creating video job with ${resolved.provider}/${resolved.model}…`,
+ () =>
+ generateVideo({
+ adapter: adapter as never,
+ prompt,
+ size: (typeof ctx.options.size === 'string'
+ ? ctx.options.size
+ : undefined) as never,
+ modelOptions: modelOptions as never,
+ debug: false,
+ }),
+ )
+
+ if (ctx.options.wait === false) {
+ if (ctx.mode === 'pretty') {
+ await renderArtifactPath({
+ label: `Video job created (${job.model})`,
+ path: job.jobId,
+ })
+ return
+ }
+ emitJson({ jobId: job.jobId, model: job.model, status: 'pending' })
+ return
+ }
+
+ const final = await pollToCompletion(ctx, adapter, job.jobId)
+ if (final.status === 'failed' || !final.url) {
+ throw new CliError(
+ 'PROVIDER',
+ `Video job failed: ${final.error ?? 'no URL returned'}.`,
+ {
+ provider: resolved.provider,
+ detail: { jobId: job.jobId },
+ },
+ )
+ }
+
+ const bytes = await fetchBytes(final.url)
+ const output =
+ typeof ctx.options.output === 'string' ? ctx.options.output : undefined
+ const outputDir =
+ typeof ctx.options.outputDir === 'string'
+ ? ctx.options.outputDir
+ : undefined
+ const path = await writeArtifact(
+ 'video',
+ { bytes, ext: 'mp4', mimeType: 'video/mp4' },
+ { output, outputDir },
+ ctx.now,
+ )
+
+ if (ctx.mode === 'pretty') {
+ await renderArtifactPath({
+ label: `Video generated with ${job.model}`,
+ path: path ?? '(stdout)',
+ })
+ return
+ }
+ emitJson({ jobId: job.jobId, model: job.model, path, mimeType: 'video/mp4' })
+}
+
+/** `ts-ai video status ` — one-shot status check for an existing job. */
+export async function runVideoStatus(
+ ctx: RunContext,
+ jobId: string,
+): Promise {
+ const { resolved, apiKey, adapterConfig } = resolveAdapterContext(ctx.options)
+ const adapter = await instantiateAdapter({
+ resolved,
+ activity: 'video',
+ apiKey,
+ config: adapterConfig,
+ })
+ const status = await getVideoJobStatus({ adapter: adapter as never, jobId })
+
+ if (ctx.mode === 'pretty') {
+ await renderArtifactPath({
+ label: `Video job ${jobId}`,
+ path: status.status,
+ meta: {
+ ...(status.progress != null ? { progress: `${status.progress}%` } : {}),
+ ...(status.url ? { url: status.url } : {}),
+ ...(status.error ? { error: status.error } : {}),
+ },
+ })
+ return
+ }
+ emitJson({ jobId, ...status })
+}
+
+async function pollToCompletion(
+ ctx: RunContext,
+ adapter: unknown,
+ jobId: string,
+) {
+ const deadline = Date.now() + MAX_POLL_MS
+ for (;;) {
+ const status = await getVideoJobStatus({
+ adapter: adapter as never,
+ jobId,
+ })
+ ctx.logger.info(
+ `job ${jobId}: ${status.status}${status.progress != null ? ` (${status.progress}%)` : ''}`,
+ )
+ if (status.status === 'completed' || status.status === 'failed')
+ return status
+ if (Date.now() >= deadline) {
+ throw new CliError(
+ 'PROVIDER',
+ `Timed out after ${Math.round(MAX_POLL_MS / 60000)}m waiting for video job ${jobId}. Re-check with: ts-ai video status ${jobId}`,
+ { detail: { jobId } },
+ )
+ }
+ await sleep(POLL_INTERVAL_MS)
+ }
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
diff --git a/packages/ai-cli/src/cli/artifact.ts b/packages/ai-cli/src/cli/artifact.ts
new file mode 100644
index 000000000..3a7a9c471
--- /dev/null
+++ b/packages/ai-cli/src/cli/artifact.ts
@@ -0,0 +1,96 @@
+import { mkdir, writeFile } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import { CliError } from '../core/exit-codes'
+import { emitBytes } from '../core/emit'
+
+/** Bytes of a generated artifact plus the file extension to use by default. */
+export interface Artifact {
+ bytes: Uint8Array
+ ext: string
+ mimeType: string
+}
+
+/** Where to write an artifact: an explicit full path and/or a target directory. */
+export interface OutputTarget {
+ /** `-o/--output`: explicit path ("-" = stdout). Wins over `outputDir`. */
+ output?: string
+ /** `--outputDir`: directory for the auto-generated filename. Defaults to cwd. */
+ outputDir?: string
+}
+
+/**
+ * Resolve where an artifact should be written. Precedence: an explicit
+ * `--output` path wins; otherwise an auto-generated filename inside
+ * `--outputDir` (default: the current directory). Cross-platform via node:path.
+ * `-o -` is handled by the caller (stdout).
+ */
+export function resolveOutputPath(
+ command: string,
+ ext: string,
+ target: OutputTarget,
+ now: number,
+): string {
+ if (target.output && target.output !== '-') return target.output
+ const dir = target.outputDir ?? '.'
+ return join(dir, `ts-ai-${command}-${now}.${ext}`)
+}
+
+/**
+ * Persist an artifact, creating the target directory if needed. Returns the
+ * path written, or null when bytes were sent to stdout (`-o -`).
+ */
+export async function writeArtifact(
+ command: string,
+ artifact: Artifact,
+ target: OutputTarget,
+ now: number,
+): Promise {
+ if (target.output === '-') {
+ await emitBytes(artifact.bytes)
+ return null
+ }
+ const path = resolveOutputPath(command, artifact.ext, target, now)
+ try {
+ const dir = dirname(path)
+ if (dir && dir !== '.') await mkdir(dir, { recursive: true })
+ await writeFile(path, artifact.bytes)
+ } catch (cause) {
+ throw new CliError('RUNTIME', `Failed to write artifact to "${path}".`, {
+ cause,
+ })
+ }
+ return path
+}
+
+/** Fetch bytes from a generated-media URL, with a timeout and normalized errors. */
+export async function fetchBytes(url: string): Promise {
+ let res: Response
+ try {
+ res = await fetch(url, { signal: AbortSignal.timeout(120_000) })
+ } catch (cause) {
+ const timedOut = cause instanceof Error && cause.name === 'TimeoutError'
+ throw new CliError(
+ 'PROVIDER',
+ `Failed to download artifact from ${url}${timedOut ? ' (timed out)' : ''}.`,
+ { cause },
+ )
+ }
+ if (!res.ok) {
+ throw new CliError(
+ 'PROVIDER',
+ `Failed to download artifact (${res.status}) from ${url}.`,
+ )
+ }
+ return new Uint8Array(await res.arrayBuffer())
+}
+
+/** Resolve a `{ url } | { b64Json }` media source into raw bytes. */
+export async function mediaSourceToBytes(source: {
+ url?: string
+ b64Json?: string
+}): Promise {
+ if (source.b64Json)
+ return new Uint8Array(Buffer.from(source.b64Json, 'base64'))
+ if (source.url) return fetchBytes(source.url)
+ throw new CliError('PROVIDER', 'Generated media has neither url nor b64Json.')
+}
diff --git a/packages/ai-cli/src/cli/bin.ts b/packages/ai-cli/src/cli/bin.ts
new file mode 100644
index 000000000..64cc96147
--- /dev/null
+++ b/packages/ai-cli/src/cli/bin.ts
@@ -0,0 +1,22 @@
+// pkg is bundled into the bin by tsup; provides the version reported by
+// --version and embedded in the introspect manifest.
+import pkg from '../../package.json'
+import { loadDotEnv } from '../core/env'
+import { run } from './run'
+
+// Load a conventional .env from cwd before anything resolves API keys.
+loadDotEnv()
+
+const argv = process.argv.slice(2)
+
+run(argv, pkg.version)
+ .then((code) => {
+ process.exitCode = code
+ })
+ .catch((err: unknown) => {
+ // Last-resort guard; run() is expected to handle everything itself.
+ process.stderr.write(
+ `error: ${err instanceof Error ? err.message : String(err)}\n`,
+ )
+ process.exitCode = 1
+ })
diff --git a/packages/ai-cli/src/cli/code-mode.ts b/packages/ai-cli/src/cli/code-mode.ts
new file mode 100644
index 000000000..e2b7a2e2a
--- /dev/null
+++ b/packages/ai-cli/src/cli/code-mode.ts
@@ -0,0 +1,51 @@
+import { CliError } from '../core/exit-codes'
+import type * as CodeModeModule from '@tanstack/ai-code-mode'
+import type * as IsolateNodeModule from '@tanstack/ai-isolate-node'
+
+export interface CodeModeWiring {
+ tool: unknown
+ systemPrompt: string
+}
+
+/**
+ * Wire up Code Mode: wrap the supplied tools (discovered from `--mcp` servers)
+ * in a sandboxed `execute_typescript` tool plus its system prompt, using the
+ * Node isolate driver. Code Mode requires at least one tool to orchestrate, so
+ * `--code-mode` must be combined with `--mcp`.
+ *
+ * `@tanstack/ai-code-mode` and `@tanstack/ai-isolate-node` are imported lazily.
+ */
+export async function buildCodeMode(
+ tools: Array,
+): Promise {
+ if (tools.length === 0) {
+ throw new CliError(
+ 'USAGE',
+ '--code-mode needs tools to orchestrate. Combine it with one or more --mcp servers.',
+ )
+ }
+
+ let codeMode: typeof CodeModeModule
+ let isolate: typeof IsolateNodeModule
+ try {
+ codeMode = await import('@tanstack/ai-code-mode')
+ isolate = await import('@tanstack/ai-isolate-node')
+ } catch (cause) {
+ throw new CliError(
+ 'PROVIDER_NOT_INSTALLED',
+ '--code-mode requires @tanstack/ai-code-mode and @tanstack/ai-isolate-node.',
+ {
+ detail: {
+ packages: ['@tanstack/ai-code-mode', '@tanstack/ai-isolate-node'],
+ },
+ cause,
+ },
+ )
+ }
+
+ const { tool, systemPrompt } = codeMode.createCodeMode({
+ driver: isolate.createNodeIsolateDriver(),
+ tools: tools as never,
+ })
+ return { tool, systemPrompt }
+}
diff --git a/packages/ai-cli/src/cli/context.ts b/packages/ai-cli/src/cli/context.ts
new file mode 100644
index 000000000..53ad339b9
--- /dev/null
+++ b/packages/ai-cli/src/cli/context.ts
@@ -0,0 +1,88 @@
+import { loadConfig, mergeOptions } from '../core/config'
+import { resolveOutputMode } from '../core/output'
+import { CliLogger } from '../core/logger'
+import { startSpinner } from '../core/spinner'
+import { resolveApiKey, resolveModelSlug } from '../core/providers'
+import { CliError } from '../core/exit-codes'
+import type { OutputMode } from '../core/output'
+import type { ResolvedModel } from '../core/providers'
+
+/**
+ * Everything a command handler needs after common flags are resolved: the
+ * merged option bag (flags > config), the output mode, a stderr logger, and a
+ * progress spinner.
+ */
+export interface RunContext {
+ mode: OutputMode
+ logger: CliLogger
+ /** Merged options: parsed flags layered over the `--config` object. */
+ options: Record
+ /** Wall-clock used for deterministic artifact naming within one invocation. */
+ now: number
+ /** Start a progress spinner (stderr); returns a stop function. No-op when --quiet. */
+ spinner: (label: string) => () => void
+}
+
+export async function createRunContext(
+ rawFlags: Record,
+): Promise {
+ const config = await loadConfig(rawFlags.config as string | undefined)
+ const options = mergeOptions(rawFlags, config)
+ const mode = resolveOutputMode({
+ json: Boolean(options.json),
+ stream: Boolean(options.stream),
+ })
+ const quiet = Boolean(options.quiet)
+ const logger = new CliLogger({ verbose: Boolean(options.verbose), quiet })
+ const spinner = (label: string) => (quiet ? () => {} : startSpinner(label))
+ return { mode, logger, options, now: Date.now(), spinner }
+}
+
+/** Run `fn` with a progress spinner showing `label` until it settles. */
+export async function withSpinner(
+ ctx: RunContext,
+ label: string,
+ fn: () => Promise,
+): Promise {
+ const stop = ctx.spinner(label)
+ try {
+ return await fn()
+ } finally {
+ stop()
+ }
+}
+
+export interface ResolvedAdapterContext {
+ resolved: ResolvedModel
+ apiKey: string
+ adapterConfig: Record
+ modelOptions: Record | undefined
+}
+
+/** Resolve model slug + API key + adapter config from the merged options. */
+export function resolveAdapterContext(
+ options: Record,
+): ResolvedAdapterContext {
+ const model = options.model
+ if (typeof model !== 'string' || !model) {
+ throw new CliError('USAGE', 'Missing --model (e.g. openai/gpt-5.5).')
+ }
+ const resolved = resolveModelSlug(model)
+ const apiKey = resolveApiKey(
+ resolved.entry,
+ resolved.provider,
+ options.apiKey as string | undefined,
+ )
+ const modelOptions =
+ typeof options.modelOptions === 'object' && options.modelOptions !== null
+ ? (options.modelOptions as Record)
+ : undefined
+ const baseURL =
+ typeof options.baseURL === 'string' ? { baseURL: options.baseURL } : {}
+ return {
+ resolved,
+ apiKey,
+ adapterConfig: { ...baseURL },
+ modelOptions,
+ }
+}
diff --git a/packages/ai-cli/src/cli/dispatch.ts b/packages/ai-cli/src/cli/dispatch.ts
new file mode 100644
index 000000000..a521e50d9
--- /dev/null
+++ b/packages/ai-cli/src/cli/dispatch.ts
@@ -0,0 +1,69 @@
+import { CliError } from '../core/exit-codes'
+import { resolvePrompt } from '../core/io'
+import { createRunContext } from './context'
+import { runImage } from './activities/image'
+import { runSummarize } from './activities/summarize'
+import { runChat } from './activities/chat'
+import { runSpeech } from './activities/speech'
+import { runAudio } from './activities/audio'
+import { runTranscribe } from './activities/transcribe'
+import { runVideo } from './activities/video'
+import type { CommandSpec } from '../manifest/types'
+
+/**
+ * Dispatch a parsed generation command to its activity handler. `chat`,
+ * `image`, and `summarize` are fully wired; the remaining activities are
+ * recognized (and introspectable) but not yet implemented in this build.
+ */
+export async function dispatchCommand(
+ spec: CommandSpec,
+ positional: Array,
+ rawFlags: Record,
+): Promise {
+ const ctx = await createRunContext(rawFlags)
+
+ if (spec.experimental) {
+ ctx.logger.warn(`"${spec.name}" is experimental and may change.`)
+ }
+
+ switch (spec.name) {
+ case 'chat': {
+ const prompt = await resolvePrompt(positional, { required: false })
+ // No prompt on a TTY → drop into the interactive REPL.
+ if (!prompt && ctx.mode === 'pretty') {
+ const { runChatRepl } = await import('./interactive')
+ const model =
+ typeof ctx.options.model === 'string' && ctx.options.model
+ ? ctx.options.model
+ : 'openai/gpt-5.5'
+ await runChatRepl(model)
+ return
+ }
+ return runChat(ctx, prompt)
+ }
+ case 'image': {
+ const prompt = await resolvePrompt(positional, { required: true })
+ return runImage(ctx, prompt)
+ }
+ case 'summarize': {
+ const prompt = await resolvePrompt(positional, { required: true })
+ return runSummarize(ctx, prompt)
+ }
+ case 'speech': {
+ const prompt = await resolvePrompt(positional, { required: true })
+ return runSpeech(ctx, prompt)
+ }
+ case 'audio': {
+ const prompt = await resolvePrompt(positional, { required: true })
+ return runAudio(ctx, prompt)
+ }
+ case 'video': {
+ const prompt = await resolvePrompt(positional, { required: true })
+ return runVideo(ctx, prompt)
+ }
+ case 'transcribe':
+ return runTranscribe(ctx, positional)
+ default:
+ throw new CliError('USAGE', `Unknown command "${spec.name}".`)
+ }
+}
diff --git a/packages/ai-cli/src/cli/interactive.ts b/packages/ai-cli/src/cli/interactive.ts
new file mode 100644
index 000000000..0576f7b1d
--- /dev/null
+++ b/packages/ai-cli/src/cli/interactive.ts
@@ -0,0 +1,91 @@
+import { StreamProcessor, chat } from '@tanstack/ai'
+import {
+ instantiateAdapter,
+ resolveApiKey,
+ resolveModelSlug,
+} from '../core/providers'
+import { findCommand } from '../manifest/manifest'
+import { renderChatRepl, renderMenu } from '../render/lazy'
+import { dispatchCommand } from './dispatch'
+import type { ReplMessage } from '../render/repl'
+
+/** Best-known default models per command for the zero-config interactive flow. */
+const DEFAULT_MODELS: Record = {
+ chat: 'openai/gpt-5.5',
+ summarize: 'openai/gpt-5.5',
+ image: 'openai/gpt-image-1',
+ speech: 'openai/gpt-4o-mini-tts',
+ transcribe: 'openai/gpt-4o-mini-transcribe',
+}
+
+/**
+ * The home screen shown when `ts-ai` is run with no command on a TTY: an
+ * animated wordmark + menu. Acts as a hub — after each action (or pressing Esc
+ * inside a sub-flow) it returns to the menu, until the user quits.
+ */
+export async function runHome(modelOverride?: string): Promise {
+ let first = true
+ for (;;) {
+ const choice = await renderMenu(first)
+ first = false
+ if (choice.command === 'quit') return 0
+
+ try {
+ if (choice.command === 'chat') {
+ // Esc in the REPL unmounts it and returns here → back to the menu.
+ await runChatRepl(
+ modelOverride ?? DEFAULT_MODELS['chat'] ?? 'openai/gpt-5.5',
+ )
+ continue
+ }
+
+ const model = modelOverride ?? DEFAULT_MODELS[choice.command]
+ const spec = findCommand(choice.command)
+ if (!model || !spec) {
+ process.stderr.write(
+ `Run it with a model, e.g.:\n ts-ai ${choice.command} "${choice.prompt ?? ''}" --model \n`,
+ )
+ continue
+ }
+ await dispatchCommand(spec, choice.prompt ? [choice.prompt] : [], {
+ model,
+ preview: true,
+ })
+ } catch (err) {
+ // A failed action shouldn't crash the hub — report and return to the menu.
+ process.stderr.write(
+ `error: ${err instanceof Error ? err.message : String(err)}\n`,
+ )
+ }
+ }
+}
+
+/** Launch the interactive chat REPL (also used by `ts-ai chat` with no prompt on a TTY). */
+export async function runChatRepl(modelSlug: string): Promise {
+ const resolved = resolveModelSlug(modelSlug)
+ const apiKey = resolveApiKey(resolved.entry, resolved.provider, undefined)
+ const adapter = await instantiateAdapter({
+ resolved,
+ activity: 'chat',
+ apiKey,
+ })
+
+ const respond = async (messages: Array): Promise => {
+ const processor = new StreamProcessor()
+ const result = await processor.process(
+ chat({
+ adapter: adapter as never,
+ messages,
+ stream: true,
+ debug: false,
+ }),
+ )
+ return result.content || '(no response)'
+ }
+
+ await renderChatRepl({
+ model: `${resolved.provider}/${resolved.model}`,
+ respond,
+ })
+ return 0
+}
diff --git a/packages/ai-cli/src/cli/introspect.ts b/packages/ai-cli/src/cli/introspect.ts
new file mode 100644
index 000000000..c68e1aff5
--- /dev/null
+++ b/packages/ai-cli/src/cli/introspect.ts
@@ -0,0 +1,10 @@
+import { buildManifest } from '../manifest/manifest'
+import { emitJson } from '../core/emit'
+
+/**
+ * `ts-ai introspect` — emit the machine-readable manifest of the entire CLI
+ * surface so a harness can auto-generate tool/function definitions.
+ */
+export function runIntrospect(cliVersion: string): void {
+ emitJson(buildManifest(cliVersion))
+}
diff --git a/packages/ai-cli/src/cli/mcp-clients.ts b/packages/ai-cli/src/cli/mcp-clients.ts
new file mode 100644
index 000000000..941d4f217
--- /dev/null
+++ b/packages/ai-cli/src/cli/mcp-clients.ts
@@ -0,0 +1,104 @@
+import { CliError } from '../core/exit-codes'
+import type * as McpModule from '@tanstack/ai-mcp'
+import type * as McpStdioModule from '@tanstack/ai-mcp/stdio'
+
+/** A connected MCP client, structurally compatible with chat()'s `mcp.clients`. */
+export interface McpClientLike {
+ tools: (options?: { lazy?: boolean }) => Promise>
+ close: () => Promise
+}
+
+/**
+ * Build connected MCP clients from `--mcp` specs. A spec is either an HTTP(S)
+ * URL (streamable-HTTP transport) or a shell command (stdio transport), e.g.
+ * --mcp https://example.com/mcp
+ * --mcp "npx -y @modelcontextprotocol/server-filesystem /tmp"
+ *
+ * `@tanstack/ai-mcp` is imported lazily so the machine path that doesn't use
+ * tools never loads it.
+ */
+export async function buildMcpClients(
+ specs: Array,
+): Promise> {
+ if (specs.length === 0) return []
+
+ let mcp: typeof McpModule
+ let stdio: typeof McpStdioModule
+ try {
+ mcp = await import('@tanstack/ai-mcp')
+ stdio = await import('@tanstack/ai-mcp/stdio')
+ } catch (cause) {
+ throw new CliError(
+ 'PROVIDER_NOT_INSTALLED',
+ 'MCP support requires @tanstack/ai-mcp. Install it: pnpm add @tanstack/ai-mcp',
+ { detail: { package: '@tanstack/ai-mcp' }, cause },
+ )
+ }
+
+ const clients: Array = []
+ for (const spec of specs) {
+ const httpTransport: { type: 'http'; url: string } = {
+ type: 'http',
+ url: spec,
+ }
+ const transport = isUrl(spec)
+ ? httpTransport
+ : stdio.stdioTransport(parseCommand(spec))
+ try {
+ const client = await mcp.createMCPClient({ transport })
+ clients.push(client)
+ } catch (cause) {
+ // Don't leak the connections opened so far if a later one fails.
+ await Promise.all(clients.map((c) => c.close().catch(() => undefined)))
+ throw new CliError(
+ 'RUNTIME',
+ `Failed to connect to MCP server "${spec}".`,
+ { cause },
+ )
+ }
+ }
+ return clients
+}
+
+function isUrl(spec: string): boolean {
+ return /^https?:\/\//i.test(spec)
+}
+
+/**
+ * Tokenize a command string into argv, respecting single/double quotes so paths
+ * with spaces survive, e.g. `node "C:\Program Files\srv.js" --flag`.
+ */
+export function tokenizeCommand(spec: string): Array {
+ const tokens: Array = []
+ let current = ''
+ let quote: '"' | "'" | null = null
+ let started = false
+ for (const ch of spec) {
+ if (quote) {
+ if (ch === quote) quote = null
+ else current += ch
+ } else if (ch === '"' || ch === "'") {
+ quote = ch
+ started = true
+ } else if (/\s/.test(ch)) {
+ if (started) {
+ tokens.push(current)
+ current = ''
+ started = false
+ }
+ } else {
+ current += ch
+ started = true
+ }
+ }
+ if (started) tokens.push(current)
+ return tokens
+}
+
+function parseCommand(spec: string): { command: string; args: Array } {
+ const [command, ...args] = tokenizeCommand(spec)
+ if (!command) {
+ throw new CliError('USAGE', `Invalid --mcp spec: "${spec}".`)
+ }
+ return { command, args }
+}
diff --git a/packages/ai-cli/src/cli/mcp.ts b/packages/ai-cli/src/cli/mcp.ts
new file mode 100644
index 000000000..7106e4dc2
--- /dev/null
+++ b/packages/ai-cli/src/cli/mcp.ts
@@ -0,0 +1,121 @@
+import { spawn } from 'node:child_process'
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
+import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
+import { z } from 'zod'
+import { COMMANDS } from '../manifest/manifest'
+
+const PINK = '[38;2;236;72;153m'
+const DIM = '[2m'
+const RESET = '[0m'
+
+/**
+ * Human-facing connection info for `ts-ai mcp`, returned for writing to STDERR
+ * (stdout is the JSON-RPC channel and must stay clean). Includes a ready-to-paste
+ * MCP client config, transport, server identity, and the tool list.
+ */
+export function describeMcpServer(cliVersion: string): string {
+ const tty = Boolean(process.stderr.isTTY)
+ const pink = (s: string) => (tty ? `${PINK}${s}${RESET}` : s)
+ const dim = (s: string) => (tty ? `${DIM}${s}${RESET}` : s)
+ const tools = COMMANDS.map((c) => c.name).join(', ')
+ const config = JSON.stringify(
+ { mcpServers: { 'tanstack-ai': { command: 'ts-ai', args: ['mcp'] } } },
+ null,
+ 2,
+ )
+ .split('\n')
+ .map((line) => ` ${line}`)
+ .join('\n')
+
+ return [
+ '',
+ `${pink('TanStack AI')} ${dim(`· MCP server (stdio) · v${cliVersion}`)}`,
+ '',
+ dim('Add to your MCP client (e.g. Claude Desktop / Cursor):'),
+ config,
+ '',
+ `${dim('Transport :')} stdio`,
+ `${dim('Tools :')} ${tools}`,
+ '',
+ `${pink('●')} listening on stdio — connect a client…`,
+ '',
+ ].join('\n')
+}
+
+/**
+ * `ts-ai mcp` — expose each generation command as an MCP tool over stdio.
+ *
+ * Each tool call re-invokes the `ts-ai` binary as a subprocess with `--json`
+ * and an inline `--config` blob, then returns the parsed JSON. Shelling out to
+ * ourselves keeps the JSON-RPC stdio channel cleanly separated from the
+ * command's own stdout payload and reuses the entire option/precedence
+ * pipeline without duplicating logic.
+ */
+export async function runMcpServer(cliVersion: string): Promise {
+ const server = new McpServer({ name: 'ts-ai', version: cliVersion })
+ // The real CLI entry (bin.js), not this lazily-imported chunk's own path.
+ const binPath = process.argv[1] ?? ''
+
+ for (const spec of COMMANDS) {
+ server.registerTool(
+ spec.name,
+ {
+ title: spec.name,
+ description: spec.description,
+ inputSchema: {
+ prompt: z
+ .string()
+ .optional()
+ .describe('Prompt / input text for the command.'),
+ options: z
+ .record(z.string(), z.any())
+ .optional()
+ .describe('Command options (model, size, etc.) as a JSON object.'),
+ },
+ },
+ async (args: { prompt?: string; options?: Record }) => {
+ const result = await invokeSelf(binPath, spec.name, args)
+ return { content: [{ type: 'text' as const, text: result }] }
+ },
+ )
+ }
+
+ // Log connection info to stderr BEFORE listening — stdout is the JSON-RPC
+ // channel, stderr is free and is surfaced in MCP client logs.
+ process.stderr.write(describeMcpServer(cliVersion))
+
+ const transport = new StdioServerTransport()
+ await server.connect(transport)
+}
+
+function invokeSelf(
+ binPath: string,
+ command: string,
+ args: { prompt?: string; options?: Record },
+): Promise {
+ // Options first, then a `--` end-of-options terminator before the untrusted
+ // prompt so an MCP client can't smuggle flags (e.g. a prompt starting with
+ // `--api-key`) into the spawned CLI. commander treats everything after `--`
+ // as positional operands.
+ const argv = [binPath, command, '--json']
+ if (args.options && Object.keys(args.options).length > 0) {
+ argv.push('--config', JSON.stringify(args.options))
+ }
+ if (args.prompt) argv.push('--', args.prompt)
+
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, argv, {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ })
+ let stdout = ''
+ let stderr = ''
+ child.stdout.on('data', (chunk) => (stdout += String(chunk)))
+ child.stderr.on('data', (chunk) => (stderr += String(chunk)))
+ child.on('error', reject)
+ child.on('close', (code) => {
+ // Non-zero still returns the structured error JSON on stdout; pass it
+ // through so the MCP client sees a parseable result either way.
+ resolve(stdout.trim() || stderr.trim() || `exit ${code}`)
+ })
+ })
+}
diff --git a/packages/ai-cli/src/cli/options.ts b/packages/ai-cli/src/cli/options.ts
new file mode 100644
index 000000000..305dfd164
--- /dev/null
+++ b/packages/ai-cli/src/cli/options.ts
@@ -0,0 +1,63 @@
+import { CliError } from '../core/exit-codes'
+import { COMMON_FLAGS } from '../manifest/manifest'
+import type { CommandSpec, FlagSpec } from '../manifest/types'
+
+/**
+ * Coerce commander's raw (mostly-string) option bag into typed values per the
+ * manifest: numbers parsed, `json` flags JSON-parsed, repeatable flags kept as
+ * arrays. `--config` is intentionally left as a raw string (loaded later).
+ */
+export function coerceFlags(
+ spec: CommandSpec,
+ raw: Record,
+): Record {
+ const flags = [...COMMON_FLAGS, ...spec.flags]
+ const byName = new Map(flags.map((f) => [f.name, f]))
+ const out: Record = {}
+
+ for (const [key, value] of Object.entries(raw)) {
+ if (value === undefined) continue
+ const flag = byName.get(key)
+ if (!flag) {
+ out[key] = value
+ continue
+ }
+ out[key] = coerceValue(flag, value)
+ }
+ return out
+}
+
+function coerceValue(flag: FlagSpec, value: unknown): unknown {
+ // --config and --schema stay strings; loadJsonInput() handles the
+ // file-vs-inline distinction and parsing at the handler.
+ if (flag.name === 'config' || flag.name === 'schema') return value
+
+ switch (flag.type) {
+ case 'number': {
+ const n = Number(value)
+ if (Number.isNaN(n)) {
+ throw new CliError(
+ 'USAGE',
+ `--${flag.name} must be a number, got "${String(value)}".`,
+ )
+ }
+ return n
+ }
+ case 'json':
+ return parseJsonFlag(flag.name, value)
+ case 'string[]':
+ return Array.isArray(value) ? value : [value]
+ case 'string':
+ case 'boolean':
+ return value
+ }
+}
+
+function parseJsonFlag(name: string, value: unknown): unknown {
+ if (typeof value !== 'string') return value
+ try {
+ return JSON.parse(value)
+ } catch (cause) {
+ throw new CliError('USAGE', `--${name} must be valid JSON.`, { cause })
+ }
+}
diff --git a/packages/ai-cli/src/cli/program.ts b/packages/ai-cli/src/cli/program.ts
new file mode 100644
index 000000000..eb7637b47
--- /dev/null
+++ b/packages/ai-cli/src/cli/program.ts
@@ -0,0 +1,136 @@
+import { Command, Option } from 'commander'
+import { COMMANDS, COMMON_FLAGS, toKebabFlag } from '../manifest/manifest'
+import { coerceFlags } from './options'
+import { dispatchCommand } from './dispatch'
+import { runIntrospect } from './introspect'
+import type { CommandSpec, FlagSpec } from '../manifest/types'
+
+/** Build the full commander program from the declarative manifest. */
+export function buildProgram(cliVersion: string): Command {
+ const program = new Command()
+ program
+ .name('ts-ai')
+ .description(
+ 'Type-safe CLI over TanStack AI — chat, image, video, audio, speech, transcribe, summarize.',
+ )
+ .version(cliVersion, '--version')
+ .showHelpAfterError()
+
+ // No command: show the animated home menu on a TTY, help otherwise.
+ program.action(async () => {
+ if (process.stdout.isTTY) {
+ const { runHome } = await import('./interactive')
+ await runHome()
+ } else {
+ program.outputHelp()
+ }
+ })
+
+ for (const spec of COMMANDS) {
+ registerGenerationCommand(program, spec)
+ }
+
+ registerIntrospect(program, cliVersion)
+ registerMcp(program, cliVersion)
+ registerUpdate(program)
+
+ return program
+}
+
+function registerGenerationCommand(program: Command, spec: CommandSpec): void {
+ const cmd = program.command(spec.name).description(spec.description)
+ for (const alias of spec.aliases ?? []) cmd.alias(alias)
+
+ // Positional input: a prompt for prompt-accepting commands, otherwise a
+ // single optional input (e.g. transcribe's audio file path).
+ cmd.argument(spec.acceptsPrompt ? '[prompt...]' : '[input]', 'Prompt / input')
+
+ for (const flag of [...COMMON_FLAGS, ...spec.flags]) applyFlag(cmd, flag)
+
+ cmd.action(
+ async (
+ positional: Array | string | undefined,
+ _opts,
+ command: Command,
+ ) => {
+ const raw = coerceFlags(spec, command.opts())
+ const args = Array.isArray(positional)
+ ? positional
+ : positional
+ ? [positional]
+ : []
+ await dispatchCommand(spec, args, raw)
+ },
+ )
+
+ // `ts-ai video status ` — poll an existing job.
+ if (spec.name === 'video') {
+ const status = cmd
+ .command('status ')
+ .description('Check the status of a video generation job.')
+ for (const flag of COMMON_FLAGS) applyFlag(status, flag)
+ status.action(async (jobId: string, _opts, command: Command) => {
+ // Options can land on the parent `video` command or on `status`; merge both.
+ const merged = { ...(command.parent?.opts() ?? {}), ...command.opts() }
+ const raw = coerceFlags(spec, merged)
+ const { createRunContext } = await import('./context')
+ const { runVideoStatus } = await import('./activities/video')
+ const ctx = await createRunContext(raw)
+ await runVideoStatus(ctx, jobId)
+ })
+ }
+}
+
+function registerIntrospect(program: Command, cliVersion: string): void {
+ program
+ .command('introspect')
+ .description(
+ 'Print a machine-readable manifest of the entire CLI surface as JSON.',
+ )
+ .action(() => runIntrospect(cliVersion))
+}
+
+function registerMcp(program: Command, cliVersion: string): void {
+ program
+ .command('mcp')
+ .description('Start an MCP server exposing each command as a tool (stdio).')
+ .action(async () => {
+ const { runMcpServer } = await import('./mcp')
+ await runMcpServer(cliVersion)
+ })
+}
+
+function registerUpdate(program: Command): void {
+ program
+ .command('update')
+ .description('Update ts-ai to the latest version.')
+ .action(async () => {
+ const { runUpdate } = await import('./update')
+ await runUpdate()
+ })
+}
+
+/** Translate a manifest FlagSpec into a commander option. */
+function applyFlag(cmd: Command, flag: FlagSpec): void {
+ const kebab = toKebabFlag(flag.name)
+ // A default-true boolean is expressed as a `--no-x` negatable flag.
+ if (flag.type === 'boolean' && flag.default === true) {
+ cmd.option(`--no-${kebab}`, flag.description)
+ return
+ }
+
+ const long = `--${kebab}`
+ const namePart = flag.short ? `-${flag.short}, ${long}` : long
+ const flagStr = flag.type === 'boolean' ? namePart : `${namePart} `
+
+ const option = new Option(flagStr, flag.description)
+ if (flag.hidden) option.hideHelp()
+ if (flag.repeatable || flag.type === 'string[]') {
+ option.argParser((value: string, previous: Array = []) => [
+ ...previous,
+ value,
+ ])
+ option.default([])
+ }
+ cmd.addOption(option)
+}
diff --git a/packages/ai-cli/src/cli/run.ts b/packages/ai-cli/src/cli/run.ts
new file mode 100644
index 000000000..bf6647f62
--- /dev/null
+++ b/packages/ai-cli/src/cli/run.ts
@@ -0,0 +1,54 @@
+import { CommanderError } from 'commander'
+import { ExitCode, toCliError } from '../core/exit-codes'
+import { emitError } from '../core/emit'
+import { isMachine, resolveOutputMode } from '../core/output'
+import { buildProgram } from './program'
+import type { ExitCodeValue } from '../core/exit-codes'
+
+/**
+ * Parse argv and run. Returns the process exit code; never throws. All command
+ * errors are funneled through CliError so the exit code and (in machine mode)
+ * the structured stdout error object are consistent.
+ */
+export async function run(
+ argv: Array,
+ cliVersion: string,
+): Promise {
+ const program = buildProgram(cliVersion)
+ // Take control of exits so commander's own usage/help/version paths don't
+ // call process.exit out from under us.
+ program.exitOverride()
+
+ try {
+ await program.parseAsync(argv, { from: 'user' })
+ return ExitCode.Success
+ } catch (err) {
+ if (err instanceof CommanderError) {
+ // Help and version are successful terminal states.
+ if (
+ err.code === 'commander.helpDisplayed' ||
+ err.code === 'commander.help' ||
+ err.code === 'commander.version'
+ ) {
+ return ExitCode.Success
+ }
+ // Everything else from commander is a usage/validation problem.
+ return ExitCode.Usage
+ }
+
+ const cliError = toCliError(err)
+ const mode = resolveOutputMode({
+ json: argv.includes('--json'),
+ stream: argv.includes('--stream'),
+ })
+ if (isMachine(mode)) {
+ emitError(cliError)
+ } else {
+ // Pretty (TTY) mode: a branded red ✗ instead of a bare "error:" line.
+ const red = process.stderr.isTTY ? '[38;2;244;63;94m' : ''
+ const reset = process.stderr.isTTY ? '[39m' : ''
+ process.stderr.write(`${red}✗${reset} ${cliError.message}\n`)
+ }
+ return cliError.exitCode
+ }
+}
diff --git a/packages/ai-cli/src/cli/update.ts b/packages/ai-cli/src/cli/update.ts
new file mode 100644
index 000000000..a735d0759
--- /dev/null
+++ b/packages/ai-cli/src/cli/update.ts
@@ -0,0 +1,65 @@
+import { spawn } from 'node:child_process'
+import { CliError } from '../core/exit-codes'
+
+const PKG = '@tanstack/ai-cli'
+
+/**
+ * `ts-ai update` — upgrade to the latest published version using whichever
+ * package manager appears to have installed the binary. Under npx/dlx there is
+ * nothing to update (each run already fetches on demand).
+ */
+export async function runUpdate(): Promise {
+ const agent = process.env.npm_config_user_agent ?? ''
+
+ if (isOnDemand()) {
+ process.stderr.write(
+ `You're running ${PKG} on-demand (npx/dlx) — each invocation already uses the latest. Nothing to update.\n`,
+ )
+ return
+ }
+
+ const { cmd, args } = upgradeCommand(agent)
+ process.stderr.write(`Updating ${PKG} via: ${cmd} ${args.join(' ')}\n`)
+ const status = await runProcess(cmd, args)
+ if (status !== 0) {
+ throw new CliError(
+ 'RUNTIME',
+ `Update failed (${cmd} exited with ${status ?? 'signal'}).`,
+ )
+ }
+}
+
+function runProcess(cmd: string, args: Array): Promise {
+ return new Promise((resolve, reject) => {
+ const child = spawn(cmd, args, {
+ stdio: 'inherit',
+ shell: process.platform === 'win32',
+ })
+ child.on('error', reject)
+ child.on('close', (code) => resolve(code))
+ })
+}
+
+function isOnDemand(): boolean {
+ // Detect one-shot runners (npx, pnpm dlx, yarn dlx, bunx) — there's nothing
+ // to update, since each invocation already fetches the latest.
+ const execPath = process.env.npm_execpath ?? ''
+ const userAgent = process.env.npm_config_user_agent ?? ''
+ return (
+ execPath.includes('_npx') ||
+ execPath.includes('dlx') ||
+ process.env.npm_command === 'exec' ||
+ /\bdlx\b/.test(userAgent)
+ )
+}
+
+function upgradeCommand(agent: string): { cmd: string; args: Array } {
+ const target = `${PKG}@latest`
+ if (agent.startsWith('pnpm'))
+ return { cmd: 'pnpm', args: ['add', '-g', target] }
+ if (agent.startsWith('yarn'))
+ return { cmd: 'yarn', args: ['global', 'add', target] }
+ if (agent.startsWith('bun'))
+ return { cmd: 'bun', args: ['add', '-g', target] }
+ return { cmd: 'npm', args: ['install', '-g', target] }
+}
diff --git a/packages/ai-cli/src/core/config.ts b/packages/ai-cli/src/core/config.ts
new file mode 100644
index 000000000..025b57962
--- /dev/null
+++ b/packages/ai-cli/src/core/config.ts
@@ -0,0 +1,65 @@
+import { readFile } from 'node:fs/promises'
+import { existsSync } from 'node:fs'
+import { CliError } from './exit-codes'
+
+/**
+ * The `--config` value: either a path to a JSON file or an inline JSON string.
+ * Its shape mirrors the resolved options object for the command; provider
+ * specific options live under `modelOptions`.
+ */
+export function loadConfig(
+ value: string | undefined,
+): Promise> {
+ return loadJsonInput(value, '--config')
+}
+
+/**
+ * Load a JSON-object input that may be either an inline JSON string (starts with
+ * `{`) or a path to a JSON file. Used by `--config` and `--schema`.
+ */
+export async function loadJsonInput(
+ value: string | undefined,
+ label: string,
+): Promise> {
+ if (!value) return {}
+
+ const looksInline = value.trimStart().startsWith('{')
+ let raw: string
+ if (looksInline) {
+ raw = value
+ } else if (existsSync(value)) {
+ raw = await readFile(value, 'utf8')
+ } else {
+ throw new CliError(
+ 'USAGE',
+ `${label} "${value}" is neither inline JSON nor an existing file.`,
+ )
+ }
+
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(raw)
+ } catch (cause) {
+ throw new CliError('USAGE', `${label} is not valid JSON.`, { cause })
+ }
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
+ throw new CliError('USAGE', `${label} must be a JSON object.`)
+ }
+ return parsed as Record
+}
+
+/**
+ * Merge resolved options with precedence flags > config > env-derived defaults.
+ * `flags` are the values commander parsed (undefined when absent), `config` is
+ * the parsed `--config` object. Undefined flag values never clobber config.
+ */
+export function mergeOptions(
+ flags: Record,
+ config: Record,
+): Record {
+ const merged: Record = { ...config }
+ for (const [key, value] of Object.entries(flags)) {
+ if (value !== undefined) merged[key] = value
+ }
+ return merged
+}
diff --git a/packages/ai-cli/src/core/emit.ts b/packages/ai-cli/src/core/emit.ts
new file mode 100644
index 000000000..bd16ca97c
--- /dev/null
+++ b/packages/ai-cli/src/core/emit.ts
@@ -0,0 +1,35 @@
+import type { CliError } from './exit-codes'
+
+/**
+ * stdout discipline for the machine path: stdout carries ONLY the payload.
+ * Everything else (progress, logs, warnings) goes to stderr via the logger.
+ */
+
+/** Write a single buffered JSON object as the command's result. */
+export function emitJson(payload: unknown): void {
+ process.stdout.write(JSON.stringify(payload) + '\n')
+}
+
+/** Write one NDJSON line (used to serialize the AG-UI event stream). */
+export function emitEvent(event: unknown): void {
+ process.stdout.write(JSON.stringify(event) + '\n')
+}
+
+/**
+ * Write raw artifact bytes to stdout (for `-o -` piping), awaiting the write so
+ * large binary payloads aren't truncated if the process exits before the
+ * stdout pipe drains.
+ */
+export function emitBytes(bytes: Uint8Array): Promise {
+ return new Promise((resolve, reject) => {
+ process.stdout.write(bytes, (err) => (err ? reject(err) : resolve()))
+ })
+}
+
+/**
+ * Emit a structured error object to stdout in machine mode so the caller can
+ * parse the failure rather than scraping stderr.
+ */
+export function emitError(error: CliError): void {
+ emitJson({ error: error.toErrorObject() })
+}
diff --git a/packages/ai-cli/src/core/env.ts b/packages/ai-cli/src/core/env.ts
new file mode 100644
index 000000000..0a1ce5df5
--- /dev/null
+++ b/packages/ai-cli/src/core/env.ts
@@ -0,0 +1,37 @@
+import { existsSync, readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+/**
+ * Load a conventional `.env` from the current working directory into
+ * `process.env`. Existing env vars are never overridden, so real environment
+ * values and `--apiKey` always win. Parsing is intentionally minimal:
+ * `KEY=VALUE` lines, `#` comments, optional surrounding quotes.
+ */
+export function loadDotEnv(cwd: string = process.cwd()): void {
+ const path = resolve(cwd, '.env')
+ if (!existsSync(path)) return
+
+ let contents: string
+ try {
+ contents = readFileSync(path, 'utf8')
+ } catch {
+ return
+ }
+
+ for (const rawLine of contents.split(/\r?\n/)) {
+ const line = rawLine.trim()
+ if (!line || line.startsWith('#')) continue
+ const eq = line.indexOf('=')
+ if (eq <= 0) continue
+ const key = line.slice(0, eq).trim()
+ if (key in process.env) continue
+ let value = line.slice(eq + 1).trim()
+ if (
+ (value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))
+ ) {
+ value = value.slice(1, -1)
+ }
+ if (value) process.env[key] = value
+ }
+}
diff --git a/packages/ai-cli/src/core/exit-codes.ts b/packages/ai-cli/src/core/exit-codes.ts
new file mode 100644
index 000000000..97326116a
--- /dev/null
+++ b/packages/ai-cli/src/core/exit-codes.ts
@@ -0,0 +1,89 @@
+/**
+ * Process exit codes. A harness branches on these, so they are part of the
+ * public contract and must stay stable.
+ */
+export const ExitCode = {
+ /** Success. */
+ Success: 0,
+ /** Generic runtime error (unexpected throw, I/O failure, etc.). */
+ Runtime: 1,
+ /** Usage / validation error — bad flags, missing prompt, malformed config. */
+ Usage: 2,
+ /** Provider / API error, or output-schema validation failure. */
+ Provider: 3,
+ /** A required provider package is not installed. */
+ ProviderNotInstalled: 4,
+} as const
+
+export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode]
+
+/** Machine-readable error codes carried in the JSON error envelope. */
+export type CliErrorCode =
+ | 'USAGE'
+ | 'PROVIDER'
+ | 'PROVIDER_NOT_INSTALLED'
+ | 'OUTPUT_VALIDATION'
+ | 'RUNTIME'
+
+const EXIT_BY_CODE: Record = {
+ USAGE: ExitCode.Usage,
+ PROVIDER: ExitCode.Provider,
+ OUTPUT_VALIDATION: ExitCode.Provider,
+ PROVIDER_NOT_INSTALLED: ExitCode.ProviderNotInstalled,
+ RUNTIME: ExitCode.Runtime,
+}
+
+/**
+ * An error carrying everything needed to (a) pick the right exit code and
+ * (b) emit a structured `{ error }` object on stdout in `--json` mode.
+ */
+export class CliError extends Error {
+ readonly code: CliErrorCode
+ readonly provider?: string
+ /** Extra machine-readable detail merged into the emitted error object. */
+ readonly detail?: Record
+
+ constructor(
+ code: CliErrorCode,
+ message: string,
+ options?: {
+ provider?: string
+ detail?: Record
+ cause?: unknown
+ },
+ ) {
+ super(
+ message,
+ options?.cause === undefined ? undefined : { cause: options.cause },
+ )
+ this.name = 'CliError'
+ this.code = code
+ this.provider = options?.provider
+ this.detail = options?.detail
+ }
+
+ get exitCode(): ExitCodeValue {
+ return EXIT_BY_CODE[this.code]
+ }
+
+ toErrorObject(): {
+ code: CliErrorCode
+ message: string
+ provider?: string
+ } & Record {
+ return {
+ // Spread detail first so it can never override the canonical fields.
+ ...(this.detail ?? {}),
+ code: this.code,
+ message: this.message,
+ ...(this.provider ? { provider: this.provider } : {}),
+ }
+ }
+}
+
+/** Coerce any thrown value into a CliError for uniform handling. */
+export function toCliError(err: unknown): CliError {
+ if (err instanceof CliError) return err
+ const message = err instanceof Error ? err.message : String(err)
+ return new CliError('RUNTIME', message, { cause: err })
+}
diff --git a/packages/ai-cli/src/core/io.ts b/packages/ai-cli/src/core/io.ts
new file mode 100644
index 000000000..511e9ea7d
--- /dev/null
+++ b/packages/ai-cli/src/core/io.ts
@@ -0,0 +1,109 @@
+import { readFile } from 'node:fs/promises'
+import { extname } from 'node:path'
+import { CliError } from './exit-codes'
+
+let stdinBufferCache: Buffer | undefined
+
+/**
+ * Read all of stdin as raw bytes. Returns an empty buffer on an interactive TTY.
+ * Memoized: stdin can only be drained once, so a later consumer (e.g.
+ * `--attachment -` after the prompt was read from stdin) gets the same bytes.
+ */
+async function readStdinBuffer(): Promise {
+ if (process.stdin.isTTY) return Buffer.alloc(0)
+ if (stdinBufferCache !== undefined) return stdinBufferCache
+ const chunks: Array = []
+ for await (const chunk of process.stdin) {
+ chunks.push(chunk as Buffer)
+ }
+ stdinBufferCache = Buffer.concat(chunks)
+ return stdinBufferCache
+}
+
+/** Read all of stdin as a UTF-8 string (for text prompts). */
+export async function readStdin(): Promise {
+ return (await readStdinBuffer()).toString('utf8')
+}
+
+/** Read all of stdin as raw bytes (for binary attachments — no text decoding). */
+export async function readStdinBytes(): Promise {
+ return readStdinBuffer()
+}
+
+/**
+ * Resolve the prompt for a command.
+ *
+ * Positional args win; stdin is read ONLY when no positional prompt is given.
+ * Reading stdin unconditionally would block whenever a harness leaves an open
+ * (non-TTY) stdin pipe attached, so the positional case must never touch it.
+ */
+export async function resolvePrompt(
+ positional: Array,
+ options: { required?: boolean } = {},
+): Promise {
+ const fromArgs = positional.join(' ').trim()
+ if (fromArgs) return fromArgs
+
+ const fromStdin = (await readStdin()).trim()
+ if (!fromStdin && options.required) {
+ throw new CliError(
+ 'USAGE',
+ 'No prompt provided. Pass it as arguments or pipe via stdin.',
+ )
+ }
+ return fromStdin
+}
+
+const MIME_BY_EXT: Record = {
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.webp': 'image/webp',
+ '.mp3': 'audio/mpeg',
+ '.wav': 'audio/wav',
+ '.ogg': 'audio/ogg',
+ '.flac': 'audio/flac',
+ '.m4a': 'audio/mp4',
+ '.mp4': 'video/mp4',
+ '.webm': 'video/webm',
+ '.pdf': 'application/pdf',
+ '.txt': 'text/plain',
+ '.md': 'text/markdown',
+}
+
+export interface LoadedAttachment {
+ path: string
+ mimeType: string
+ /** Base64-encoded bytes. */
+ data: string
+}
+
+/** Infer a MIME type from a file extension, defaulting to octet-stream. */
+export function inferMimeType(path: string): string {
+ return MIME_BY_EXT[extname(path).toLowerCase()] ?? 'application/octet-stream'
+}
+
+/** Load `--attachment` files into base64 + inferred mime. `-` reads stdin. */
+export async function loadAttachments(
+ paths: Array,
+): Promise> {
+ const out: Array = []
+ for (const path of paths) {
+ try {
+ const buffer =
+ path === '-' ? await readStdinBytes() : await readFile(path)
+ out.push({
+ path,
+ mimeType:
+ path === '-' ? 'application/octet-stream' : inferMimeType(path),
+ data: buffer.toString('base64'),
+ })
+ } catch (cause) {
+ throw new CliError('USAGE', `Cannot read attachment "${path}".`, {
+ cause,
+ })
+ }
+ }
+ return out
+}
diff --git a/packages/ai-cli/src/core/logger.ts b/packages/ai-cli/src/core/logger.ts
new file mode 100644
index 000000000..72e8422f7
--- /dev/null
+++ b/packages/ai-cli/src/core/logger.ts
@@ -0,0 +1,41 @@
+/**
+ * Stderr logger. All human-facing chatter — progress, warnings, experimental
+ * notices, debug — goes to stderr so stdout stays a clean machine payload.
+ */
+export interface LoggerOptions {
+ verbose?: boolean
+ quiet?: boolean
+}
+
+export class CliLogger {
+ private readonly verbose: boolean
+ private readonly quiet: boolean
+
+ constructor(options: LoggerOptions = {}) {
+ this.verbose = options.verbose ?? false
+ this.quiet = options.quiet ?? false
+ }
+
+ /** Informational progress. Suppressed by --quiet. */
+ info(message: string): void {
+ if (this.quiet) return
+ process.stderr.write(message + '\n')
+ }
+
+ /** Warnings (e.g. experimental notice). Suppressed by --quiet. */
+ warn(message: string): void {
+ if (this.quiet) return
+ process.stderr.write(`warning: ${message}\n`)
+ }
+
+ /** Always shown, even with --quiet. */
+ error(message: string): void {
+ process.stderr.write(`error: ${message}\n`)
+ }
+
+ /** Verbose debug, only with --verbose. */
+ debug(message: string): void {
+ if (!this.verbose) return
+ process.stderr.write(`debug: ${message}\n`)
+ }
+}
diff --git a/packages/ai-cli/src/core/output.ts b/packages/ai-cli/src/core/output.ts
new file mode 100644
index 000000000..c06bbc6c1
--- /dev/null
+++ b/packages/ai-cli/src/core/output.ts
@@ -0,0 +1,31 @@
+/**
+ * Output-mode resolution.
+ *
+ * The hard split that lets one binary serve humans and harnesses: pretty (Ink)
+ * rendering only when stdout is an interactive TTY and no machine mode was
+ * requested. `--json` and `--stream` are mutually exclusive machine modes.
+ */
+export type OutputMode = 'pretty' | 'json' | 'stream'
+
+export interface OutputModeInput {
+ json?: boolean
+ stream?: boolean
+ /** Override TTY detection (tests). */
+ isTTY?: boolean
+}
+
+export function resolveOutputMode(input: OutputModeInput): OutputMode {
+ if (input.json && input.stream) {
+ // Caller asked for both; stream is the more specific machine mode.
+ return 'stream'
+ }
+ if (input.stream) return 'stream'
+ if (input.json) return 'json'
+ const tty = input.isTTY ?? Boolean(process.stdout.isTTY)
+ return tty ? 'pretty' : 'json'
+}
+
+/** True when stdout must carry only the machine payload. */
+export function isMachine(mode: OutputMode): boolean {
+ return mode !== 'pretty'
+}
diff --git a/packages/ai-cli/src/core/providers.ts b/packages/ai-cli/src/core/providers.ts
new file mode 100644
index 000000000..e61645b2e
--- /dev/null
+++ b/packages/ai-cli/src/core/providers.ts
@@ -0,0 +1,279 @@
+import { CliError } from './exit-codes'
+import type { Activity } from '../manifest/types'
+
+/**
+ * Provider registry.
+ *
+ * Maps a `provider/model` slug onto the right `@tanstack/ai-` package,
+ * dynamically imports it, and instantiates the correct adapter for the requested
+ * activity. Every adapter factory in the ecosystem follows the uniform shape
+ * `create(model, apiKey, config?)`, so resolution is a
+ * matter of (a) picking the package, (b) reading the API key, and (c) calling
+ * the factory whose name we derive from provider + activity.
+ */
+
+interface ProviderEntry {
+ /** npm package name. */
+ pkg: string
+ /** Capitalized provider segment used in factory names, e.g. `Openai`. */
+ factoryPrefix: string
+ /** Bundled as a hard dependency (zero-install). */
+ bundled: boolean
+ /** Conventional env vars checked in order for the API key. */
+ envKeys: Array
+ /**
+ * How the factory receives the API key:
+ * - `'apiKeyArg'` (default): `create(model, apiKey, config)` — openai, anthropic, …
+ * - `'configObject'`: `create(model, { apiKey, ...config })` — fal.
+ */
+ configStyle?: 'apiKeyArg' | 'configObject'
+ /**
+ * Alternate factory name prefix to try when `create` is
+ * absent — e.g. fal exposes `falImage` / `falVideo` rather than
+ * `createFal`.
+ */
+ altFactoryPrefix?: string
+}
+
+const PROVIDERS: Record = {
+ openai: {
+ pkg: '@tanstack/ai-openai',
+ factoryPrefix: 'Openai',
+ bundled: true,
+ envKeys: ['OPENAI_API_KEY'],
+ },
+ anthropic: {
+ pkg: '@tanstack/ai-anthropic',
+ factoryPrefix: 'Anthropic',
+ bundled: true,
+ envKeys: ['ANTHROPIC_API_KEY'],
+ },
+ gemini: {
+ pkg: '@tanstack/ai-gemini',
+ factoryPrefix: 'Gemini',
+ bundled: true,
+ envKeys: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'],
+ },
+ openrouter: {
+ pkg: '@tanstack/ai-openrouter',
+ factoryPrefix: 'OpenRouter',
+ bundled: true,
+ envKeys: ['OPENROUTER_API_KEY'],
+ },
+ fal: {
+ pkg: '@tanstack/ai-fal',
+ factoryPrefix: 'Fal',
+ bundled: true,
+ envKeys: ['FAL_KEY'],
+ configStyle: 'configObject',
+ altFactoryPrefix: 'fal',
+ },
+ ollama: {
+ pkg: '@tanstack/ai-ollama',
+ factoryPrefix: 'Ollama',
+ bundled: false,
+ envKeys: ['OLLAMA_API_KEY'],
+ },
+ grok: {
+ pkg: '@tanstack/ai-grok',
+ factoryPrefix: 'Grok',
+ bundled: false,
+ envKeys: ['GROK_API_KEY', 'XAI_API_KEY'],
+ },
+ groq: {
+ pkg: '@tanstack/ai-groq',
+ factoryPrefix: 'Groq',
+ bundled: false,
+ envKeys: ['GROQ_API_KEY'],
+ },
+ elevenlabs: {
+ pkg: '@tanstack/ai-elevenlabs',
+ factoryPrefix: 'ElevenLabs',
+ bundled: false,
+ envKeys: ['ELEVENLABS_API_KEY'],
+ },
+}
+
+/**
+ * The factory export names to try, in order, for a provider + activity.
+ *
+ * Pure and deterministic — no module resolution — so it can be unit-tested.
+ * Chat factory naming varies by provider (`Chat` vs `Text` vs `ResponsesText`);
+ * every other activity uses a single `create` name, with an
+ * optional alternate prefix (e.g. fal's `falImage`).
+ */
+export function factoryCandidatesForProvider(
+ provider: string,
+ activity: Activity,
+): Array {
+ const entry = PROVIDERS[provider]
+ if (!entry) return []
+ const alt = entry.altFactoryPrefix
+ if (activity === 'chat') {
+ return [
+ `create${entry.factoryPrefix}Chat`,
+ `create${entry.factoryPrefix}Text`,
+ `create${entry.factoryPrefix}ResponsesText`,
+ ...(alt ? [`${alt}Chat`, `${alt}Text`] : []),
+ ]
+ }
+ return [
+ `create${entry.factoryPrefix}${ACTIVITY_SUFFIX[activity]}`,
+ ...(alt ? [`${alt}${ACTIVITY_SUFFIX[activity]}`] : []),
+ ]
+}
+
+/** Factory-name suffix per activity (the irregular `chat` -> `Chat`/text case included). */
+const ACTIVITY_SUFFIX: Record = {
+ chat: 'Chat',
+ image: 'Image',
+ video: 'Video',
+ audio: 'Audio',
+ speech: 'Speech',
+ transcription: 'Transcription',
+ summarize: 'Summarize',
+}
+
+export interface ResolvedModel {
+ provider: string
+ /** Bare model id with the provider prefix stripped. */
+ model: string
+ entry: ProviderEntry
+}
+
+/**
+ * Parse a `--model` value into provider + model. Accepts the canonical
+ * `provider/model` slug; a bare model id falls back to the popular-model lookup.
+ */
+export function resolveModelSlug(rawModel: string): ResolvedModel {
+ const slashIndex = rawModel.indexOf('/')
+ if (slashIndex > 0) {
+ const provider = rawModel.slice(0, slashIndex)
+ const model = rawModel.slice(slashIndex + 1)
+ const entry = PROVIDERS[provider]
+ if (!entry) {
+ throw new CliError(
+ 'USAGE',
+ `Unknown provider "${provider}". Known providers: ${Object.keys(PROVIDERS).join(', ')}.`,
+ )
+ }
+ if (!model) {
+ throw new CliError('USAGE', `Missing model after "${provider}/".`)
+ }
+ return { provider, model, entry }
+ }
+
+ const inferred = POPULAR_MODEL_PROVIDERS[rawModel]
+ const inferredEntry = inferred ? PROVIDERS[inferred] : undefined
+ if (!inferred || !inferredEntry) {
+ throw new CliError(
+ 'USAGE',
+ `Cannot infer a provider from "${rawModel}". Use the "provider/model" form, e.g. "openai/${rawModel}".`,
+ )
+ }
+ return { provider: inferred, model: rawModel, entry: inferredEntry }
+}
+
+/**
+ * Resolve the API key: explicit `--apiKey` wins, otherwise the first matching
+ * conventional env var.
+ */
+export function resolveApiKey(
+ entry: ProviderEntry,
+ provider: string,
+ explicitKey: string | undefined,
+ env: NodeJS.ProcessEnv = process.env,
+): string {
+ if (explicitKey) return explicitKey
+ for (const key of entry.envKeys) {
+ const value = env[key]
+ if (value) return value
+ }
+ throw new CliError(
+ 'USAGE',
+ `No API key for "${provider}". Pass --apiKey or set ${entry.envKeys.join(' / ')}.`,
+ { provider },
+ )
+}
+
+/**
+ * Dynamically import a provider package and instantiate the adapter for the
+ * given activity. Throws a typed CliError if the package is missing
+ * (exit 4) or the provider does not support the activity (exit 2).
+ */
+export async function instantiateAdapter(params: {
+ resolved: ResolvedModel
+ activity: Activity
+ apiKey: string
+ /** Adapter config (baseURL, modelOptions, etc.). */
+ config?: Record
+}): Promise {
+ const { resolved, activity, apiKey, config } = params
+ const { entry, provider, model } = resolved
+
+ const mod = await importProvider(entry, provider)
+ const moduleExports = mod as Record
+ const candidates = factoryCandidatesForProvider(provider, activity)
+
+ for (const name of candidates) {
+ const factory = moduleExports[name]
+ if (typeof factory === 'function') {
+ const fn = factory as (...factoryArgs: Array) => unknown
+ return entry.configStyle === 'configObject'
+ ? fn(model, { apiKey, ...config })
+ : fn(model, apiKey, config)
+ }
+ }
+
+ throw new CliError(
+ 'USAGE',
+ `Provider "${provider}" does not support "${activity}" (tried: ${candidates.join(', ')}).`,
+ { provider, detail: { activity } },
+ )
+}
+
+async function importProvider(
+ entry: ProviderEntry,
+ provider: string,
+): Promise {
+ try {
+ return await import(entry.pkg)
+ } catch (cause) {
+ const code = (cause as { code?: string }).code
+ const message = cause instanceof Error ? cause.message : String(cause)
+ // Only treat a genuinely-absent package as "not installed". If the package
+ // is present but throws while loading (broken build, missing transitive),
+ // surface the real error instead of a misleading install hint.
+ const missingPackage =
+ (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') &&
+ message.includes(entry.pkg)
+ if (missingPackage) {
+ throw new CliError(
+ 'PROVIDER_NOT_INSTALLED',
+ `Provider "${provider}" requires ${entry.pkg}. Install it: pnpm add ${entry.pkg}`,
+ { provider, detail: { package: entry.pkg }, cause },
+ )
+ }
+ throw new CliError(
+ 'RUNTIME',
+ `Failed to load provider "${provider}" (${entry.pkg}): ${message}`,
+ { provider, detail: { package: entry.pkg }, cause },
+ )
+ }
+}
+
+export function bundledProviders(): Array {
+ return Object.entries(PROVIDERS)
+ .filter(([, entry]) => entry.bundled)
+ .map(([name]) => name)
+}
+
+/**
+ * Minimal popular-model -> provider table for the bare `--model` convenience
+ * form. The documented form is always `provider/model`; this only covers a few
+ * unambiguous flagships so quick one-offs work without the prefix.
+ */
+const POPULAR_MODEL_PROVIDERS: Record = {
+ 'gpt-5.5': 'openai',
+ 'gpt-image-1': 'openai',
+}
diff --git a/packages/ai-cli/src/core/spinner.ts b/packages/ai-cli/src/core/spinner.ts
new file mode 100644
index 000000000..67205c5d2
--- /dev/null
+++ b/packages/ai-cli/src/core/spinner.ts
@@ -0,0 +1,30 @@
+const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
+const PINK = '[38;2;236;72;153m'
+const RESET = '[0m'
+
+/**
+ * Show a progress spinner on stderr while a long operation runs, returning a
+ * stop function. On a non-TTY stderr (e.g. a harness capturing logs) it writes
+ * the label once instead of animating. stdout — the machine payload — is never
+ * touched.
+ */
+export function startSpinner(label: string): () => void {
+ if (!process.stderr.isTTY) {
+ process.stderr.write(`${label}\n`)
+ return () => {}
+ }
+ let i = 0
+ process.stderr.write('[?25l') // hide cursor
+ const render = () => {
+ process.stderr.write(
+ `\r${PINK}${FRAMES[i % FRAMES.length]}${RESET} ${label}`,
+ )
+ i += 1
+ }
+ render()
+ const id = setInterval(render, 80)
+ return () => {
+ clearInterval(id)
+ process.stderr.write('\r[2K[?25h') // clear line + show cursor
+ }
+}
diff --git a/packages/ai-cli/src/index.ts b/packages/ai-cli/src/index.ts
new file mode 100644
index 000000000..b39b83be8
--- /dev/null
+++ b/packages/ai-cli/src/index.ts
@@ -0,0 +1,25 @@
+/**
+ * Programmatic entry point for `@tanstack/ai-cli`.
+ *
+ * The executable lives in `src/cli` (built separately as the `ts-ai` bin). This
+ * module exports the declarative manifest and supporting types so tooling can
+ * introspect the CLI surface without spawning the binary.
+ */
+export {
+ buildManifest,
+ MANIFEST_VERSION,
+ COMMANDS,
+ COMMON_FLAGS,
+ findCommand,
+} from './manifest/manifest'
+export type {
+ Activity,
+ CliManifest,
+ CommandSpec,
+ FlagSpec,
+ FlagType,
+} from './manifest/types'
+export { ExitCode, CliError } from './core/exit-codes'
+export type { ExitCodeValue, CliErrorCode } from './core/exit-codes'
+export { resolveModelSlug, bundledProviders } from './core/providers'
+export type { OutputMode } from './core/output'
diff --git a/packages/ai-cli/src/manifest/manifest.ts b/packages/ai-cli/src/manifest/manifest.ts
new file mode 100644
index 000000000..ee07cf179
--- /dev/null
+++ b/packages/ai-cli/src/manifest/manifest.ts
@@ -0,0 +1,294 @@
+import { ExitCode } from '../core/exit-codes'
+import { bundledProviders } from '../core/providers'
+import type { CliManifest, CommandSpec, FlagSpec } from './types'
+
+/** Schema version of the introspect document; bump on breaking surface changes. */
+export const MANIFEST_VERSION = '1'
+
+/** Flags accepted by every command. */
+export const COMMON_FLAGS: Array = [
+ {
+ name: 'model',
+ type: 'string',
+ description: 'Model as a "provider/model" slug, e.g. openai/gpt-5.5.',
+ },
+ {
+ name: 'apiKey',
+ type: 'string',
+ description: 'API key (overrides env vars).',
+ },
+ {
+ name: 'json',
+ type: 'boolean',
+ description: 'Emit a single buffered JSON result to stdout.',
+ },
+ {
+ name: 'stream',
+ type: 'boolean',
+ description: 'Emit the AG-UI event stream as NDJSON to stdout.',
+ },
+ {
+ name: 'output',
+ short: 'o',
+ type: 'string',
+ description: 'Write artifact to this path. "-" writes bytes to stdout.',
+ },
+ {
+ name: 'preview',
+ type: 'boolean',
+ default: true,
+ description:
+ 'Inline-preview artifacts in a capable terminal (use --no-preview to disable).',
+ },
+ {
+ name: 'config',
+ type: 'json',
+ description: 'Options as a JSON file path or inline JSON string.',
+ },
+ {
+ name: 'verbose',
+ type: 'boolean',
+ description: 'Verbose debug logging to stderr.',
+ },
+ {
+ name: 'quiet',
+ type: 'boolean',
+ description: 'Suppress non-error stderr output.',
+ },
+]
+
+const ATTACHMENT_FLAG: FlagSpec = {
+ name: 'attachment',
+ type: 'string[]',
+ repeatable: true,
+ description: 'Attach a file (repeatable). "-" reads stdin.',
+}
+
+/** Directory for generated artifacts (image/video/audio/speech). */
+const OUTPUT_DIR_FLAG: FlagSpec = {
+ name: 'outputDir',
+ type: 'string',
+ description:
+ 'Directory for generated files (default: current directory; created if missing). -o sets a full path and wins.',
+}
+
+export const COMMANDS: Array = [
+ {
+ name: 'chat',
+ description:
+ 'Chat / agentic text generation with optional tools and structured output.',
+ activity: 'chat',
+ acceptsPrompt: true,
+ producesArtifact: false,
+ flags: [
+ ATTACHMENT_FLAG,
+ {
+ name: 'system',
+ type: 'string',
+ description: 'System prompt (text or file path).',
+ },
+ {
+ name: 'messages',
+ type: 'json',
+ description:
+ 'Full message history as a JSON array (stateless multi-turn).',
+ },
+ {
+ name: 'threadId',
+ type: 'string',
+ description: 'Correlation id passed through to telemetry/AG-UI.',
+ },
+ {
+ name: 'maxSteps',
+ type: 'number',
+ description: 'Max agent-loop iterations (tool-calling).',
+ },
+ {
+ name: 'mcp',
+ type: 'string[]',
+ repeatable: true,
+ description: 'MCP server (command or URL) exposing tools (repeatable).',
+ },
+ {
+ name: 'codeMode',
+ type: 'boolean',
+ description: 'Enable the sandboxed execute_typescript tool.',
+ },
+ {
+ name: 'schema',
+ type: 'json',
+ description:
+ 'JSON Schema for structured output (file path or inline). Result is under .data.',
+ },
+ ],
+ },
+ {
+ name: 'image',
+ description: 'Generate an image from a prompt.',
+ activity: 'image',
+ acceptsPrompt: true,
+ producesArtifact: true,
+ flags: [
+ ATTACHMENT_FLAG,
+ OUTPUT_DIR_FLAG,
+ {
+ name: 'size',
+ type: 'string',
+ description: 'Output size, e.g. 1024x1024.',
+ },
+ {
+ name: 'count',
+ type: 'number',
+ default: 1,
+ description: 'Number of images to generate.',
+ },
+ ],
+ },
+ {
+ name: 'video',
+ description:
+ 'Generate a video from a prompt (async job; blocks until done by default).',
+ activity: 'video',
+ acceptsPrompt: true,
+ producesArtifact: true,
+ experimental: true,
+ flags: [
+ ATTACHMENT_FLAG,
+ OUTPUT_DIR_FLAG,
+ {
+ name: 'wait',
+ type: 'boolean',
+ default: true,
+ description:
+ 'Poll until the job completes (use --no-wait to return the job id immediately).',
+ },
+ {
+ name: 'size',
+ type: 'string',
+ description: 'Output size / resolution.',
+ },
+ ],
+ },
+ {
+ name: 'audio',
+ description: 'Generate audio (music / sound effects) from a prompt.',
+ activity: 'audio',
+ acceptsPrompt: true,
+ producesArtifact: true,
+ flags: [
+ OUTPUT_DIR_FLAG,
+ {
+ name: 'duration',
+ type: 'number',
+ description: 'Desired duration in seconds.',
+ },
+ ],
+ },
+ {
+ name: 'speech',
+ aliases: ['tts'],
+ description: 'Synthesize speech audio from text (text-to-speech).',
+ activity: 'speech',
+ acceptsPrompt: true,
+ producesArtifact: true,
+ flags: [
+ OUTPUT_DIR_FLAG,
+ { name: 'voice', type: 'string', description: 'Voice id.' },
+ {
+ name: 'format',
+ type: 'string',
+ description: 'Audio format: mp3, opus, aac, flac, wav, pcm.',
+ },
+ {
+ name: 'speed',
+ type: 'number',
+ description: 'Playback speed 0.25–4.0.',
+ },
+ ],
+ },
+ {
+ name: 'transcribe',
+ aliases: ['stt'],
+ description: 'Transcribe an audio file to text (speech-to-text).',
+ activity: 'transcription',
+ acceptsPrompt: false,
+ producesArtifact: false,
+ flags: [
+ ATTACHMENT_FLAG,
+ {
+ name: 'language',
+ type: 'string',
+ description: 'ISO-639-1 language hint, e.g. en.',
+ },
+ ],
+ },
+ {
+ name: 'summarize',
+ description: 'Summarize input text.',
+ activity: 'summarize',
+ acceptsPrompt: true,
+ producesArtifact: false,
+ flags: [
+ {
+ name: 'maxLength',
+ type: 'number',
+ description: 'Maximum summary length.',
+ },
+ {
+ name: 'style',
+ type: 'string',
+ description: 'Summary style: bullet-points, paragraph, concise.',
+ },
+ {
+ name: 'focus',
+ type: 'string[]',
+ repeatable: true,
+ description: 'Topic to focus on (repeatable).',
+ },
+ ],
+ },
+]
+
+/** Convert a camelCase flag identifier to its kebab-case CLI spelling. */
+export function toKebabFlag(name: string): string {
+ return name.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)
+}
+
+/** Annotate a flag with its exact CLI spelling for the introspect document. */
+function withFlagSpelling(flag: FlagSpec): FlagSpec {
+ const kebab = toKebabFlag(flag.name)
+ // Default-true booleans are negatable flags (`--no-x`).
+ const spelling =
+ flag.type === 'boolean' && flag.default === true
+ ? `--no-${kebab}`
+ : `--${kebab}`
+ return { ...flag, flag: spelling }
+}
+
+/** Build the full serializable manifest. */
+export function buildManifest(cliVersion: string): CliManifest {
+ return {
+ bin: 'ts-ai',
+ manifestVersion: MANIFEST_VERSION,
+ cliVersion,
+ bundledProviders: bundledProviders(),
+ commonFlags: COMMON_FLAGS.map(withFlagSpelling),
+ commands: COMMANDS.map((c) => ({
+ ...c,
+ flags: c.flags.map(withFlagSpelling),
+ })),
+ exitCodes: {
+ success: ExitCode.Success,
+ runtime: ExitCode.Runtime,
+ usage: ExitCode.Usage,
+ provider: ExitCode.Provider,
+ providerNotInstalled: ExitCode.ProviderNotInstalled,
+ },
+ }
+}
+
+export function findCommand(name: string): CommandSpec | undefined {
+ return COMMANDS.find(
+ (c) => c.name === name || (c.aliases?.includes(name) ?? false),
+ )
+}
diff --git a/packages/ai-cli/src/manifest/types.ts b/packages/ai-cli/src/manifest/types.ts
new file mode 100644
index 000000000..161f57eb8
--- /dev/null
+++ b/packages/ai-cli/src/manifest/types.ts
@@ -0,0 +1,73 @@
+/**
+ * Declarative command manifest types.
+ *
+ * The manifest is the single source of truth for the CLI surface. The commander
+ * program, the `introspect --json` document, and the `ts-ai mcp` tool list are
+ * all generated from it, so they can never drift apart.
+ */
+
+/** A core `@tanstack/ai` activity a generation command maps onto. */
+export type Activity =
+ | 'chat'
+ | 'image'
+ | 'video'
+ | 'audio'
+ | 'speech'
+ | 'transcription'
+ | 'summarize'
+
+export type FlagType = 'string' | 'number' | 'boolean' | 'string[]' | 'json'
+
+/** A single command-line flag. */
+export interface FlagSpec {
+ /** Canonical camelCase identifier; also the key on the parsed options bag. */
+ name: string
+ /** The exact CLI flag spelling (kebab-cased), populated in the introspect doc. */
+ flag?: string
+ /** Single-char alias without dash, e.g. `o` for `-o`. */
+ short?: string
+ type: FlagType
+ description: string
+ /** Default value, surfaced in `introspect` and `--help`. */
+ default?: string | number | boolean
+ /** Repeatable flag (collected into an array). Implies `string[]`. */
+ repeatable?: boolean
+ /** Hidden from `--help` (still parsed). */
+ hidden?: boolean
+}
+
+/**
+ * A command in the manifest. `activity` is present for the seven generation
+ * commands and absent for meta commands (`introspect`, `mcp`, `update`).
+ */
+export interface CommandSpec {
+ name: string
+ /** Hidden command aliases, e.g. `tts` -> `speech`. */
+ aliases?: Array
+ description: string
+ activity?: Activity
+ /** Whether the command consumes a positional prompt / input text. */
+ acceptsPrompt: boolean
+ /** Whether the activity writes a binary artifact (image/video/audio/speech). */
+ producesArtifact: boolean
+ /** Marked experimental in core (currently `video`). */
+ experimental?: boolean
+ /** Command-specific flags (common flags are added globally). */
+ flags: Array
+}
+
+/** The serialized `introspect` document. */
+export interface CliManifest {
+ /** CLI binary name. */
+ bin: string
+ /** Manifest schema version (independent of package version). */
+ manifestVersion: string
+ /** Package version this binary was built from. */
+ cliVersion: string
+ /** Providers bundled for zero-install use. */
+ bundledProviders: Array
+ /** Common flags accepted by every command. */
+ commonFlags: Array
+ commands: Array
+ exitCodes: Record
+}
diff --git a/packages/ai-cli/src/render/exit.ts b/packages/ai-cli/src/render/exit.ts
new file mode 100644
index 000000000..f23ddd3b2
--- /dev/null
+++ b/packages/ai-cli/src/render/exit.ts
@@ -0,0 +1,15 @@
+/** True when an Ink key event is Ctrl+C. */
+export function isCtrlC(input: string, key: { ctrl: boolean }): boolean {
+ return key.ctrl && input === 'c'
+}
+
+/**
+ * Exit the whole CLI immediately (used for Ctrl+C from any Ink screen). Restores
+ * the terminal first — show the cursor and leave raw mode — so the user's shell
+ * isn't left without echo. Uses exit code 130 (128 + SIGINT), the convention.
+ */
+export function forceExit(): never {
+ process.stdout.write('[?25h') // show cursor
+ if (process.stdin.isTTY) process.stdin.setRawMode(false)
+ process.exit(130)
+}
diff --git a/packages/ai-cli/src/render/ink.tsx b/packages/ai-cli/src/render/ink.tsx
new file mode 100644
index 000000000..bdd3a96ac
--- /dev/null
+++ b/packages/ai-cli/src/render/ink.tsx
@@ -0,0 +1,118 @@
+import { useEffect } from 'react'
+import { Box, Text, render, useApp, useInput } from 'ink'
+import terminalImage from 'terminal-image'
+import { DIM, PINK, SUCCESS } from './theme'
+import { forceExit, isCtrlC } from './exit'
+import type { ReactNode } from 'react'
+import type { RenderedImage } from './lazy'
+
+/**
+ * Encode an image file into a terminal-renderable string — native iTerm2/Kitty
+ * graphics where supported, ANSI block-art otherwise (blocky, but a preview is
+ * better than none). Returns null only on failure.
+ */
+async function encodePreview(path: string): Promise {
+ try {
+ return await terminalImage.file(path, { height: 24 })
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Wrap a finished result. On an interactive terminal it stays on screen until
+ * the user presses Esc/Enter (so a hub action's output isn't instantly cleared,
+ * and one-shot renders don't hang); otherwise it unmounts immediately.
+ */
+function ResultView({ children }: { children: ReactNode }) {
+ const { exit } = useApp()
+ const interactive = Boolean(process.stdin.isTTY)
+
+ useInput(
+ (input, key) => {
+ if (isCtrlC(input, key)) forceExit()
+ if (key.escape || key.return) exit()
+ },
+ { isActive: interactive },
+ )
+ useEffect(() => {
+ if (!interactive) exit()
+ }, [interactive, exit])
+
+ return (
+
+ {children}
+ {interactive ? (
+
+ Press Esc to continue
+
+ ) : null}
+
+ )
+}
+
+async function renderResult(content: ReactNode): Promise {
+ const { waitUntilExit } = render({content}, {
+ exitOnCtrlC: false,
+ })
+ await waitUntilExit()
+}
+
+/** Render generated images with an inline preview + the saved path(s). */
+export async function renderImageResultInk(input: {
+ model: string
+ images: Array
+ preview: boolean
+}): Promise {
+ const previews = input.preview
+ ? await Promise.all(input.images.map((image) => encodePreview(image.path)))
+ : input.images.map(() => null)
+
+ await renderResult(
+
+
+ ✓
+ Generated {input.images.length} image(s) with{' '}
+ {input.model}
+
+ {input.images.map((image, index) => (
+
+ {previews[index] ? {previews[index]} : null}
+ {image.path}
+ {image.revisedPrompt ? (
+ “{image.revisedPrompt}”
+ ) : null}
+
+ ))}
+ ,
+ )
+}
+
+/** Render a block of finished text (e.g. chat one-shot, summary). */
+export async function renderTextInk(text: string): Promise {
+ await renderResult({text})
+}
+
+/** Render a saved-artifact confirmation with the path and metadata. */
+export async function renderArtifactPathInk(input: {
+ label: string
+ path: string
+ meta?: Record
+}): Promise {
+ await renderResult(
+
+
+ ✓
+ {input.label}
+
+ {input.path}
+ {input.meta
+ ? Object.entries(input.meta).map(([key, value]) => (
+
+ {key}: {String(value)}
+
+ ))
+ : null}
+ ,
+ )
+}
diff --git a/packages/ai-cli/src/render/lazy.ts b/packages/ai-cli/src/render/lazy.ts
new file mode 100644
index 000000000..2b8ab53bc
--- /dev/null
+++ b/packages/ai-cli/src/render/lazy.ts
@@ -0,0 +1,52 @@
+/**
+ * Lazy render boundary.
+ *
+ * Every function here dynamically imports the Ink implementation so that React,
+ * the Ink reconciler, and ink-picture are loaded ONLY on the interactive/pretty
+ * path. The machine path (`--json` / `--stream` / non-TTY) never touches them,
+ * keeping cold start fast for agent harnesses.
+ */
+
+import type { MenuChoice } from './menu'
+import type { ReplMessage } from './repl'
+
+export interface RenderedImage {
+ path: string
+ revisedPrompt?: string
+}
+
+export async function renderImageResult(input: {
+ model: string
+ images: Array
+ preview: boolean
+}): Promise {
+ const { renderImageResultInk } = await import('./ink')
+ await renderImageResultInk(input)
+}
+
+export async function renderText(text: string): Promise {
+ const { renderTextInk } = await import('./ink')
+ await renderTextInk(text)
+}
+
+export async function renderArtifactPath(input: {
+ label: string
+ path: string
+ meta?: Record
+}): Promise {
+ const { renderArtifactPathInk } = await import('./ink')
+ await renderArtifactPathInk(input)
+}
+
+export async function renderMenu(animate = true): Promise {
+ const { runMenuInk } = await import('./menu')
+ return runMenuInk(animate)
+}
+
+export async function renderChatRepl(input: {
+ model: string
+ respond: (messages: Array) => Promise
+}): Promise {
+ const { runChatReplInk } = await import('./repl')
+ await runChatReplInk(input)
+}
diff --git a/packages/ai-cli/src/render/menu.tsx b/packages/ai-cli/src/render/menu.tsx
new file mode 100644
index 000000000..789e0c818
--- /dev/null
+++ b/packages/ai-cli/src/render/menu.tsx
@@ -0,0 +1,183 @@
+import { useState } from 'react'
+import { Box, Text, render, useApp, useInput } from 'ink'
+import { DIM, PINK } from './theme'
+import { forceExit, isCtrlC } from './exit'
+import { WelcomeHeader, loadLogo } from './welcome'
+
+/** A selectable action from the home menu. */
+export interface MenuChoice {
+ /** Command to run, or 'quit'. */
+ command: string
+ /** Prompt text the user typed (for non-chat commands). */
+ prompt?: string
+}
+
+interface MenuItem {
+ command: string
+ label: string
+ hint: string
+ /** Whether this command needs a prompt typed before running. */
+ needsPrompt: boolean
+}
+
+const ITEMS: Array