diff --git a/.changeset/ai-harness-cli.md b/.changeset/ai-harness-cli.md new file mode 100644 index 0000000000..122cb06865 --- /dev/null +++ b/.changeset/ai-harness-cli.md @@ -0,0 +1,11 @@ +--- +'@tanstack/ai-harness-cli': minor +--- + +Add `@tanstack/ai-harness-cli`: `runCli(harness)` runs a harness from the terminal. + +- Line mode reads one message or `/command` per line, answers approvals and questions, hides a secret answer while the user types, and sends `@path` files. Pass `ui` to show your own screen on a session view instead. +- `-p "prompt"` runs one prompt for scripts and CI, and `--output ndjson` prints every AG-UI event. +- `--serve` serves the session protocol over HTTP behind a bearer token. +- `--acp`, `--mcp`, and `--dashboard` load `@tanstack/ai-acp`, `@tanstack/ai-mcp`, and `@tanstack/ai-dashboard` only when you use the flag. A missing package, or one without harness support, fails that flag with a clear message. +- `host` and `principal` options let the CLI use your durable host and run as your user. diff --git a/.changeset/harness-cli-handler-options.md b/.changeset/harness-cli-handler-options.md new file mode 100644 index 0000000000..c2f77f3e99 --- /dev/null +++ b/.changeset/harness-cli-handler-options.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-harness-cli': patch +--- + +`--serve` passes `canAccess` and `mediaSecret` to the harness handler, which needs both. diff --git a/docs/config.json b/docs/config.json index fc89d9c9dd..baef8656bd 100644 --- a/docs/config.json +++ b/docs/config.json @@ -941,6 +941,11 @@ "addedAt": "2026-10-13", "updatedAt": "2026-10-14" }, + { + "label": "Run in the terminal", + "to": "harness/cli", + "addedAt": "2026-10-13" + }, { "label": "Deploy a harness", "to": "harness/deploy", diff --git a/docs/harness/cli.md b/docs/harness/cli.md new file mode 100644 index 0000000000..f23d58c3a3 --- /dev/null +++ b/docs/harness/cli.md @@ -0,0 +1,189 @@ +--- +title: Run a harness in the terminal +id: harness-cli +order: 3 +description: "Run your harness in a terminal with line mode or your own screen, a print mode for scripts and CI, NDJSON output, an ACP mode for editors, and an HTTP server." +keywords: + - tanstack ai + - harness + - cli + - terminal + - ink +--- + +You built a harness and want to use it like Claude Code: type in a terminal, watch it work, approve tools. You also want to run it in CI. `runCli` gives one harness all of these modes. + +## Install + + + +react: @tanstack/ai-harness-cli +vue: @tanstack/ai-harness-cli +solid: @tanstack/ai-harness-cli +svelte: @tanstack/ai-harness-cli +preact: @tanstack/ai-harness-cli +angular: @tanstack/ai-harness-cli +vanilla: @tanstack/ai-harness-cli +octane: @tanstack/ai-harness-cli + + + +## 1. Write the entry file + +```ts group=harness-cli +import { defineHarness } from '@tanstack/ai-harness' +import { runCli } from '@tanstack/ai-harness-cli' +import { openaiText } from '@tanstack/ai-openai' + +const assistant = defineHarness({ + name: 'acme/assistant', + adapter: openaiText('gpt-6.1-sol'), +}) + +process.exitCode = await runCli(assistant) +``` + +## 2. Pick a mode + +To use the harness yourself: + +- No flags: line mode. Type a message and press Enter. With a `ui`, your own screen starts in its place (see [Run your own screen](#run-your-own-screen)). +- `-p "prompt"`: run one prompt, print the answer, and exit. +- `-p "prompt" --output ndjson`: print every AG-UI event as one JSON line. + +To use the harness from another program: + +- `--acp`: serve the harness as an ACP v2 agent over stdio, for editors. Needs `@tanstack/ai-acp` with harness support. +- `--mcp`: serve the harness as an MCP server over stdio, for Claude Code, Cursor, and other MCP clients. Needs `@tanstack/ai-mcp` with harness support. Add `--yes` to approve every tool call. +- `--serve`: serve the session protocol over HTTP on `127.0.0.1:8787`. Every request needs the bearer token. Pass `--token`, set `HARNESS_TOKEN`, or copy the token the CLI prints. With `@tanstack/ai-mcp` and its harness support, it also serves MCP at `/mcp`. One token, one user, every thread, so keep it on a network you trust (see [Serve over HTTP safely](#serve-over-http-safely)). + +`--mcp` and `--serve` run only the commands in `expose.commands` of the harness. `--serve` also sets only the config keys in `expose.config`. Add the names to `expose` in `defineHarness`, for example `expose: { commands: ['connect:github'] }`. Line mode and `-p` run every command. See [Choose what clients can change](./connect#choose-what-clients-can-change). + +Line mode reads one message or command per line and waits for each turn. It works the same in a terminal and with piped input. In a terminal, it also opens sign-in links in the browser. + +## 3. Use it in CI + +`-p` exits with a code your script can check: + +| Code | Meaning | +|---|---| +| 0 | The turn finished. | +| 1 | The turn failed. | +| 2 | The turn waits for an approval. | +| 130 | The turn was cancelled. | + +## Serve over HTTP safely + +`--serve` is the quick way to put a harness on HTTP, so it makes the trust decisions for you: one bearer token, one user, every thread. Anyone with the token acts as that one user. They can open or drive any thread on the host, including the tools it runs (shell commands, file edits) on the machine the CLI runs on. Treat the token like a shell login. + +- It binds to `127.0.0.1:8787`, reachable only from the same machine. Change the bind with `--host` and `--port`. `--host 0.0.0.0` puts it on the network, so do that only behind TLS and a proxy you trust. +- The CLI signs media links with the token, so rotating the token stops any link a turn already handed out. + +Need per-user access, your own sign-in check, or a rule for which threads each user can open? Use [`createHarnessHandler`](./connect) instead. It requires `authorize` and `canAccess`, so no thread is open by accident. + +## Use your own host and user + +By default, the CLI builds its own host in memory and opens sessions with no user. Your server already has a durable host with leases, recovery, and stores, and each person has their own credentials. Give the CLI that host and the user: + +```ts group=harness-cli-host +import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { runCli } from '@tanstack/ai-harness-cli' +import { memoryLogStore, memoryPersistence } from '@tanstack/ai-persistence' +import { openaiText } from '@tanstack/ai-openai' + +const assistant = defineHarness({ + name: 'acme/assistant', + adapter: openaiText('gpt-6.1-sol'), +}) + +const { runs, metadata, credentials } = memoryPersistence().stores +const host = createHarnessHost({ + persistence: { stores: { log: memoryLogStore(), runs, metadata, credentials } }, +}) + +process.exitCode = await runCli(assistant, { host, principal: { id: 'ada' } }) +await host.close() +``` + +- You own the host, so the CLI does not close it. Close it yourself when `runCli` returns. Without `host`, the CLI builds a host and closes it. +- Give `host` or `persistence`, not both. With both, `runCli` writes `Give runCli a host or persistence, not both.` and returns code 1. +- Line mode, `-p`, and your own `ui` open the session as `principal`, so `/connect` saves keys for that user. +- `--serve` gives the same principal to each request with the token. +- `--acp`, `--mcp`, and `--dashboard` open their sessions without the principal. + +## Commands in line mode + +For the session: + +- `/agents`: list the agents. +- `/agent {"json":"input"}`: run an agent in the background. When it is done, a new turn starts with its result. +- `/config`: show the settings. `/config ` changes one. +- `/connect ` and `/disconnect `: sign in to a connector, or out. + +For the running work: + +- `/cancel`: cancel the running turn. +- `/status`: show what runs and what waits. +- `/exit`: quit. + +Plugin commands (for example `/model` or `/todos`) show up in `/help`. When a turn stops for an approval or a plugin asks a question, type your answer. For yes-or-no questions, `y` approves and `n` refuses. + +## Send files and save media + +To send a file with a message, write `@` and the path of the file: + +```text +what is wrong in @./bug.png +compare @"old logo.png" with @./new-logo.png +``` + +- The path is relative to the working folder. Put quotes around a path with spaces. +- `@path` works in line mode, with `-p`, with piped input, and in your own `ui`. +- A `@word` that is not a file stays in the text, for example `@types/node`. +- If the CLI does not know the type of the file, it does not send the message. To send the path as text, remove the `@`. + +The CLI saves the media that a turn makes in a folder. The default folder is `./-media` in the working folder, for example `./acme-assistant-media`. To use another folder, add `--media-dir `: + +```bash +npx tsx cli.ts -p "Draw a logo for acme" --media-dir ./out +``` + +- Line mode prints `[image saved: ]` for each file. +- `-p` prints the same line on stderr, so stdout keeps only the answer. +- `-p --output ndjson` adds the saved `path` to the value of the `harness.media` event. + +The CLI never replaces a file. If the name is taken, it adds `-1`, `-2`, and so on to the new name. + +## Run your own screen + +Line mode prints plain lines. For a full screen with your own layout, pass `ui` to `runCli`. It works with any TUI library, for example Ink, OpenTUI, or blessed. + +`ui` gets a ready [session view](./custom-ui) and resolves when the user quits. This entry file starts an Ink screen: + +```tsx ignore +import { render } from 'ink' +import { runCli } from '@tanstack/ai-harness-cli' +import { assistant } from './harness' +import { Screen } from './screen' + +process.exitCode = await runCli(assistant, { + ui: async (view) => { + await render().waitUntilExit() + }, +}) +``` + +- `ui` runs only in an interactive terminal. Piped input uses line mode. `-p`, `--acp`, `--mcp`, `--serve`, and `--dashboard` do not use `ui`. +- When `ui` resolves, the CLI disposes the view and `runCli` returns. +- To write `Screen`, read [Build your own UI](./custom-ui). + +For an Ink screen with approvals, questions, sign-ins, and child agents, copy [`examples/harness-cli/src/tui.tsx`](https://github.com/TanStack/ai/blob/main/examples/harness-cli/src/tui.tsx). + +## What you have now + +- One entry file that runs your harness as a terminal app, a script step, an editor agent, an MCP server, or an HTTP server. +- A CLI that uses your durable host and runs as your user. +- Your own terminal screen on the same session, with any TUI library. +- Files that you send with `@path`, and a folder with the media that your agents make. + +Next: keep long turns alive through crashes with [durable sessions](./durable-sessions). diff --git a/docs/harness/connect.md b/docs/harness/connect.md index dfd4fe46ba..cf2eb1d34d 100644 --- a/docs/harness/connect.md +++ b/docs/harness/connect.md @@ -356,4 +356,4 @@ If a plugin picks the model of the harness, the harness has no adapter to read i - Messages from the browser to a running agent. - A WebSocket, and a harness you can call from `chat()`. -Next: send more than a prompt with [inputs](./inputs). +Next: run the same harness in a terminal with the [CLI](./cli). diff --git a/docs/harness/custom-ui.md b/docs/harness/custom-ui.md index 1052b1c6e5..204e559611 100644 --- a/docs/harness/custom-ui.md +++ b/docs/harness/custom-ui.md @@ -103,6 +103,8 @@ await view.send('Summarize the README.') Put the code of steps 1 and 2 in one `.tsx` file, then run it. The screen shows your prompt, then the answer while it streams, then the status `idle`. Press Ctrl+C to quit. +An Ink screen with approvals, questions, sign-ins, and child agents is in [`examples/harness-cli/src/tui.tsx`](https://github.com/TanStack/ai/blob/main/examples/harness-cli/src/tui.tsx). It runs from [`runCli({ ui })`](./cli#run-your-own-screen). + Each TanStack Store adapter reads the store the same way. Vue, Solid, Svelte, and Preact have `useSelector`. Angular has `injectSelector`. With no UI library, subscribe to the store: ```ts group=harness-custom-ui diff --git a/examples/README.md b/examples/README.md index 34a6460347..4ac39cdae9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -12,6 +12,7 @@ Choose an example based on your use case: - **Want typed ticket routing?** → [Evaluate](#evaluate) - **Want a blog desk with three agents?** → [Subagents](#subagents) - **Want your own MCP server?** → [MCP server](#mcp-server) +- **Want an agent in the terminal?** → [Harness CLI](#harness-cli) - **Want a full-stack TypeScript app?** → [TanStack Chat (ts-react-chat)](#tanstack-chat-ts-react-chat) - **Need a live world stream?** → [World generation (ts-react-media)](#world-generation-ts-react-media) - **Need a vanilla JS frontend?** → [Vanilla Chat](#vanilla-chat) @@ -146,6 +147,24 @@ Open http://localhost:3100. Click Call the server. --- +### Harness CLI + +A notes assistant in the terminal. A harness with two tools, run by `runCli`, with a small Ink screen on the session view. + +**Path:** `examples/harness-cli` + +**Getting Started:** + +```bash +OPENAI_API_KEY=sk-... pnpm --filter harness-cli-example start +``` + +Ask it to save a note. Press `y` to approve the write. + +📖 [Full Documentation](harness-cli/README.md) + +--- + ### TanStack Chat (ts-react-chat) A full-featured chat application built with the TanStack ecosystem. diff --git a/examples/harness-cli/.gitignore b/examples/harness-cli/.gitignore new file mode 100644 index 0000000000..f30475e545 --- /dev/null +++ b/examples/harness-cli/.gitignore @@ -0,0 +1,4 @@ +# The notes the assistant saves. +notes/ +# Media the CLI saves (the default --media-dir). +*-media/ diff --git a/examples/harness-cli/README.md b/examples/harness-cli/README.md new file mode 100644 index 0000000000..1ad8102879 --- /dev/null +++ b/examples/harness-cli/README.md @@ -0,0 +1,29 @@ +# Harness CLI example + +A notes assistant in your terminal, built with `@tanstack/ai-harness` and `@tanstack/ai-harness-cli`. Ask it to write a note. It asks you before it saves the file. + +## Run it + +From the repo root: + +1. `pnpm install` +2. `pnpm build:all` (the example uses the local packages) +3. `OPENAI_API_KEY=sk-... pnpm --filter harness-cli-example start` + +Type `Save a note about the moon landing`. The screen shows the `save_note` call. Press `y`, and the note is in `examples/harness-cli/notes/`. + +## What is in it + +- `src/harness.ts`: the harness. One model, a `list_notes` tool, and a `save_note` tool that needs approval. +- `src/cli.ts`: `runCli(assistant, { ui: runTui })`. That is the whole entry file. +- `src/tui.tsx`: an Ink screen on the session view. It shows the messages, tool calls, child agents, sign-ins, approvals (`y` or `n`), and questions. + +## Other modes + +The same entry file has every mode of `runCli`. Run these from `examples/harness-cli`: + +- `pnpm start -p "List my notes"` runs one prompt and prints the answer. +- `echo "List my notes" | pnpm start` uses line mode. +- `pnpm start --serve` serves the session protocol over HTTP with a bearer token. + +Read [Run a harness in the terminal](../../docs/harness/cli.md) and [Build your own UI](../../docs/harness/custom-ui.md). diff --git a/examples/harness-cli/package.json b/examples/harness-cli/package.json new file mode 100644 index 0000000000..4af3c26221 --- /dev/null +++ b/examples/harness-cli/package.json @@ -0,0 +1,25 @@ +{ + "name": "harness-cli-example", + "private": true, + "type": "module", + "description": "A notes assistant in the terminal, built with @tanstack/ai-harness and @tanstack/ai-harness-cli", + "scripts": { + "start": "tsx src/cli.ts", + "test:types": "tsc --noEmit" + }, + "dependencies": { + "@tanstack/ai": "workspace:*", + "@tanstack/ai-harness": "workspace:*", + "@tanstack/ai-harness-cli": "workspace:*", + "@tanstack/ai-openai": "workspace:*", + "@tanstack/react-store": "^0.11.1", + "ink": "^7.1.1", + "react": "^19.2.3", + "zod": "^4.2.0" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "tsx": "^4.23.12" + } +} diff --git a/examples/harness-cli/src/cli.ts b/examples/harness-cli/src/cli.ts new file mode 100644 index 0000000000..8874103bd9 --- /dev/null +++ b/examples/harness-cli/src/cli.ts @@ -0,0 +1,7 @@ +import { runCli } from '@tanstack/ai-harness-cli' +import { assistant } from './harness' +import { runTui } from './tui' + +// A terminal shows the Ink screen of ./tui. Piped input uses line mode, and +// `-p "prompt"` and `--serve` work as in every runCli app. +process.exitCode = await runCli(assistant, { ui: runTui }) diff --git a/examples/harness-cli/src/harness.ts b/examples/harness-cli/src/harness.ts new file mode 100644 index 0000000000..bb9743020f --- /dev/null +++ b/examples/harness-cli/src/harness.ts @@ -0,0 +1,39 @@ +import { mkdir, readdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { toolDefinition } from '@tanstack/ai' +import { defineHarness } from '@tanstack/ai-harness' +import { openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' + +// The notes go in a `notes` folder where you start the app. +const NOTES = join(process.cwd(), 'notes') + +const listNotes = toolDefinition({ + name: 'list_notes', + description: 'List the file names of the saved notes', + inputSchema: z.object({}), +}).server(async () => ({ notes: await readdir(NOTES).catch(() => []) })) + +// A write asks the user first, so the screen shows an approval. +const saveNote = toolDefinition({ + name: 'save_note', + description: 'Save a note as a Markdown file', + inputSchema: z.object({ title: z.string(), text: z.string() }), + needsApproval: true, +}).server(async ({ title, text }) => { + // The time keeps two notes with the same title apart. + const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-') || 'note' + const file = `${Date.now()}-${slug}.md` + await mkdir(NOTES, { recursive: true }) + await writeFile(join(NOTES, file), text) + return { saved: file } +}) + +export const assistant = defineHarness({ + name: 'example/notes', + adapter: openaiText('gpt-6.1-sol'), + systemPrompts: [ + 'You help the user write short notes. Save a note only when the user asks.', + ], + tools: [listNotes, saveNote], +}) diff --git a/examples/harness-cli/src/tui.tsx b/examples/harness-cli/src/tui.tsx new file mode 100644 index 0000000000..53b8b89694 --- /dev/null +++ b/examples/harness-cli/src/tui.tsx @@ -0,0 +1,174 @@ +import { useState } from 'react' +import { Box, Static, Text, render, useApp, useInput } from 'ink' +import { useSelector } from '@tanstack/react-store' +import type { + SessionView, + ViewMessage, + ViewPart, +} from '@tanstack/ai-harness/view' + +function Part({ part }: { part: ViewPart }) { + switch (part.type) { + case 'text': + return {part.text} + case 'reasoning': + return {part.text} + case 'tool-call': + return ( + + {`* ${part.name} (${part.status})`} + + ) + case 'agent': + return ( + + {`${part.name} (${part.status})`} + {part.parts.map((child, index) => ( + + ))} + + ) + case 'media': + return {`[${part.kind}: ${part.name}]`} + } +} + +function Message({ message }: { message: ViewMessage }) { + if (message.role === 'user') { + return {`> ${message.text}`} + } + if (message.role === 'notice') { + return ( + + {message.text} + + ) + } + return ( + + {message.parts.map((part, index) => ( + + ))} + + ) +} + +/** The typed answer: JSON (a number, a boolean) unless the question wants text. */ +function answerOf(text: string, schema: unknown): unknown { + const wantsText = + typeof schema !== 'object' || + schema === null || + Reflect.get(schema, 'type') === 'string' + if (wantsText) return text + try { + return JSON.parse(text) + } catch { + return text + } +} + +/** The whole screen: the messages, then one line for what waits for you. */ +function Screen({ view }: { view: SessionView }) { + const { exit } = useApp() + const state = useSelector(view.store) + const [line, setLine] = useState('') + // The turn goes on when every approval and interrupt has an answer, so the + // screen asks them one by one. + const [answered, setAnswered] = useState>([]) + const approval = state.approvals.find(({ id }) => !answered.includes(id)) + const interrupt = state.interrupts.find(({ id }) => !answered.includes(id)) + const [question] = state.questions + + useInput((input, key) => { + if (key.escape) { + void view.cancel() + return + } + // An approval takes one key: y or n. + if (approval) { + if (input !== 'y' && input !== 'n') return + if (input === 'y') approval.approve() + else approval.reject() + setAnswered((ids) => [...ids, approval.id]) + return + } + if (key.return) { + const text = line.trim() + setLine('') + if (text === '/exit') exit() + else if (interrupt) { + interrupt.resolve(answerOf(text, interrupt.responseSchema)) + setAnswered((ids) => [...ids, interrupt.id]) + } else if (question) { + void question + .answer(answerOf(text, question.schema)) + .then((receipt) => { + if (receipt.status === 'rejected') { + view.notice(`Answer again: ${receipt.reason ?? 'not accepted'}`) + } + }) + } + // A `/command` runs the command. Text while a turn runs steers it. + else void view.send(text) + return + } + if (key.backspace || key.delete) { + setLine((value) => value.slice(0, -1)) + return + } + // A paste can hold line breaks. The line stays one line. + if (!key.ctrl && !key.meta) { + setLine((value) => value + input.replace(/[\r\n]+/g, ' ')) + } + }) + + // The turns before the last message you sent do not change, so Ink prints + // them once. Only the running turn redraws. + let lastSent = 0 + state.messages.forEach((message, index) => { + if (message.role === 'user') lastSent = index + }) + const done = state.messages.slice(0, lastSent) + const live = state.messages.slice(lastSent) + + const typed = question?.secret ? '*'.repeat(line.length) : line + const asking = interrupt + ? (interrupt.message ?? interrupt.reason) + : question?.message + const prompt = asking ? `? ${asking} ` : '> ' + return ( + + + {(message) => } + + {live.map((message) => ( + + ))} + {state.signIns.map((signIn) => ( + + {`Sign in to ${signIn.connector}: ${signIn.url ?? ''} ${signIn.userCode ?? ''}`} + + ))} + {approval ? ( + + {`Run ${approval.tool} ${JSON.stringify(approval.args)}? (y/n)`} + + ) : ( + + {question?.url ? ( + {`Open ${question.url}`} + ) : null} + {`${prompt}${typed}`} + + )} + + {`${state.status}. Enter sends, Esc cancels, /exit quits.`} + + + ) +} + +/** The `ui` of runCli: show the screen until the user quits. */ +export async function runTui(view: SessionView) { + await render().waitUntilExit() +} diff --git a/examples/harness-cli/tsconfig.json b/examples/harness-cli/tsconfig.json new file mode 100644 index 0000000000..cc93365a78 --- /dev/null +++ b/examples/harness-cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"], + "esModuleInterop": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/packages/ai-harness-cli/README.md b/packages/ai-harness-cli/README.md new file mode 100644 index 0000000000..70b706f81a --- /dev/null +++ b/packages/ai-harness-cli/README.md @@ -0,0 +1,63 @@ +
+ + + + TanStack AI + +
+ +
+ +# @tanstack/ai-harness-cli + +Run a TanStack AI harness from the terminal: line mode or your own screen, a print mode for scripts and CI, NDJSON output, an ACP mode for editors, and an HTTP server. + +## Installation + +```bash +npm install @tanstack/ai-harness @tanstack/ai-harness-cli +``` + +## Usage + +```ts +#!/usr/bin/env node +import { defineHarness } from '@tanstack/ai-harness' +import { runCli } from '@tanstack/ai-harness-cli' +import { openaiText } from '@tanstack/ai-openai' + +const assistant = defineHarness({ + name: 'acme/assistant', + adapter: openaiText('gpt-6.1-sol'), +}) + +process.exitCode = await runCli(assistant) +``` + +| Flag | Mode | +| ----------------------------- | -------------------------------------------------- | +| (none) | Line mode, or your `ui` screen in a terminal | +| `-p "prompt"` | Run one prompt and print the answer | +| `-p "prompt" --output ndjson` | Print every AG-UI event as JSON lines | +| `--acp` | ACP v2 agent over stdio (needs `@tanstack/ai-acp`) | +| `--serve` | Session protocol over HTTP, with a bearer token | + +Line mode reads one message or command per line. To show your own screen in a terminal, pass `ui` to `runCli`, with any TUI library. `ui` gets a ready session view from `@tanstack/ai-harness/view` and resolves when the user quits. An Ink screen is in [`examples/harness-cli/src/tui.tsx`](https://github.com/TanStack/ai/blob/main/examples/harness-cli/src/tui.tsx). + +## Documentation + +Read [Run a harness in the terminal](https://tanstack.com/ai/latest/docs/harness/cli). + +## License + +MIT diff --git a/packages/ai-harness-cli/package.json b/packages/ai-harness-cli/package.json new file mode 100644 index 0000000000..95a749a9b1 --- /dev/null +++ b/packages/ai-harness-cli/package.json @@ -0,0 +1,90 @@ +{ + "name": "@tanstack/ai-harness-cli", + "version": "0.0.0", + "description": "Run a TanStack AI harness from the terminal: line mode or your own screen, print mode, NDJSON, ACP, MCP, and an HTTP server", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-harness-cli" + }, + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + } + }, + "sideEffects": false, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:oxlint": "oxlint src --type-aware", + "test:coverage": "vitest run --passWithNoTests --coverage --coverage.include='src/**' --coverage.reporter=text-summary --coverage.reporter=json-summary", + "test:lib": "vitest --passWithNoTests", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "nx": { + "targets": { + "test:lib": { + "dependsOn": [ + "build", + "^build" + ] + }, + "test:coverage": { + "dependsOn": [ + "build", + "^build" + ] + }, + "test:types": { + "dependsOn": [ + "build", + "^build" + ] + } + } + }, + "keywords": [ + "ai", + "tanstack", + "harness", + "agent", + "cli", + "terminal", + "acp", + "mcp" + ], + "peerDependencies": { + "@tanstack/ai": "workspace:^", + "@tanstack/ai-acp": "workspace:^", + "@tanstack/ai-harness": "workspace:^", + "@tanstack/ai-mcp": "workspace:^" + }, + "peerDependenciesMeta": { + "@tanstack/ai-acp": { + "optional": true + }, + "@tanstack/ai-mcp": { + "optional": true + } + }, + "devDependencies": { + "@tanstack/ai": "workspace:*", + "@tanstack/ai-harness": "workspace:*", + "@tanstack/ai-persistence": "workspace:*", + "@vitest/coverage-v8": "4.1.10", + "zod": "^4.2.0" + } +} diff --git a/packages/ai-harness-cli/src/args.ts b/packages/ai-harness-cli/src/args.ts new file mode 100644 index 0000000000..c5b89c797f --- /dev/null +++ b/packages/ai-harness-cli/src/args.ts @@ -0,0 +1,88 @@ +import { parseArgs } from 'node:util' + +export interface CliArgs { + help: boolean + print?: string + output: 'text' | 'ndjson' + acp: boolean + mcp: boolean + yes: boolean + serve: boolean + port: number + hostname: string + token?: string + thread: string + dashboard?: string + mediaDir?: string +} + +export const USAGE = `Usage: [options] + +With no options in a terminal, starts the UI of this CLI, if it has one. +Otherwise, and for piped input, reads one message or command per line. +In a message, @path sends a file with it (@"my cat.png" for a path with spaces). + +Options: + -p, --print Run one prompt, print the answer, and exit + --output Print format for --print: text (default) or ndjson + --acp Serve the harness as an ACP v2 agent over stdio + --mcp Serve the harness as an MCP server over stdio + --yes Approve every tool call in MCP mode (--mcp, and /mcp of --serve) + --serve Serve the session protocol over HTTP (and MCP at /mcp, with @tanstack/ai-mcp) + --port Port for --serve (default 8787) + --host Host name for --serve (default 127.0.0.1) + --token Bearer token for --serve (default: HARNESS_TOKEN, or a new random token) + --thread Conversation id (default: main) + --dashboard Connect to a dashboard (pairs on first use; set HARNESS_DASHBOARD_TOKEN to skip) + --media-dir Save the media a turn makes here (default: ./-media) + -h, --help Show this help + +Exit codes for --print: 0 done, 1 failed, 2 waiting for approval, 130 cancelled.` + +/** Parse CLI flags. Throws with a short message on an unknown flag. */ +export function parseCliArgs(argv: ReadonlyArray): CliArgs { + const { values } = parseArgs({ + args: [...argv], + options: { + help: { type: 'boolean', short: 'h' }, + print: { type: 'string', short: 'p' }, + output: { type: 'string' }, + acp: { type: 'boolean' }, + mcp: { type: 'boolean' }, + yes: { type: 'boolean' }, + serve: { type: 'boolean' }, + port: { type: 'string' }, + host: { type: 'string' }, + token: { type: 'string' }, + thread: { type: 'string' }, + dashboard: { type: 'string' }, + 'media-dir': { type: 'string' }, + }, + strict: true, + }) + const output = values.output ?? 'text' + if (output !== 'text' && output !== 'ndjson') { + throw new Error(`--output must be text or ndjson, got "${output}".`) + } + const port = Number(values.port ?? '8787') + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`--port must be a port number, got "${values.port}".`) + } + return { + help: values.help ?? false, + ...(values.print !== undefined ? { print: values.print } : {}), + output, + acp: values.acp ?? false, + mcp: values.mcp ?? false, + yes: values.yes ?? false, + serve: values.serve ?? false, + port, + hostname: values.host ?? '127.0.0.1', + ...(values.token !== undefined ? { token: values.token } : {}), + thread: values.thread ?? 'main', + ...(values.dashboard !== undefined ? { dashboard: values.dashboard } : {}), + ...(values['media-dir'] !== undefined + ? { mediaDir: values['media-dir'] } + : {}), + } +} diff --git a/packages/ai-harness-cli/src/attach.ts b/packages/ai-harness-cli/src/attach.ts new file mode 100644 index 0000000000..880689a407 --- /dev/null +++ b/packages/ai-harness-cli/src/attach.ts @@ -0,0 +1,155 @@ +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { basename, join, resolve } from 'node:path' +import { mediaPart, mimeTypeOf } from '@tanstack/ai-harness' +import type { ContentPart } from '@tanstack/ai' +import type { + HarnessSession, + MediaRecord, + UserInput, +} from '@tanstack/ai-harness' + +/** + * An `@path` token: an `@` at the start of the text or after white space, + * then a quoted path (`@"my cat.png"`) or the text up to the next white + * space. The white space before the `@` is part of the match, so it goes + * out of the text with the token. + */ +const TOKEN = /(^|\s)@(?:"([^"]+)"|(\S+))/g + +/** The path of a token. One of the two groups always matches. */ +function pathOf(token: RegExpExecArray) { + return token[2] ?? token[3] ?? '' +} + +/** + * The file that `path` names, when it is a readable regular file, else + * `undefined`. A file with an extension that `mimeTypeOf` does not know + * throws. + */ +async function readToken(path: string, cwd: string) { + const file = resolve(cwd, path) + const info = await stat(file).catch(() => undefined) + if (!info?.isFile()) return undefined + const name = basename(file) + const mimeType = mimeTypeOf(name) + if (mimeType === undefined) { + throw new Error( + `Cannot attach ${name}: the file type is not known. To send the path as text, remove the @.`, + ) + } + // ponytail: reads the whole file, and the store refuses it after that when + // it is over `media.maxBytes`. Stream it when big local files matter. + const bytes = await readFile(file).catch(() => undefined) + return bytes === undefined ? undefined : { bytes, mimeType, name } +} + +/** + * The message for `text`, with each `@path` token that names a file sent as + * a stored media part. The token goes out of the text. Any other `@` stays + * as typed: `me@acme.dev`, `@types/node`, or a path with no file. Returns + * `text` as it is when no token names a file. + * + * Throws for a file of a type it does not know, before it stores a file. + * `putMedia` throws a `MediaError` for a file over `media.maxBytes`, or of a + * kind that the harness does not take. The message does not go then. + */ +export async function attach( + session: HarnessSession, + text: string, + options: { cwd: string }, +) { + const tokens = [...text.matchAll(TOKEN)] + // Read every file first, so a bad file stops the send before a store. + const files = await Promise.all( + tokens.map((token) => readToken(pathOf(token), options.cwd)), + ) + if (files.every((file) => file === undefined)) return text + + const media: Array = [] + let rest = '' + let end = 0 + for (const [index, token] of tokens.entries()) { + const file = files[index] + if (file === undefined) continue + rest += text.slice(end, token.index) + end = token.index + token[0].length + const record = await session.putMedia(file.bytes, { + mimeType: file.mimeType, + name: file.name, + }) + media.push(mediaPart(record)) + } + const content = `${rest}${text.slice(end)}`.trim() + const input: UserInput = + content === '' ? media : [{ type: 'text', content }, ...media] + return input +} + +function isTaken(error: unknown) { + return error instanceof Error && 'code' in error && error.code === 'EEXIST' +} + +/** + * Write the bytes of a media file into `dir`, and return the path. The file + * gets the name of the record, with `-1`, `-2`, ... before the extension + * when that name is taken. It never overwrites a file. A name with a folder + * in it (`/`, `\`, a drive `:`), `.`, or `..` is refused, because a name can + * come from an upload. + */ +export async function saveMedia( + session: HarnessSession, + record: Pick, + dir: string, +) { + const { name } = record + const isPlainName = + name !== '' && name !== '.' && name !== '..' && !/[\\/:]/.test(name) + if (!isPlainName) { + throw new Error( + `Cannot save ${JSON.stringify(name)}: it is not a plain file name.`, + ) + } + const bytes = await session.loadMedia(record.id) + await mkdir(dir, { recursive: true }) + const dot = name.lastIndexOf('.') + const stem = dot > 0 ? name.slice(0, dot) : name + const extension = dot > 0 ? name.slice(dot) : '' + for (let count = 0; ; count += 1) { + const path = join(dir, count === 0 ? name : `${stem}-${count}${extension}`) + try { + // `wx` fails when the file is there, so a file is never overwritten. + await writeFile(path, bytes, { flag: 'wx' }) + return path + } catch (error) { + if (!isTaken(error)) throw error + } + } +} + +/** + * Save a media file with `saveMedia`, and get the line that says where it + * went, or why it was not saved. It does not throw. + */ +export async function saveMediaLine( + session: HarnessSession, + record: Pick, + dir: string, +) { + try { + const path = await saveMedia(session, record, dir) + return { path, text: `[${record.kind} saved: ${path}]` } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + return { text: `[${record.kind} not saved: ${reason}]` } + } +} + +/** + * The default media folder of a harness, relative to the working folder: + * the name as a safe folder name, then `-media`. `acme/coder` saves into + * `acme-coder-media`. + */ +export function defaultMediaDir(harnessName: string) { + const safe = harnessName.replace(/[^\w.-]+/g, '-').replace(/^[-.]+|-+$/g, '') + return `${safe || 'harness'}-media` +} diff --git a/packages/ai-harness-cli/src/commands.ts b/packages/ai-harness-cli/src/commands.ts new file mode 100644 index 0000000000..0254fcf0fe --- /dev/null +++ b/packages/ai-harness-cli/src/commands.ts @@ -0,0 +1,238 @@ +import { attach } from './attach' +import type { HarnessSession, UserInput } from '@tanstack/ai-harness' + +export const HELP_TEXT = [ + 'Type a message and press Enter. While the agent works, a new message steers it.', + 'Commands:', + ' /agents list the agents', + ' /agent [json] run an agent in the background', + ' /config [key value] show or change a setting', + ' /connect sign in to a connector (and /disconnect )', + ' /cancel cancel the running turn (or press Esc)', + ' /status show the session status', + ' /exit quit', +].join('\n') + +/** A command argument: JSON when it parses, else the text itself. */ +function parseArg(text: string): unknown { + if (text === '') return undefined + try { + return JSON.parse(text) + } catch { + return text + } +} + +function show(value: unknown): string { + if (value === undefined) return 'Done.' + return typeof value === 'string' ? value : JSON.stringify(value, null, 2) +} + +/** + * Turn the user's reply into an answer: `y`/`n` for yes-or-no questions, + * JSON when it parses, else the text. + */ +export function parseAnswer(reply: string, schema: unknown): unknown { + const text = reply.trim() + const isBoolean = + typeof schema === 'object' && + schema !== null && + 'type' in schema && + schema.type === 'boolean' + if (isBoolean && /^(y|yes)$/i.test(text)) return true + if (isBoolean && /^(n|no)$/i.test(text)) return false + return parseArg(text) +} + +async function runCommand( + session: HarnessSession, + name: string, + input: unknown, +): Promise { + const describe = ( + outcome: { ok: true; value: unknown } | { ok: false; error: unknown }, + ) => + outcome.ok + ? show(outcome.value) + : `/${name} failed: ${outcome.error instanceof Error ? outcome.error.message : String(outcome.error)}` + const settled = Promise.resolve( + session.command(name, input).then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }), + ), + ) + // A command that asks a question waits for the next line, so return now + // and print its result when it is done. + let done = false + void settled.then(() => { + done = true + }) + while (!done) { + if (session.snapshot().pendingQuestions.length > 0) { + return { type: 'notice', text: '', later: settled.then(describe) } + } + await new Promise((resolve) => setTimeout(resolve, 10)) + } + return { type: 'notice', text: describe(await settled) } +} + +/** What the UI does after a line of input. */ +export type LineResult = + | { type: 'notice'; text: string; later?: Promise } + | { type: 'exit' } + | { type: 'sent' } + +/** + * Handle one line of user input: a slash command, a steer while a turn runs, + * or a new prompt. Used by the line mode, and by any UI that calls it. A + * prompt or a steer sends each `@path` file (relative to the working folder) + * with it. A command and an answer do not. + */ +export async function handleLine( + session: HarnessSession, + line: string, +): Promise { + const text = line.trim() + if (text === '') return { type: 'notice', text: '' } + const [question] = session.snapshot().pendingQuestions + if (question) { + // A secret (a key) is the text as typed: `123456` stays a string. + const receipt = await session.answer({ + questionId: question.questionId, + value: question.secret ? text : parseAnswer(text, question.schema), + }) + return { + type: 'notice', + text: + receipt.status === 'rejected' + ? `Answer again: ${receipt.reason ?? ''}` + : '', + } + } + if (!text.startsWith('/')) { + let input: UserInput + try { + input = await attach(session, text, { cwd: process.cwd() }) + } catch (error) { + // A file that cannot go (a type it does not know, too big) stops the send. + return { + type: 'notice', + text: error instanceof Error ? error.message : String(error), + } + } + if (session.snapshot().status === 'running') await session.steer(input) + else void session.prompt(input) + return { type: 'sent' } + } + const [name = '', ...rest] = text.slice(1).split(' ') + const argText = rest.join(' ').trim() + switch (name) { + case 'help': { + const commands = session.commands() + return { + type: 'notice', + text: + commands.length === 0 + ? HELP_TEXT + : `${HELP_TEXT}\nPlugin commands:\n${commands + .map((command) => ` /${command.name} ${command.description}`) + .join('\n')}`, + } + } + case 'config': { + const [key = '', ...valueParts] = argText.split(' ') + if (!key) { + const entries = Object.entries(session.config()) + return { + type: 'notice', + text: + entries.length === 0 + ? 'This harness has no settings.' + : entries + .map( + ([option, entry]) => + ` ${option} = ${JSON.stringify(entry.value)}`, + ) + .join('\n'), + } + } + const receipt = await session.setConfig({ + key, + value: parseArg(valueParts.join(' ').trim()), + }) + return { + type: 'notice', + text: + receipt.status === 'rejected' + ? `Not changed: ${receipt.reason ?? ''}` + : `${key} changed.`, + } + } + case 'connect': + case 'disconnect': + return runCommand(session, `${name}:${argText}`, undefined) + case 'exit': + case 'quit': + return { type: 'exit' } + case 'cancel': { + const receipt = await session.cancel() + return { + type: 'notice', + text: + receipt.status === 'rejected' ? 'Nothing is running.' : 'Cancelled.', + } + } + case 'status': { + const snapshot = session.snapshot() + const active = snapshot.activeOperations + .map((operation) => operation.agent ?? operation.kind) + .join(', ') + return { + type: 'notice', + text: `Status: ${snapshot.status}. Running: ${active || 'nothing'}. Queued turns: ${snapshot.queuedTurns}.`, + } + } + case 'agents': { + const agents = session.registry.list() + return { + type: 'notice', + text: + agents.length === 0 + ? 'This harness has no agents.' + : agents + .map((agent) => ` ${agent.name}: ${agent.description}`) + .join('\n'), + } + } + case 'agent': { + const [agentName = '', ...inputParts] = argText.split(' ') + const handle = session.agent(agentName) + if (!handle) + return { + type: 'notice', + text: `Unknown agent: ${agentName || '(none)'}`, + } + const inputText = inputParts.join(' ').trim() + let input: unknown + try { + input = inputText === '' ? undefined : JSON.parse(inputText) + } catch { + return { + type: 'notice', + text: 'The agent input must be JSON, for example {"vendor":"acme"}.', + } + } + const operation = handle.start(input, { wake: true }) + operation.then( + () => {}, + () => {}, + ) + return { type: 'notice', text: `Started ${agentName} in the background.` } + } + default: + if (session.commands().some((command) => command.name === name)) { + return runCommand(session, name, parseArg(argText)) + } + return { type: 'notice', text: `Unknown command: /${name}. Type /help.` } + } +} diff --git a/packages/ai-harness-cli/src/index.ts b/packages/ai-harness-cli/src/index.ts new file mode 100644 index 0000000000..c7d14fc638 --- /dev/null +++ b/packages/ai-harness-cli/src/index.ts @@ -0,0 +1,270 @@ +import { createHarnessHost } from '@tanstack/ai-harness' +import { createSessionView } from '@tanstack/ai-harness/view' +import { USAGE, parseCliArgs } from './args' +import { attach, defaultMediaDir } from './attach' +import { runLines } from './lines' +import { loadOptional } from './optional' +import { EXIT, runPrint } from './print' +import { createToken, serve } from './serve' +import type { + AnyHarness, + HarnessHost, + HarnessPersistence, + HarnessSession, + Principal, + UserInput, +} from '@tanstack/ai-harness' +import type { SessionView, SessionViewSource } from '@tanstack/ai-harness/view' +import type { + AcpAgentModule, + DashboardConnectModule, + McpHarnessModule, + McpStdioModule, +} from './optional' + +/** The error for a flag whose optional package is missing or too old. */ +function needs(flag: string, name: string): Error { + return new Error( + `${flag} needs ${name} with harness support. Install it next to @tanstack/ai-harness-cli.`, + ) +} + +export interface RunCliOptions { + /** Where sessions keep state. Default: in memory. Do not set it with `host`. */ + persistence?: HarnessPersistence + /** + * Your own host, so the CLI keeps its hooks: leases, recovery, and stores. + * You own it: the CLI does not close it. Do not set it with `persistence`. + */ + host?: HarnessHost + /** + * Who uses the CLI. Sessions open as this principal, so credentials and + * provider keys are theirs. `--serve` authorizes each request as them. + */ + principal?: Principal + /** Default: `process.argv.slice(2)`. */ + argv?: ReadonlyArray + stdin?: NodeJS.ReadStream + stdout?: { write: (text: string) => unknown } + stderr?: { write: (text: string) => unknown } + env?: Record + /** + * Your own screen for an interactive terminal, with any UI library. It gets + * a ready session view and resolves when the user quits. Piped input and + * the other modes (`--print`, `--acp`, `--mcp`, `--serve`, `--dashboard`) do not use it. + * `view.send(text)` sends each `@path` file in `text`, as line mode does. + */ + ui?: (view: SessionView) => Promise | void +} + +/** + * The session for a `ui` view: a typed message or steer sends its `@path` + * files, as in line mode. + */ +function attachingSource(session: HarnessSession) { + const withFiles = (message: UserInput) => + typeof message === 'string' + ? attach(session, message, { cwd: process.cwd() }) + : Promise.resolve(message) + const source: SessionViewSource = { + // The view shows a failed operation from its events, so only a file that + // cannot go rejects here. It becomes an error notice in the view. + prompt: (message) => + withFiles(message).then((input) => { + session.prompt(input).then(undefined, () => {}) + }), + steer: async (message) => session.steer(await withFiles(message)), + resolve: (resume) => session.resolve(resume), + cancel: (operationId) => session.cancel(operationId), + answer: (answer) => session.answer(answer), + command: (name, input) => session.command(name, input), + setConfig: (change) => session.setConfig(change), + events: (options) => session.events(options), + snapshot: () => session.snapshot(), + transcript: () => session.transcript(), + describe: () => session.describe(), + mediaUrl: (id) => session.mediaUrl(id), + loadMedia: (id) => session.loadMedia(id), + } + return source +} + +/** + * Run a harness from the terminal. Resolves to the process exit code. + * + * @example + * ```ts + * #!/usr/bin/env node + * process.exitCode = await runCli(studio) + * ``` + */ +export async function runCli( + harness: AnyHarness, + options: RunCliOptions = {}, +): Promise { + const stdout = options.stdout ?? process.stdout + const stderr = options.stderr ?? process.stderr + const stdin = options.stdin ?? process.stdin + const env = options.env ?? process.env + + let args + try { + args = parseCliArgs(options.argv ?? process.argv.slice(2)) + } catch (error) { + stderr.write( + `${error instanceof Error ? error.message : String(error)}\n\n${USAGE}\n`, + ) + return EXIT.failed + } + if (args.help) { + stdout.write(`${USAGE}\n`) + return EXIT.ok + } + + if (options.host && options.persistence) { + stderr.write('Give runCli a host or persistence, not both.\n') + return EXIT.failed + } + const host = + options.host ?? + createHarnessHost( + options.persistence ? { persistence: options.persistence } : {}, + ) + const { principal } = options + const approvals = args.yes ? 'auto' : 'ask' + try { + if (args.acp) { + const acp = await loadOptional( + '@tanstack/ai-acp/agent', + 'serveAcp', + ) + if (!acp) throw needs('--acp', '@tanstack/ai-acp') + const connection = acp.serveAcp({ host, harness }) + await connection.closed + return EXIT.ok + } + + if (args.mcp) { + const [mcp, stdio] = await Promise.all([ + loadOptional( + '@tanstack/ai-mcp/harness', + 'createHarnessMcpServer', + ), + loadOptional( + '@tanstack/ai-mcp/server/stdio', + 'serveMCPStdio', + ), + ]) + if (!mcp || !stdio) throw needs('--mcp', '@tanstack/ai-mcp') + const server = await mcp.createHarnessMcpServer({ + host, + harness, + threadId: args.thread, + approvals, + // A local client can attach the files of the working folder by path. + filePaths: [process.cwd()], + }) + // stdout carries only MCP messages. The server stops when stdin ends. + const ended = new Promise((resolve) => { + process.stdin.once('end', resolve) + }) + const served = stdio.serveMCPStdio(server) + await ended + await served.close() + return EXIT.ok + } + + if (args.dashboard) { + const dashboard = await loadOptional( + '@tanstack/ai-dashboard/connect', + 'connectDashboard', + ) + if (!dashboard) throw needs('--dashboard', '@tanstack/ai-dashboard') + const savedToken = env.HARNESS_DASHBOARD_TOKEN + const connection = await dashboard.connectDashboard({ + host, + harness, + url: args.dashboard, + threads: [args.thread], + ...(savedToken ? { token: savedToken } : {}), + onPairingCode: (code) => + stderr.write( + `Pair this host in the dashboard with the code ${code}. Waiting for approval...\n`, + ), + onToken: (token) => + stderr.write( + `Paired. Set HARNESS_DASHBOARD_TOKEN=${token} to skip pairing next time.\n`, + ), + }) + stderr.write(`Connected to ${args.dashboard}. Press Ctrl+C to stop.\n`) + await new Promise((resolve) => { + process.once('SIGINT', resolve) + process.once('SIGTERM', resolve) + }) + connection.close() + return EXIT.ok + } + + if (args.serve) { + const token = args.token ?? env.HARNESS_TOKEN ?? createToken() + const server = await serve({ + host, + harness, + port: args.port, + hostname: args.hostname, + token, + threadId: args.thread, + approvals, + ...(principal ? { principal } : {}), + }) + stderr.write(`Serving ${harness.name} at ${server.url}\n`) + if (!args.token && !env.HARNESS_TOKEN) stderr.write(`Token: ${token}\n`) + await new Promise((resolve) => { + process.once('SIGINT', resolve) + process.once('SIGTERM', resolve) + }) + await server.close() + return EXIT.ok + } + + const session = await host.open(harness, { + threadId: args.thread, + ...(principal ? { principal } : {}), + }) + // Relative to the working folder, as a relative --media-dir is. + const mediaDir = args.mediaDir ?? defaultMediaDir(harness.name) + if (args.print !== undefined) { + return await runPrint(session, args.print, { + output: args.output, + stdout, + stderr, + mediaDir, + }) + } + if (stdin.isTTY && options.ui) { + const view = createSessionView(attachingSource(session)) + try { + await view.ready + await options.ui(view) + } finally { + view.dispose() + } + } else { + await runLines(session, stdin, stdout, { + openSignIns: Boolean(stdin.isTTY), + mediaDir, + }) + } + return EXIT.ok + } catch (error) { + stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + return EXIT.failed + } finally { + // A host the caller gave stays open: the caller owns it. + if (!options.host) await host.close() + } +} + +export { parseCliArgs, USAGE } from './args' +export type { CliArgs } from './args' +export { EXIT } from './print' diff --git a/packages/ai-harness-cli/src/lines.ts b/packages/ai-harness-cli/src/lines.ts new file mode 100644 index 0000000000..e23916cbee --- /dev/null +++ b/packages/ai-harness-cli/src/lines.ts @@ -0,0 +1,142 @@ +import { createInterface } from 'node:readline' +import { createSessionView } from '@tanstack/ai-harness/view' +import { saveMediaLine } from './attach' +import { handleLine } from './commands' +import { createPrinter } from './printer' +import { approvalQuestion, openUrl, resolveAll, waitIdle } from './session-view' +import type { HarnessSession } from '@tanstack/ai-harness' + +interface Output { + write: (text: string) => unknown +} + +/** A terminal, which can stop showing what the user types. A pipe cannot. */ +function isTerminal(input: NodeJS.ReadableStream): input is NodeJS.ReadStream { + return 'isTTY' in input && input.isTTY === true && 'setRawMode' in input +} + +/** + * The text of a line typed in raw mode, where the terminal does not edit + * it: a backspace removes the character before it, and other control + * characters are dropped. + */ +function typedText(raw: string) { + let text = '' + for (const char of raw) { + if (char === '\u007f' || char === '\b') text = text.slice(0, -1) + else if (char >= ' ') text += char + } + return text +} + +/** + * Hide what the user types in a terminal while a secret question (a key) + * waits. Raw mode stops the echo. It also stops Ctrl+C, so this sends + * SIGINT itself. Does nothing for a pipe. + */ +function secretTyping(input: NodeJS.ReadableStream) { + const terminal = isTerminal(input) ? input : undefined + let hidden = false + const onKeys = (chunk: unknown) => { + if (!String(chunk).includes('\u0003')) return + show() + process.kill(process.pid, 'SIGINT') + } + const show = () => { + if (!terminal || !hidden) return + hidden = false + terminal.off('data', onKeys) + terminal.setRawMode(false) + } + return { + canHide: terminal !== undefined, + isHidden: () => hidden, + hide: () => { + if (!terminal || hidden) return + hidden = true + terminal.setRawMode(true) + terminal.on('data', onKeys) + }, + show, + } +} + +/** + * The line mode, for piped input: one message or command per line. Each + * line waits for the turn it started. When a turn stops for approval, the + * next line answers it (`y` approves, anything else rejects). In a + * terminal, the answer to a secret question (a key) is not shown as the + * user types. Line mode never prints the answer. + * + * `openSignIns`: open sign-in links in the browser (for an interactive terminal). + * `mediaDir`: the folder for the media a turn makes. Each new file is saved + * there, with one `[image saved: ]` line. + */ +export async function runLines( + session: HarnessSession, + input: NodeJS.ReadableStream, + stdout: Output, + options: { openSignIns?: boolean; mediaDir: string }, +) { + const view = createSessionView(session) + const saves: Array> = [] + const typing = secretTyping(input) + const printer = createPrinter((text) => stdout.write(text), { + onMedia: (part) => { + const saved = saveMediaLine(session, part, options.mediaDir) + saves.push(saved.then(({ text }) => printer.line(text))) + }, + hidesSecrets: typing.canHide, + }) + await view.ready + // Line mode prints only what happens from now on. + printer.mark(view.store.get()) + const printing = view.store.subscribe((state) => printer.print(state)) + const stopOpening = options.openSignIns + ? view.on('signIn', (signIn) => { + if (signIn.url) openUrl(signIn.url) + }) + : () => {} + const line = printer.line + + const lines = createInterface({ input, crlfDelay: Infinity }) + const pendingLater: Array> = [] + try { + for await (const raw of lines) { + const typed = typing.isHidden() ? typedText(raw) : raw + typing.show() + const snapshot = session.snapshot() + if (snapshot.status === 'requires_action') { + await resolveAll( + session, + snapshot.pendingInterrupts, + /^y(es)?$/i.test(typed.trim()), + ) + } else { + const result = await handleLine(session, typed) + if (result.type === 'exit') break + if (result.type === 'notice' && result.text) line(result.text) + if (result.type === 'notice' && result.later) { + pendingLater.push(result.later.then((text) => line(text))) + } + } + await waitIdle(session) + const after = session.snapshot() + if (after.status === 'requires_action') { + line(approvalQuestion(after.pendingInterrupts)) + } + if (after.pendingQuestions[0]?.secret) typing.hide() + } + } finally { + typing.show() + } + lines.close() + await Promise.allSettled(pendingLater) + await waitIdle(session) + stopOpening() + printing.unsubscribe() + printer.print(view.store.get()) + await Promise.allSettled(saves) + view.dispose() + printer.end() +} diff --git a/packages/ai-harness-cli/src/optional.ts b/packages/ai-harness-cli/src/optional.ts new file mode 100644 index 0000000000..2fc807be6a --- /dev/null +++ b/packages/ai-harness-cli/src/optional.ts @@ -0,0 +1,72 @@ +import type { AnyHarness, HarnessHost } from '@tanstack/ai-harness' + +// The packages behind `--acp`, `--mcp`, `/mcp`, and `--dashboard` are +// optional. The specifier is a variable, so a build or a type check never +// needs them, and a version without harness support is the same as none. + +/** + * Import `specifier` and check that it has the functions in `names`. + * Resolves to `undefined` when the package is not installed, or when it has + * no such functions. + */ +export async function loadOptional( + specifier: string, + ...names: Array +): Promise { + const loaded: unknown = await import(/* @vite-ignore */ specifier).catch( + () => undefined, + ) + return hasFunctions(loaded, names) ? loaded : undefined +} + +function hasFunctions( + value: unknown, + names: ReadonlyArray, +): value is T { + return ( + typeof value === 'object' && + value !== null && + names.every((name) => typeof Reflect.get(value, name) === 'function') + ) +} + +/** A server that answers MCP requests, from `@tanstack/ai-mcp/harness`. */ +export interface McpFetchServer { + fetch: (request: Request) => Promise +} + +/** `@tanstack/ai-mcp/harness` */ +export interface McpHarnessModule { + createHarnessMcpServer: (options: { + host: HarnessHost + harness: AnyHarness + threadId?: string + approvals?: 'ask' | 'auto' + filePaths?: Array + }) => Promise +} + +/** `@tanstack/ai-mcp/server/stdio` */ +export interface McpStdioModule { + serveMCPStdio: (server: McpFetchServer) => { close: () => unknown } +} + +/** `@tanstack/ai-acp/agent` */ +export interface AcpAgentModule { + serveAcp: (options: { host: HarnessHost; harness: AnyHarness }) => { + closed: Promise + } +} + +/** `@tanstack/ai-dashboard/connect` */ +export interface DashboardConnectModule { + connectDashboard: (options: { + host: HarnessHost + harness: AnyHarness + url: string + threads: Array + token?: string + onPairingCode?: (code: string) => void + onToken?: (token: string) => void + }) => Promise<{ close: () => void }> +} diff --git a/packages/ai-harness-cli/src/print.ts b/packages/ai-harness-cli/src/print.ts new file mode 100644 index 0000000000..c5c06d2b16 --- /dev/null +++ b/packages/ai-harness-cli/src/print.ts @@ -0,0 +1,83 @@ +import { EventType } from '@tanstack/ai' +import { HARNESS_EVENTS, isMediaRecord } from '@tanstack/ai-harness' +import { attach, saveMediaLine } from './attach' +import type { HarnessSession } from '@tanstack/ai-harness' + +export const EXIT = { + ok: 0, + failed: 1, + needsAction: 2, + cancelled: 130, +} as const + +interface Output { + write: (text: string) => unknown +} + +/** + * Run one prompt and print it. `text` prints the answer as it streams. + * `ndjson` prints every AG-UI event as one JSON line. Each `@path` file in + * the prompt goes with it. + * + * Each media file the turn makes is saved into `mediaDir`. `ndjson` adds the + * saved `path` to the value of its `harness.media` event. `text` prints the + * saved line to stderr, so stdout keeps only the answer. + */ +export async function runPrint( + session: HarnessSession, + prompt: string, + options: { + output: 'text' | 'ndjson' + stdout: Output + stderr: Output + mediaDir: string + }, +) { + const { stdout, stderr } = options + const input = await attach(session, prompt, { cwd: process.cwd() }) + const operation = session.prompt(input) + for await (const chunk of operation.stream()) { + const media = + chunk.type === EventType.CUSTOM && + chunk.name === HARNESS_EVENTS.media && + isMediaRecord(chunk.value) + ? chunk.value + : undefined + if (media !== undefined) { + const saved = await saveMediaLine(session, media, options.mediaDir) + if (options.output === 'text' || saved.path === undefined) { + stderr.write(`${saved.text}\n`) + } + if (options.output === 'ndjson') { + // A file that was not saved has no `path`: JSON drops `undefined`. + const value = { ...media, path: saved.path } + stdout.write(`${JSON.stringify({ ...chunk, value })}\n`) + } + } else if (options.output === 'ndjson') { + stdout.write(`${JSON.stringify(chunk)}\n`) + } else if ( + chunk.type === EventType.TEXT_MESSAGE_CONTENT && + !('subagentRunId' in chunk && chunk.subagentRunId) + ) { + stdout.write(chunk.delta) + } + } + const failure: unknown = await operation.then( + () => undefined, + (error: unknown) => error, + ) + if (options.output === 'text') stdout.write('\n') + const status = operation.status() + if (status === 'completed') return EXIT.ok + if (status === 'interrupted') { + stderr.write( + 'The turn stopped for an approval. Run without --print, in line mode, to answer it.\n', + ) + return EXIT.needsAction + } + if (status === 'cancelled') return EXIT.cancelled + stderr.write( + `The turn failed: ${failure instanceof Error ? failure.message : String(failure)}\n`, + ) + return EXIT.failed +} diff --git a/packages/ai-harness-cli/src/printer.ts b/packages/ai-harness-cli/src/printer.ts new file mode 100644 index 0000000000..f39bb7aaf0 --- /dev/null +++ b/packages/ai-harness-cli/src/printer.ts @@ -0,0 +1,134 @@ +import { shortAnswer, signInText } from './session-view' +import type { + AgentPart, + MediaPart, + SessionViewState, + ViewPart, +} from '@tanstack/ai-harness/view' + +/** + * Prints a session view as lines: streamed text as it grows, and one + * bracketed line per tool call, child agent, notice, question, and sign-in. + * Notices that line mode prints itself (command results, rejected inputs, + * and UI notes) are skipped. + * + * Media parts print nothing here. `onMedia` gets each new media part of an + * assistant message once, from the lead or from a child at any depth. + * `hidesSecrets`: the input hides what the user types for a secret + * question, so its line says so. + */ +export function createPrinter( + write: (text: string) => unknown, + options: { onMedia?: (part: MediaPart) => void; hidesSecrets?: boolean } = {}, +) { + const done = new Set() + const written = new Map() + // True while streamed text has no line break at its end yet. + let midLine = false + let lastStream = '' + let marking = false + + const line = (text: string) => { + if (marking) return + if (midLine) write('\n') + midLine = false + write(`${text}\n`) + } + const once = (key: string, text: string) => { + if (done.has(key)) return + done.add(key) + line(`[${text}]`) + } + const stream = (key: string, text: string) => { + const before = written.get(key) ?? 0 + if (text.length <= before) return + written.set(key, text.length) + if (marking) return + if (midLine && key !== lastStream) write('\n') + write(text.slice(before)) + midLine = true + lastStream = key + } + const agent = (key: string, part: AgentPart) => { + // A child that ended before its saved parts loaded (a reopened thread) + // printed its end already. Its parts that come later are old: mark them. + const old = done.has(`${key}:end`) + const wasMarking = marking + marking ||= old + once(`${key}:start`, `agent ${part.name} started`) + part.parts.forEach((inner, index) => { + if (inner.type === 'tool-call') + once(`${key}:${index}`, `${part.name}: tool ${inner.name}`) + }) + const answer = shortAnswer( + part.parts + .map((inner) => (inner.type === 'text' ? inner.text : '')) + .join(''), + ) + if (part.status === 'done') + once( + `${key}:end`, + answer + ? `agent ${part.name} finished: ${answer}` + : `agent ${part.name} finished`, + ) + if (part.status === 'failed') + once(`${key}:end`, `agent ${part.name} failed: ${part.error ?? ''}`) + if (old) media(part.parts) + marking = wasMarking + } + const media = (parts: ReadonlyArray) => { + for (const part of parts) { + if (part.type === 'agent') media(part.parts) + if (part.type !== 'media' || done.has(`media:${part.id}`)) continue + done.add(`media:${part.id}`) + if (!marking) options.onMedia?.(part) + } + } + const print = (state: SessionViewState) => { + for (const message of state.messages) { + if (message.role === 'notice') { + if (message.kind === 'error' || message.kind === 'info') + once(message.id, message.text) + continue + } + if (message.role !== 'assistant') continue + message.parts.forEach((part, index) => { + const key = `${message.id}:${index}` + if (part.type === 'text') stream(key, part.text) + if (part.type === 'tool-call') once(key, `tool ${part.name}`) + if (part.type === 'agent') agent(key, part) + }) + media(message.parts) + } + for (const question of state.questions) { + const isHidden = question.secret === true && options.hidesSecrets === true + once( + `question:${question.id}`, + isHidden + ? `? ${question.message} (typing is hidden)` + : `? ${question.message}`, + ) + } + for (const signIn of state.signIns) + once( + `sign-in:${signIn.connector}:${signIn.url ?? ''}`, + signInText(signIn), + ) + } + + return { + print, + /** Mark everything in `state` as printed, for history. */ + mark: (state: SessionViewState) => { + marking = true + print(state) + marking = false + }, + line, + end: () => { + if (midLine) write('\n') + midLine = false + }, + } +} diff --git a/packages/ai-harness-cli/src/serve.ts b/packages/ai-harness-cli/src/serve.ts new file mode 100644 index 0000000000..ed8c574847 --- /dev/null +++ b/packages/ai-harness-cli/src/serve.ts @@ -0,0 +1,163 @@ +import { createServer } from 'node:http' +import { Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { randomBytes, timingSafeEqual } from 'node:crypto' +import { createHarnessHandler } from '@tanstack/ai-harness' +import { loadOptional } from './optional' +import type { IncomingMessage, ServerResponse } from 'node:http' +import type { ReadableStream as NodeReadableStream } from 'node:stream/web' +import type { AnyHarness, HarnessHost, Principal } from '@tanstack/ai-harness' +import type { McpHarnessModule } from './optional' + +export interface ServeOptions { + host: HarnessHost + harness: AnyHarness + port: number + hostname: string + /** Every request must send `Authorization: Bearer `. */ + token: string + /** The conversation of an MCP tool call that names none. Default `'main'`. */ + threadId?: string + /** How `/mcp` handles tool calls that need approval. Default `'ask'`. */ + approvals?: 'ask' | 'auto' + /** Who a request with the token is. Default `{ id: 'cli' }`. */ + principal?: Principal +} + +/** The MCP server for `/mcp`, or `undefined` without `@tanstack/ai-mcp`. */ +async function mcpServer(options: ServeOptions) { + const mcp = await loadOptional( + '@tanstack/ai-mcp/harness', + 'createHarnessMcpServer', + ) + if (mcp === undefined) return undefined + return mcp.createHarnessMcpServer({ + host: options.host, + harness: options.harness, + threadId: options.threadId, + approvals: options.approvals, + }) +} + +/** A random token for `--serve` when none is given. */ +export function createToken(): string { + return randomBytes(24).toString('base64url') +} + +function sameToken(given: string | null, expected: string): boolean { + if (!given?.startsWith('Bearer ')) return false + const a = Buffer.from(given.slice(7)) + const b = Buffer.from(expected) + return a.length === b.length && timingSafeEqual(a, b) +} + +async function toRequest( + req: IncomingMessage, + base: string, + signal: AbortSignal, +): Promise { + const headers = new Headers() + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === 'string') headers.set(key, value) + else if (Array.isArray(value)) headers.set(key, value.join(', ')) + } + const hasBody = req.method !== 'GET' && req.method !== 'HEAD' + const chunks: Array = [] + if (hasBody) for await (const chunk of req) chunks.push(Buffer.from(chunk)) + return new Request(new URL(req.url ?? '/', base), { + method: req.method ?? 'GET', + headers, + signal, + ...(hasBody ? { body: Buffer.concat(chunks) } : {}), + }) +} + +async function send(res: ServerResponse, response: Response): Promise { + res.writeHead(response.status, Object.fromEntries(response.headers)) + if (!response.body) { + res.end() + return + } + // `Readable.fromWeb` takes the stream type from node:stream/web. When the + // body fails, for example after the client left, `pipeline` ends `res`. + await pipeline( + Readable.fromWeb(response.body as NodeReadableStream), + res, + ).catch(() => {}) +} + +/** + * Serve the harness session protocol over HTTP (capabilities, run, events, + * control, snapshot). With `@tanstack/ai-mcp` installed, `/mcp` serves the + * harness as an MCP server. Every request needs the bearer token. + */ +export async function serve( + options: ServeOptions, +): Promise<{ url: string; close: () => Promise }> { + const isAllowed = (request: Request) => + sameToken(request.headers.get('authorization'), options.token) + const principal = options.principal ?? { id: 'cli' } + const handler = createHarnessHandler({ + host: options.host, + harness: options.harness, + authorize: (request) => (isAllowed(request) ? principal : null), + // One principal holds the token, so it can use every thread. + canAccess: () => true, + // ponytail: the token signs media URLs, so they work as long as it does. + mediaSecret: options.token, + }) + const mcp = await mcpServer(options) + const route = (request: Request) => { + const isMcp = mcp !== undefined && new URL(request.url).pathname === '/mcp' + if (!isMcp) return handler(request) + if (!isAllowed(request)) { + return Response.json({ error: 'unauthorized' }, { status: 401 }) + } + return mcp.fetch(request) + } + const server = createServer((req, res) => { + const aborter = new AbortController() + res.on('close', () => aborter.abort()) + // Refuse a body without the token before reading it. Signed media URLs + // carry no token, but they are GET requests. + const hasBody = req.method !== 'GET' && req.method !== 'HEAD' + if ( + hasBody && + !sameToken(req.headers.authorization ?? null, options.token) + ) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'unauthorized' })) + return + } + void (async () => { + try { + const base = `http://${req.headers.host ?? `${options.hostname}:${options.port}`}` + await send(res, await route(await toRequest(req, base, aborter.signal))) + } catch (error) { + if (!res.headersSent) + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify({ + error: error instanceof Error ? error.message : String(error), + }), + ) + } + })() + }) + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(options.port, options.hostname, () => { + const address = server.address() + const port = + typeof address === 'object' && address ? address.port : options.port + resolve({ + url: `http://${options.hostname}:${port}`, + close: () => + new Promise((done) => { + server.closeAllConnections() + server.close(() => done()) + }), + }) + }) + }) +} diff --git a/packages/ai-harness-cli/src/session-view.ts b/packages/ai-harness-cli/src/session-view.ts new file mode 100644 index 0000000000..29dea42704 --- /dev/null +++ b/packages/ai-harness-cli/src/session-view.ts @@ -0,0 +1,77 @@ +import type { Interrupt, RunAgentResumeItem } from '@tanstack/ai' +import type { HarnessSession } from '@tanstack/ai-harness' +import type { SignIn } from '@tanstack/ai-harness/view' + +/** Answer every open interrupt of the last turn with one decision. */ +export function resolveAll( + session: HarnessSession, + interrupts: ReadonlyArray, + approved: boolean, +) { + const resume: Array = interrupts.map((interrupt) => ({ + interruptId: interrupt.id, + status: 'resolved', + payload: approved, + })) + return session.resolve(resume) +} + +/** Open an http(s) URL in the default browser. No shell is involved. */ +export function openUrl(url: string) { + if (!/^https?:\/\//.test(url)) return + void import('node:child_process').then(({ spawn }) => { + const [command, args] = + process.platform === 'win32' + ? // Not `cmd /c start`: cmd would read `&` in the URL as a command separator. + ['rundll32', ['url.dll,FileProtocolHandler', url]] + : process.platform === 'darwin' + ? ['open', [url]] + : ['xdg-open', [url]] + try { + spawn(command, args, { stdio: 'ignore', detached: true }).unref() + } catch { + // The notice still shows the URL. + } + }) +} + +/** A short question for the open interrupts. */ +export function approvalQuestion(interrupts: ReadonlyArray) { + const names = interrupts.map( + (interrupt) => interrupt.message ?? interrupt.toolCallId ?? interrupt.id, + ) + return `Approve ${names.join(', ')}? [y/n]` +} + +/** + * Resolves when no chat turn runs or waits in the queue, or when the + * session waits for an answer to a question. + */ +export async function waitIdle(session: HarnessSession) { + while (true) { + const snapshot = session.snapshot() + if (snapshot.pendingQuestions.length > 0) return + const chatActive = snapshot.activeOperations.some( + (operation) => operation.kind === 'chat', + ) + const isIdle = + snapshot.status !== 'running' && !chatActive && snapshot.queuedTurns === 0 + if (isIdle) return + await new Promise((resolve) => setTimeout(resolve, 20)) + } +} + +/** The first 200 characters of a child's answer, on one line. */ +export function shortAnswer(text: string) { + const line = text.replace(/\s+/g, ' ').trim() + return line.length > 200 ? `${line.slice(0, 200)}...` : line +} + +/** The sign-in line for a connector: its link and code, or the command. */ +export function signInText(signIn: SignIn) { + const code = signIn.userCode ? ` and enter the code ${signIn.userCode}` : '' + const where = signIn.url + ? ` Open ${signIn.url}${code}.` + : ` Run /connect ${signIn.connector}.` + return `Sign in to ${signIn.connector}.${where}` +} diff --git a/packages/ai-harness-cli/tests/attach.test.ts b/packages/ai-harness-cli/tests/attach.test.ts new file mode 100644 index 0000000000..1f0aaea15b --- /dev/null +++ b/packages/ai-harness-cli/tests/attach.test.ts @@ -0,0 +1,208 @@ +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest' +import { + createHarnessHost, + defineHarness, + mediaIdOf, +} from '@tanstack/ai-harness' +import { memoryPersistence } from '@tanstack/ai-persistence' +import { attach, defaultMediaDir, saveMedia } from '../src/attach' +import type { AnyTextAdapter } from '@tanstack/ai' +import type { + HarnessHost, + HarnessSession, + UserInput, +} from '@tanstack/ai-harness' + +// The real file system, with spies, so a test can see which files are read. +vi.mock('node:fs/promises', { spy: true }) + +/** A model that is never called: these tests only store and save files. */ +function idleModel(): AnyTextAdapter { + return { + kind: 'text', + name: 'mock', + model: 'test-model', + // `~types` holds types only. Its values are never read, so they are casts. + '~types': { + providerOptions: {} as Record, + inputModalities: ['text'] as readonly ['text'], + messageMetadataByModality: { + text: undefined as unknown, + image: undefined as unknown, + audio: undefined as unknown, + video: undefined as unknown, + document: undefined as unknown, + }, + toolCapabilities: [] as ReadonlyArray, + toolCallMetadata: undefined as unknown, + systemPromptMetadata: undefined as never, + }, + chatStream: () => { + throw new Error('The model must not be called.') + }, + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } +} + +/** The part that `attach` makes for a stored PNG. */ +const pngPart = { + type: 'image', + source: { + type: 'url', + value: expect.stringMatching(/^harness-media:./), + mimeType: 'image/png', + }, +} + +let dir = '' +let host: HarnessHost +let session: HarnessSession + +beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), 'harness-attach-')) + await writeFile(join(dir, 'cat.png'), 'hello') + await writeFile(join(dir, 'my cat.png'), 'meow') + await writeFile(join(dir, 'notes.xyz'), 'plain') + await mkdir(join(dir, 'folder.png')) + host = createHarnessHost({ persistence: memoryPersistence() }) + session = await host.open( + defineHarness({ name: 'test/attach', adapter: idleModel() }), + { threadId: 't' }, + ) +}) + +afterAll(async () => { + await host.close() + await rm(dir, { recursive: true, force: true }) +}) + +beforeEach(() => { + vi.clearAllMocks() +}) + +/** The name and text of each stored file that `input` sends. */ +async function storedFiles(input: UserInput) { + const parts = typeof input === 'string' ? [] : input + const ids = parts.flatMap((part) => mediaIdOf(part) ?? []) + return Promise.all( + ids.map(async (id) => ({ + name: (await session.getMedia(id))?.name, + text: new TextDecoder().decode(await session.loadMedia(id)), + })), + ) +} + +describe('attach', () => { + it('sends a file token as a media part and takes the token out of the text', async () => { + const input = await attach(session, 'what is wrong in @cat.png today', { + cwd: dir, + }) + + expect(input).toEqual([ + { type: 'text', content: 'what is wrong in today' }, + pngPart, + ]) + expect(await storedFiles(input)).toEqual([ + { name: 'cat.png', text: 'hello' }, + ]) + }) + + it('reads a quoted path with spaces', async () => { + const input = await attach(session, 'look at @"my cat.png"', { cwd: dir }) + + expect(input).toEqual([{ type: 'text', content: 'look at' }, pngPart]) + expect(await storedFiles(input)).toEqual([ + { name: 'my cat.png', text: 'meow' }, + ]) + }) + + it('sends no text part for a message that is only a file', async () => { + expect(await attach(session, '@cat.png', { cwd: dir })).toEqual([pngPart]) + }) + + it('keeps an email and a package name as text, and reads no file for the email', async () => { + const email = 'write to me@acme.dev' + const packageName = 'install @types/node first' + + expect(await attach(session, email, { cwd: dir })).toBe(email) + expect(stat).not.toHaveBeenCalled() + expect(readFile).not.toHaveBeenCalled() + expect(await attach(session, packageName, { cwd: dir })).toBe(packageName) + // A token that can be a path is looked up, so the spy above sees reads. + expect(stat).toHaveBeenCalled() + }) + + it.each([ + ['a missing file', 'see @missing.png now'], + ['a folder', 'see @folder.png now'], + ])('keeps %s as text', async (_label, text) => { + expect(await attach(session, text, { cwd: dir })).toBe(text) + }) + + it('refuses a file of a type it does not know', async () => { + await expect( + attach(session, 'read @notes.xyz', { cwd: dir }), + ).rejects.toThrow( + 'Cannot attach notes.xyz: the file type is not known. To send the path as text, remove the @.', + ) + }) +}) + +describe('saveMedia', () => { + it('makes the folder and adds a number instead of overwriting a file', async () => { + const record = await session.putMedia(new TextEncoder().encode('hello'), { + mimeType: 'image/png', + name: 'cat.png', + }) + const out = join(dir, 'saved', 'media') + + const paths = [ + await saveMedia(session, record, out), + await saveMedia(session, record, out), + await saveMedia(session, record, out), + ] + + expect(paths).toEqual([ + join(out, 'cat.png'), + join(out, 'cat-1.png'), + join(out, 'cat-2.png'), + ]) + expect(await readFile(join(out, 'cat-2.png'), 'utf8')).toBe('hello') + }) + + it.each(['../escaped.png', 'sub/escaped.png', 'sub\\escaped.png', '..'])( + 'refuses the name %s', + async (name) => { + const record = await session.putMedia(new TextEncoder().encode('x'), { + mimeType: 'image/png', + name, + }) + const out = join(dir, 'refused') + + await expect(saveMedia(session, record, out)).rejects.toThrow( + 'it is not a plain file name', + ) + await expect(stat(join(dir, 'escaped.png'))).rejects.toThrow() + }, + ) +}) + +describe('defaultMediaDir', () => { + it.each([ + ['acme/coder', 'acme-coder-media'], + ['@acme/coder', 'acme-coder-media'], + ])('saves the media of %s into %s', (name, folder) => { + expect(defaultMediaDir(name)).toBe(folder) + }) +}) diff --git a/packages/ai-harness-cli/tests/child-view.test.ts b/packages/ai-harness-cli/tests/child-view.test.ts new file mode 100644 index 0000000000..f06771b9fa --- /dev/null +++ b/packages/ai-harness-cli/tests/child-view.test.ts @@ -0,0 +1,140 @@ +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { EventType, defineAgent } from '@tanstack/ai' +import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { memoryPersistence } from '@tanstack/ai-persistence' +import { runLines } from '../src/lines' +import type { AnyTextAdapter, StreamChunk } from '@tanstack/ai' + +// `~types` holds types only. Its values are never read, so they are casts. +const TYPES = { + providerOptions: {} as Record, + inputModalities: ['text'] as readonly ['text'], + messageMetadataByModality: { + text: undefined as unknown, + image: undefined as unknown, + audio: undefined as unknown, + video: undefined as unknown, + document: undefined as unknown, + }, + toolCapabilities: [] as ReadonlyArray, + toolCallMetadata: undefined as unknown, + systemPromptMetadata: undefined as never, +} + +function model(turns: Array>): AnyTextAdapter { + let call = 0 + return { + kind: 'text', + name: 'mock', + model: 'mock', + '~types': TYPES, + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + chatStream: () => { + const chunks = turns[call] ?? [] + call += 1 + return (async function* () { + yield* chunks + })() + }, + } +} + +const say = (text: string, id: string): Array => [ + { type: EventType.RUN_STARTED, runId: 'r', threadId: 't', timestamp: 1 }, + { + type: EventType.TEXT_MESSAGE_START, + messageId: id, + role: 'assistant', + timestamp: 1, + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: id, + delta: text, + timestamp: 1, + }, + { type: EventType.TEXT_MESSAGE_END, messageId: id, timestamp: 1 }, + { + type: EventType.RUN_FINISHED, + runId: 'r', + threadId: 't', + timestamp: 1, + metadata: { tanstack: { finishReason: 'stop' } }, + }, +] + +describe('line mode with a child agent', () => { + it('prints the child once, in its finish line, and the lead answer after it', async () => { + const helper = defineAgent({ + name: 'helper', + description: 'Helps', + inputSchema: z.object({ task: z.string() }), + run: (ctx) => + ctx.chat({ + adapter: model([say('child says hi', 'c1')]), + messages: [{ role: 'user', content: ctx.input.task }], + }), + }) + const lead = model([ + [ + { + type: EventType.RUN_STARTED, + runId: 'r', + threadId: 't', + timestamp: 1, + }, + { + type: EventType.TOOL_CALL_START, + toolCallId: 'call-1', + toolCallName: 'helper', + timestamp: 1, + }, + { + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'call-1', + delta: '{"task":"say hi"}', + timestamp: 1, + }, + { type: EventType.TOOL_CALL_END, toolCallId: 'call-1', timestamp: 1 }, + { + type: EventType.RUN_FINISHED, + runId: 'r', + threadId: 't', + timestamp: 1, + metadata: { tanstack: { finishReason: 'tool_calls' } }, + }, + ], + say('lead done', 'l1'), + ]) + const host = createHarnessHost({ persistence: memoryPersistence() }) + const session = await host.open( + defineHarness({ + name: 'test/child-lines', + adapter: lead, + subagents: { agents: [helper] }, + }), + { threadId: 't' }, + ) + const input = new PassThrough() + let out = '' + // This turn makes no media, so nothing is written to the media folder. + const running = runLines( + session, + input, + { write: (text: string) => (out += text) }, + { mediaDir: join(tmpdir(), 'child-view-media') }, + ) + input.end('go\n') + await running + await host.close() + + expect(out).toContain('[agent helper started]') + expect(out).toContain('[agent helper finished: child says hi]') + expect(out.split('child says hi')).toHaveLength(2) + expect(out.indexOf('lead done')).toBeGreaterThan(out.indexOf('finished')) + }) +}) diff --git a/packages/ai-harness-cli/tests/cli-media.test.ts b/packages/ai-harness-cli/tests/cli-media.test.ts new file mode 100644 index 0000000000..5cce9b5142 --- /dev/null +++ b/packages/ai-harness-cli/tests/cli-media.test.ts @@ -0,0 +1,323 @@ +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Readable } from 'node:stream' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { EventType, defineAgent } from '@tanstack/ai' +import { defineHarness } from '@tanstack/ai-harness' +import { memoryPersistence } from '@tanstack/ai-persistence' +import { EXIT, runCli } from '../src' +import type { AnyTextAdapter, ImageAdapter, StreamChunk } from '@tanstack/ai' + +// ponytail: `textTurn`, `toolTurn`, `scripted`, `capture`, and `stdinFrom` +// repeat the ones in cli.test.ts. Move them to a helpers file when a third +// test file needs them. +const now = () => Date.now() +const textTurn = (text: string): Array => [ + { type: EventType.RUN_STARTED, runId: 'r', threadId: 't', timestamp: now() }, + { + type: EventType.TEXT_MESSAGE_START, + messageId: 'm', + role: 'assistant', + timestamp: now(), + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'm', + delta: text, + timestamp: now(), + }, + { type: EventType.TEXT_MESSAGE_END, messageId: 'm', timestamp: now() }, + { + type: EventType.RUN_FINISHED, + runId: 'r', + threadId: 't', + timestamp: now(), + metadata: { tanstack: { finishReason: 'stop' } }, + }, +] +const toolTurn = (name: string, args: string): Array => [ + { type: EventType.RUN_STARTED, runId: 'r', threadId: 't', timestamp: now() }, + { + type: EventType.TOOL_CALL_START, + toolCallId: 'call_1', + toolCallName: name, + timestamp: now(), + }, + { + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'call_1', + delta: args, + timestamp: now(), + }, + { type: EventType.TOOL_CALL_END, toolCallId: 'call_1', timestamp: now() }, + { + type: EventType.RUN_FINISHED, + runId: 'r', + threadId: 't', + timestamp: now(), + metadata: { tanstack: { finishReason: 'tool_calls' } }, + }, +] + +function scripted(turns: Array>) { + let call = 0 + const seen: Array> = [] + const adapter: AnyTextAdapter = { + kind: 'text', + name: 'mock', + model: 'test-model', + // `~types` holds types only. Its values are never read, so they are casts. + '~types': { + providerOptions: {} as Record, + inputModalities: ['text'] as readonly ['text'], + messageMetadataByModality: { + text: undefined as unknown, + image: undefined as unknown, + audio: undefined as unknown, + video: undefined as unknown, + document: undefined as unknown, + }, + toolCapabilities: [] as ReadonlyArray, + toolCallMetadata: undefined as unknown, + systemPromptMetadata: undefined as never, + }, + chatStream: (options) => { + seen.push(options.messages) + const chunks = turns[call] ?? textTurn('') + call += 1 + return (async function* () { + yield* chunks + })() + }, + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } + return { adapter, seen } +} + +function capture() { + let text = '' + return { + write: (chunk: string) => (text += chunk), + get text() { + return text + }, + } +} + +/** A stdin that reads `lines`, as a terminal or as a pipe. */ +function stdinFrom(lines: Array, { isTTY }: { isTTY: boolean }) { + // NodeJS.ReadStream is a TTY socket. A test cannot make one without a real + // terminal, and runCli only reads the lines and `isTTY`, so this casts. + return Object.assign(Readable.from(lines), { + isTTY, + }) as unknown as NodeJS.ReadStream +} + +/** An image model that paints the bytes "hello". */ +function imageAdapter(): ImageAdapter { + return { + kind: 'image', + name: 'fake-image', + model: 'fake-image-model', + '~types': { + providerOptions: {}, + modelProviderOptionsByName: {}, + modelSizeByName: {}, + modelInputModalitiesByName: {}, + }, + // 'aGVsbG8=' is base64 for "hello". + generateImages: async () => ({ + id: 'image-1', + model: 'fake-image-model', + images: [{ b64Json: 'aGVsbG8=' }], + }), + } +} + +const painter = defineAgent({ + name: 'painter', + description: 'Paints', + run: async (ctx) => { + await ctx.generateImage({ adapter: imageAdapter(), prompt: 'a cat' }) + return 'painted' + }, +}) + +/** + * Run a harness whose lead calls the painter once, then answers, with + * `argv` and the piped `lines`. Gives the exit code and the output. + */ +async function paint(argv: Array, lines: Array = []) { + const { adapter } = scripted([ + toolTurn('painter', '{}'), + textTurn('Here is your cat.'), + ]) + const harness = defineHarness({ + name: 'test/painting', + adapter, + subagents: { agents: [painter] }, + }) + const stdout = capture() + const stderr = capture() + const code = await runCli(harness, { + argv, + stdin: stdinFrom(lines, { isTTY: false }), + stdout, + stderr, + persistence: memoryPersistence(), + }) + return { code, stdout: stdout.text, stderr: stderr.text } +} + +let root = '' + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'harness-cli-media-')) + await writeFile(join(root, 'cat.png'), 'hello') + await writeFile(join(root, 'notes.xyz'), 'plain') +}) + +afterAll(async () => { + await rm(root, { recursive: true, force: true }) +}) + +/** A prompt that sends cat.png by its full path, quoted for spaces. */ +const aboutCat = () => `what is @"${join(root, 'cat.png')}"` + +/** What the model sees for `aboutCat()`: the text, then the bytes. */ +const seenAboutCat = [ + { type: 'text', content: 'what is' }, + { + type: 'image', + source: { type: 'data', value: 'aGVsbG8=', mimeType: 'image/png' }, + }, +] + +/** The one file in `dir`, as its path and its text. */ +async function onlyFile(dir: string) { + const names = await readdir(dir) + expect(names).toHaveLength(1) + const path = join(dir, names[0] ?? '') + return { path, text: await readFile(path, 'utf8') } +} + +describe('media in line mode', () => { + it('saves a new image into the media folder once and prints where', async () => { + const mediaDir = join(root, 'lines') + + const { code, stdout } = await paint( + ['--media-dir', mediaDir], + ['paint a cat\n'], + ) + + expect(code).toBe(EXIT.ok) + const saved = await onlyFile(mediaDir) + expect(saved.text).toBe('hello') + expect(stdout).toContain(`[image saved: ${saved.path}]\n`) + expect(stdout).toContain('Here is your cat.') + }) + + it('prints why a file cannot go, and does not call the model', async () => { + const { adapter, seen } = scripted([]) + const stdout = capture() + + await runCli(defineHarness({ name: 'test/media-refused', adapter }), { + argv: [], + stdin: stdinFrom([`read @"${join(root, 'notes.xyz')}"\n`], { + isTTY: false, + }), + stdout, + stderr: capture(), + persistence: memoryPersistence(), + }) + + expect(stdout.text).toContain( + 'Cannot attach notes.xyz: the file type is not known.', + ) + expect(seen).toHaveLength(0) + }) +}) + +describe('media in print mode', () => { + it('prints the media event with the saved path in ndjson', async () => { + const mediaDir = join(root, 'ndjson') + + const { stdout } = await paint([ + '-p', + 'paint a cat', + '--output', + 'ndjson', + '--media-dir', + mediaDir, + ]) + + const saved = await onlyFile(mediaDir) + const media = stdout + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + .filter((event) => event.name === 'harness.media') + expect(media).toEqual([ + expect.objectContaining({ + type: 'CUSTOM', + value: expect.objectContaining({ + kind: 'image', + source: 'generated', + size: 5, + path: saved.path, + }), + }), + ]) + }) + + it('prints the saved line to stderr and only the answer to stdout', async () => { + const mediaDir = join(root, 'text') + + const { code, stdout, stderr } = await paint([ + '-p', + 'paint a cat', + '--media-dir', + mediaDir, + ]) + + expect(code).toBe(EXIT.ok) + const saved = await onlyFile(mediaDir) + expect(stdout).toBe('Here is your cat.\n') + expect(stderr).toBe(`[image saved: ${saved.path}]\n`) + }) + + it('sends an @file to the model as data', async () => { + const { adapter, seen } = scripted([textTurn('A cat.')]) + + const code = await runCli(defineHarness({ name: 'test/attach', adapter }), { + argv: ['-p', aboutCat()], + stdout: capture(), + stderr: capture(), + persistence: memoryPersistence(), + }) + + expect(code).toBe(EXIT.ok) + expect(seen[0]?.at(-1)?.content).toEqual(seenAboutCat) + }) +}) + +describe('media in a custom ui', () => { + it('sends an @file of view.send to the model', async () => { + const { adapter, seen } = scripted([textTurn('A cat.')]) + + await runCli(defineHarness({ name: 'test/ui-attach', adapter }), { + argv: [], + stdin: stdinFrom([], { isTTY: true }), + stdout: capture(), + stderr: capture(), + persistence: memoryPersistence(), + ui: async (view) => { + await view.send(aboutCat()) + await vi.waitFor(() => expect(seen).toHaveLength(1)) + }, + }) + + expect(seen[0]?.at(-1)?.content).toEqual(seenAboutCat) + }) +}) diff --git a/packages/ai-harness-cli/tests/cli.test.ts b/packages/ai-harness-cli/tests/cli.test.ts new file mode 100644 index 0000000000..1d2b9de6e2 --- /dev/null +++ b/packages/ai-harness-cli/tests/cli.test.ts @@ -0,0 +1,623 @@ +import { Readable } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import { EventType, defineAgent, toolDefinition } from '@tanstack/ai' +import { z } from 'zod' +import { + configOption, + createHarnessHost, + defineCommand, + defineHarness, + definePlugin, +} from '@tanstack/ai-harness' +import { memoryPersistence } from '@tanstack/ai-persistence' +import { EXIT, parseCliArgs, runCli } from '../src' +import { serve } from '../src/serve' +import type { AnyTextAdapter, StreamChunk } from '@tanstack/ai' + +const now = () => Date.now() +const textTurn = (text: string): Array => [ + { type: EventType.RUN_STARTED, runId: 'r', threadId: 't', timestamp: now() }, + { + type: EventType.TEXT_MESSAGE_START, + messageId: 'm', + role: 'assistant', + timestamp: now(), + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'm', + delta: text, + timestamp: now(), + }, + { type: EventType.TEXT_MESSAGE_END, messageId: 'm', timestamp: now() }, + { + type: EventType.RUN_FINISHED, + runId: 'r', + threadId: 't', + timestamp: now(), + metadata: { tanstack: { finishReason: 'stop' } }, + }, +] +const toolTurn = (name: string, args: string): Array => [ + { type: EventType.RUN_STARTED, runId: 'r', threadId: 't', timestamp: now() }, + { + type: EventType.TOOL_CALL_START, + toolCallId: 'call_1', + toolCallName: name, + timestamp: now(), + }, + { + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'call_1', + delta: args, + timestamp: now(), + }, + { type: EventType.TOOL_CALL_END, toolCallId: 'call_1', timestamp: now() }, + { + type: EventType.RUN_FINISHED, + runId: 'r', + threadId: 't', + timestamp: now(), + metadata: { tanstack: { finishReason: 'tool_calls' } }, + }, +] + +function scripted(turns: Array>) { + let call = 0 + const seen: Array> = [] + const adapter: AnyTextAdapter = { + kind: 'text', + name: 'mock', + model: 'test-model', + // `~types` holds types only. Its values are never read, so they are casts. + '~types': { + providerOptions: {} as Record, + inputModalities: ['text'] as readonly ['text'], + messageMetadataByModality: { + text: undefined as unknown, + image: undefined as unknown, + audio: undefined as unknown, + video: undefined as unknown, + document: undefined as unknown, + }, + toolCapabilities: [] as ReadonlyArray, + toolCallMetadata: undefined as unknown, + systemPromptMetadata: undefined as never, + }, + chatStream: (options) => { + seen.push(options.messages) + const chunks = turns[call] ?? textTurn('') + call += 1 + return (async function* () { + yield* chunks + })() + }, + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } + return { adapter, seen } +} + +/** `/connect acme` asks for a key as a secret question, then saves it. */ +const acmeKeys = definePlugin({ + name: 'test/acme-keys', + setup: (ctx) => ({ + commands: { + 'connect:acme': defineCommand({ + description: 'Save an Acme API key', + run: async (_input, { credentials }) => { + const key = await ctx.session.ask({ + message: 'Paste your Acme API key', + schema: z.string(), + secret: true, + }) + await credentials.set({ + id: 'acme', + credential: { type: 'api_key', value: key }, + }) + return `Connected to Acme (key ...${key.slice(-4)}).` + }, + }), + }, + }), +}) + +function capture() { + let text = '' + return { + write: (chunk: string) => (text += chunk), + get text() { + return text + }, + } +} + +/** A stdin that reads `lines`, as a terminal or as a pipe. */ +function stdinFrom(lines: Array, { isTTY }: { isTTY: boolean }) { + // NodeJS.ReadStream is a TTY socket. A test cannot make one without a real + // terminal, and runCli only reads the lines and `isTTY`, so this casts. + return Object.assign(Readable.from(lines), { + isTTY, + }) as unknown as NodeJS.ReadStream +} + +describe('parseCliArgs', () => { + it('reads flags and rejects bad values', () => { + expect(parseCliArgs(['-p', 'hi', '--output', 'ndjson'])).toMatchObject({ + print: 'hi', + output: 'ndjson', + thread: 'main', + }) + expect(() => parseCliArgs(['--output', 'xml'])).toThrow('--output') + expect(() => parseCliArgs(['--nope'])).toThrow() + }) + + it('reads --mcp and --yes, both off by default', () => { + expect(parseCliArgs(['--mcp', '--yes'])).toMatchObject({ + mcp: true, + yes: true, + }) + expect(parseCliArgs([])).toMatchObject({ mcp: false, yes: false }) + }) +}) + +describe('print mode', () => { + it('prints the answer and exits 0', async () => { + const { adapter } = scripted([textTurn('Hello from the harness.')]) + const stdout = capture() + const code = await runCli(defineHarness({ name: 'test/print', adapter }), { + argv: ['-p', 'hi'], + stdout, + stderr: capture(), + persistence: memoryPersistence(), + }) + expect(code).toBe(EXIT.ok) + expect(stdout.text).toBe('Hello from the harness.\n') + }) + + it('prints NDJSON AG-UI events', async () => { + const { adapter } = scripted([textTurn('x')]) + const stdout = capture() + await runCli(defineHarness({ name: 'test/ndjson', adapter }), { + argv: ['-p', 'hi', '--output', 'ndjson'], + stdout, + stderr: capture(), + persistence: memoryPersistence(), + }) + const events = stdout.text + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(events.map((event) => event.type)).toContain('TEXT_MESSAGE_CONTENT') + expect(events.at(-1).name).toBe('harness.operation.finished') + }) + + it('exits 2 when the turn waits for an approval', async () => { + const { adapter } = scripted([toolTurn('remove', '{}')]) + const stderr = capture() + const code = await runCli( + defineHarness({ + name: 'test/approval', + adapter, + tools: [ + toolDefinition({ + name: 'remove', + description: 'Remove', + needsApproval: true, + }).server(async () => 'ok'), + ], + }), + { + argv: ['-p', 'remove it'], + stdout: capture(), + stderr, + persistence: memoryPersistence(), + }, + ) + expect(code).toBe(EXIT.needsAction) + expect(stderr.text).toContain('approval') + }) +}) + +describe('line mode', () => { + it('runs piped lines as turns, answers approvals, and runs commands', async () => { + const remove = vi.fn(async () => 'removed') + const pricer = defineAgent({ + name: 'pricer', + description: 'Prices a vendor', + run: async () => 'twelve dollars', + }) + const { adapter } = scripted([ + textTurn('First answer.'), + toolTurn('remove', '{}'), + textTurn('Done removing.'), + ]) + const stdout = capture() + const code = await runCli( + defineHarness({ + name: 'test/lines', + adapter, + agents: [pricer], + tools: [ + toolDefinition({ + name: 'remove', + description: 'Remove', + needsApproval: true, + }).server(remove), + ], + }), + { + argv: [], + stdin: stdinFrom( + ['hello\n', 'remove the file\n', 'y\n', '/agents\n', '/exit\n'], + { isTTY: false }, + ), + stdout, + stderr: capture(), + persistence: memoryPersistence(), + }, + ) + expect(code).toBe(EXIT.ok) + expect(remove).toHaveBeenCalledTimes(1) + expect(stdout.text).toContain('First answer.') + expect(stdout.text).toContain('[y/n]') + expect(stdout.text).toContain('Done removing.') + expect(stdout.text).toContain('pricer: Prices a vendor') + }) +}) + +describe('plugin commands in line mode', () => { + it('runs plugin commands, answers questions, and changes settings', async () => { + const tools = definePlugin({ + name: 'test/tools', + setup: (ctx) => ({ + config: { + tone: configOption.select({ + options: ['plain', 'warm'], + default: 'plain', + }), + }, + commands: { + greet: defineCommand({ + description: 'Greet someone', + run: (input: unknown) => + `Hello, ${typeof input === 'object' && input !== null && 'name' in input ? String(input.name) : 'you'}!`, + }), + confirm: defineCommand({ + description: 'Ask first', + run: async () => { + const sure = await ctx.session.ask({ + message: 'Really?', + schema: { type: 'boolean' }, + }) + return sure === true ? 'Confirmed.' : 'Stopped.' + }, + }), + }, + }), + }) + const { adapter } = scripted([]) + const stdout = capture() + await runCli( + defineHarness({ + name: 'test/plugin-lines', + adapter, + plugins: () => [tools], + }), + { + argv: [], + stdin: stdinFrom( + [ + '/greet {"name":"Ada"}\n', + '/confirm\n', + 'yes\n', + '/config tone warm\n', + '/config\n', + '/help\n', + '/exit\n', + ], + { isTTY: false }, + ), + stdout, + stderr: capture(), + persistence: memoryPersistence(), + }, + ) + expect(stdout.text).toContain('Hello, Ada!') + expect(stdout.text).toContain('? Really?') + expect(stdout.text).toContain('Confirmed.') + expect(stdout.text).toContain('tone changed.') + expect(stdout.text).toContain('tone = "warm"') + expect(stdout.text).toContain('/greet Greet someone') + }) +}) + +describe('secret answers in line mode', () => { + /** Run `/connect acme` and answer with `key`. Returns what was printed and saved. */ + async function connect( + key: string, + stdin: (lines: Array) => NodeJS.ReadStream, + ) { + const stdout = capture() + const persistence = memoryPersistence() + await runCli( + defineHarness({ + name: 'test/secret', + adapter: scripted([]).adapter, + plugins: () => [acmeKeys], + }), + { + argv: [], + stdin: stdin(['/connect acme\n', `${key}\n`, '/exit\n']), + stdout, + stderr: capture(), + persistence, + }, + ) + const saved = await persistence.stores.credentials.get({ + scope: { threadId: 'main' }, + id: 'acme', + }) + return { printed: stdout.text, saved } + } + + it('saves a pasted key as typed and never prints it', async () => { + const { printed, saved } = await connect('12345678', (lines) => + stdinFrom(lines, { isTTY: false }), + ) + expect(saved).toEqual({ type: 'api_key', value: '12345678' }) + expect(printed).toContain('[? Paste your Acme API key]') + expect(printed).toContain('Connected to Acme (key ...5678).') + expect(printed).not.toContain('12345678') + }) + + it('hides typing in a terminal while the key question waits', async () => { + const rawModes: Array = [] + const { printed, saved } = await connect('sk-acme-x\u007fy-4321', (lines) => + Object.assign(stdinFrom(lines, { isTTY: true }), { + setRawMode: (mode: boolean) => rawModes.push(mode), + }), + ) + expect(rawModes).toEqual([true, false]) + // Raw mode does not edit the line, so line mode applies the backspace. + expect(saved).toEqual({ type: 'api_key', value: 'sk-acme-y-4321' }) + expect(printed).toContain('[? Paste your Acme API key (typing is hidden)]') + expect(printed).not.toContain('sk-acme') + }) +}) + +describe('a host and a principal', () => { + it('uses the given host, leaves it open, and saves keys for the principal', async () => { + const persistence = memoryPersistence() + const host = createHarnessHost({ persistence }) + const close = vi.spyOn(host, 'close') + const code = await runCli( + defineHarness({ + name: 'test/host', + adapter: scripted([]).adapter, + plugins: () => [acmeKeys], + }), + { + argv: [], + stdin: stdinFrom(['/connect acme\n', 'sk-ada\n', '/exit\n'], { + isTTY: false, + }), + stdout: capture(), + stderr: capture(), + host, + principal: { id: 'ada' }, + }, + ) + expect(code).toBe(EXIT.ok) + expect(close).not.toHaveBeenCalled() + const saved = (userId?: string) => + persistence.stores.credentials.get({ + scope: { threadId: 'main', ...(userId ? { userId } : {}) }, + id: 'acme', + }) + expect(await saved('ada')).toEqual({ type: 'api_key', value: 'sk-ada' }) + expect(await saved()).toBeNull() + await host.close() + }) + + it('opens the session as the principal', async () => { + const { adapter } = scripted([textTurn('ok')]) + const seen: Array = [] + const code = await runCli( + defineHarness({ + name: 'test/principal', + adapter, + plugins: () => [ + definePlugin({ + name: 'test/whoami', + setup: (ctx) => void seen.push(ctx.session.principal?.id), + }), + ], + }), + { + argv: ['-p', 'hi'], + stdout: capture(), + stderr: capture(), + persistence: memoryPersistence(), + principal: { id: 'ada' }, + }, + ) + expect(code).toBe(EXIT.ok) + expect(seen).toEqual(['ada']) + }) + + it('refuses a host and persistence together', async () => { + const host = createHarnessHost({ persistence: memoryPersistence() }) + const stderr = capture() + const code = await runCli( + defineHarness({ name: 'test/both', adapter: scripted([]).adapter }), + { + argv: ['-p', 'hi'], + stdout: capture(), + stderr, + host, + persistence: memoryPersistence(), + }, + ) + expect(code).toBe(EXIT.failed) + expect(stderr.text).toContain('host or persistence') + await host.close() + }) + + it('serves as the principal', async () => { + const persistence = memoryPersistence() + const host = createHarnessHost({ persistence }) + const server = await serve({ + host, + harness: defineHarness({ + name: 'test/serve-principal', + adapter: scripted([]).adapter, + plugins: () => [acmeKeys], + expose: { commands: ['connect:acme'] }, + }), + port: 0, + hostname: '127.0.0.1', + token: 'secret', + principal: { id: 'ada' }, + }) + try { + const control = (input: unknown) => + fetch(`${server.url}/control`, { + method: 'POST', + headers: { + authorization: 'Bearer secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ threadId: 't', input }), + }) + await control({ op: 'command', name: 'connect:acme' }) + const question = await vi.waitFor(async () => { + const snapshot = await fetch(`${server.url}/snapshot?threadId=t`, { + headers: { authorization: 'Bearer secret' }, + }) + const [pending] = (await snapshot.json()).pendingQuestions + expect(pending).toBeDefined() + return pending + }) + await control({ + op: 'answer', + questionId: question.questionId, + value: 'sk-ada', + }) + await vi.waitFor(async () => + expect( + await persistence.stores.credentials.get({ + scope: { threadId: 't', userId: 'ada' }, + id: 'acme', + }), + ).toEqual({ type: 'api_key', value: 'sk-ada' }), + ) + } finally { + await server.close() + await host.close() + } + }) +}) + +describe('serve mode', () => { + it('serves the session protocol and requires the token', async () => { + const { adapter } = scripted([textTurn('served')]) + const harness = defineHarness({ name: 'test/serve', adapter }) + const host = createHarnessHost({ persistence: memoryPersistence() }) + const server = await serve({ + host, + harness, + port: 0, + hostname: '127.0.0.1', + token: 'secret', + }) + try { + const denied = await fetch(`${server.url}/capabilities`) + expect(denied.status).toBe(401) + const wrong = await fetch(`${server.url}/capabilities`, { + headers: { authorization: 'Bearer nope' }, + }) + expect(wrong.status).toBe(401) + const capabilities = await fetch(`${server.url}/capabilities`, { + headers: { authorization: 'Bearer secret' }, + }) + expect((await capabilities.json()).identity.name).toBe('test/serve') + const receipt = await fetch(`${server.url}/control`, { + method: 'POST', + headers: { + authorization: 'Bearer secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + threadId: 't', + input: { op: 'prompt', message: 'hi' }, + }), + }) + expect((await receipt.json()).status).toBe('accepted') + } finally { + await server.close() + await host.close() + } + }) +}) + +describe('custom ui', () => { + it('gives an interactive terminal a ready session view and waits for the ui', async () => { + const { adapter } = scripted([textTurn('Hi from the ui.')]) + const seen: Array = [] + const code = await runCli(defineHarness({ name: 'test/ui', adapter }), { + argv: [], + stdin: stdinFrom([], { isTTY: true }), + stdout: capture(), + stderr: capture(), + persistence: memoryPersistence(), + ui: async (view) => { + seen.push(view.store.get().threadId) + await view.send('hello') + await vi.waitFor(() => + expect(JSON.stringify(view.store.get().messages)).toContain( + 'Hi from the ui.', + ), + ) + }, + }) + expect(code).toBe(EXIT.ok) + expect(seen).toEqual(['main']) + }) + + it('uses line mode for piped input, even with a ui', async () => { + const { adapter } = scripted([textTurn('Piped answer.')]) + const stdout = capture() + const ui = vi.fn() + const code = await runCli( + defineHarness({ name: 'test/ui-piped', adapter }), + { + argv: [], + stdin: stdinFrom(['hello\n'], { isTTY: false }), + stdout, + stderr: capture(), + persistence: memoryPersistence(), + ui, + }, + ) + expect(code).toBe(EXIT.ok) + expect(ui).not.toHaveBeenCalled() + expect(stdout.text).toContain('Piped answer.') + }) + + it('uses line mode in a terminal without a ui', async () => { + const { adapter } = scripted([textTurn('Terminal answer.')]) + const stdout = capture() + const code = await runCli( + defineHarness({ name: 'test/ui-none', adapter }), + { + argv: [], + stdin: stdinFrom(['hello\n'], { isTTY: true }), + stdout, + stderr: capture(), + persistence: memoryPersistence(), + }, + ) + expect(code).toBe(EXIT.ok) + expect(stdout.text).toContain('Terminal answer.') + }) +}) diff --git a/packages/ai-harness-cli/tests/commands.test.ts b/packages/ai-harness-cli/tests/commands.test.ts new file mode 100644 index 0000000000..e1d9c650cf --- /dev/null +++ b/packages/ai-harness-cli/tests/commands.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { EventType, defineAgent } from '@tanstack/ai' +import { + configOption, + createHarnessHost, + defineCommand, + defineHarness, + definePlugin, +} from '@tanstack/ai-harness' +import { memoryPersistence } from '@tanstack/ai-persistence' +import { HELP_TEXT, handleLine, parseAnswer } from '../src/commands' +import type { AnyTextAdapter, StreamChunk } from '@tanstack/ai' + +/** A model that answers `echo: `, or waits for cancel on "wait". */ +function model(): AnyTextAdapter { + return { + kind: 'text', + name: 'mock', + model: 'test-model', + // `~types` holds types only. Its values are never read, so they are casts. + '~types': { + providerOptions: {} as Record, + inputModalities: ['text'] as readonly ['text'], + messageMetadataByModality: { + text: undefined as unknown, + image: undefined as unknown, + audio: undefined as unknown, + video: undefined as unknown, + document: undefined as unknown, + }, + toolCapabilities: [] as ReadonlyArray, + toolCallMetadata: undefined as unknown, + systemPromptMetadata: undefined as never, + }, + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + chatStream: (options) => + (async function* (): AsyncGenerator { + const said = String(options.messages.at(-1)?.content ?? '') + const now = Date.now() + yield { + type: EventType.RUN_STARTED, + runId: 'r', + threadId: 't', + timestamp: now, + } + if (said === 'wait') { + const signal = + options.abortController?.signal ?? options.request?.signal + await new Promise((resolve) => { + if (!signal || signal.aborted) return resolve() + signal.addEventListener('abort', () => resolve(), { once: true }) + }) + return + } + yield { + type: EventType.TEXT_MESSAGE_START, + messageId: 'm', + role: 'assistant', + timestamp: now, + } + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'm', + delta: `echo: ${said}`, + timestamp: now, + } + yield { + type: EventType.TEXT_MESSAGE_END, + messageId: 'm', + timestamp: now, + } + yield { + type: EventType.RUN_FINISHED, + runId: 'r', + threadId: 't', + timestamp: now, + metadata: { tanstack: { finishReason: 'stop' } }, + } + })(), + } +} + +const tools = definePlugin({ + name: 'test/tools', + setup: (ctx) => ({ + config: { + level: configOption.select({ options: ['low', 'high'], default: 'low' }), + }, + commands: { + quiet: defineCommand({ + description: 'Returns nothing', + run: () => undefined, + }), + stats: defineCommand({ + description: 'Returns an object', + run: () => ({ ok: true }), + }), + broken: defineCommand({ + description: 'Throws', + run: () => { + throw new Error('it broke') + }, + }), + echo: defineCommand({ + description: 'Echoes', + run: (input: unknown) => input, + }), + confirm: defineCommand({ + description: 'Asks a yes or no question', + run: async () => { + const answer = await ctx.session.ask({ + message: 'Sure?', + schema: z.boolean(), + }) + return answer ? 'confirmed' : 'declined' + }, + }), + 'connect:svc': defineCommand({ + description: 'Sign in', + run: () => 'Connected to Svc.', + }), + 'disconnect:svc': defineCommand({ + description: 'Sign out', + run: () => { + throw new Error('not signed in') + }, + }), + }, + }), +}) + +const counter = defineAgent({ + name: 'counter', + description: 'Counts letters', + inputSchema: z.object({ word: z.string() }), + run: async (ctx) => ctx.input.word.length, +}) + +async function open(plugins = true) { + const host = createHarnessHost({ persistence: memoryPersistence() }) + const session = await host.open( + defineHarness({ + name: 'test/cli-commands', + adapter: model(), + ...(plugins ? { agents: [counter], plugins: () => [tools] } : {}), + }), + { threadId: 't' }, + ) + const say = async (line: string) => { + const result = await handleLine(session, line) + return result.type === 'notice' ? result.text : result.type + } + return { host, session, say } +} + +describe('handleLine', () => { + it('answers built-in commands for a bare harness', async () => { + const { host, say } = await open(false) + expect(await say(' ')).toBe('') + expect(await say('/help')).toBe(HELP_TEXT) + expect(await say('/config')).toBe('This harness has no settings.') + expect(await say('/agents')).toBe('This harness has no agents.') + expect(await say('/cancel')).toBe('Nothing is running.') + expect(await say('/status')).toBe( + 'Status: idle. Running: nothing. Queued turns: 0.', + ) + expect(await say('/nope')).toBe('Unknown command: /nope. Type /help.') + expect(await say('/exit')).toBe('exit') + expect(await say('/quit')).toBe('exit') + await host.close() + }) + + it('lists plugin commands, settings, and agents, and changes settings', async () => { + const { host, say } = await open() + expect(await say('/help')).toContain('Plugin commands:\n') + expect(await say('/help')).toContain('/quiet Returns nothing') + expect(await say('/config')).toBe(' level = "low"') + expect(await say('/config level high')).toBe('level changed.') + expect(await say('/config level extreme')).toMatch(/^Not changed: /) + expect(await say('/agents')).toBe(' counter: Counts letters') + await host.close() + }) + + it('runs plugin commands and shows their results or errors', async () => { + const { host, say } = await open() + expect(await say('/quiet')).toBe('Done.') + expect(await say('/stats')).toBe('{\n "ok": true\n}') + expect(await say('/broken')).toBe('/broken failed: it broke') + expect(await say('/echo {"a":1}')).toBe('{\n "a": 1\n}') + expect(await say('/echo plain words')).toBe('plain words') + expect(await say('/connect svc')).toBe('Connected to Svc.') + expect(await say('/disconnect svc')).toBe( + '/disconnect:svc failed: not signed in', + ) + await host.close() + }) + + it('starts agents in the background and checks their input', async () => { + const { host, say } = await open() + expect(await say('/agent')).toBe('Unknown agent: (none)') + expect(await say('/agent ghost')).toBe('Unknown agent: ghost') + expect(await say('/agent counter {bad')).toContain('must be JSON') + expect(await say('/agent counter {"word":"four"}')).toBe( + 'Started counter in the background.', + ) + await host.close() + }) + + it('answers questions, including a rejected answer, then shows the result later', async () => { + const { host, session } = await open() + const asked = await handleLine(session, '/confirm') + expect(asked).toMatchObject({ type: 'notice', text: '' }) + const later = asked.type === 'notice' ? asked.later : undefined + expect(later).toBeDefined() + expect(session.snapshot().pendingQuestions).toHaveLength(1) + expect(await handleLine(session, 'maybe')).toMatchObject({ + text: expect.stringMatching(/^Answer again: /), + }) + expect(await handleLine(session, 'y')).toMatchObject({ text: '' }) + expect(await later).toBe('confirmed') + await host.close() + }) + + it('prompts when idle, steers while running, and cancels', async () => { + const { host, session, say } = await open() + expect(await say('wait')).toBe('sent') + await vi.waitFor(() => expect(session.snapshot().status).toBe('running')) + expect(await say('/status')).toMatch(/^Status: running\. Running: chat/) + expect(await say('go faster')).toBe('sent') + expect(await say('/cancel')).toBe('Cancelled.') + await host.close() + }) +}) + +describe('parseAnswer', () => { + it('reads yes and no for boolean questions, JSON, and plain text', () => { + const boolean = { type: 'boolean' } + expect(parseAnswer('Yes', boolean)).toBe(true) + expect(parseAnswer(' n ', boolean)).toBe(false) + expect(parseAnswer('yes', { type: 'string' })).toBe('yes') + expect(parseAnswer('{"n":2}', undefined)).toEqual({ n: 2 }) + expect(parseAnswer('', undefined)).toBeUndefined() + }) +}) diff --git a/packages/ai-harness-cli/tests/optional.test.ts b/packages/ai-harness-cli/tests/optional.test.ts new file mode 100644 index 0000000000..a448cfeffe --- /dev/null +++ b/packages/ai-harness-cli/tests/optional.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { fakeText } from '@tanstack/ai/testing' +import { defineHarness } from '@tanstack/ai-harness' +import { memoryPersistence } from '@tanstack/ai-persistence' +import { EXIT, runCli } from '../src' +import { loadOptional } from '../src/optional' + +describe('optional packages', () => { + it('loads a module that has the functions', async () => { + const path = await loadOptional<{ join: () => string }>('node:path', 'join') + expect(typeof path?.join).toBe('function') + }) + + it('gives undefined for a missing package or a module without the functions', async () => { + expect( + await loadOptional<{ run: () => void }>( + '@tanstack/no-such-package', + 'run', + ), + ).toBeUndefined() + // An installed package with no harness support. + expect( + await loadOptional<{ serveAcp: () => void }>('node:path', 'serveAcp'), + ).toBeUndefined() + }) + + it('fails a flag whose package is missing, with a clear message', async () => { + let text = '' + const harness = defineHarness({ + name: 'test/optional', + adapter: fakeText(), + }) + const code = await runCli(harness, { + argv: ['--dashboard', 'http://127.0.0.1:1'], + stdout: { write: () => {} }, + stderr: { write: (chunk) => (text += chunk) }, + persistence: memoryPersistence(), + }) + expect(code).toBe(EXIT.failed) + expect(text).toContain( + '--dashboard needs @tanstack/ai-dashboard with harness support.', + ) + }) +}) diff --git a/packages/ai-harness-cli/tests/printer.test.ts b/packages/ai-harness-cli/tests/printer.test.ts new file mode 100644 index 0000000000..87c4767190 --- /dev/null +++ b/packages/ai-harness-cli/tests/printer.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it, vi } from 'vitest' +import { createPrinter } from '../src/printer' +import type { SessionViewState, ViewPart } from '@tanstack/ai-harness/view' + +const base: SessionViewState = { + threadId: 't', + status: 'idle', + connection: 'open', + messages: [], + approvals: [], + clientTools: [], + interrupts: [], + questions: [], + signIns: [], + agents: [], + queuedTurns: 0, + waitingInputs: [], + commands: [], + config: [], + models: [], + tools: [], + plugins: {}, +} + +describe('line printer', () => { + it('streams text growth, prints each item once, and breaks lines between turns', () => { + let out = '' + const printer = createPrinter((text) => (out += text)) + printer.print({ + ...base, + messages: [ + { + id: 'turn-1', + role: 'assistant', + parts: [{ type: 'text', text: 'Hel' }], + }, + ], + }) + printer.print({ + ...base, + messages: [ + { + id: 'turn-1', + role: 'assistant', + parts: [ + { type: 'text', text: 'Hello' }, + { + type: 'tool-call', + id: 'c', + name: 'read', + argsText: '{}', + args: {}, + status: 'done', + }, + { + type: 'agent', + id: 'a', + name: 'helper', + status: 'done', + parts: [ + { type: 'text', text: 'child says hi' }, + { + type: 'tool-call', + id: 'd', + name: 'Edit', + argsText: '', + args: undefined, + status: 'done', + }, + ], + }, + ], + }, + { id: 'notice-1', role: 'notice', kind: 'error', text: 'Error: boom' }, + { + id: 'notice-2', + role: 'notice', + kind: 'command', + text: 'printed by handleLine', + }, + { + id: 'turn-2', + role: 'assistant', + parts: [{ type: 'text', text: 'Second.' }], + }, + ], + questions: [ + { + id: 'q', + message: 'Really?', + answer: async () => ({ inputId: 'i', status: 'accepted' }), + }, + ], + signIns: [{ connector: 'notion', url: 'https://x' }], + }) + printer.end() + + expect(out).toBe( + [ + 'Hello', + '[tool read]', + '[agent helper started]', + '[helper: tool Edit]', + '[agent helper finished: child says hi]', + '[Error: boom]', + 'Second.', + '[? Really?]', + '[Sign in to notion. Open https://x.]', + '', + ].join('\n'), + ) + }) + + it('cuts long child answers, reports failed children, and prints each kind of sign-in', () => { + let out = '' + const printer = createPrinter((text) => (out += text)) + printer.print({ + ...base, + messages: [ + { id: 'user-1', role: 'user', text: 'hi' }, + { + id: 'turn-1', + role: 'assistant', + parts: [ + { type: 'reasoning', text: 'thinking' }, + { + type: 'agent', + id: 'a', + name: 'codex', + status: 'done', + parts: [{ type: 'text', text: 'word '.repeat(60) }], + }, + { + type: 'agent', + id: 'b', + name: 'painter', + status: 'failed', + error: 'crashed', + parts: [], + }, + { + type: 'agent', + id: 'c', + name: 'quiet', + status: 'done', + parts: [], + }, + ], + }, + { + id: 'notice-1', + role: 'notice', + kind: 'info', + text: 'Resumed a turn that a crash stopped.', + }, + { + id: 'notice-2', + role: 'notice', + kind: 'rejected', + text: 'Not accepted: busy', + }, + ], + signIns: [ + { connector: 'gh', url: 'https://gh.example/device', userCode: 'ABCD' }, + { connector: 'svc' }, + ], + }) + + expect(out).toBe( + [ + '[agent codex started]', + `[agent codex finished: ${'word '.repeat(40)}...]`, + '[agent painter started]', + '[agent painter failed: crashed]', + '[agent quiet started]', + '[agent quiet finished]', + '[Resumed a turn that a crash stopped.]', + '[Sign in to gh. Open https://gh.example/device and enter the code ABCD.]', + '[Sign in to svc. Run /connect svc.]', + '', + ].join('\n'), + ) + }) + + it('does not print what mark() saw', () => { + let out = '' + const printer = createPrinter((text) => (out += text)) + const old: SessionViewState = { + ...base, + messages: [ + { + id: 'history-0', + role: 'assistant', + parts: [{ type: 'text', text: 'Old.' }], + }, + ], + } + printer.mark(old) + printer.print(old) + printer.line('new line') + expect(out).toBe('new line\n') + }) + + it('does not print the saved parts of a finished child that load after mark()', () => { + let out = '' + const onMedia = vi.fn() + const printer = createPrinter((text) => (out += text), { onMedia }) + const withChild = (parts: Array): SessionViewState => ({ + ...base, + messages: [ + { + id: 'history-0', + role: 'assistant', + parts: [ + { type: 'agent', id: 'a', name: 'painter', status: 'done', parts }, + ], + }, + ], + }) + printer.mark(withChild([])) + printer.print( + withChild([ + { + type: 'tool-call', + id: 'd', + name: 'paint', + argsText: '{}', + args: {}, + status: 'done', + }, + { + type: 'media', + id: 'm-1', + kind: 'image', + mimeType: 'image/png', + name: 'old.png', + size: 3, + load: async () => new Uint8Array(), + }, + ]), + ) + expect(out).toBe('') + expect(onMedia).not.toHaveBeenCalled() + }) +}) diff --git a/packages/ai-harness-cli/tsconfig.json b/packages/ai-harness-cli/tsconfig.json new file mode 100644 index 0000000000..29112eff9f --- /dev/null +++ b/packages/ai-harness-cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["vite.config.ts", "./src", "./tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-harness-cli/vite.config.ts b/packages/ai-harness-cli/vite.config.ts new file mode 100644 index 0000000000..be372645e8 --- /dev/null +++ b/packages/ai-harness-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', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + ], + include: ['src/**/*.{ts,tsx}'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ca0f8f472..18b1f45f6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,43 @@ importers: specifier: ^8.2.1 version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + examples/harness-cli: + dependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../../packages/ai + '@tanstack/ai-harness': + specifier: workspace:* + version: link:../../packages/ai-harness + '@tanstack/ai-harness-cli': + specifier: workspace:* + version: link:../../packages/ai-harness-cli + '@tanstack/ai-openai': + specifier: workspace:* + version: link:../../packages/ai-openai + '@tanstack/react-store': + specifier: ^0.11.1 + version: 0.11.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + ink: + specifier: ^7.1.1 + version: 7.1.1(@types/react@19.2.7)(react-devtools-core@6.1.5)(react@19.2.3) + react: + specifier: ^19.2.3 + version: 19.2.3 + zod: + specifier: ^4.2.0 + version: 4.2.1 + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.10.3 + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + tsx: + specifier: ^4.23.12 + version: 4.23.12 + examples/react/basic-chat: dependencies: '@tailwindcss/vite': @@ -2621,6 +2658,31 @@ importers: specifier: ^4.2.0 version: 4.2.1 + packages/ai-harness-cli: + dependencies: + '@tanstack/ai-acp': + specifier: workspace:^ + version: link:../ai-acp + '@tanstack/ai-mcp': + specifier: workspace:^ + version: link:../ai-mcp + devDependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@tanstack/ai-harness': + specifier: workspace:* + version: link:../ai-harness + '@tanstack/ai-persistence': + specifier: workspace:* + version: link:../ai-persistence + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.11) + zod: + specifier: ^4.2.0 + version: 4.2.1 + packages/ai-isolate-cloudflare: dependencies: '@tanstack/ai-code-mode': @@ -4058,6 +4120,10 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@alcalzone/ansi-tokenize@0.3.1': + resolution: {integrity: sha512-cUD0PT0YX+rImRhfn7BfMRduP/tX/Zqt/N0dHlAyn2RlcJssCb5KsCJkhln61eh9vuJDkH8wKn3L8zJzzWXPFg==} + engines: {node: '>=18'} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -11945,10 +12011,6 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - ansi-regex@6.3.0: resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} engines: {node: '>=12'} @@ -12066,6 +12128,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} @@ -12515,6 +12581,10 @@ packages: classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + 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'} @@ -12523,6 +12593,10 @@ 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'} @@ -12539,6 +12613,10 @@ packages: resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} engines: {node: '>=20'} + cli-truncate@6.1.1: + resolution: {integrity: sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==} + engines: {node: '>=22'} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -12574,6 +12652,10 @@ packages: code-block-writer@12.0.0: resolution: {integrity: sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w==} + 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==} @@ -12704,6 +12786,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==} @@ -13417,6 +13503,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + esbuild-plugin-solid@0.5.0: resolution: {integrity: sha512-ITK6n+0ayGFeDVUZWNMxX+vLsasEN1ILrg4pISsNOQ+mq4ljlJJiuXotInd+HE0MzwTcA9wExT1yzDE2hsqPsg==} peerDependencies: @@ -13449,6 +13538,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'} @@ -13991,10 +14084,6 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.6.0: - resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} - engines: {node: '>=18'} - get-east-asian-width@1.7.0: resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} engines: {node: '>=18'} @@ -14386,6 +14475,10 @@ 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==} @@ -14395,6 +14488,19 @@ packages: injection-js@2.6.1: resolution: {integrity: sha512-dbR5bdhi7TWDoCye9cByZqeg/gAfamm8Vu3G1KZOTYkOif8WkuM8CD0oeDPtZYMzT5YH76JAFB7bkmyY9OJi2A==} + ink@7.1.1: + resolution: {integrity: sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==} + 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==} @@ -14515,6 +14621,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-in-ssh@1.0.0: resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} engines: {node: '>=20'} @@ -16247,6 +16358,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==} @@ -16698,6 +16813,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'} @@ -16927,6 +17048,10 @@ 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'} @@ -17324,6 +17449,10 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} + slice-ansi@9.0.1: + resolution: {integrity: sha512-aBY19bn/XA+hKOKX0qL7Z0UfdBsbQvc9hn993U8ALUjzxCvDcuZqLoRXjGJrUARWAlwMnsRVD9sw2AFNOYvalA==} + engines: {node: '>=22'} + slugify@1.6.9: resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} engines: {node: '>=8.0.0'} @@ -17416,6 +17545,10 @@ packages: resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} engines: {node: '>=10.16.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==} @@ -17491,10 +17624,6 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} - string-width@8.2.1: - resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} - engines: {node: '>=20'} - string-width@8.2.2: resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} engines: {node: '>=20'} @@ -17666,6 +17795,10 @@ packages: 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'} @@ -17904,6 +18037,10 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} + type-fest@5.10.0: + resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} + engines: {node: '>=20'} + type-fest@5.3.1: resolution: {integrity: sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg==} engines: {node: '>=20'} @@ -18790,6 +18927,10 @@ packages: engines: {node: '>=8'} hasBin: true + 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'} @@ -18814,6 +18955,10 @@ packages: '@cloudflare/workers-types': optional: true + wrap-ansi@10.0.2: + resolution: {integrity: sha512-6OLRNZqRntVGBm26ghZ5eZhCuWWyOkRCSn8XsOIuq5stpCE/tjLbdozd93naILZig0lmD8ANfRwnhn0DLeXGlg==} + engines: {node: '>=20'} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -18986,6 +19131,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} @@ -19043,6 +19191,11 @@ snapshots: dependencies: zod: 4.6.5 + '@alcalzone/ansi-tokenize@0.3.1': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -21819,7 +21972,7 @@ snapshots: terminal-link: 2.1.1 toqr: 0.1.1 wrap-ansi: 7.0.0 - ws: 8.21.0 + ws: 8.21.3 zod: 3.25.76 optionalDependencies: react-native: 0.85.3(@babel/core@7.29.7)(@types/react@19.2.7)(react@19.2.3)(supports-color@7.2.0) @@ -21894,7 +22047,7 @@ snapshots: terminal-link: 2.1.1 toqr: 0.1.1 wrap-ansi: 7.0.0 - ws: 8.21.0 + ws: 8.21.3 zod: 3.25.76 optionalDependencies: react-native: 0.85.3(@babel/core@7.29.7)(@types/react@19.2.7)(react@19.2.3) @@ -22531,7 +22684,7 @@ snapshots: dependencies: string-width: 5.1.2 string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 strip-ansi-cjs: strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 @@ -27103,7 +27256,7 @@ snapshots: '@tanstack/devtools-event-bus@0.3.3': dependencies: - ws: 8.21.0 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -29154,8 +29307,6 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.2.2: {} - ansi-regex@6.3.0: {} ansi-styles@3.2.1: @@ -29280,6 +29431,8 @@ snapshots: asynckit@0.4.0: {} + auto-bind@5.0.1: {} + autoprefixer@10.4.22(postcss@8.5.26): dependencies: browserslist: 4.28.1 @@ -29907,6 +30060,8 @@ snapshots: classnames@2.5.1: optional: true + cli-boxes@4.0.1: {} + cli-cursor@2.1.0: dependencies: restore-cursor: 2.0.0 @@ -29915,6 +30070,10 @@ 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 @@ -29926,7 +30085,12 @@ snapshots: cli-truncate@5.2.0: dependencies: slice-ansi: 8.0.0 - string-width: 8.2.1 + string-width: 8.2.2 + + cli-truncate@6.1.1: + dependencies: + slice-ansi: 9.0.1 + string-width: 8.2.2 cli-width@4.1.0: {} @@ -29962,6 +30126,10 @@ snapshots: code-block-writer@12.0.0: {} + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -30077,6 +30245,8 @@ snapshots: convert-source-map@2.0.0: {} + convert-to-spaces@2.0.1: {} + cookie-es@1.2.2: {} cookie-es@2.0.1: {} @@ -30699,6 +30869,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 + es-toolkit@1.52.0: {} + esbuild-plugin-solid@0.5.0(esbuild@0.28.1)(solid-js@1.9.10): dependencies: '@babel/core': 7.29.7 @@ -30802,6 +30974,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: {} @@ -31497,8 +31671,6 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.6.0: {} - get-east-asian-width@1.7.0: {} get-intrinsic@1.3.0: @@ -31982,6 +32154,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@5.0.0: {} + inherits@2.0.4: {} ini@1.3.8: {} @@ -31990,6 +32164,41 @@ snapshots: dependencies: tslib: 2.8.1 + ink@7.1.1(@types/react@19.2.7)(react-devtools-core@6.1.5)(react@19.2.3): + dependencies: + '@alcalzone/ansi-tokenize': 0.3.1 + 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.1.1 + code-excerpt: 4.0.0 + es-toolkit: 1.52.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.1 + stack-utils: 2.0.6 + string-width: 8.2.2 + terminal-size: 4.0.1 + type-fest: 5.10.0 + widest-line: 6.0.0 + wrap-ansi: 10.0.2 + ws: 8.21.3 + 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: {} @@ -32094,7 +32303,7 @@ snapshots: is-fullwidth-code-point@5.1.0: dependencies: - get-east-asian-width: 1.6.0 + get-east-asian-width: 1.7.0 is-glob@4.0.3: dependencies: @@ -32106,6 +32315,8 @@ snapshots: dependencies: html-tags: 3.3.1 + is-in-ci@2.0.0: {} + is-in-ssh@1.0.0: {} is-inside-container@1.0.0: @@ -34843,6 +35054,8 @@ snapshots: partial-json@0.1.7: {} + patch-console@2.0.0: {} + path-browserify@1.0.1: {} path-exists@3.0.0: {} @@ -35210,7 +35423,7 @@ snapshots: devtools-protocol: 0.0.1581282 typed-query-selector: 2.12.1 webdriver-bidi-protocol: 0.4.1 - ws: 8.21.0 + ws: 8.21.3 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -35585,6 +35798,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.18.0: {} @@ -35932,6 +36150,11 @@ 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 @@ -36564,6 +36787,11 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + slice-ansi@9.0.1: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + slugify@1.6.9: {} smart-buffer@4.2.0: {} @@ -36658,6 +36886,10 @@ snapshots: cpu-features: 0.0.10 nan: 2.27.0 + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} stackframe@1.3.4: {} @@ -36723,18 +36955,13 @@ snapshots: dependencies: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.6.0 - strip-ansi: 7.1.2 - - string-width@8.2.1: - dependencies: - get-east-asian-width: 1.6.0 - strip-ansi: 7.1.2 + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 string-width@8.2.2: dependencies: @@ -36764,7 +36991,7 @@ snapshots: strip-ansi@7.1.2: dependencies: - ansi-regex: 6.2.2 + ansi-regex: 6.3.0 strip-ansi@7.2.0: dependencies: @@ -36962,6 +37189,8 @@ snapshots: 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 @@ -37235,6 +37464,10 @@ snapshots: type-fest@0.7.1: {} + type-fest@5.10.0: + dependencies: + tagged-tag: 1.0.0 + type-fest@5.3.1: dependencies: tagged-tag: 1.0.0 @@ -38180,6 +38413,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@6.0.0: + dependencies: + string-width: 8.2.2 + word-wrap@1.2.5: {} workerd@1.20260609.1: @@ -38215,6 +38452,11 @@ snapshots: - bufferutil - utf-8-validate + wrap-ansi@10.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.2.2 + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -38231,7 +38473,7 @@ snapshots: dependencies: ansi-styles: 6.2.3 string-width: 5.1.2 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 wrap-ansi@9.0.2: dependencies: @@ -38338,6 +38580,8 @@ snapshots: yoctocolors@2.1.2: {} + yoga-layout@3.2.1: {} + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3