diff --git a/.changeset/ai-cli-initial.md b/.changeset/ai-cli-initial.md new file mode 100644 index 000000000..e4efcc6bb --- /dev/null +++ b/.changeset/ai-cli-initial.md @@ -0,0 +1,23 @@ +--- +'@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 Ink layer: running `ts-ai` with no command on +a TTY opens an animated home screen 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), and `ts-ai update`. diff --git a/docs/cli/overview.md b/docs/cli/overview.md new file mode 100644 index 000000000..3af412571 --- /dev/null +++ b/docs/cli/overview.md @@ -0,0 +1,187 @@ +--- +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 an animated home screen +that asks what you want to do, with 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 always write the artifact to a file (override with `-o`, or +`-o -` to stream raw bytes to stdout) and report the path in the JSON. + +### 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. + +### 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..55fb526f3 100644 --- a/docs/config.json +++ b/docs/config.json @@ -216,6 +216,16 @@ } ] }, + { + "label": "CLI", + "children": [ + { + "label": "ts-ai CLI", + "to": "cli/overview", + "addedAt": "2026-06-07" + } + ] + }, { "label": "Media", "children": [ 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/package.json b/packages/ai-cli/package.json new file mode 100644 index 000000000..c73d8cba1 --- /dev/null +++ b/packages/ai-cli/package.json @@ -0,0 +1,82 @@ +{ + "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" + ], + "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": [ + "dist", + "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/src/cli/activities/audio.ts b/packages/ai-cli/src/cli/activities/audio.ts new file mode 100644 index 000000000..ed716e942 --- /dev/null +++ b/packages/ai-cli/src/cli/activities/audio.ts @@ -0,0 +1,87 @@ +import { generateAudio } from '@tanstack/ai' +import { instantiateAdapter } from '../../core/providers' +import { emitJson } from '../../core/emit' +import { mediaSourceToBytes, writeArtifact } from '../artifact' +import { resolveAdapterContext } 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, + }) + + ctx.logger.info( + `Generating audio with ${resolved.provider}/${resolved.model}…`, + ) + + const duration = + typeof ctx.options.duration === 'number' + ? ctx.options.duration + : typeof ctx.options.duration === 'string' + ? Number(ctx.options.duration) + : undefined + + const result = (await 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 path = await writeArtifact( + 'audio', + { bytes, ext, mimeType: result.audio.contentType ?? `audio/${ext}` }, + output, + 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..7677e11aa --- /dev/null +++ b/packages/ai-cli/src/cli/activities/chat.ts @@ -0,0 +1,184 @@ +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 } 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 + + // 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 ? { agentLoopStrategy: maxIterations(maxSteps) } : {}), + } + + // Structured output: schema-bearing call resolves to the validated object. + if (schema !== undefined) { + const data = await (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 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.') + } + 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..177c1a9bc --- /dev/null +++ b/packages/ai-cli/src/cli/activities/image.ts @@ -0,0 +1,122 @@ +import { generateImage } from '@tanstack/ai' +import { instantiateAdapter } from '../../core/providers' +import { emitJson } from '../../core/emit' +import { mediaSourceToBytes, writeArtifact } from '../artifact' +import { resolveAdapterContext } 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, + }) + + ctx.logger.info( + `Generating image with ${resolved.provider}/${resolved.model}…`, + ) + + const result = (await generateImage({ + // The CLI resolves adapters at runtime; the static generic shape 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 written: Array<{ + path: string | null + mimeType: string + revisedPrompt?: string + }> = [] + + for (const [index, image] of result.images.entries()) { + const bytes = await mediaSourceToBytes(image) + const mimeType = '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 }, + target, + 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..a8af10379 --- /dev/null +++ b/packages/ai-cli/src/cli/activities/speech.ts @@ -0,0 +1,80 @@ +import { generateSpeech } from '@tanstack/ai' +import { instantiateAdapter } from '../../core/providers' +import { emitJson } from '../../core/emit' +import { writeArtifact } from '../artifact' +import { resolveAdapterContext } 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, + }) + + ctx.logger.info( + `Synthesizing speech with ${resolved.provider}/${resolved.model}…`, + ) + + const result = (await 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}` }, + str(ctx.options.output), + 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..7634789a9 --- /dev/null +++ b/packages/ai-cli/src/cli/activities/summarize.ts @@ -0,0 +1,63 @@ +import { summarize } from '@tanstack/ai' +import { instantiateAdapter } from '../../core/providers' +import { emitJson } from '../../core/emit' +import { resolveAdapterContext } 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, + }) + + ctx.logger.info(`Summarizing with ${resolved.provider}/${resolved.model}…`) + + 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 + + const result = (await 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..754573984 --- /dev/null +++ b/packages/ai-cli/src/cli/activities/transcribe.ts @@ -0,0 +1,81 @@ +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 } 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, + }) + } + + ctx.logger.info(`Transcribing with ${resolved.provider}/${resolved.model}…`) + + const result = (await 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..1e8413f08 --- /dev/null +++ b/packages/ai-cli/src/cli/activities/video.ts @@ -0,0 +1,135 @@ +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 } from '../context' +import { renderArtifactPath } from '../../render/lazy' +import type { RunContext } from '../context' + +const POLL_INTERVAL_MS = 3000 + +/** + * `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, + }) + + ctx.logger.info( + `Creating video job with ${resolved.provider}/${resolved.model}…`, + ) + + const job = await 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 path = await writeArtifact( + 'video', + { bytes, ext: 'mp4', mimeType: 'video/mp4' }, + output, + 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, +) { + 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 + 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..268208231 --- /dev/null +++ b/packages/ai-cli/src/cli/artifact.ts @@ -0,0 +1,72 @@ +import { writeFile } from 'node:fs/promises' +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 +} + +/** + * Resolve where an artifact should be written. Explicit `-o` wins; otherwise a + * timestamped name in the cwd. `-o -` is handled by the caller (stdout). + */ +export function resolveOutputPath( + command: string, + ext: string, + output: string | undefined, + now: number, +): string { + if (output && output !== '-') return output + return `./ts-ai-${command}-${now}.${ext}` +} + +/** + * Persist an artifact. Returns the path written, or null when bytes were sent + * to stdout (`-o -`). + */ +export async function writeArtifact( + command: string, + artifact: Artifact, + output: string | undefined, + now: number, +): Promise { + if (output === '-') { + await emitBytes(artifact.bytes) + return null + } + const path = resolveOutputPath(command, artifact.ext, output, now) + try { + 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. */ +export async function fetchBytes(url: string): Promise { + const res = await fetch(url) + 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..9297f553d --- /dev/null +++ b/packages/ai-cli/src/cli/context.ts @@ -0,0 +1,71 @@ +import { loadConfig, mergeOptions } from '../core/config' +import { resolveOutputMode } from '../core/output' +import { CliLogger } from '../core/logger' +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, and a stderr logger. + */ +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 +} + +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 logger = new CliLogger({ + verbose: Boolean(options.verbose), + quiet: Boolean(options.quiet), + }) + return { mode, logger, options, now: Date.now() } +} + +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..19004c1f9 --- /dev/null +++ b/packages/ai-cli/src/cli/interactive.ts @@ -0,0 +1,80 @@ +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. Resolves the chosen action and runs it. + */ +export async function runHome(modelOverride?: string): Promise { + const choice = await renderMenu() + if (choice.command === 'quit') return 0 + + if (choice.command === 'chat') { + return runChatRepl( + modelOverride ?? DEFAULT_MODELS['chat'] ?? 'openai/gpt-5.5', + ) + } + + const model = modelOverride ?? DEFAULT_MODELS[choice.command] + if (!model) { + process.stderr.write( + `Run it with a model, e.g.:\n ts-ai ${choice.command} "${choice.prompt ?? ''}" --model \n`, + ) + return 0 + } + + const spec = findCommand(choice.command) + if (!spec) return 0 + await dispatchCommand(spec, choice.prompt ? [choice.prompt] : [], { + model, + preview: true, + }) + return 0 +} + +/** 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..720c09bff --- /dev/null +++ b/packages/ai-cli/src/cli/mcp-clients.ts @@ -0,0 +1,102 @@ +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) { + 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..6a6f29304 --- /dev/null +++ b/packages/ai-cli/src/cli/mcp.ts @@ -0,0 +1,79 @@ +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' + +/** + * `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 }] } + }, + ) + } + + 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..da63e4c73 --- /dev/null +++ b/packages/ai-cli/src/cli/run.ts @@ -0,0 +1,51 @@ +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 { + process.stderr.write(`error: ${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..c8a2b9c8a --- /dev/null +++ b/packages/ai-cli/src/cli/update.ts @@ -0,0 +1,57 @@ +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 { + const execPath = process.env.npm_execpath ?? '' + return execPath.includes('_npx') || process.env.npm_command === 'exec' +} + +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..2f668e695 --- /dev/null +++ b/packages/ai-cli/src/core/exit-codes.ts @@ -0,0 +1,88 @@ +/** + * 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 { + code: this.code, + message: this.message, + ...(this.provider ? { provider: this.provider } : {}), + ...(this.detail ?? {}), + } + } +} + +/** 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..f02627c16 --- /dev/null +++ b/packages/ai-cli/src/core/io.ts @@ -0,0 +1,102 @@ +import { readFile } from 'node:fs/promises' +import { extname } from 'node:path' +import { CliError } from './exit-codes' + +let stdinCache: string | undefined + +/** + * Read all of stdin as a string. Returns '' if stdin is an interactive TTY. + * The result is memoized: stdin can only be drained once, so a later consumer + * (e.g. `--attachment -` after the prompt was read from stdin) gets the same + * content instead of an empty buffer. + */ +export async function readStdin(): Promise { + if (process.stdin.isTTY) return '' + if (stdinCache !== undefined) return stdinCache + const chunks: Array = [] + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer) + } + stdinCache = Buffer.concat(chunks).toString('utf8') + return stdinCache +} + +/** + * 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 === '-' + ? Buffer.from(await readStdin(), 'utf8') + : 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..c808a5b9e --- /dev/null +++ b/packages/ai-cli/src/core/providers.ts @@ -0,0 +1,264 @@ +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) { + throw new CliError( + 'PROVIDER_NOT_INSTALLED', + `Provider "${provider}" requires ${entry.pkg}. Install it: pnpm add ${entry.pkg}`, + { 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/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..9d858f15f --- /dev/null +++ b/packages/ai-cli/src/manifest/manifest.ts @@ -0,0 +1,282 @@ +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.', +} + +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, + { + 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, + { + 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: [ + { + 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: [ + { 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/ink.tsx b/packages/ai-cli/src/render/ink.tsx new file mode 100644 index 000000000..c1dca6d21 --- /dev/null +++ b/packages/ai-cli/src/render/ink.tsx @@ -0,0 +1,77 @@ +import { Box, Text, render } from 'ink' +import terminalImage from 'terminal-image' +import type { RenderedImage } from './lazy' + +/** + * Encode an image file into a terminal-renderable string (native iTerm2/Kitty + * graphics where supported, ANSI block-art otherwise). Returns null on failure + * so a missing preview never breaks the run. + */ +async function encodePreview(path: string): Promise { + try { + return await terminalImage.file(path, { height: 20 }) + } catch { + return null + } +} + +/** 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) + + const { waitUntilExit } = render( + + + ✓ 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} + + ))} + , + ) + await waitUntilExit() +} + +/** Render a block of finished text (e.g. chat one-shot, summary). */ +export async function renderTextInk(text: string): Promise { + const { waitUntilExit } = render( + + {text} + , + ) + await waitUntilExit() +} + +/** Render a saved-artifact confirmation with the path and metadata. */ +export async function renderArtifactPathInk(input: { + label: string + path: string + meta?: Record +}): Promise { + const { waitUntilExit } = render( + + ✓ {input.label} + {input.path} + {input.meta + ? Object.entries(input.meta).map(([key, value]) => ( + + {key}: {String(value)} + + )) + : null} + , + ) + await waitUntilExit() +} diff --git a/packages/ai-cli/src/render/lazy.ts b/packages/ai-cli/src/render/lazy.ts new file mode 100644 index 000000000..f39ca01a7 --- /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(): Promise { + const { runMenuInk } = await import('./menu') + return runMenuInk() +} + +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..7c7972ecd --- /dev/null +++ b/packages/ai-cli/src/render/menu.tsx @@ -0,0 +1,188 @@ +import { useEffect, useState } from 'react' +import { Box, Text, render, useApp, useInput } from 'ink' + +/** 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 = [ + { + command: 'chat', + label: 'Chat', + hint: 'Interactive agentic chat', + needsPrompt: false, + }, + { + command: 'image', + label: 'Image', + hint: 'Generate an image', + needsPrompt: true, + }, + { + command: 'video', + label: 'Video', + hint: 'Generate a video', + needsPrompt: true, + }, + { + command: 'audio', + label: 'Audio', + hint: 'Generate music / sfx', + needsPrompt: true, + }, + { + command: 'speech', + label: 'Speech', + hint: 'Text to speech', + needsPrompt: true, + }, + { + command: 'summarize', + label: 'Summarize', + hint: 'Summarize text', + needsPrompt: true, + }, + { + command: 'transcribe', + label: 'Transcribe', + hint: 'Audio file to text', + needsPrompt: true, + }, + { command: 'quit', label: 'Quit', hint: 'Exit', needsPrompt: false }, +] + +const TITLE = 'TANSTACK AI' +const GRADIENT = [ + 'cyan', + 'cyanBright', + 'blueBright', + 'magenta', + 'magentaBright', + 'blueBright', +] + +/** Animated wordmark: a gradient sweeps across the letters. */ +function Title() { + const [tick, setTick] = useState(0) + useEffect(() => { + const id = setInterval(() => setTick((t) => t + 1), 90) + return () => clearInterval(id) + }, []) + return ( + + + {TITLE.split('').map((ch, i) => ( + + {ch} + + ))} + + + ) +} + +function Menu({ onChoose }: { onChoose: (choice: MenuChoice) => void }) { + const { exit } = useApp() + const [index, setIndex] = useState(0) + const [promptFor, setPromptFor] = useState(null) + const [draft, setDraft] = useState('') + + useInput((input, key) => { + if (promptFor) { + if (key.return) { + onChoose({ command: promptFor.command, prompt: draft }) + exit() + return + } + if (key.escape) { + setPromptFor(null) + setDraft('') + return + } + if (key.backspace || key.delete) { + setDraft((d) => d.slice(0, -1)) + return + } + if (input && !key.ctrl && !key.meta) setDraft((d) => d + input) + return + } + + if (key.upArrow) setIndex((i) => (i - 1 + ITEMS.length) % ITEMS.length) + else if (key.downArrow) setIndex((i) => (i + 1) % ITEMS.length) + else if (key.return) { + const item = ITEMS[index] + if (!item) return + if (item.command === 'quit') { + onChoose({ command: 'quit' }) + exit() + } else if (item.needsPrompt) { + setPromptFor(item) + } else { + onChoose({ command: item.command }) + exit() + } + } else if (key.escape) { + onChoose({ command: 'quit' }) + exit() + } + }) + + if (promptFor) { + return ( + + + <Text> + {promptFor.label}: <Text color="cyan">{draft}</Text> + <Text color="gray">▌</Text> + </Text> + <Text dimColor>Enter to run · Esc to go back</Text> + </Box> + ) + } + + return ( + <Box flexDirection="column"> + <Title /> + <Text dimColor>What do you want to do?</Text> + <Box flexDirection="column" marginTop={1}> + {ITEMS.map((item, i) => ( + <Text key={item.command} color={i === index ? 'cyan' : undefined}> + {i === index ? '❯ ' : ' '} + <Text bold={i === index}>{item.label}</Text> + <Text dimColor> — {item.hint}</Text> + </Text> + ))} + </Box> + <Box marginTop={1}> + <Text dimColor>↑/↓ to move · Enter to select · Esc to quit</Text> + </Box> + </Box> + ) +} + +/** Render the home screen and resolve the user's choice. */ +export function runMenuInk(): Promise<MenuChoice> { + return new Promise((resolve) => { + let choice: MenuChoice = { command: 'quit' } + const { waitUntilExit } = render( + <Menu + onChoose={(c) => { + choice = c + }} + />, + ) + void waitUntilExit().then(() => resolve(choice)) + }) +} diff --git a/packages/ai-cli/src/render/repl.tsx b/packages/ai-cli/src/render/repl.tsx new file mode 100644 index 000000000..5e42c8fcc --- /dev/null +++ b/packages/ai-cli/src/render/repl.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react' +import { Box, Text, render, useApp, useInput } from 'ink' + +export interface ReplMessage { + role: 'user' | 'assistant' + content: string +} + +/** + * Interactive chat REPL. Stateless across turns from the CLI's perspective — + * the full message list is handed to `respond` each turn. `respond` returns the + * assistant's reply text. `/exit` quits, `/clear` resets the conversation. + */ +function Repl({ + model, + respond, +}: { + model: string + respond: (messages: Array<ReplMessage>) => Promise<string> +}) { + const { exit } = useApp() + const [messages, setMessages] = useState<Array<ReplMessage>>([]) + const [draft, setDraft] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState<string | null>(null) + + useInput((input, key) => { + if (busy) return + if (key.return) { + const text = draft.trim() + setDraft('') + if (!text) return + if (text === '/exit' || text === '/quit') { + exit() + return + } + if (text === '/clear') { + setMessages([]) + setError(null) + return + } + const next = [...messages, { role: 'user' as const, content: text }] + setMessages(next) + setBusy(true) + setError(null) + respond(next) + .then((reply) => { + setMessages((m) => [...m, { role: 'assistant', content: reply }]) + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : String(err)) + }) + .finally(() => setBusy(false)) + return + } + if (key.escape) { + exit() + return + } + if (key.backspace || key.delete) { + setDraft((d) => d.slice(0, -1)) + return + } + if (input && !key.ctrl && !key.meta) setDraft((d) => d + input) + }) + + return ( + <Box flexDirection="column"> + <Text dimColor>chat · {model} · /clear to reset · /exit to quit</Text> + <Box flexDirection="column" marginTop={1}> + {messages.map((m, i) => ( + <Box key={i} marginBottom={1} flexDirection="column"> + <Text bold color={m.role === 'user' ? 'cyan' : 'green'}> + {m.role === 'user' ? 'you' : 'ai'} + </Text> + <Text>{m.content}</Text> + </Box> + ))} + </Box> + {error ? <Text color="red">error: {error}</Text> : null} + <Box> + {busy ? ( + <Text color="yellow">…thinking</Text> + ) : ( + <Text> + <Text color="cyan">❯ </Text> + {draft} + <Text color="gray">▌</Text> + </Text> + )} + </Box> + </Box> + ) +} + +/** Render the REPL and resolve when the user exits. */ +export async function runChatReplInk(input: { + model: string + respond: (messages: Array<ReplMessage>) => Promise<string> +}): Promise<void> { + const { waitUntilExit } = render( + <Repl model={input.model} respond={input.respond} />, + ) + await waitUntilExit() +} diff --git a/packages/ai-cli/tests/core.test.ts b/packages/ai-cli/tests/core.test.ts new file mode 100644 index 000000000..af50f83a4 --- /dev/null +++ b/packages/ai-cli/tests/core.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from 'vitest' +import { + bundledProviders, + factoryCandidatesForProvider, + resolveApiKey, + resolveModelSlug, +} from '../src/core/providers' +import { mergeOptions } from '../src/core/config' +import { resolveOutputMode } from '../src/core/output' +import { coerceFlags } from '../src/cli/options' +import { CliError, ExitCode, toCliError } from '../src/core/exit-codes' +import { inferMimeType, resolvePrompt } from '../src/core/io' +import { tokenizeCommand } from '../src/cli/mcp-clients' +import { findCommand } from '../src/manifest/manifest' +import type { CommandSpec } from '../src/manifest/types' + +describe('resolveModelSlug', () => { + it('parses a provider/model slug', () => { + const r = resolveModelSlug('openai/gpt-5.5') + expect(r.provider).toBe('openai') + expect(r.model).toBe('gpt-5.5') + }) + + it('infers provider for a known bare model', () => { + expect(resolveModelSlug('gpt-5.5').provider).toBe('openai') + }) + + it('rejects an unknown provider', () => { + expect(() => resolveModelSlug('bogus/x')).toThrowError(CliError) + }) + + it('rejects a bare model it cannot infer', () => { + expect(() => resolveModelSlug('mystery-model')).toThrowError( + /provider\/model/, + ) + }) + + it('rejects a slug with an empty model', () => { + expect(() => resolveModelSlug('openai/')).toThrowError(CliError) + }) + + it('splits only on the first slash (multi-segment model ids)', () => { + const or = resolveModelSlug('openrouter/openai/gpt-oss-120b') + expect(or.provider).toBe('openrouter') + expect(or.model).toBe('openai/gpt-oss-120b') + const fal = resolveModelSlug('fal/fal-ai/ltx-video') + expect(fal.provider).toBe('fal') + expect(fal.model).toBe('fal-ai/ltx-video') + }) +}) + +describe('factoryCandidatesForProvider (pure factory-name derivation)', () => { + it('tries create<Prefix>Chat first for chat', () => { + expect(factoryCandidatesForProvider('openai', 'chat')[0]).toBe( + 'createOpenaiChat', + ) + }) + + it('uses the OpenRouter *Text factory with correct casing (regression)', () => { + // OpenRouter exports createOpenRouterText (capital R, "Text" not "Chat"). + const candidates = factoryCandidatesForProvider('openrouter', 'chat') + expect(candidates).toContain('createOpenRouterText') + expect(candidates).not.toContain('createOpenrouterChat') + }) + + it('tries createFalImage then falImage for fal (alt prefix regression)', () => { + expect(factoryCandidatesForProvider('fal', 'image')).toEqual([ + 'createFalImage', + 'falImage', + ]) + }) + + it('returns no candidates for an unknown provider', () => { + expect(factoryCandidatesForProvider('nope', 'chat')).toEqual([]) + }) +}) + +describe('resolveApiKey', () => { + const { entry } = resolveModelSlug('openai/gpt-5.5') + + it('prefers the explicit key', () => { + expect(resolveApiKey(entry, 'openai', 'sk-explicit', {})).toBe( + 'sk-explicit', + ) + }) + + it('falls back to the conventional env var', () => { + expect( + resolveApiKey(entry, 'openai', undefined, { OPENAI_API_KEY: 'sk-env' }), + ).toBe('sk-env') + }) + + it('throws a USAGE error when no key is available', () => { + try { + resolveApiKey(entry, 'openai', undefined, {}) + expect.unreachable('should have thrown') + } catch (err) { + expect(err).toBeInstanceOf(CliError) + expect((err as CliError).exitCode).toBe(ExitCode.Usage) + } + }) +}) + +describe('bundledProviders', () => { + it('lists exactly the five zero-install providers', () => { + expect(bundledProviders().sort()).toEqual( + ['anthropic', 'fal', 'gemini', 'openai', 'openrouter'].sort(), + ) + }) +}) + +describe('mergeOptions', () => { + it('lets defined flags win over config but keeps config for undefined flags', () => { + const merged = mergeOptions( + { model: 'openai/gpt-5.5', size: undefined }, + { model: 'anthropic/x', size: '1024x1024', extra: true }, + ) + expect(merged).toEqual({ + model: 'openai/gpt-5.5', + size: '1024x1024', + extra: true, + }) + }) +}) + +describe('resolveOutputMode', () => { + it('returns json when --json is set', () => { + expect(resolveOutputMode({ json: true })).toBe('json') + }) + it('returns stream when --stream is set', () => { + expect(resolveOutputMode({ stream: true })).toBe('stream') + }) + it('prefers stream when both are set', () => { + expect(resolveOutputMode({ json: true, stream: true })).toBe('stream') + }) + it('is pretty on a TTY and json otherwise', () => { + expect(resolveOutputMode({ isTTY: true })).toBe('pretty') + expect(resolveOutputMode({ isTTY: false })).toBe('json') + }) +}) + +describe('coerceFlags', () => { + const spec = findCommand('image') as CommandSpec + + it('coerces numbers and leaves config/strings alone', () => { + const out = coerceFlags(spec, { + count: '3', + model: 'openai/gpt-image-1', + config: '{"a":1}', + }) + expect(out.count).toBe(3) + expect(out.model).toBe('openai/gpt-image-1') + expect(out.config).toBe('{"a":1}') + }) + + it('throws on a non-numeric number flag', () => { + expect(() => coerceFlags(spec, { count: 'abc' })).toThrowError( + /must be a number/, + ) + }) + + it('parses json flags from the chat command', () => { + const chatSpec = findCommand('chat') as CommandSpec + const out = coerceFlags(chatSpec, { + messages: '[{"role":"user","content":"hi"}]', + }) + expect(Array.isArray(out.messages)).toBe(true) + }) +}) + +describe('exit codes', () => { + it('maps error codes to exit codes', () => { + expect(new CliError('USAGE', 'x').exitCode).toBe(ExitCode.Usage) + expect(new CliError('PROVIDER_NOT_INSTALLED', 'x').exitCode).toBe( + ExitCode.ProviderNotInstalled, + ) + expect(new CliError('PROVIDER', 'x').exitCode).toBe(ExitCode.Provider) + }) + + it('coerces unknown throws into a runtime CliError', () => { + const e = toCliError(new Error('boom')) + expect(e).toBeInstanceOf(CliError) + expect(e.code).toBe('RUNTIME') + }) + + it('serializes a structured error object', () => { + const obj = new CliError('PROVIDER', 'nope', { + provider: 'openai', + }).toErrorObject() + expect(obj).toMatchObject({ + code: 'PROVIDER', + message: 'nope', + provider: 'openai', + }) + }) +}) + +describe('io helpers', () => { + it('infers mime types from extensions', () => { + expect(inferMimeType('a.png')).toBe('image/png') + expect(inferMimeType('a.mp3')).toBe('audio/mpeg') + expect(inferMimeType('a.unknown')).toBe('application/octet-stream') + }) + + it('uses positional args as the prompt without touching stdin', async () => { + // No stdin read happens because positional args are present. + expect(await resolvePrompt(['hello', 'world'])).toBe('hello world') + }) + + it('rejects a stale find for aliases', () => { + expect(findCommand('tts')?.name).toBe('speech') + expect(findCommand('stt')?.name).toBe('transcribe') + }) +}) + +describe('tokenizeCommand (--mcp stdio spec)', () => { + it('splits a simple command on whitespace', () => { + expect(tokenizeCommand('npx -y @scope/server /tmp')).toEqual([ + 'npx', + '-y', + '@scope/server', + '/tmp', + ]) + }) + + it('keeps quoted paths with spaces intact', () => { + expect(tokenizeCommand('node "C:\\Program Files\\srv.js" --flag')).toEqual([ + 'node', + 'C:\\Program Files\\srv.js', + '--flag', + ]) + expect(tokenizeCommand("node '/opt/my srv/x.js'")).toEqual([ + 'node', + '/opt/my srv/x.js', + ]) + }) + + it('collapses runs of whitespace', () => { + expect(tokenizeCommand(' node server.js ')).toEqual([ + 'node', + 'server.js', + ]) + }) +}) diff --git a/packages/ai-cli/tsconfig.json b/packages/ai-cli/tsconfig.json new file mode 100644 index 000000000..773e16853 --- /dev/null +++ b/packages/ai-cli/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "jsx": "react-jsx", + "types": ["node"] + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-cli/tsup.bin.config.ts b/packages/ai-cli/tsup.bin.config.ts new file mode 100644 index 000000000..8461835e6 --- /dev/null +++ b/packages/ai-cli/tsup.bin.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsup' + +/** + * The `ts-ai` executable. Our own source is bundled into a single `bin.js`; + * every package.json dependency (Ink, React, the MCP SDK, and all provider + * adapters) stays external and is resolved from node_modules at runtime. The + * provider packages are loaded via dynamic `import()` on demand, so they are + * intentionally kept external here too. + */ +export default defineConfig({ + entry: { bin: 'src/cli/bin.ts' }, + outDir: 'dist/bin', + format: ['esm'], + platform: 'node', + target: 'node18', + // tsup externalizes package.json deps by default; nothing extra to inline. + banner: { js: '#!/usr/bin/env node' }, +}) diff --git a/packages/ai-cli/vite.config.ts b/packages/ai-cli/vite.config.ts new file mode 100644 index 000000000..7c1cbf317 --- /dev/null +++ b/packages/ai-cli/vite.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + // The bin entry, Ink render layer, and pure type modules are exercised by + // the testing/cli subprocess suite, not unit-covered here. + exclude: [ + 'src/cli/**', + 'src/render/**', + '**/*.test.ts', + 'src/**/types.ts', + ], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab84c8703..446465aee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1035,6 +1035,73 @@ importers: specifier: ^4.2.0 version: 4.2.1 + packages/ai-cli: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.3.6) + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@tanstack/ai-anthropic': + specifier: workspace:* + version: link:../ai-anthropic + '@tanstack/ai-code-mode': + specifier: workspace:* + version: link:../ai-code-mode + '@tanstack/ai-fal': + specifier: workspace:* + version: link:../ai-fal + '@tanstack/ai-gemini': + specifier: workspace:* + version: link:../ai-gemini + '@tanstack/ai-isolate-node': + specifier: workspace:* + version: link:../ai-isolate-node + '@tanstack/ai-mcp': + specifier: workspace:* + version: link:../ai-mcp + '@tanstack/ai-openai': + specifier: workspace:* + version: link:../ai-openai + '@tanstack/ai-openrouter': + specifier: workspace:* + version: link:../ai-openrouter + commander: + specifier: ^13.1.0 + version: 13.1.0 + ink: + specifier: ^7.0.5 + version: 7.0.5(@types/react@19.2.7)(react-devtools-core@6.1.5)(react@19.2.3) + react: + specifier: ^19.2.3 + version: 19.2.3 + react-devtools-core: + specifier: ^6.1.5 + version: 6.1.5 + terminal-image: + specifier: ^4.3.0 + version: 4.3.0 + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.10.3 + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.15))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + tsup: + specifier: ^8.5.1 + version: 8.5.1(@microsoft/api-extractor@7.47.7(@types/node@24.10.3))(jiti@2.6.1)(postcss@8.5.15)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + vite: + specifier: ^7.3.3 + version: 7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + zod: + specifier: ^4.2.0 + version: 4.3.6 + packages/ai-client: dependencies: '@tanstack/ai': @@ -1276,7 +1343,7 @@ importers: version: link:../openai-base openai: specifier: ^6.41.0 - version: 6.41.0(ws@8.19.0)(zod@4.3.6) + version: 6.41.0(ws@8.21.0)(zod@4.3.6) zod: specifier: ^4.0.0 version: 4.3.6 @@ -1307,7 +1374,7 @@ importers: version: link:../openai-base openai: specifier: ^6.41.0 - version: 6.41.0(ws@8.19.0)(zod@4.3.6) + version: 6.41.0(ws@8.21.0)(zod@4.3.6) zod: specifier: ^4.0.0 version: 4.3.6 @@ -1418,7 +1485,7 @@ importers: version: link:../openai-base openai: specifier: ^6.41.0 - version: 6.41.0(ws@8.19.0)(zod@4.3.6) + version: 6.41.0(ws@8.21.0)(zod@4.3.6) devDependencies: '@tanstack/ai': specifier: workspace:* @@ -1755,7 +1822,7 @@ importers: version: link:../ai-utils openai: specifier: ^6.41.0 - version: 6.41.0(ws@8.19.0)(zod@4.3.6) + version: 6.41.0(ws@8.21.0)(zod@4.3.6) devDependencies: '@tanstack/ai': specifier: workspace:* @@ -1833,6 +1900,18 @@ importers: specifier: ^2.11.10 version: 2.11.10(solid-js@1.9.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + testing/cli: + devDependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.3.6) + '@tanstack/ai-cli': + specifier: workspace:* + version: link:../../packages/ai-cli + vitest: + specifier: ^4.0.14 + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.15))(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + testing/e2e: dependencies: '@copilotkit/aimock': @@ -2117,6 +2196,10 @@ packages: '@ag-ui/core@0.0.52': resolution: {integrity: sha512-Xo0bUaNV56EqylzcrAuhUkQX7et7+SZIrqZZtEByGwEq/I1EHny6ZMkWHLkKR7UNi0FJZwJyhKYmKJS3B2SEgA==} + '@alcalzone/ansi-tokenize@0.3.0': + resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} + engines: {node: '>=18'} + '@anthropic-ai/sdk@0.97.1': resolution: {integrity: sha512-wOf7AUeJPitcVpvKO4UMu63mWH5SaVipkGd7OOQJt/G6VYGlV8D2Gp9dLxOrttDJh/9gqPqdaBwDGcBevumeAg==} hasBin: true @@ -2666,6 +2749,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@bufbuild/protobuf@1.10.1': resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==} @@ -4132,6 +4218,118 @@ packages: resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jimp/core@1.6.1': + resolution: {integrity: sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A==} + engines: {node: '>=18'} + + '@jimp/diff@1.6.1': + resolution: {integrity: sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ==} + engines: {node: '>=18'} + + '@jimp/file-ops@1.6.1': + resolution: {integrity: sha512-T+gX6osHjprbDRad0/B71Evyre7ZdVY1z/gFGEG9Z8KOtZPKboWvPeP2UjbZYWQLy9UKCPQX1FNAnDiOPkJL7w==} + engines: {node: '>=18'} + + '@jimp/js-bmp@1.6.1': + resolution: {integrity: sha512-xzWzNT4/u5zGrTT3Tme9sGU7YzIKxi13+BCQwLqACbt5DXf9SAfdzRkopZQnmDko+6In5nqaT89Gjs43/WdnYQ==} + engines: {node: '>=18'} + + '@jimp/js-gif@1.6.1': + resolution: {integrity: sha512-YjY2W26rQa05XhanYhRZ7dingCiNN+T2Ymb1JiigIbABY0B28wHE3v3Cf1/HZPWGu0hOg36ylaKgV5KxF2M58w==} + engines: {node: '>=18'} + + '@jimp/js-jpeg@1.6.1': + resolution: {integrity: sha512-HT9H3yOmlOFzYmdI15IYdfy6ggQhSRIaHeA+OTJSEORXBqEo97sUZu/DsgHIcX5NJ7TkJBTgZ9BZXsV6UbsyMg==} + engines: {node: '>=18'} + + '@jimp/js-png@1.6.1': + resolution: {integrity: sha512-SZ/KVhI5UjcSzzlXsXdIi/LhJ7UShf2NkMOtVrbZQcGzsqNtynAelrOXeoTxcanfVqmNhAoVHg8yR2cYoqrYjA==} + engines: {node: '>=18'} + + '@jimp/js-tiff@1.6.1': + resolution: {integrity: sha512-jDG/eJquID1M4MBlKMmDRBmz2TpXMv7TUyu2nIRUxhlUc2ogC82T+VQUkca9GJH1BBJ9dx5sSE5dGkWNjIbZxw==} + engines: {node: '>=18'} + + '@jimp/plugin-blit@1.6.1': + resolution: {integrity: sha512-MwnI7C7K81uWddY9FLw1fCOIy6SsPIUftUz36Spt7jisCn8/40DhQMlSxpxTNelnZb/2SnloFimQfRZAmHLOqQ==} + engines: {node: '>=18'} + + '@jimp/plugin-blur@1.6.1': + resolution: {integrity: sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ==} + engines: {node: '>=18'} + + '@jimp/plugin-circle@1.6.1': + resolution: {integrity: sha512-kK1PavY6cKHNNKce37vdV4Tmpc1/zDKngGoeOV3j+EMatoHFZUinV3s6F9aWryPs3A0xhCLZgdJ6Zeea1d5LCQ==} + engines: {node: '>=18'} + + '@jimp/plugin-color@1.6.1': + resolution: {integrity: sha512-LtUN1vAP+LRlZAtTNVhDRSiXx+26Kbz3zJaG6a5k59gQ95jgT5mknnF8lxkHcqJthM4MEk3/tPxkdJpEybyF/A==} + engines: {node: '>=18'} + + '@jimp/plugin-contain@1.6.1': + resolution: {integrity: sha512-m0qhrfA8jkTqretGv4w+T/ADFR4GwBpE0sCOC2uJ0dzr44/ddOMsIdrpi89kabqYiPYIrxkgdCVCLm3zn1Vkkg==} + engines: {node: '>=18'} + + '@jimp/plugin-cover@1.6.1': + resolution: {integrity: sha512-hZytnsth0zoll6cPf434BrT+p/v569Wr5tyO6Dp0dH1IDPhzhB5F38sZGMLDo7bzQiN9JFVB3fxkcJ/WYCJ3Mg==} + engines: {node: '>=18'} + + '@jimp/plugin-crop@1.6.1': + resolution: {integrity: sha512-EerRSLlclXyKDnYc/H9w/1amZW7b7v3OGi/VlerPd2M/pAu5X8TkyYWtfqYCXnNp1Ixtd8oCo9zGfY9zoXT4rg==} + engines: {node: '>=18'} + + '@jimp/plugin-displace@1.6.1': + resolution: {integrity: sha512-K07QVl7xQwIfD6KfxRV/c3E9e7ZBXxUXdWuvoTWcKHL2qV48MOF5Nqbz/aJW4ThnQARIsxvYlZjPFiqkCjlU+g==} + engines: {node: '>=18'} + + '@jimp/plugin-dither@1.6.1': + resolution: {integrity: sha512-+2V+GCV2WycMoX1/z977TkZ8Zq/4MVSKElHYatgUqtwXMi2fDK2gKYU2g9V39IqFvTJsTIsK0+58VFz/ROBVew==} + engines: {node: '>=18'} + + '@jimp/plugin-fisheye@1.6.1': + resolution: {integrity: sha512-XtS5ZyoZ0vxZxJ6gkqI63SivhtI58vX95foMPM+cyzYkRsJXMOYCr8DScxF5bp4Xr003NjYm/P+7+08tibwzHA==} + engines: {node: '>=18'} + + '@jimp/plugin-flip@1.6.1': + resolution: {integrity: sha512-ws38W/sGj7LobNRayQ83garxiktOyWxM5vO/y4a/2cy9v65SLEUzVkrj+oeAaUSSObdz4HcCEla7XtGlnAGAaA==} + engines: {node: '>=18'} + + '@jimp/plugin-hash@1.6.1': + resolution: {integrity: sha512-sZt6ZcMX6i8vFWb4GYnw0pR/o9++ef0dTVcboTB5B/g7nrxCODIB4wfEkJ/YqZM5wUvol77K1qeS0/rVO6z21A==} + engines: {node: '>=18'} + + '@jimp/plugin-mask@1.6.1': + resolution: {integrity: sha512-SIG0/FcmEj3tkwFxc7fAGLO8o4uNzMpSOdQOhbCgxefQKq5wOVMk9BQx/sdMPBwtMLr9WLq0GzLA/rk6t2v20A==} + engines: {node: '>=18'} + + '@jimp/plugin-print@1.6.1': + resolution: {integrity: sha512-BYVz/X3Xzv8XYilVeDy11NOp0h7BTDjlOtu0BekIFHP1yHVd24AXNzbOy52XlzYZWQ0Dl36HOHEpl/nSNrzc6w==} + engines: {node: '>=18'} + + '@jimp/plugin-quantize@1.6.1': + resolution: {integrity: sha512-J2En9PLURfP+vwYDtuZ9T8yBW6BWYZBScydAjRiPBmJfEhTcNQqiiQODrZf7EqbbX/Sy5H6dAeRiqkgoV9N6Ww==} + engines: {node: '>=18'} + + '@jimp/plugin-resize@1.6.1': + resolution: {integrity: sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg==} + engines: {node: '>=18'} + + '@jimp/plugin-rotate@1.6.1': + resolution: {integrity: sha512-nOjVjbbj705B02ksysKnh0POAwEBXZtJ9zQ5qC+X7Tavl3JNn+P3BzQovbBxLPSbUSld6XID9z5ijin4PtOAUg==} + engines: {node: '>=18'} + + '@jimp/plugin-threshold@1.6.1': + resolution: {integrity: sha512-JOKv9F8s6tnVLf4sB/2fF0F339EFnHvgEdFYugO6VhowKLsap0pEZmLyE/DlRnYtIj2RddHZVxVMp/eKJ04l2Q==} + engines: {node: '>=18'} + + '@jimp/types@1.6.1': + resolution: {integrity: sha512-leI7YbveTNi565m910XgIOwXyuu074H5qazAD1357HImJSv2hqxnWXpwxQbadGWZ7goZRYBDZy5lpqud0p7q5w==} + engines: {node: '>=18'} + + '@jimp/utils@1.6.1': + resolution: {integrity: sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==} + engines: {node: '>=18'} + '@jitl/quickjs-ffi-types@0.31.0': resolution: {integrity: sha512-1yrgvXlmXH2oNj3eFTrkwacGJbmM0crwipA3ohCrjv52gBeDaD7PsTvFYinlAnqU8iPME3LGP437yk05a2oejw==} @@ -7124,6 +7322,13 @@ packages: '@types/react-dom': optional: true + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} @@ -7241,6 +7446,9 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@16.9.1': + resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==} + '@types/node@20.19.26': resolution: {integrity: sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==} @@ -7788,6 +7996,10 @@ packages: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@4.1.1: resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} engines: {node: '>=6'} @@ -7820,6 +8032,9 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} + any-base@1.1.0: + resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -7827,6 +8042,10 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + app-path@4.0.0: + resolution: {integrity: sha512-mgBO9PZJ3MpbKbwFTljTi36ZKBvG5X/fkVR1F85ANsVcVllEb+C0LGNdJfGUm84GpC4xxgN6HFkmkMU8VEO4mA==} + engines: {node: '>=12'} + archiver-utils@5.0.2: resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} engines: {node: '>= 14'} @@ -7903,6 +8122,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + autoprefixer@10.4.22: resolution: {integrity: sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==} engines: {node: ^10 || ^12 || >=14} @@ -7914,6 +8137,10 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + await-to-js@3.0.0: + resolution: {integrity: sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==} + engines: {node: '>=6.0.0'} + axios@1.13.2: resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} @@ -8081,6 +8308,9 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + bmp-ts@1.0.9: + resolution: {integrity: sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==} + body-parser@2.2.1: resolution: {integrity: sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==} engines: {node: '>=18'} @@ -8298,6 +8528,10 @@ packages: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} + cli-boxes@4.0.1: + resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} + engines: {node: '>=18.20 <19 || >=20.10'} + cli-cursor@2.1.0: resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} engines: {node: '>=4'} @@ -8306,10 +8540,22 @@ packages: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + cli-spinners@2.6.1: resolution: {integrity: sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==} engines: {node: '>=6'} + cli-truncate@6.0.0: + resolution: {integrity: sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==} + engines: {node: '>=22'} + clipboardy@4.0.0: resolution: {integrity: sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==} engines: {node: '>=18'} @@ -8334,6 +8580,10 @@ packages: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -8444,6 +8694,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cookie-es@1.2.2: resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} @@ -8905,6 +9159,10 @@ packages: miniflare: optional: true + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -8939,6 +9197,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.47.0: + resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} + esbuild-plugin-solid@0.5.0: resolution: {integrity: sha512-ITK6n+0ayGFeDVUZWNMxX+vLsasEN1ILrg4pISsNOQ+mq4ljlJJiuXotInd+HE0MzwTcA9wExT1yzDE2hsqPsg==} peerDependencies: @@ -8981,6 +9242,10 @@ packages: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -9160,10 +9425,17 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + execa@8.0.1: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} + exif-parser@0.1.12: + resolution: {integrity: sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -9343,6 +9615,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -9530,6 +9806,10 @@ packages: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + get-stream@8.0.1: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} @@ -9545,6 +9825,9 @@ packages: resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} engines: {node: '>=6'} + gifwrap@0.10.1: + resolution: {integrity: sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==} + giget@2.0.0: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true @@ -9814,6 +10097,10 @@ packages: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -9837,6 +10124,14 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + image-dimensions@2.5.1: + resolution: {integrity: sha512-It7CkTNp7IYA8jhvGgz8K18OAbHF3/Juk8RNEyTyMFfolJOIU1Y2wGDnzDQPbGCjyxVF/++pwIZE0BKJUEOb1g==} + engines: {node: '>=18'} + hasBin: true + + image-q@4.0.0: + resolution: {integrity: sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==} + image-size@1.2.1: resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==} engines: {node: '>=16.x'} @@ -9861,12 +10156,29 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ink@7.0.5: + resolution: {integrity: sha512-zWNjGHQPxSeiSAmDUOq+QPQ6CfmMhmNi85vrJIuy4prafKKUSoZlXEy4wbM7LuLuF1pDURk7qvF4fxrQlLxv3w==} + engines: {node: '>=22'} + peerDependencies: + '@types/react': '>=19.2.0' + react: '>=19.2.0' + react-devtools-core: '>=6.1.2' + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + inline-style-parser@0.1.1: resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==} @@ -9892,10 +10204,6 @@ packages: resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} engines: {node: '>=12.22.0'} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} - engines: {node: '>= 12'} - ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -9969,6 +10277,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -9980,6 +10292,11 @@ packages: resolution: {integrity: sha512-S+OpgB5i7wzIue/YSE5hg0e5ZYfG3hhpNh9KGl6ayJ38p7ED6wxQLd1TV91xHpcTvw90KMJ9EwN3F/iNflHBVg==} engines: {node: '>=8'} + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -10133,6 +10450,10 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + iterm2-version@5.0.0: + resolution: {integrity: sha512-WdLXcMYvN3SXT6vEtuW78vnZs4pVWm2nBnb4VKjOPPXmdlR1xTHmBgqKacOzAe4RXOiY/V+0u/0zsU3LoGQoBg==} + engines: {node: '>=12'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -10159,6 +10480,10 @@ packages: jimp-compact@0.16.1: resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==} + jimp@1.6.1: + resolution: {integrity: sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==} + engines: {node: '>=18'} + jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true @@ -10177,6 +10502,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + js-beautify@1.15.4: resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} engines: {node: '>=14'} @@ -10495,6 +10823,10 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + log-update@8.0.0: + resolution: {integrity: sha512-lddSgOt3bPASrylL54ZSpy8nBHns+vBVSoILlVOx+dei300pnLRN958rj/EdlVLKuWlSESU3qdnDZdAI7FXYGg==} + engines: {node: '>=22'} + loglevel@1.9.2: resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} engines: {node: '>= 0.6.0'} @@ -10883,6 +11215,10 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + miniflare@4.20260504.0: resolution: {integrity: sha512-HeI/HLx+rbeo/UB4qb6NsNcFdUVD7xDzyCexZJTVtFMlfpfexUKEDmdeTRRpzeHrJseZFGua+v9JO1kfPublUw==} engines: {node: '>=22.0.0'} @@ -11238,6 +11574,9 @@ packages: ollama@0.6.3: resolution: {integrity: sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg==} + omggif@1.0.10: + resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} + on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} engines: {node: '>= 0.8'} @@ -11265,6 +11604,10 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + open@7.4.2: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} @@ -11363,10 +11706,22 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-bmfont-ascii@1.0.6: + resolution: {integrity: sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==} + + parse-bmfont-binary@1.0.6: + resolution: {integrity: sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==} + + parse-bmfont-xml@1.1.6: + resolution: {integrity: sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -11397,6 +11752,10 @@ packages: partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -11476,6 +11835,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pixelmatch@5.3.0: + resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==} + hasBin: true + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -11508,6 +11871,14 @@ packages: resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} engines: {node: '>=4.0.0'} + pngjs@6.0.0: + resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} + engines: {node: '>=12.13.0'} + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -11762,6 +12133,12 @@ packages: '@types/react': optional: true + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} @@ -11971,6 +12348,14 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -12287,6 +12672,10 @@ packages: simple-plist@1.3.1: resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} + simple-xml-to-json@1.2.7: + resolution: {integrity: sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==} + engines: {node: '>=20.12.2'} + sirv@3.0.2: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} @@ -12302,6 +12691,10 @@ packages: resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} engines: {node: '>=14.16'} + slice-ansi@9.0.0: + resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} + engines: {node: '>=22'} + slugify@1.6.9: resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} engines: {node: '>=8.0.0'} @@ -12394,6 +12787,10 @@ packages: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -12451,6 +12848,10 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + engines: {node: '>=20'} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -12472,10 +12873,18 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + strip-final-newline@3.0.0: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} @@ -12491,6 +12900,10 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + structured-headers@0.4.1: resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} @@ -12532,6 +12945,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + supports-terminal-graphics@0.1.0: + resolution: {integrity: sha512-+KdfozhS0Fw8y5Sghw8kkZNGT8nWYzJ1EzcoIvVjxhl+26TJTs26y02yfBgvc1jh5AS/c8jcI3xtahhR95KRyQ==} + engines: {node: '>=20'} + svelte-check@4.3.4: resolution: {integrity: sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw==} engines: {node: '>= 18.0.0'} @@ -12589,14 +13006,26 @@ packages: teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + term-img@7.1.0: + resolution: {integrity: sha512-au++khgSDly2KXNhC6BOU3mLi2v+Dk5mChYKDcpB5xYwhlwqYQtj0z59dIqFEmr+w7ndZaNqurHapkGc6/hprQ==} + engines: {node: '>=18'} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} + terminal-image@4.3.0: + resolution: {integrity: sha512-P4bCi7Ich17LgN/P2zM9sYbeleYNgu1Skk3q/uc15Ol9zWSiCldrCcIJ1Pd6dpgRGDmWhGVTgWHjV+ZMk1m/5g==} + engines: {node: '>=20'} + terminal-link@2.1.1: resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} engines: {node: '>=8'} + terminal-size@4.0.1: + resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} + engines: {node: '>=18'} + terser@5.44.1: resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==} engines: {node: '>=10'} @@ -12624,6 +13053,9 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -12673,6 +13105,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + toqr@0.1.1: resolution: {integrity: sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==} @@ -12822,6 +13258,10 @@ packages: resolution: {integrity: sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg==} engines: {node: '>=20'} + type-fest@5.7.0: + resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} + engines: {node: '>=20'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -12876,6 +13316,10 @@ packages: ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + ultrahtml@1.6.0: resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} @@ -13270,6 +13714,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utif2@4.1.0: + resolution: {integrity: sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -13708,6 +14155,10 @@ packages: resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} engines: {node: '>=12'} + widest-line@6.0.0: + resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} + engines: {node: '>=20'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -13727,6 +14178,10 @@ packages: '@cloudflare/workers-types': optional: true + wrap-ansi@10.0.0: + resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} + engines: {node: '>=20'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -13790,6 +14245,18 @@ packages: utf-8-validate: optional: true + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xcode@3.0.1: resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} engines: {node: '>=10.0.0'} @@ -13798,6 +14265,13 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml-parse-from-string@1.0.1: + resolution: {integrity: sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==} + + xml2js@0.5.0: + resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} + engines: {node: '>=4.0.0'} + xml2js@0.6.0: resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} engines: {node: '>=4.0.0'} @@ -13855,6 +14329,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} @@ -13896,7 +14373,12 @@ snapshots: dependencies: zod: 3.25.76 - '@anthropic-ai/sdk@0.97.1(zod@4.2.1)': + '@alcalzone/ansi-tokenize@0.3.0': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + '@anthropic-ai/sdk@0.97.1(zod@4.2.1)': dependencies: json-schema-to-ts: 3.1.1 standardwebhooks: 1.0.0 @@ -14007,7 +14489,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-annotate-as-pure@7.29.7': dependencies: @@ -14093,7 +14575,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.28.5': dependencies: '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -14106,7 +14588,7 @@ snapshots: '@babel/helper-module-imports@7.18.6': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-module-imports@7.27.1': dependencies: @@ -14133,7 +14615,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-optimise-call-expression@7.29.7': dependencies: @@ -14547,8 +15029,8 @@ snapshots: '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@babel/template@7.29.7': dependencies: @@ -14609,6 +15091,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@borewit/text-codec@0.2.2': {} + '@bufbuild/protobuf@1.10.1': {} '@changesets/apply-release-plan@7.1.0': @@ -15935,6 +16419,229 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 + '@jimp/core@1.6.1': + dependencies: + '@jimp/file-ops': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + await-to-js: 3.0.0 + exif-parser: 0.1.12 + file-type: 21.3.4 + mime: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@jimp/diff@1.6.1': + dependencies: + '@jimp/plugin-resize': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + pixelmatch: 5.3.0 + transitivePeerDependencies: + - supports-color + + '@jimp/file-ops@1.6.1': {} + + '@jimp/js-bmp@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + bmp-ts: 1.0.9 + transitivePeerDependencies: + - supports-color + + '@jimp/js-gif@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + gifwrap: 0.10.1 + omggif: 1.0.10 + transitivePeerDependencies: + - supports-color + + '@jimp/js-jpeg@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + jpeg-js: 0.4.4 + transitivePeerDependencies: + - supports-color + + '@jimp/js-png@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + pngjs: 7.0.0 + transitivePeerDependencies: + - supports-color + + '@jimp/js-tiff@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + utif2: 4.1.0 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-blit@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + + '@jimp/plugin-blur@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/utils': 1.6.1 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-circle@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + zod: 3.25.76 + + '@jimp/plugin-color@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + tinycolor2: 1.6.0 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-contain@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/plugin-blit': 1.6.1 + '@jimp/plugin-resize': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-cover@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/plugin-crop': 1.6.1 + '@jimp/plugin-resize': 1.6.1 + '@jimp/types': 1.6.1 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-crop@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-displace@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + + '@jimp/plugin-dither@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + + '@jimp/plugin-fisheye@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + + '@jimp/plugin-flip@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + zod: 3.25.76 + + '@jimp/plugin-hash@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/js-bmp': 1.6.1 + '@jimp/js-jpeg': 1.6.1 + '@jimp/js-png': 1.6.1 + '@jimp/js-tiff': 1.6.1 + '@jimp/plugin-color': 1.6.1 + '@jimp/plugin-resize': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + any-base: 1.1.0 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-mask@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + zod: 3.25.76 + + '@jimp/plugin-print@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/js-jpeg': 1.6.1 + '@jimp/js-png': 1.6.1 + '@jimp/plugin-blit': 1.6.1 + '@jimp/types': 1.6.1 + parse-bmfont-ascii: 1.0.6 + parse-bmfont-binary: 1.0.6 + parse-bmfont-xml: 1.1.6 + simple-xml-to-json: 1.2.7 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-quantize@1.6.1': + dependencies: + image-q: 4.0.0 + zod: 3.25.76 + + '@jimp/plugin-resize@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-rotate@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/plugin-crop': 1.6.1 + '@jimp/plugin-resize': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-threshold@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/plugin-color': 1.6.1 + '@jimp/plugin-hash': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/types@1.6.1': + dependencies: + zod: 3.25.76 + + '@jimp/utils@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + tinycolor2: 1.6.0 + '@jitl/quickjs-ffi-types@0.31.0': {} '@jitl/quickjs-wasmfile-debug-asyncify@0.31.0': @@ -18202,7 +18909,7 @@ snapshots: '@babel/code-frame': 7.27.1 '@babel/core': 7.28.5 '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@tanstack/router-utils': 1.131.2 babel-dead-code-elimination: 1.0.10 tiny-invariant: 1.3.3 @@ -18215,7 +18922,7 @@ snapshots: '@babel/code-frame': 7.27.1 '@babel/core': 7.28.5 '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@tanstack/router-utils': 1.141.0 babel-dead-code-elimination: 1.0.10 pathe: 2.0.3 @@ -18663,7 +19370,7 @@ snapshots: '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@tanstack/router-core': 1.131.50 '@tanstack/router-generator': 1.131.50 '@tanstack/router-utils': 1.131.2 @@ -18738,7 +19445,7 @@ snapshots: dependencies: '@babel/core': 7.28.5 '@babel/generator': 7.29.1 - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.7 '@babel/preset-typescript': 7.28.5(@babel/core@7.28.5) ansis: 4.2.0 diff: 8.0.2 @@ -19048,7 +19755,7 @@ snapshots: dependencies: '@babel/code-frame': 7.26.2 '@babel/core': 7.28.5 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@tanstack/router-core': 1.131.50 '@tanstack/router-generator': 1.131.50 '@tanstack/router-plugin': 1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -19432,6 +20139,15 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@tootallnate/quickjs-emscripten@0.23.0': {} '@tybys/wasm-util@0.10.1': @@ -19554,6 +20270,8 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@16.9.1': {} + '@types/node@20.19.26': dependencies: undici-types: 6.21.0 @@ -20259,6 +20977,10 @@ snapshots: dependencies: type-fest: 0.21.3 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@4.1.1: {} ansi-regex@5.0.1: {} @@ -20279,6 +21001,8 @@ snapshots: ansis@4.2.0: {} + any-base@1.1.0: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -20286,6 +21010,10 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + app-path@4.0.0: + dependencies: + execa: 5.1.1 + archiver-utils@5.0.2: dependencies: glob: 10.5.0 @@ -20377,6 +21105,8 @@ snapshots: asynckit@0.4.0: {} + auto-bind@5.0.1: {} + autoprefixer@10.4.22(postcss@8.5.15): dependencies: browserslist: 4.28.1 @@ -20391,6 +21121,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + await-to-js@3.0.0: {} + axios@1.13.2: dependencies: follow-redirects: 1.15.11 @@ -20607,6 +21339,8 @@ snapshots: blake3-wasm@2.1.5: {} + bmp-ts@1.0.9: {} + body-parser@2.2.1: dependencies: bytes: 3.1.2 @@ -20881,6 +21615,8 @@ snapshots: cli-boxes@3.0.0: {} + cli-boxes@4.0.1: {} + cli-cursor@2.1.0: dependencies: restore-cursor: 2.0.0 @@ -20889,8 +21625,21 @@ snapshots: dependencies: restore-cursor: 3.1.0 + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + cli-spinners@2.6.1: {} + cli-truncate@6.0.0: + dependencies: + slice-ansi: 9.0.0 + string-width: 8.2.1 + clipboardy@4.0.0: dependencies: execa: 8.0.1 @@ -20915,6 +21664,10 @@ snapshots: cluster-key-slot@1.1.2: {} + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -21018,6 +21771,8 @@ snapshots: convert-source-map@2.0.0: {} + convert-to-spaces@2.0.1: {} + cookie-es@1.2.2: {} cookie-es@2.0.0: {} @@ -21395,6 +22150,8 @@ snapshots: optionalDependencies: miniflare: 4.20260504.0 + environment@1.1.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -21436,6 +22193,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.47.0: {} + esbuild-plugin-solid@0.5.0(esbuild@0.27.7)(solid-js@1.9.10): dependencies: '@babel/core': 7.28.5 @@ -21594,6 +22353,8 @@ snapshots: escape-string-regexp@1.0.5: {} + escape-string-regexp@2.0.0: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -21829,6 +22590,18 @@ snapshots: dependencies: eventsource-parser: 3.1.0 + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + execa@8.0.1: dependencies: cross-spawn: 7.0.6 @@ -21841,6 +22614,8 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + exif-parser@0.1.12: {} + expect-type@1.3.0: {} expo-asset@56.0.15(expo@56.0.5)(react-native@0.85.3(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3))(react@19.2.3)(typescript@5.9.3): @@ -22064,6 +22839,15 @@ snapshots: dependencies: flat-cache: 4.0.1 + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + file-uri-to-path@1.0.0: {} fill-range@7.1.1: @@ -22257,6 +23041,8 @@ snapshots: dependencies: pump: 3.0.4 + get-stream@6.0.1: {} + get-stream@8.0.1: {} get-tsconfig@4.13.0: @@ -22273,6 +23059,11 @@ snapshots: getenv@2.0.0: {} + gifwrap@0.10.1: + dependencies: + image-q: 4.0.0 + omggif: 1.0.10 + giget@2.0.0: dependencies: citty: 0.1.6 @@ -22643,6 +23434,8 @@ snapshots: human-id@4.1.3: {} + human-signals@2.1.0: {} + human-signals@5.0.0: {} iconv-lite@0.6.3: @@ -22659,6 +23452,12 @@ snapshots: ignore@7.0.5: {} + image-dimensions@2.5.1: {} + + image-q@4.0.0: + dependencies: + '@types/node': 16.9.1 + image-size@1.2.1: dependencies: queue: 6.0.2 @@ -22676,10 +23475,47 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@5.0.0: {} + inherits@2.0.4: {} ini@1.3.8: {} + ink@7.0.5(@types/react@19.2.7)(react-devtools-core@6.1.5)(react@19.2.3): + dependencies: + '@alcalzone/ansi-tokenize': 0.3.0 + ansi-escapes: 7.3.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 4.0.1 + cli-cursor: 4.0.0 + cli-truncate: 6.0.0 + code-excerpt: 4.0.0 + es-toolkit: 1.47.0 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.2.3 + react-reconciler: 0.33.0(react@19.2.3) + scheduler: 0.27.0 + signal-exit: 3.0.7 + slice-ansi: 9.0.0 + stack-utils: 2.0.6 + string-width: 8.2.1 + terminal-size: 4.0.1 + type-fest: 5.7.0 + widest-line: 6.0.0 + wrap-ansi: 10.0.0 + ws: 8.21.0 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 19.2.7 + react-devtools-core: 6.1.5 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + inline-style-parser@0.1.1: {} inline-style-parser@0.2.7: {} @@ -22724,8 +23560,6 @@ snapshots: transitivePeerDependencies: - supports-color - ip-address@10.1.0: {} - ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -22786,6 +23620,10 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -22796,6 +23634,8 @@ snapshots: dependencies: html-tags: 3.3.1 + is-in-ci@2.0.0: {} + is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -22927,6 +23767,11 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + iterm2-version@5.0.0: + dependencies: + app-path: 4.0.0 + plist: 3.1.1 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -22969,6 +23814,38 @@ snapshots: jimp-compact@0.16.1: {} + jimp@1.6.1: + dependencies: + '@jimp/core': 1.6.1 + '@jimp/diff': 1.6.1 + '@jimp/js-bmp': 1.6.1 + '@jimp/js-gif': 1.6.1 + '@jimp/js-jpeg': 1.6.1 + '@jimp/js-png': 1.6.1 + '@jimp/js-tiff': 1.6.1 + '@jimp/plugin-blit': 1.6.1 + '@jimp/plugin-blur': 1.6.1 + '@jimp/plugin-circle': 1.6.1 + '@jimp/plugin-color': 1.6.1 + '@jimp/plugin-contain': 1.6.1 + '@jimp/plugin-cover': 1.6.1 + '@jimp/plugin-crop': 1.6.1 + '@jimp/plugin-displace': 1.6.1 + '@jimp/plugin-dither': 1.6.1 + '@jimp/plugin-fisheye': 1.6.1 + '@jimp/plugin-flip': 1.6.1 + '@jimp/plugin-hash': 1.6.1 + '@jimp/plugin-mask': 1.6.1 + '@jimp/plugin-print': 1.6.1 + '@jimp/plugin-quantize': 1.6.1 + '@jimp/plugin-resize': 1.6.1 + '@jimp/plugin-rotate': 1.6.1 + '@jimp/plugin-threshold': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + transitivePeerDependencies: + - supports-color + jiti@1.21.7: {} jiti@2.6.1: {} @@ -22979,6 +23856,8 @@ snapshots: joycon@3.1.1: {} + jpeg-js@0.4.4: {} + js-beautify@1.15.4: dependencies: config-chain: 1.1.13 @@ -23323,6 +24202,15 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + log-update@8.0.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 9.0.0 + string-width: 8.2.1 + strip-ansi: 7.2.0 + wrap-ansi: 10.0.0 + loglevel@1.9.2: {} long@5.3.2: {} @@ -24004,6 +24892,8 @@ snapshots: mimic-fn@4.0.0: {} + mimic-function@5.0.1: {} + miniflare@4.20260504.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -24610,6 +25500,8 @@ snapshots: dependencies: whatwg-fetch: 3.6.20 + omggif@1.0.10: {} + on-finished@2.3.0: dependencies: ee-first: 1.1.1 @@ -24636,6 +25528,10 @@ snapshots: dependencies: mimic-fn: 4.0.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + open@7.4.2: dependencies: is-docker: 2.2.1 @@ -24647,9 +25543,9 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@6.41.0(ws@8.19.0)(zod@4.3.6): + openai@6.41.0(ws@8.21.0)(zod@4.3.6): optionalDependencies: - ws: 8.19.0 + ws: 8.21.0 zod: 4.3.6 optionator@0.9.4: @@ -24811,10 +25707,21 @@ snapshots: package-manager-detector@1.6.0: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 + parse-bmfont-ascii@1.0.6: {} + + parse-bmfont-binary@1.0.6: {} + + parse-bmfont-xml@1.1.6: + dependencies: + xml-parse-from-string: 1.0.1 + xml2js: 0.5.0 + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -24857,6 +25764,8 @@ snapshots: partial-json@0.1.7: {} + patch-console@2.0.0: {} + path-browserify@1.0.1: {} path-exists@3.0.0: {} @@ -24907,6 +25816,10 @@ snapshots: pirates@4.0.7: {} + pixelmatch@5.3.0: + dependencies: + pngjs: 6.0.0 + pkce-challenge@5.0.1: {} pkg-dir@3.0.0: @@ -24941,6 +25854,10 @@ snapshots: pngjs@3.4.0: {} + pngjs@6.0.0: {} + + pngjs@7.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.15)(tsx@4.21.0)(yaml@2.8.2): @@ -25322,6 +26239,11 @@ snapshots: - supports-color - utf-8-validate + react-reconciler@0.33.0(react@19.2.3): + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + react-refresh@0.14.2: {} react-refresh@0.17.0: {} @@ -25575,6 +26497,16 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + retry@0.13.1: {} reusify@1.1.0: {} @@ -26021,6 +26953,8 @@ snapshots: bplist-parser: 0.3.1 plist: 3.1.1 + simple-xml-to-json@1.2.7: {} + sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.29 @@ -26033,6 +26967,11 @@ snapshots: slash@5.1.0: {} + slice-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + slugify@1.6.9: {} smart-buffer@4.2.0: {} @@ -26051,7 +26990,7 @@ snapshots: socks@2.8.7: dependencies: - ip-address: 10.1.0 + ip-address: 10.2.0 smart-buffer: 4.2.0 solid-js@1.9.10: @@ -26118,6 +27057,10 @@ snapshots: stable-hash-x@0.2.0: {} + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} stackframe@1.3.4: {} @@ -26173,6 +27116,11 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 + string-width@8.2.1: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.1.2 + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -26198,8 +27146,14 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} + strip-final-newline@2.0.0: {} + strip-final-newline@3.0.0: {} strip-json-comments@3.1.1: {} @@ -26210,6 +27164,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + structured-headers@0.4.1: {} style-to-js@1.1.21: @@ -26255,6 +27213,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + supports-terminal-graphics@0.1.0: {} + svelte-check@4.3.4(picomatch@4.0.4)(svelte@5.45.10)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -26349,13 +27309,32 @@ snapshots: - react-native-b4a optional: true + term-img@7.1.0: + dependencies: + ansi-escapes: 7.3.0 + iterm2-version: 5.0.0 + term-size@2.2.1: {} + terminal-image@4.3.0: + dependencies: + chalk: 5.6.2 + image-dimensions: 2.5.1 + jimp: 1.6.1 + log-update: 8.0.0 + omggif: 1.0.10 + supports-terminal-graphics: 0.1.0 + term-img: 7.1.0 + transitivePeerDependencies: + - supports-color + terminal-link@2.1.1: dependencies: ansi-escapes: 4.3.2 supports-hyperlinks: 2.3.0 + terminal-size@4.0.1: {} + terser@5.44.1: dependencies: '@jridgewell/source-map': 0.3.11 @@ -26385,6 +27364,8 @@ snapshots: tinybench@2.9.0: {} + tinycolor2@1.6.0: {} + tinyexec@0.3.2: {} tinyexec@1.0.2: {} @@ -26421,6 +27402,12 @@ snapshots: toidentifier@1.0.1: {} + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + toqr@0.1.1: {} totalist@3.0.1: {} @@ -26560,6 +27547,10 @@ snapshots: dependencies: tagged-tag: 1.0.0 + type-fest@5.7.0: + dependencies: + tagged-tag: 1.0.0 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -26611,6 +27602,8 @@ snapshots: ufo@1.6.3: {} + uint8array-extras@1.5.0: {} + ultrahtml@1.6.0: {} unconfig-core@7.4.2: @@ -26909,6 +27902,10 @@ snapshots: dependencies: react: 19.2.3 + utif2@4.1.0: + dependencies: + pako: 1.0.11 + util-deprecate@1.0.2: {} utils-merge@1.0.1: {} @@ -27379,6 +28376,10 @@ snapshots: dependencies: string-width: 5.1.2 + widest-line@6.0.0: + dependencies: + string-width: 8.2.1 + word-wrap@1.2.5: {} workerd@1.20260504.1: @@ -27406,6 +28407,12 @@ snapshots: - bufferutil - utf-8-validate + wrap-ansi@10.0.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.2.1 + strip-ansi: 7.1.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -27433,6 +28440,8 @@ snapshots: ws@8.19.0: {} + ws@8.21.0: {} + xcode@3.0.1: dependencies: simple-plist: 1.3.1 @@ -27440,6 +28449,13 @@ snapshots: xml-name-validator@5.0.0: {} + xml-parse-from-string@1.0.1: {} + + xml2js@0.5.0: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + xml2js@0.6.0: dependencies: sax: 1.6.0 @@ -27494,6 +28510,8 @@ snapshots: yocto-queue@0.1.0: {} + yoga-layout@3.2.1: {} + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 diff --git a/testing/cli/README.md b/testing/cli/README.md new file mode 100644 index 000000000..078e21c16 --- /dev/null +++ b/testing/cli/README.md @@ -0,0 +1,23 @@ +# @tanstack/ai-cli-tests + +Subprocess E2E suite for the `ts-ai` binary (`@tanstack/ai-cli`). + +Each test spawns the **built** `ts-ai` binary as a real subprocess and asserts +the machine-facing contract: `--json` payload shape, `--stream` AG-UI events, +exit codes, written artifacts, the `introspect` manifest, and `ts-ai mcp`. This +mirrors exactly how an agent harness drives the CLI. + +## Running + +```bash +# Build the CLI first (the suite runs the compiled bin) +pnpm --filter @tanstack/ai-cli build + +# Run the suite +pnpm --filter @tanstack/ai-cli-tests test:e2e +``` + +The contract tests above need no API keys — they exercise version, introspect, +and the error/exit-code paths. Tests that perform real generations point a +provider `baseURL` at a local mock (aimock for chat/text; media-endpoint mock +routes are added here as those commands gain coverage) and supply a dummy key. diff --git a/testing/cli/package.json b/testing/cli/package.json new file mode 100644 index 000000000..0a25ec26e --- /dev/null +++ b/testing/cli/package.json @@ -0,0 +1,14 @@ +{ + "name": "@tanstack/ai-cli-tests", + "private": true, + "type": "module", + "scripts": { + "test:e2e": "vitest run", + "test:e2e:dev": "vitest" + }, + "devDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@tanstack/ai-cli": "workspace:*", + "vitest": "^4.0.14" + } +} diff --git a/testing/cli/tests/cli.spec.ts b/testing/cli/tests/cli.spec.ts new file mode 100644 index 000000000..a17d92321 --- /dev/null +++ b/testing/cli/tests/cli.spec.ts @@ -0,0 +1,211 @@ +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const BIN = fileURLToPath( + new URL('../../../packages/ai-cli/dist/bin/bin.js', import.meta.url), +) + +interface RunResult { + code: number | null + stdout: string + stderr: string +} + +/** + * Run the built `ts-ai` binary with stdin closed (the harness shape) and a + * scrubbed env so no real provider key leaks in. Returns the captured streams + * and exit code. + */ +function runCli( + args: Array<string>, + env: Record<string, string> = {}, +): Promise<RunResult> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [BIN, ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { PATH: process.env.PATH ?? '', ...env }, + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (c) => (stdout += String(c))) + child.stderr.on('data', (c) => (stderr += String(c))) + child.on('error', reject) + child.on('close', (code) => resolve({ code, stdout, stderr })) + }) +} + +describe('ts-ai meta commands', () => { + it('reports its version', async () => { + const { code, stdout } = await runCli(['--version']) + expect(code).toBe(0) + expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/) + }) + + it('introspect emits a parseable manifest with all commands', async () => { + const { code, stdout } = await runCli(['introspect']) + expect(code).toBe(0) + const manifest = JSON.parse(stdout) + expect(manifest.bin).toBe('ts-ai') + expect(manifest.bundledProviders).toEqual( + expect.arrayContaining([ + 'openai', + 'anthropic', + 'gemini', + 'openrouter', + 'fal', + ]), + ) + const names = manifest.commands.map((c: { name: string }) => c.name) + expect(names).toEqual( + expect.arrayContaining([ + 'chat', + 'image', + 'video', + 'audio', + 'speech', + 'transcribe', + 'summarize', + ]), + ) + }) +}) + +describe('ts-ai machine-mode error contract', () => { + it('emits a structured USAGE error + exit 2 when --model is missing', async () => { + const { code, stdout } = await runCli(['image', 'a cat', '--json']) + expect(code).toBe(2) + expect(JSON.parse(stdout)).toMatchObject({ error: { code: 'USAGE' } }) + }) + + it('rejects an unknown provider with a USAGE error', async () => { + const { code, stdout } = await runCli([ + 'chat', + 'hi', + '--model', + 'bogus/x', + '--json', + ]) + expect(code).toBe(2) + expect(JSON.parse(stdout).error.code).toBe('USAGE') + }) + + it('reports a missing API key with the provider attached', async () => { + const { code, stdout } = await runCli([ + 'chat', + 'hi', + '--model', + 'openai/gpt-5.5', + '--json', + ]) + expect(code).toBe(2) + expect(JSON.parse(stdout).error).toMatchObject({ + code: 'USAGE', + provider: 'openai', + }) + }) + + it('keeps stdout free of human chatter in --json mode', async () => { + const { stdout } = await runCli(['image', 'a cat', '--json']) + // Exactly one JSON line on stdout, nothing else. + expect(stdout.trim().split('\n')).toHaveLength(1) + expect(() => JSON.parse(stdout)).not.toThrow() + }) + + it('video status receives --model from the parent command (errors on key, not model)', async () => { + const { code, stdout } = await runCli([ + 'video', + 'status', + 'job_123', + '--model', + 'openai/sora-2', + '--json', + ]) + expect(code).toBe(2) + const err = JSON.parse(stdout).error + expect(err.code).toBe('USAGE') + // If --model weren't merged from the parent it would fail with "Missing + // --model" instead — assert it got far enough to need a key. + expect(err.message).toMatch(/api key/i) + }) + + it('parses kebab-case flags and coerces their values (--max-steps)', async () => { + const { code, stdout } = await runCli([ + 'chat', + 'hi', + '--model', + 'openai/gpt-5.5', + '--max-steps', + 'not-a-number', + '--json', + ]) + expect(code).toBe(2) + const err = JSON.parse(stdout).error + expect(err.code).toBe('USAGE') + expect(err.message).toMatch(/number/i) + }) +}) + +describe('ts-ai argv-injection guard', () => { + it('treats a flag passed as the prompt as text, not an option', async () => { + // Control: a real --version flag prints the version. + const version = await runCli(['chat', '--version']) + expect(version.code).toBe(0) + expect(version.stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/) + + // After `--`, the same token is the prompt — never re-parsed as a flag, so + // it falls through to the missing-model error rather than printing version. + const injected = await runCli(['chat', '--json', '--', '--version']) + expect(injected.code).toBe(2) + const err = JSON.parse(injected.stdout).error + expect(err.code).toBe('USAGE') + expect(err.message).toMatch(/model/i) + }) + + it('does not let a prompt smuggle --api-key', async () => { + const { code, stdout } = await runCli([ + 'chat', + '--json', + '--', + '--api-key', + 'LEAKED', + ]) + expect(code).toBe(2) + expect(JSON.parse(stdout).error.code).toBe('USAGE') + }) +}) + +describe('ts-ai introspect flag spelling', () => { + it('emits kebab-cased CLI flag strings for multi-word options', async () => { + const { stdout } = await runCli(['introspect']) + const manifest = JSON.parse(stdout) + const apiKey = manifest.commonFlags.find( + (f: { name: string }) => f.name === 'apiKey', + ) + expect(apiKey.flag).toBe('--api-key') + const chat = manifest.commands.find( + (c: { name: string }) => c.name === 'chat', + ) + const maxSteps = chat.flags.find( + (f: { name: string }) => f.name === 'maxSteps', + ) + expect(maxSteps.flag).toBe('--max-steps') + const video = manifest.commands.find( + (c: { name: string }) => c.name === 'video', + ) + const wait = video.flags.find((f: { name: string }) => f.name === 'wait') + // default-true booleans render as negatable --no-x flags. + expect(wait.flag).toBe('--no-wait') + }) +}) + +describe('ts-ai no command (non-TTY)', () => { + it('prints help instead of launching the interactive menu', async () => { + // stdout is a pipe here (not a TTY), so the home menu must not start. + const { code, stdout } = await runCli([]) + expect(code).toBe(0) + expect(stdout).toContain('ts-ai') + expect(stdout).toContain('chat') + expect(stdout).toContain('image') + }) +}) diff --git a/testing/cli/tests/mcp.spec.ts b/testing/cli/tests/mcp.spec.ts new file mode 100644 index 000000000..d49d55894 --- /dev/null +++ b/testing/cli/tests/mcp.spec.ts @@ -0,0 +1,78 @@ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' + +const BIN = fileURLToPath( + new URL('../../../packages/ai-cli/dist/bin/bin.js', import.meta.url), +) +// Run the server from a directory with no `.env`, so the shelled-out ts-ai has +// no API key and deterministically returns the structured no-key error rather +// than making a real provider call. +const NO_ENV_CWD = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +let client: Client + +beforeAll(async () => { + client = new Client({ name: 'mcp-spec', version: '1.0.0' }) + await client.connect( + new StdioClientTransport({ + command: process.execPath, + args: [BIN, 'mcp'], + cwd: NO_ENV_CWD, + }), + ) +}) + +afterAll(async () => { + await client?.close() +}) + +function toolResultJson(res: { + content?: Array<{ type: string; text?: string }> +}): any { + const text = res.content?.find((c) => c.type === 'text')?.text ?? '' + return JSON.parse(text) +} + +describe('ts-ai mcp server', () => { + it('registers every generation command as a tool', async () => { + const { tools } = await client.listTools() + const names = tools.map((t) => t.name).sort() + expect(names).toEqual( + [ + 'audio', + 'chat', + 'image', + 'speech', + 'summarize', + 'transcribe', + 'video', + ].sort(), + ) + }) + + it('round-trips a tool call: client -> server -> ts-ai -> structured JSON', async () => { + // options -> --config blob; with a model but no key, ts-ai resolves the + // model then fails on the missing key — proving the whole pipeline ran. + const res = await client.callTool({ + name: 'chat', + arguments: { prompt: 'hi', options: { model: 'openai/gpt-5.5' } }, + }) + const payload = toolResultJson(res as never) + expect(payload.error.code).toBe('USAGE') + expect(payload.error.message).toMatch(/api key/i) + }) + + it('does not let the prompt smuggle CLI flags through the tool call', async () => { + const res = await client.callTool({ + name: 'chat', + arguments: { prompt: '--version', options: { model: 'openai/gpt-5.5' } }, + }) + const payload = toolResultJson(res as never) + // If --version had been parsed as a flag, this would be a version string, + // not a structured key error. + expect(payload.error.code).toBe('USAGE') + }) +}) diff --git a/testing/cli/vitest.config.ts b/testing/cli/vitest.config.ts new file mode 100644 index 000000000..b674becc1 --- /dev/null +++ b/testing/cli/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + name: '@tanstack/ai-cli-tests', + environment: 'node', + include: ['tests/**/*.spec.ts'], + // Spawning a real subprocess per case is slower than a unit test; give the + // suite room and run files serially to keep stdout assertions clean. + testTimeout: 30_000, + fileParallelism: false, + }, +})