diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..56668394 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "tui-harness": { + "command": "node", + "args": ["dist/mcp-harness/index.mjs"] + } + } +} diff --git a/AGENTS.md b/AGENTS.md index 9045dace..a59add6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ Note: CDK L3 constructs are in a separate package `@aws/agentcore-cdk`. ## Primitives Architecture -All resource types (agent, memory, identity, gateway, mcp-tool) are modeled as **primitives** — self-contained classes +All resource types (agent, memory, identity, gateway, mcp-tool) are modeled as **primitives** -- self-contained classes in `src/cli/primitives/` that own the full add/remove lifecycle for their resource type. Each primitive extends `BasePrimitive` and implements: `add()`, `remove()`, `previewRemove()`, `getRemovable()`, @@ -61,11 +61,11 @@ Each primitive extends `BasePrimitive` and implements: `add()`, `remove()`, `pre Current primitives: -- `AgentPrimitive` — agent creation (template + BYO), removal, credential resolution -- `MemoryPrimitive` — memory creation with strategies, removal -- `CredentialPrimitive` — credential/identity creation, .env management, removal -- `GatewayPrimitive` — MCP gateway creation/removal (hidden, coming soon) -- `GatewayTargetPrimitive` — MCP tool creation/removal with code generation (hidden, coming soon) +- `AgentPrimitive` -- agent creation (template + BYO), removal, credential resolution +- `MemoryPrimitive` -- memory creation with strategies, removal +- `CredentialPrimitive` -- credential/identity creation, .env management, removal +- `GatewayPrimitive` -- MCP gateway creation/removal (hidden, coming soon) +- `GatewayTargetPrimitive` -- MCP tool creation/removal with code generation (hidden, coming soon) Singletons are created in `registry.ts` and wired into CLI commands via `cli.ts`. See `src/cli/AGENTS.md` for details on adding new primitives. @@ -121,3 +121,7 @@ See `docs/TESTING.md` for details. - Always look for existing types before creating a new type inline. - Re-usable constants must be defined in a constants file in the closest sensible subdirectory. + +## TUI Harness + +See `docs/tui-harness.md` for the full TUI harness usage guide (MCP tools, screen markers, examples, and error recovery). diff --git a/docs/TESTING.md b/docs/TESTING.md index 291a3d51..30889b17 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -6,6 +6,7 @@ npm test # Run unit tests npm run test:watch # Run tests in watch mode npm run test:integ # Run integration tests +npm run test:tui # Run TUI integration tests (builds first) npm run test:all # Run all tests (unit + integ) ``` @@ -125,12 +126,234 @@ Review the changes in `src/assets/__tests__/__snapshots__/` before committing. - Contents of all template files (CDK, Python frameworks, MCP, static assets) - Any file addition or removal +## TUI Integration Tests + +TUI integration tests run the full CLI binary inside a pseudo-terminal (PTY) and verify screen output, keyboard +navigation, and end-to-end wizard flows. + +> **Note:** TUI tests require `node-pty` (native addon). If node-pty is not installed, TUI tests are automatically +> skipped. + +### Running TUI Tests + +```bash +npm run test:tui # Builds first, then runs TUI tests +npx vitest run --project tui # Skip build (use when build is fresh) +``` + +### Test Organization + +``` +integ-tests/tui/ +├── setup.ts # Global setup: availability check, afterAll cleanup +├── helpers.ts # createMinimalProjectDir, common test setup +├── harness.test.ts # TuiSession self-tests (spawn, send, read) +├── navigation.test.ts # Screen navigation flows +├── create-flow.test.ts # Create wizard end-to-end +├── add-flow.test.ts # Add resource flows +└── deploy-screen.test.ts # Deploy screen rendering +``` + +### Writing a TUI Flow Test + +Below is a complete example showing the typical pattern for a TUI flow test: + +```typescript +import { isAvailable } from '../../src/test-utils/tui-harness/index.js'; +import { TuiSession } from '../../src/test-utils/tui-harness/index.js'; +import { createMinimalProjectDir } from './helpers.js'; +import { afterEach, describe, expect, it } from 'vitest'; + +describe.skipIf(!isAvailable)('my TUI flow', () => { + let session: TuiSession; + + afterEach(async () => { + await session?.close(); + }); + + it('navigates to the add screen', async () => { + // createMinimalProjectDir makes a temp dir with agentcore config (~10ms) + const { dir, cleanup } = await createMinimalProjectDir({ hasAgents: true }); + + try { + // Launch the CLI TUI in the project directory + session = await TuiSession.launch({ + command: 'node', + args: ['../../dist/cli/index.mjs'], + cwd: dir, + }); + + // Wait for the HelpScreen to render + await session.waitFor('Commands'); + + // Navigate: type 'add' to filter, then Enter + await session.sendKeys('add'); + await session.sendSpecialKey('enter'); + + // Verify we reached the AddScreen + await session.waitFor('agent'); + const screen = session.readScreen(); + expect(screen.lines.join('\n')).toContain('agent'); + } finally { + await cleanup(); + } + }); +}); +``` + +Key points: + +- **`describe.skipIf(!isAvailable)`** -- gracefully skips when `node-pty` is missing. +- **`afterEach` with `session?.close()`** -- always clean up PTY processes. +- **`createMinimalProjectDir`** -- fast temp directory setup (no `npm install`). +- **`try/finally` with `cleanup()`** -- always remove temp directories. + +### TuiSession API Quick Reference + +| Method | Returns | Description | +| -------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------- | +| `TuiSession.launch(options)` | `Promise` | Spawn CLI in PTY. Throws `LaunchError` if process exits during startup. | +| `session.sendKeys(text, waitMs?)` | `Promise` | Type text, wait for screen to settle, return screen. | +| `session.sendSpecialKey(key, waitMs?)` | `Promise` | Send special key (enter, tab, escape, etc.), wait, return screen. | +| `session.readScreen(options?)` | `ScreenState` | Read current screen (synchronous). Options: `{ includeScrollback?, numbered? }`. | +| `session.waitFor(pattern, timeoutMs?)` | `Promise` | Wait for text/regex on screen. **Throws `WaitForTimeoutError` on timeout** (default 5000ms). | +| `session.close(signal?)` | `Promise` | Close session. Returns exit code, signal, final screen. | +| `session.info` | `SessionInfo` | Session metadata: sessionId, pid, dimensions, alive status. | +| `session.alive` | `boolean` | Whether the PTY process is still running. | + +### ScreenState Shape + +```typescript +interface ScreenState { + lines: string[]; // Each line of terminal text + cursor: { x: number; y: number }; // Cursor position + dimensions: { cols: number; rows: number }; // Terminal size + bufferType: 'normal' | 'alternate'; // Active buffer +} +``` + +### Special Keys + +The following special keys can be passed to `session.sendSpecialKey()`: + +`enter`, `tab`, `escape`, `backspace`, `delete`, `space`, `up`, `down`, `left`, `right`, `home`, `end`, `pageup`, +`pagedown`, `ctrl+c`, `ctrl+d`, `ctrl+q`, `ctrl+g`, `ctrl+a`, `ctrl+e`, `ctrl+w`, `ctrl+u`, `ctrl+k`, `f1` through +`f12`. + +### Key Concepts + +#### waitFor vs Settling + +- **Settling** (automatic after `sendKeys`/`sendSpecialKey`): Waits for screen text to stop changing. Good for most + screens. Fails on spinner/animation screens because text changes continuously. +- **waitFor**: Polls for a specific text pattern. Use for: (a) async operations with spinners, (b) confirming you + reached the right screen, (c) any case where you need a specific pattern before proceeding. +- **Rule of thumb**: Use `waitFor` when waiting for an async result (project creation, deployment). Use + `sendKeys`/`sendSpecialKey` (which auto-settle) for navigating between static screens. + +#### waitFor Throws on Timeout + +`waitFor()` throws `WaitForTimeoutError` when the pattern is not found within the timeout. The error includes: + +- The pattern that was not found +- How long it waited +- The full screen content at timeout + +This means tests fail fast with useful diagnostics. You do not need to check a `found` boolean. + +#### WaitForTimeoutError Output + +When `waitFor()` times out, the thrown `WaitForTimeoutError` produces a message like this: + +``` +WaitForTimeoutError: waitFor("created successfully") timed out after 5000ms. +Screen content: +AgentCore Create + +Creating project... +⠋ Installing dependencies +``` + +The error message includes the full non-blank screen content at the time of the timeout. This makes it straightforward +to diagnose why the expected pattern was not found -- was the screen still loading? Did the test land on the wrong +screen? Was there a typo in the pattern? + +If you need to inspect the error properties programmatically (for example, to log additional context or make assertions +on the screen state), you can catch the error directly: + +```typescript +import { WaitForTimeoutError } from '../../src/test-utils/tui-harness/index.js'; + +try { + await session.waitFor('expected text', 3000); +} catch (err) { + if (err instanceof WaitForTimeoutError) { + console.log(err.pattern); // 'expected text' + console.log(err.elapsed); // ~3000 + console.log(err.screen); // ScreenState with full content + } + throw err; +} +``` + +#### createMinimalProjectDir + +Creates a temp directory that AgentCore recognizes as a project in ~10ms (no npm install). Use it when your test needs a +project context: + +```typescript +const { dir, cleanup } = await createMinimalProjectDir({ + projectName: 'mytest', // optional, defaults to 'testproject' + hasAgents: true, // optional, adds a sample agent +}); +``` + +Always call `cleanup()` when done (in `finally` or `afterEach`). + +#### LaunchError + +`TuiSession.launch()` throws `LaunchError` when the spawned process exits before the screen settles. Common causes +include a missing binary, a crash on startup, or an invalid working directory. + +The error includes the following diagnostic properties: + +- `command` -- the executable that was launched +- `args` -- the arguments passed to the command +- `cwd` -- the working directory used for the spawned process +- `exitCode` -- the process exit code (or `null` if terminated by signal) +- `screen` -- the `ScreenState` captured at the time of exit + +You can assert that a launch fails with `LaunchError`: + +```typescript +import { LaunchError, TuiSession } from '../../src/test-utils/tui-harness/index.js'; + +it('throws LaunchError for missing binary', async () => { + await expect(TuiSession.launch({ command: 'nonexistent-binary' })).rejects.toThrow(LaunchError); +}); + +// Or if you need to inspect the error: +it('provides diagnostics in LaunchError', async () => { + try { + await TuiSession.launch({ command: 'node', args: ['missing-file.js'] }); + } catch (err) { + if (err instanceof LaunchError) { + console.log(err.command); // 'node' + console.log(err.exitCode); // 1 + console.log(err.screen); // ScreenState at time of crash + } + throw err; + } +}); +``` + ## Configuration Test configuration is in `vitest.config.ts` using Vitest projects: - **unit** project: `src/**/*.test.ts` (includes snapshot tests) - **integ** project: `integ-tests/**/*.test.ts` +- **tui** project: `integ-tests/tui/**/*.test.ts` (TUI integration tests) - Test timeout: 120 seconds - Hook timeout: 120 seconds diff --git a/docs/tui-harness.md b/docs/tui-harness.md new file mode 100644 index 00000000..0f3b7e10 --- /dev/null +++ b/docs/tui-harness.md @@ -0,0 +1,239 @@ +# TUI Harness + +The TUI harness provides MCP tools for programmatically driving the AgentCore CLI terminal UI. The MCP server lives at +`src/mcp-harness/` and the underlying library is at `src/test-utils/tui-harness/`. + +## Getting Started + +1. Run `npm run build:harness` to compile both the CLI and the MCP harness binary. The harness is dev-only tooling and + is not included in the standard `npm run build`. +2. Call `tui_launch` to start a TUI session. It returns a `sessionId` that all subsequent tool calls require. + - `tui_launch({})` with no arguments defaults to `command="node"`, `args=["dist/cli/index.mjs"]` (the AgentCore CLI). + - The `cwd` parameter determines what the TUI sees: if `cwd` is a directory with an `agentcore.config.json`, the TUI + opens to the HelpScreen (command list). If `cwd` has no project, it opens to the HomeScreen ("No AgentCore project + found"). +3. Common workflow: **launch** -> **navigate** -> **verify** -> **close**. + +## MCP Tools + +- `tui_launch` -- Start a TUI session (defaults to AgentCore CLI if no command specified). Returns a `sessionId` used by + all other tools. +- `tui_send_keys` -- Send text or special keys (enter, tab, escape, arrow keys, ctrl+c, etc.). +- `tui_read_screen` -- Read current screen content. Options: `numbered: true` adds line numbers (useful for referencing + specific UI elements), `includeScrollback: true` includes lines scrolled above the viewport. +- `tui_wait_for` -- Wait for text or a regex pattern to appear on screen. Returns `{found: false}` on timeout, NOT an + error. +- `tui_screenshot` -- Capture a bordered screenshot with line numbers. +- `tui_close` -- Close a session and terminate the underlying process. +- `tui_list_sessions` -- List all active sessions. + +## Screenshot Format + +`tui_screenshot` returns a bordered capture with line numbers: + +``` +┌─ TUI Screenshot (120x40) ────────────────────────────────────────┐ + 1 | + 2 | >_ AgentCore v0.3.0-preview.5.0 + 3 | + 4 | > + 5 | + 6 | No AgentCore project found in this directory. + 7 | + 8 | You can: + 9 | create - Create a new AgentCore project here + 10 | or cd into an existing project directory + 11 | + 12 | Press Enter to create a new project + ... +└──────────────────────────────────────────────────────────────────┘ +``` + +The response also includes metadata: cursor position, terminal dimensions, buffer type, and timestamp. Use line numbers +when referencing specific UI elements in your reasoning. + +## Screen Identification Markers + +Use these stable text patterns with `tui_wait_for` to identify which screen is currently displayed. + +| Screen | Stable Text Marker | Notes | +| ------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------- | +| HomeScreen (no project) | `No AgentCore project found` | Only shown when no project exists | +| HelpScreen (command list) | `Commands` or `Type to filter` | Main command list with project | +| CreateScreen (name input) | `Project name` | Text input for project name | +| CreateScreen (add agent prompt) | `add an agent` | "Would you like to add an agent now?" Yes/No | +| AddAgent (name) | `Agent name` | Text input with default "MyAgent" | +| AddAgent (type) | `agent type` | "Create new agent" vs "Bring my own code" | +| AddAgent (language) | `Python` | Language selection (TypeScript is "coming soon" / disabled) | +| AddAgent (build type) | `Direct Code Deploy` | "Direct Code Deploy" vs "Container" | +| AddAgent (framework) | `Strands Agents SDK` | Strands, LangChain, Google ADK, OpenAI Agents | +| AddAgent (model provider) | `Amazon Bedrock` | Bedrock, Anthropic, OpenAI, Google Gemini | +| AddAgent (memory) | `No memory` | None, Short-term, Long-term (only for Strands) | +| AddAgent (confirm) | `Review Configuration` | Summary of all selections before creating | +| CreateScreen (running) | `[done]` | Progress steps. Use `tui_wait_for("created successfully")` | +| CreateScreen (complete) | `created successfully` | Stable end state | +| AddScreen (resource types) | `Add Resource` | Agent, Memory, Identity, Gateway, Gateway Target | +| DeployScreen (confirm) | `Deploy` + `confirm` | Confirmation prompt | +| DeployScreen (loading) | Spinner (unstable) | Use `tui_wait_for` for specific completion text | +| Error state | `Error` or `failed` | Error messages | +| Selected list item | `>` cursor | Cursor indicator in any selection list | +| Text input active | `>` prompt | Input cursor in any text input field | +| Commands list items | `add`, `dev`, `deploy`, `create`, `invoke`, `remove`, `status`, `validate` | Individual command names visible in HelpScreen list | +| Exit prompt | `Press Esc again to exit` | Shown after first Escape on HelpScreen with no search query | + +## Example: Create Project with Agent (Full Wizard) + +The create wizard embeds the full AddAgent flow. Here is every step captured from a real TUI session: + +``` + 1. tui_launch({cwd: "/path/to/empty/dir"}) + -> Returns sessionId. Screen shows HomeScreen. + + 2. tui_wait_for({sessionId, pattern: "No AgentCore project found", timeoutMs: 10000}) + -> Confirms HomeScreen loaded. + + 3. tui_send_keys({sessionId, specialKey: "enter"}) + -> Navigates to CreateScreen. + + 4. tui_wait_for({sessionId, pattern: "Project name"}) + -> CreateScreen: name input. + + 5. tui_send_keys({sessionId, keys: "my-agent"}) + -> Types the project name. + + 6. tui_send_keys({sessionId, specialKey: "enter"}) + -> Submits name. Moves to "add an agent?" prompt. + + 7. tui_wait_for({sessionId, pattern: "add an agent"}) + -> "Would you like to add an agent now?" with Yes/No options. + + 8. tui_send_keys({sessionId, specialKey: "enter"}) + -> Selects "Yes". Moves to Agent name input. + + 9. tui_wait_for({sessionId, pattern: "Agent name"}) + -> Agent name input (default: "MyAgent"). + +10. tui_send_keys({sessionId, specialKey: "enter"}) + -> Accepts default name. Moves to agent type selection. + +11. tui_wait_for({sessionId, pattern: "agent type"}) + -> "Create new agent" vs "Bring my own code". + +12. tui_send_keys({sessionId, specialKey: "enter"}) + -> Selects "Create new agent". Moves to language. + +13. tui_wait_for({sessionId, pattern: "Python"}) + -> Language selection. Note: "TypeScript (coming soon)" is disabled. + +14. tui_send_keys({sessionId, specialKey: "enter"}) + -> Selects Python. Moves to build type. + +15. tui_wait_for({sessionId, pattern: "Direct Code Deploy"}) + -> "Direct Code Deploy" vs "Container". + +16. tui_send_keys({sessionId, specialKey: "enter"}) + -> Selects Direct Code Deploy. Moves to framework. + +17. tui_wait_for({sessionId, pattern: "Strands Agents SDK"}) + -> Framework: Strands, LangChain, Google ADK, OpenAI Agents. + +18. tui_send_keys({sessionId, specialKey: "enter"}) + -> Selects Strands. Moves to model provider. + +19. tui_wait_for({sessionId, pattern: "Amazon Bedrock"}) + -> Model: Bedrock, Anthropic, OpenAI, Google Gemini. + +20. tui_send_keys({sessionId, specialKey: "enter"}) + -> Selects Bedrock. Skips API key (Bedrock uses IAM). Moves to memory. + +21. tui_wait_for({sessionId, pattern: "No memory"}) + -> Memory: None, Short-term, Long-term (Strands-only step). + +22. tui_send_keys({sessionId, specialKey: "enter"}) + -> Selects None. Moves to review. + +23. tui_wait_for({sessionId, pattern: "Review Configuration"}) + -> Summary panel showing all selections. + +24. tui_send_keys({sessionId, specialKey: "enter"}) + -> Confirms. Project creation begins (~25 seconds). + +25. tui_wait_for({sessionId, pattern: "created successfully", timeoutMs: 60000}) + -> Wait for completion. Use a long timeout (creation runs uv sync). + +26. tui_screenshot({sessionId}) + -> Capture success screen showing created file structure. + +27. tui_close({sessionId}) + -> Clean shutdown. Returns exitCode: 0. +``` + +Notes: + +- Step 20: If you select a non-Bedrock provider (Anthropic, OpenAI, Gemini), an API key input step appears between model + selection and memory. +- Step 21: The memory step only appears when Strands SDK is selected as the framework. +- Step 25: Project creation takes ~25 seconds due to `uv sync`. The `timeoutMs` cap for `tui_wait_for` is 30000, so use + 30000 or call it in a loop. + +## Example: Navigate to Add Resource + +``` +1. tui_launch({cwd: "/path/to/existing/project"}) + -> HelpScreen with command list. + +2. tui_wait_for({sessionId, pattern: "Commands"}) + -> Confirms HelpScreen loaded. + +3. tui_send_keys({sessionId, keys: "add"}) + -> Filters command list to "add". + +4. tui_send_keys({sessionId, specialKey: "enter"}) + -> Navigates to AddScreen. + +5. tui_wait_for({sessionId, pattern: "Add Resource"}) + -> Shows: Agent, Memory, Identity, Gateway, Gateway Target. +``` + +## Known Limitations + +1. **Disabled items are invisible**: In selection lists, disabled items are shown only with dimmed color (ANSI). The + harness strips ANSI codes and returns plain text, so disabled items look identical to enabled ones. If pressing Enter + on a list item does not navigate to a new screen, the item may be disabled -- try a different item. +2. **Spinner screens do not settle**: Screens with spinners (deploy progress, create running) continuously change text + content. Do not wait for the screen to "settle" -- use `tui_wait_for` with the specific text that indicates + completion (e.g., `"created successfully"`, `"Deploy complete"`). +3. **Max 10 concurrent sessions**: The harness allows up to 10 simultaneous TUI sessions. Close sessions when done. + +## Navigation Patterns + +- **Navigate to a command**: From HelpScreen, type the command name to filter, then press Enter. Or use arrow keys to + reach it, then Enter. +- **Fill text input**: Type characters with `tui_send_keys({keys: "..."})`, then press Enter to submit. +- **Select from list**: Arrow down to the target item, then press Enter. +- **Go back**: Press Escape. +- **Exit app**: Press Escape until at HelpScreen, then Escape twice (or Ctrl+C from anywhere). +- **Slow-rendering screens**: If a screen takes time to fully render, pass `waitMs: 1000` (or higher) to `tui_send_keys` + to give the screen more time to settle before reading it. + +## Error Recovery + +When `tui_wait_for` returns `{found: false}`: + +1. Call `tui_screenshot` to see what's actually on screen. +2. Check if the screen has an error message (look for "Error" or "failed"). +3. If the screen is still loading (spinner), increase `timeoutMs` and retry. +4. If you're on the wrong screen, use `tui_send_keys({specialKey: "escape"})` to go back and try a different navigation + path. + +When `tui_send_keys` doesn't change the screen: + +1. Call `tui_read_screen` to check the current state. +2. The selected item may be disabled (see Known Limitations). +3. Try pressing Escape and navigating to a different item. + +When `tui_launch` returns an error: + +1. Ensure `npm run build:harness` was run recently -- both the CLI binary and the MCP harness must be up to date. +2. Check that `cwd` points to a valid directory. +3. The error response includes the screen content at time of failure -- use it to diagnose. diff --git a/esbuild.config.mjs b/esbuild.config.mjs index efa62e4c..d47f25bb 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -60,3 +60,43 @@ await esbuild.build({ fs.chmodSync('./dist/cli/index.mjs', '755'); console.log('CLI build complete: dist/cli/index.mjs'); + +// --------------------------------------------------------------------------- +// MCP harness build — opt-in via BUILD_HARNESS=1 +// +// The TUI harness is dev-only tooling for AI agents and integration tests. +// It is NOT shipped to end users. Build it separately with: +// BUILD_HARNESS=1 node esbuild.config.mjs +// npm run build:harness +// --------------------------------------------------------------------------- +const mcpEntryPoint = './src/tui-harness/mcp/index.ts'; + +if (process.env.BUILD_HARNESS === '1' && fs.existsSync(mcpEntryPoint)) { + await esbuild.build({ + entryPoints: [mcpEntryPoint], + outfile: './dist/mcp-harness/index.mjs', + bundle: true, + platform: 'node', + format: 'esm', + minify: true, + banner: { + js: [ + '#!/usr/bin/env node', + `import { createRequire } from 'module'; const require = createRequire(import.meta.url);`, + ].join('\n'), + }, + // node-pty is a native C++ addon and cannot be bundled. + // @xterm/headless is CJS-only (no ESM exports map) — esbuild's CJS-to-ESM + // conversion mangles its default export at runtime, so let Node handle it. + // fsevents is macOS-only optional native module. + external: ['fsevents', 'node-pty', '@xterm/headless'], + plugins: [textLoaderPlugin], + }); + + // Make executable + fs.chmodSync('./dist/mcp-harness/index.mjs', '755'); + + console.log('MCP harness build complete: dist/mcp-harness/index.mjs'); +} else if (process.env.BUILD_HARNESS === '1') { + console.log(`MCP harness build skipped: entry point ${mcpEntryPoint} does not exist yet`); +} diff --git a/integ-tests/tui/helpers.ts b/integ-tests/tui/helpers.ts new file mode 100644 index 00000000..3789503f --- /dev/null +++ b/integ-tests/tui/helpers.ts @@ -0,0 +1,12 @@ +/** + * Re-exports test helpers from the canonical location in src/. + * + * The canonical implementation of createMinimalProjectDir lives in + * src/tui-harness/helpers.ts. This file re-exports it so + * that integ-tests/tui/ test files can import from a local path + * (`./helpers.js`) without reaching into src/. + */ + +export { createMinimalProjectDir } from '../../src/tui-harness/helpers.js'; + +export type { CreateMinimalProjectDirOptions, MinimalProjectDirResult } from '../../src/tui-harness/helpers.js'; diff --git a/package-lock.json b/package-lock.json index 047301e3..74fc92f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,10 +34,12 @@ "zod": "^4.3.5" }, "bin": { + "agent-tui-harness": "dist/mcp-harness/index.mjs", "agentcore": "dist/cli/index.mjs" }, "devDependencies": { "@eslint/js": "^9.39.2", + "@modelcontextprotocol/sdk": "^1.0.0", "@secretlint/secretlint-rule-preset-recommend": "^11.3.0", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/node": "^25.0.3", @@ -45,6 +47,7 @@ "@typescript-eslint/eslint-plugin": "^8.50.0", "@typescript-eslint/parser": "^8.50.0", "@vitest/coverage-v8": "^4.0.18", + "@xterm/headless": "^6.0.0", "aws-cdk-lib": "^2.240.0", "constructs": "^10.4.4", "esbuild": "^0.27.2", @@ -59,6 +62,7 @@ "husky": "^9.1.7", "ink-testing-library": "^4.0.0", "lint-staged": "^16.2.7", + "node-pty": "^1.1.0", "prettier": "^3.7.4", "secretlint": "^11.3.0", "tsx": "^4.21.0", @@ -69,6 +73,9 @@ "engines": { "node": ">=20" }, + "optionalDependencies": { + "node-pty": "^1.1.0" + }, "peerDependencies": { "aws-cdk-lib": "^2.234.1", "constructs": "^10.0.0" @@ -3590,6 +3597,19 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -3761,6 +3781,71 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -5956,6 +6041,16 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/headless": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", + "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -5968,6 +6063,20 @@ "node": ">=6.5" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -6008,6 +6117,48 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -6973,6 +7124,31 @@ "url": "https://bevry.me/fund" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boundary": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", @@ -7063,6 +7239,16 @@ "node": ">=8.0.0" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -7331,6 +7517,30 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -7347,12 +7557,50 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -7514,6 +7762,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/diff": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", @@ -7586,6 +7844,13 @@ "url": "https://bevry.me/fund" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.307", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", @@ -7599,6 +7864,16 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -7857,6 +8132,13 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -8363,6 +8645,16 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -8397,6 +8689,29 @@ "bare-events": "^2.7.0" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -8407,6 +8722,69 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8553,6 +8931,28 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -8623,6 +9023,26 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -9031,6 +9451,16 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hono": { + "version": "4.12.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", + "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -9058,6 +9488,27 @@ "dev": true, "license": "MIT" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -9074,6 +9525,23 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -9364,6 +9832,26 @@ "node": ">= 0.4" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -9648,6 +10136,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -9915,6 +10410,16 @@ "dev": true, "license": "MIT" }, + "node_modules/jose": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz", + "integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9962,6 +10467,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -10451,6 +10963,29 @@ "node": ">= 0.4" } }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -10497,6 +11032,33 @@ "node": ">=4.0.0" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -10601,12 +11163,29 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -10636,6 +11215,17 @@ "semver": "bin/semver.js" } }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -10801,6 +11391,29 @@ ], "license": "MIT" }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -10963,6 +11576,16 @@ "dev": true, "license": "MIT" }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/patch-console": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", @@ -11020,6 +11643,17 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/path-type": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", @@ -11059,6 +11693,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -11161,6 +11805,20 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -11171,6 +11829,22 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -11191,6 +11865,32 @@ ], "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/rc-config-loader": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", @@ -11531,6 +12231,23 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -11639,6 +12356,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -11680,6 +12404,53 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -11729,6 +12500,13 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -11988,6 +12766,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -12631,6 +13419,16 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -12724,6 +13522,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -12901,6 +13714,16 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -13007,6 +13830,16 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/version-range": { "version": "4.15.0", "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", @@ -13463,6 +14296,13 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", @@ -13541,6 +14381,16 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, "node_modules/zod-validation-error": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", diff --git a/package.json b/package.json index 9a875fae..ffb059b2 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ }, "files": [ "dist", - "scripts" + "scripts", + "!dist/mcp-harness" ], "scripts": { "preinstall": "node scripts/check-old-cli.mjs", @@ -48,6 +49,7 @@ "build:lib": "tsc -p tsconfig.build.json", "build:cli": "node esbuild.config.mjs", "build:assets": "node scripts/copy-assets.mjs", + "build:harness": "BUILD_HARNESS=1 node esbuild.config.mjs", "cli": "npx tsx src/cli/index.ts", "typecheck": "tsc --noEmit", "lint": "eslint src/", @@ -64,7 +66,8 @@ "test:integ": "vitest run --project integ", "test:unit": "vitest run --project unit --coverage", "test:e2e": "vitest run --project e2e", - "test:update-snapshots": "vitest run --project unit --update" + "test:update-snapshots": "vitest run --project unit --update", + "test:tui": "npm run build:harness && vitest run --project tui" }, "dependencies": { "@aws-cdk/toolkit-lib": "^1.16.0", @@ -96,6 +99,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.2", + "@modelcontextprotocol/sdk": "^1.0.0", "@secretlint/secretlint-rule-preset-recommend": "^11.3.0", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/node": "^25.0.3", @@ -103,6 +107,7 @@ "@typescript-eslint/eslint-plugin": "^8.50.0", "@typescript-eslint/parser": "^8.50.0", "@vitest/coverage-v8": "^4.0.18", + "@xterm/headless": "^6.0.0", "aws-cdk-lib": "^2.240.0", "constructs": "^10.4.4", "esbuild": "^0.27.2", @@ -117,6 +122,7 @@ "husky": "^9.1.7", "ink-testing-library": "^4.0.0", "lint-staged": "^16.2.7", + "node-pty": "^1.1.0", "prettier": "^3.7.4", "secretlint": "^11.3.0", "tsx": "^4.21.0", diff --git a/src/tui-harness/__tests__/proof-of-concept.test.ts b/src/tui-harness/__tests__/proof-of-concept.test.ts new file mode 100644 index 00000000..9885bfae --- /dev/null +++ b/src/tui-harness/__tests__/proof-of-concept.test.ts @@ -0,0 +1,276 @@ +/** + * Proof-of-concept tests for @xterm/headless and node-pty integration. + * + * CRITICAL GATE: All subsequent TUI harness work depends on these imports + * and patterns working correctly under agentcore-cli's TypeScript config + * (module: "Preserve", moduleResolution: "bundler", verbatimModuleSyntax: true). + * + * --- Import patterns that work --- + * + * @xterm/headless (CJS bundle, no ESM exports map): + * import xtermHeadless from '@xterm/headless'; + * const { Terminal } = xtermHeadless; + * + * Why: The package's "main" is a CJS bundle. With verbatimModuleSyntax + bundler + * resolution, a default import gets the module.exports object. Named imports + * like `import { Terminal } from '@xterm/headless'` fail because the CJS bundle + * does not have a static "Terminal" export visible to the TypeScript compiler. + * + * node-pty (CJS native addon): + * import * as pty from 'node-pty'; + * + * Why: node-pty uses module.exports with named properties (spawn, fork, etc.). + * A namespace import (`import * as`) maps cleanly to the CJS exports object. + * + * --- xterm.write() is ASYNC --- + * + * terminal.write(data) does not synchronously update the buffer. You must use + * the callback form or wrap in a promise: + * await new Promise(resolve => terminal.write('hello', resolve)); + * + * Only then is it safe to read from terminal.buffer.active. + * + * --- allowProposedApi: true is REQUIRED --- + * + * Accessing terminal.buffer and terminal.parser requires allowProposedApi: true. + * Without it, xterm throws "You must set the allowProposedApi option to true + * to use proposed API". Always set this when creating Terminal instances. + * + * --- node-pty spawn requires real executables --- + * + * node-pty uses posix_spawnp under the hood. Shell built-ins like `echo` are + * not standalone executables and will cause "posix_spawnp failed". Use full + * paths like `/bin/echo` instead. + */ +import { createMinimalProjectDir } from '../helpers.js'; +import xtermHeadless from '@xterm/headless'; +import { existsSync } from 'fs'; +import { readFile } from 'fs/promises'; +import * as pty from 'node-pty'; +import { join } from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +const { Terminal } = xtermHeadless; + +// --------------------------------------------------------------------------- +// Test A: xterm standalone +// --------------------------------------------------------------------------- +describe('xterm standalone', () => { + let terminal: InstanceType; + + afterEach(() => { + terminal?.dispose(); + }); + + it('creates a terminal and reads back written text', async () => { + // allowProposedApi is required to access terminal.buffer + terminal = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }); + + // terminal.write is async -- use the callback form wrapped in a promise + await new Promise(resolve => terminal.write('hello', resolve)); + + const line = terminal.buffer.active.getLine(0)?.translateToString(true); + expect(line).toContain('hello'); + }); +}); + +// --------------------------------------------------------------------------- +// Test B: PTY + xterm wiring +// --------------------------------------------------------------------------- +describe('PTY + xterm wiring', () => { + let terminal: InstanceType; + let ptyProcess: ReturnType | undefined; + + afterEach(() => { + ptyProcess?.kill(); + ptyProcess = undefined; + terminal?.dispose(); + }); + + it('pipes PTY output through xterm and reads the buffer', async () => { + terminal = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }); + + // Use /bin/echo (absolute path) because node-pty uses posix_spawnp which + // cannot resolve shell built-ins like bare `echo`. + ptyProcess = pty.spawn('/bin/echo', ['hello'], { + cols: 80, + rows: 24, + }); + + // Wire PTY output into the terminal, accumulating a promise that + // resolves once the PTY process exits. + const exitPromise = new Promise(resolve => { + ptyProcess!.onData((data: string) => { + terminal.write(data); + }); + ptyProcess!.onExit(() => resolve()); + }); + + await exitPromise; + + // Give xterm a moment to finish parsing any remaining buffered writes. + await new Promise(resolve => terminal.write('', resolve)); + + const line = terminal.buffer.active.getLine(0)?.translateToString(true); + expect(line).toContain('hello'); + }); +}); + +// --------------------------------------------------------------------------- +// Test C: DSR/CPR handler +// --------------------------------------------------------------------------- +describe('DSR/CPR handler', () => { + let terminal: InstanceType; + let ptyProcess: ReturnType | undefined; + + afterEach(() => { + ptyProcess?.kill(); + ptyProcess = undefined; + terminal?.dispose(); + }); + + it('responds to DSR (\\x1b[6n]) with a cursor position report (standalone)', async () => { + terminal = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true, + }); + + // Capture the response our handler would send back to the PTY. + let dsrResponse = ''; + + terminal.parser.registerCsiHandler({ final: 'n' }, params => { + if (params[0] === 6) { + // CPR: report cursor position as \x1b[{row};{col}R (1-indexed) + const buf = terminal.buffer.active; + dsrResponse = `\x1b[${buf.cursorY + 1};${buf.cursorX + 1}R`; + return true; + } + if (params[0] === 5) { + // Device status: report OK + dsrResponse = '\x1b[0n'; + return true; + } + return false; + }); + + // Write the DSR request as if a TUI app emitted it through stdout. + await new Promise(resolve => terminal.write('\x1b[6n', resolve)); + + // The cursor is at row 1, col 1 (1-indexed) since nothing else was written. + expect(dsrResponse).toBe('\x1b[1;1R'); + }); + + it('DSR round-trip through PTY', async () => { + terminal = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true, + }); + + // Spawn /bin/cat via node-pty. cat echoes anything written to its stdin + // back to stdout. We use stty raw -echo so the PTY does not mangle + // escape sequences or double-echo input. + ptyProcess = pty.spawn('/bin/sh', ['-c', 'stty raw -echo; cat'], { + cols: 80, + rows: 24, + }); + + // Buffer that accumulates all raw data coming out of the PTY. + let ptyOutput = ''; + + // Wire PTY output into xterm AND capture the raw bytes. + ptyProcess.onData((data: string) => { + ptyOutput += data; + terminal.write(data); + }); + + // Register the CSI handler that responds to DSR by writing the CPR + // response back into the PTY (completing the round-trip). + // + // The REAL DSR flow in TuiSession is: + // 1. The TUI app (inside PTY) writes \x1b[6n to its stdout + // 2. ptyProcess.onData delivers it to us + // 3. We call terminal.write(data) -- xterm parses the CSI sequence + // 4. Our CSI handler fires, calls ptyProcess.write('\x1b[row;colR') + // 5. The response goes to the PTY's slave stdin -- the app reads it + terminal.parser.registerCsiHandler({ final: 'n' }, params => { + if (params[0] === 6) { + const buf = terminal.buffer.active; + ptyProcess!.write(`\x1b[${buf.cursorY + 1};${buf.cursorX + 1}R`); + return true; + } + if (params[0] === 5) { + ptyProcess!.write('\x1b[0n'); + return true; + } + return false; + }); + + // Wait briefly for stty to take effect. + await new Promise(resolve => setTimeout(resolve, 200)); + + // Write the DSR request DIRECTLY to terminal.write(), simulating + // steps 2-3 of the real flow: as if the TUI app emitted \x1b[6n + // to its stdout and onData delivered it to us. This avoids sending + // the escape sequence through the PTY line discipline (which would + // mangle it -- the original bug). + // + // The round-trip from here: + // 1. terminal.write('\x1b[6n') -- xterm parses the CSI sequence + // 2. CSI handler fires, writes '\x1b[1;1R' to ptyProcess + // 3. cat receives '\x1b[1;1R' on its stdin and echoes it to stdout + // 4. onData captures the CPR response in ptyOutput + await new Promise(resolve => terminal.write('\x1b[6n', resolve)); + + // Poll until the CPR response pattern appears in the captured output. + // Each iteration flushes xterm's internal write buffer so queued data + // gets fully processed and CSI handlers fire. + // eslint-disable-next-line no-control-regex + const cprPattern = /\x1b\[\d+;\d+R/; + const deadline = Date.now() + 5000; + while (!cprPattern.test(ptyOutput) && Date.now() < deadline) { + // Flush xterm's write buffer so any enqueued data gets processed. + await new Promise(resolve => terminal.write('', resolve)); + // Yield to the event loop so PTY I/O callbacks can fire. + await new Promise(resolve => setTimeout(resolve, 50)); + } + + expect(ptyOutput).toMatch(cprPattern); + }); +}); + +// --------------------------------------------------------------------------- +// Test D: createMinimalProjectDir +// --------------------------------------------------------------------------- +describe('createMinimalProjectDir', () => { + let cleanup: (() => Promise) | undefined; + + afterEach(async () => { + await cleanup?.(); + cleanup = undefined; + }); + + it('creates and cleans up a minimal agentcore project directory', async () => { + const result = await createMinimalProjectDir(); + cleanup = result.cleanup; + + // The directory should exist + expect(existsSync(result.dir)).toBe(true); + + // agentcore/agentcore.json should be present + const configPath = join(result.dir, 'agentcore', 'agentcore.json'); + expect(existsSync(configPath)).toBe(true); + + // Parse the config and verify it has a name field + const raw = await readFile(configPath, 'utf-8'); + const config = JSON.parse(raw) as Record; + expect(config).toHaveProperty('name'); + expect(typeof config.name).toBe('string'); + + // Clean up and verify removal + await result.cleanup(); + cleanup = undefined; // prevent double cleanup in afterEach + expect(existsSync(result.dir)).toBe(false); + }); +}); diff --git a/src/tui-harness/helpers.ts b/src/tui-harness/helpers.ts new file mode 100644 index 00000000..5e02fa76 --- /dev/null +++ b/src/tui-harness/helpers.ts @@ -0,0 +1,122 @@ +/** + * Test helpers for TUI harness tests and integration tests. + * + * Provides utilities for creating minimal project directories that the + * AgentCore CLI recognizes as valid projects, without the overhead of + * running the full create wizard or npm/uv installs. + */ +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Options for creating a minimal project directory. + * + * @property projectName - Name used in agentcore.json. Defaults to 'testproject'. + * @property hasAgents - When true, includes a sample agent in the config. + * Defaults to false. + */ +export interface CreateMinimalProjectDirOptions { + projectName?: string; + hasAgents?: boolean; +} + +/** + * Result of creating a minimal project directory. + * + * @property dir - Absolute path to the created temporary directory. + * @property cleanup - Async function that removes the directory and all its + * contents. Call this in `afterEach` or a `finally` block. + */ +export interface MinimalProjectDirResult { + dir: string; + cleanup: () => Promise; +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +/** + * Create a temporary directory that AgentCore recognizes as a valid project. + * + * The directory contains the minimum file structure needed for the CLI to + * detect a project and open to the HelpScreen (command list) rather than + * the HomeScreen ("No AgentCore project found"): + * + * ``` + * / + * agentcore/ + * agentcore.json # minimal valid config + * ``` + * + * When `hasAgents` is true, the config includes a sample agent entry + * pointing to a placeholder code directory. + * + * This function is intentionally fast (~10ms) -- it writes only the config + * files, with no `npm install` or `uv sync`. + * + * @param options - Optional configuration for the project directory. + * @returns An object with `dir` (the path) and `cleanup` (removal function). + * + * @example + * ```ts + * const { dir, cleanup } = await createMinimalProjectDir(); + * try { + * // Use dir as cwd for TuiSession.launch() + * } finally { + * await cleanup(); + * } + * ``` + */ +export async function createMinimalProjectDir( + options: CreateMinimalProjectDirOptions = {} +): Promise { + const { projectName = 'testproject', hasAgents = false } = options; + + // Create the temp directory with a recognizable prefix for debugging. + // mkdtemp always returns the created path as a string (unlike mkdir). + const dir = await mkdtemp(join(tmpdir(), 'agentcore-test-')); + + // Create the agentcore config directory. + const agentcoreDir = join(dir, 'agentcore'); + await mkdir(agentcoreDir, { recursive: true }); + + // Build the minimal config object. + const config: Record = { + name: projectName, + version: 1, + agents: [] as unknown[], + memories: [], + credentials: [], + }; + + // Optionally add a sample agent. + if (hasAgents) { + (config.agents as unknown[]).push({ + type: 'AgentCoreRuntime', + name: 'TestAgent', + build: 'CodeZip', + entrypoint: 'main.py:handler', + codeLocation: 'app/TestAgent', + runtimeVersion: 'PYTHON_3_12', + }); + + // Create the agent code directory so the CLI does not complain. + await mkdir(join(dir, 'app', 'TestAgent'), { recursive: true }); + } + + // Write the config file. + await writeFile(join(agentcoreDir, 'agentcore.json'), JSON.stringify(config, null, 2) + '\n', 'utf-8'); + + // Return the path and a cleanup function. + const cleanup = async (): Promise => { + await rm(dir, { recursive: true, force: true }); + }; + + return { dir, cleanup }; +} diff --git a/src/tui-harness/index.ts b/src/tui-harness/index.ts new file mode 100644 index 00000000..59e2a9ce --- /dev/null +++ b/src/tui-harness/index.ts @@ -0,0 +1,31 @@ +/** + * Public API surface for the TUI test harness. + * + * This barrel file re-exports only the symbols intended for external + * consumption. Internal implementation details (SettlingMonitor, screen + * reader helpers, session registry internals) are deliberately excluded. + * + * Import convention: + * import { TuiSession, isAvailable, closeAll } from '../tui-harness/index.js'; + */ + +// --- Core session class --- +export { TuiSession } from './lib/TuiSession.js'; + +// --- Types and error classes --- +export type { LaunchOptions, ScreenState, ReadOptions, CloseResult, SessionInfo } from './lib/types.js'; +export type { SpecialKey } from './lib/types.js'; +export { SPECIAL_KEY_VALUES, WaitForTimeoutError, LaunchError } from './lib/types.js'; + +// --- Key mapping --- +export { KEY_MAP, resolveKey } from './lib/key-map.js'; + +// --- Availability --- +export { isAvailable, unavailableReason } from './lib/availability.js'; + +// --- Session management (for test cleanup) --- +export { closeAll } from './lib/session-manager.js'; + +// --- Test helpers --- +export { createMinimalProjectDir } from './helpers.js'; +export type { CreateMinimalProjectDirOptions, MinimalProjectDirResult } from './helpers.js'; diff --git a/src/tui-harness/lib/TuiSession.ts b/src/tui-harness/lib/TuiSession.ts new file mode 100644 index 00000000..86517e0d --- /dev/null +++ b/src/tui-harness/lib/TuiSession.ts @@ -0,0 +1,528 @@ +/** + * Core TUI session class for the test harness. + * + * Manages the lifecycle of a headless terminal session: spawning a PTY + * process, piping its output through an xterm terminal emulator, handling + * DSR (Device Status Report) queries so Ink and other TUI frameworks can + * detect cursor position, and providing methods to interact with and + * inspect the terminal screen. + * + * Instances are created exclusively through the static {@link TuiSession.launch} + * factory method. The constructor is private to enforce proper initialization + * sequencing (PTY spawn, DSR wiring, initial settle). + */ +import { resolveKey } from './key-map.js'; +import { buildScreenState, getBufferType } from './screen.js'; +import { register, unregister } from './session-manager.js'; +import { SettlingMonitor } from './settling.js'; +import type { CloseResult, LaunchOptions, ReadOptions, ScreenState, SessionInfo, SpecialKey } from './types.js'; +import { LaunchError, WaitForTimeoutError } from './types.js'; +import xtermHeadless from '@xterm/headless'; +import { randomUUID } from 'crypto'; +import * as pty from 'node-pty'; + +const { Terminal } = xtermHeadless; +type Terminal = InstanceType; + +/** + * Map from numeric signal values to POSIX signal names. + * + * node-pty reports the termination signal as a number. This map converts + * the most common signal numbers to their human-readable names. + */ +const SIGNAL_NAMES: Record = { + 1: 'SIGHUP', + 2: 'SIGINT', + 3: 'SIGQUIT', + 4: 'SIGILL', + 6: 'SIGABRT', + 8: 'SIGFPE', + 9: 'SIGKILL', + 11: 'SIGSEGV', + 13: 'SIGPIPE', + 14: 'SIGALRM', + 15: 'SIGTERM', +}; + +/** + * A headless TUI session backed by a PTY process and an xterm terminal emulator. + * + * Provides methods to send keystrokes, read the screen, wait for patterns, + * and cleanly shut down the session. Automatically handles DSR/CPR queries + * so that TUI frameworks like Ink can detect terminal capabilities without + * hanging. + * + * Create instances via the static {@link TuiSession.launch} factory. + */ +export class TuiSession { + private readonly _sessionId: string; + private readonly terminal: Terminal; + private readonly ptyProcess: pty.IPty; + private readonly settlingMonitor: SettlingMonitor; + private readonly command: string; + private readonly args: string[]; + private readonly cwd: string; + private readonly created: Date; + private readonly disposables: { dispose(): void }[]; + private _alive: boolean; + private _exitCode: number | null; + private _exitSignal: string | null; + + private constructor( + sessionId: string, + terminal: Terminal, + ptyProcess: pty.IPty, + settlingMonitor: SettlingMonitor, + command: string, + args: string[], + cwd: string, + created: Date, + disposables: { dispose(): void }[] + ) { + this._sessionId = sessionId; + this.terminal = terminal; + this.ptyProcess = ptyProcess; + this.settlingMonitor = settlingMonitor; + this.command = command; + this.args = args; + this.cwd = cwd; + this.created = created; + this.disposables = disposables; + this._alive = true; + this._exitCode = null; + this._exitSignal = null; + } + + // --------------------------------------------------------------------------- + // Static factory + // --------------------------------------------------------------------------- + + /** + * Launch a new TUI session. + * + * Spawns the requested command in a PTY, wires its output through a + * headless xterm terminal emulator, registers a DSR handler so TUI + * frameworks can query cursor position, and waits for initial output + * to settle before returning. + * + * @param options - Configuration for the session (command, args, dimensions, etc.) + * @returns A fully initialized TuiSession ready for interaction. + * @throws {LaunchError} If the spawned process exits with a non-zero code + * before initial output settles. + */ + static async launch(options: LaunchOptions): Promise { + const sessionId = randomUUID(); + const cols = options.cols ?? 100; + const rows = options.rows ?? 30; + const cwd = options.cwd ?? process.cwd(); + const args = options.args ?? []; + const created = new Date(); + const disposables: { dispose(): void }[] = []; + + // 1. Create the headless terminal emulator. + const terminal = new Terminal({ cols, rows, allowProposedApi: true }); + + // Build the environment. We must DELETE INIT_CWD rather than set it to + // undefined — node-pty converts undefined values to the string "undefined", + // which would cause getWorkingDirectory() to return "undefined" instead + // of process.cwd(). + const { INIT_CWD: _initCwd, ...cleanEnv } = process.env; + + // 2. Spawn the PTY process. + const ptyProcess = pty.spawn(options.command, args, { + cols, + rows, + cwd, + env: { ...cleanEnv, TERM: 'xterm-256color', ...options.env }, + }); + + // 3. Wire PTY output into xterm. + const dataDisposable = ptyProcess.onData((data: string) => { + terminal.write(data); + }); + disposables.push(dataDisposable); + + // 4. Register DSR (Device Status Report) handler. + // + // TUI frameworks like Ink query cursor position by writing \x1b[6n + // (CPR request) to stdout. xterm parses this as a CSI sequence with + // final character 'n'. Our handler intercepts it and writes the + // cursor position report back into the PTY's stdin, completing the + // round-trip that the TUI app expects. + const dsrDisposable = terminal.parser.registerCsiHandler({ final: 'n' }, params => { + if (params[0] === 6) { + // CPR: report cursor position as \x1b[{row};{col}R (1-indexed) + const buf = terminal.buffer.active; + ptyProcess.write(`\x1b[${buf.cursorY + 1};${buf.cursorX + 1}R`); + return true; + } + if (params[0] === 5) { + // Device status: report OK + ptyProcess.write('\x1b[0n'); + return true; + } + return false; + }); + disposables.push(dsrDisposable); + + // 5. Create the settling monitor. + const settlingMonitor = new SettlingMonitor(terminal); + + // 6. Track process exit state. We use a promise that resolves when the + // PTY process exits, and a flag/values that the session instance + // will read. + let exited = false; + let earlyExitCode: number | null = null; + let earlyExitSignal: string | null = null; + + const exitPromise = new Promise(resolve => { + const exitDisposable = ptyProcess.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => { + exited = true; + earlyExitCode = exitCode; + earlyExitSignal = signal != null && signal > 0 ? (SIGNAL_NAMES[signal] ?? `signal(${signal})`) : null; + resolve(); + }); + disposables.push(exitDisposable); + }); + + // 7. Create the session instance (private constructor). + const session = new TuiSession( + sessionId, + terminal, + ptyProcess, + settlingMonitor, + options.command, + args, + cwd, + created, + disposables + ); + + // 8. Race initial settle against process exit. + // + // We want to detect the case where the process exits with a non-zero + // code before output settles (e.g., command not found). In that case + // we throw a LaunchError rather than returning a dead session. + const settlePromise = settlingMonitor.waitForSettle(2000); + + await Promise.race([settlePromise, exitPromise]); + + if (exited) { + // Process exited during initial settle. + // Flush any remaining xterm writes so the screen reflects final output. + await new Promise(resolve => terminal.write('', resolve)); + + if (earlyExitCode !== null && earlyExitCode !== 0) { + // Non-zero exit before settle: this is a launch failure. + const screen = buildScreenState(terminal); + terminal.dispose(); + settlingMonitor.dispose(); + throw new LaunchError(options.command, args, cwd, earlyExitCode, screen); + } + + // Zero exit code: the process exited cleanly (e.g., a short-lived command). + // Update session state and return it. + session._alive = false; + session._exitCode = earlyExitCode; + session._exitSignal = earlyExitSignal; + } else { + // Settle completed (or timed out) while process is still running. + // Wire up the exit handler to update session state going forward. + // The exit handler was already registered above via ptyProcess.onExit, + // but we need it to update the session instance fields. We set up a + // listener on the exit promise to do that. + void exitPromise.then(() => { + session._alive = false; + session._exitCode = earlyExitCode; + session._exitSignal = earlyExitSignal; + }); + } + + // 9. Register with the global session manager. + register(session); + + return session; + } + + // --------------------------------------------------------------------------- + // Properties + // --------------------------------------------------------------------------- + + /** Whether the PTY process is still running. */ + get alive(): boolean { + return this._alive; + } + + /** Unique identifier for this session. */ + get sessionId(): string { + return this._sessionId; + } + + /** Metadata about this session. */ + get info(): SessionInfo { + return { + sessionId: this._sessionId, + pid: this.ptyProcess.pid, + command: this.command, + cwd: this.cwd, + dimensions: { cols: this.terminal.cols, rows: this.terminal.rows }, + bufferType: getBufferType(this.terminal), + alive: this._alive, + created: this.created, + }; + } + + // --------------------------------------------------------------------------- + // Screen reading + // --------------------------------------------------------------------------- + + /** + * Read the current terminal screen contents. + * + * @param options - Optional configuration for scrollback inclusion and line numbering. + * @returns A ScreenState snapshot of the terminal. + */ + readScreen(options?: ReadOptions): ScreenState { + return buildScreenState(this.terminal, options); + } + + // --------------------------------------------------------------------------- + // Input methods + // --------------------------------------------------------------------------- + + /** + * Send raw keystrokes to the PTY process. + * + * @param keys - The raw characters or escape sequences to write. + * @param waitMs - Optional settling time in milliseconds. Defaults to the + * settling monitor's default (300ms). + * @returns The screen state after output settles. + */ + async sendKeys(keys: string, waitMs?: number): Promise { + this.assertAlive(); + this.ptyProcess.write(keys); + await this.settlingMonitor.waitForSettle(waitMs); + return this.readScreen(); + } + + /** + * Send a named special key to the PTY process. + * + * @param key - The special key name (e.g., 'enter', 'ctrl+c', 'f5'). + * @param waitMs - Optional settling time in milliseconds. + * @returns The screen state after output settles. + */ + async sendSpecialKey(key: SpecialKey, waitMs?: number): Promise { + this.assertAlive(); + const sequence = resolveKey(key); + this.ptyProcess.write(sequence); + await this.settlingMonitor.waitForSettle(waitMs); + return this.readScreen(); + } + + // --------------------------------------------------------------------------- + // Waiting + // --------------------------------------------------------------------------- + + /** + * Wait for a pattern to appear on the terminal screen. + * + * Checks immediately, then listens for terminal writes and polls at + * 100ms intervals until the pattern is found or the timeout expires. + * + * @param pattern - A string (checked with `includes`) or RegExp to match + * against the joined screen lines. + * @param timeoutMs - Maximum wait time in milliseconds. Defaults to 10000. + * @returns The screen state at the moment the pattern was matched. + * @throws {WaitForTimeoutError} If the pattern is not found within the timeout. + */ + async waitFor(pattern: string | RegExp, timeoutMs?: number): Promise { + const effectiveTimeout = timeoutMs ?? 10000; + + const matches = (screen: ScreenState): boolean => { + const text = screen.lines.join('\n'); + return typeof pattern === 'string' ? text.includes(pattern) : pattern.test(text); + }; + + // Check immediately -- the pattern might already be on screen. + // This runs BEFORE assertAlive so that patterns already present on a + // dead session (e.g., short-lived commands that exited with code 0) + // still resolve instead of throwing. + const immediateScreen = this.readScreen(); + if (matches(immediateScreen)) { + return immediateScreen; + } + + // Only assert alive when we need to set up listeners. If the session + // is dead and the pattern wasn't found above, there's no point waiting. + this.assertAlive(); + + const start = Date.now(); + + return new Promise((resolve, reject) => { + let settled = false; + + const cleanup = (): void => { + if (settled) return; + settled = true; + writeListener.dispose(); + exitListener.dispose(); + clearInterval(pollTimer); + clearTimeout(timeoutTimer); + }; + + const check = (): boolean => { + if (settled) return false; + const screen = this.readScreen(); + if (matches(screen)) { + cleanup(); + resolve(screen); + return true; + } + return false; + }; + + // Listen for parsed writes from xterm. + const writeListener = this.terminal.onWriteParsed(() => { + check(); + }); + + // Listen for PTY exit so we can fail fast if the process dies. + const exitListener = this.ptyProcess.onExit(() => { + if (settled) return; + // Give xterm a moment to process any final buffered output. + setTimeout(() => { + if (settled) return; + // One last check -- the pattern might have appeared in final output. + if (!check()) { + cleanup(); + const elapsed = Date.now() - start; + const currentScreen = this.readScreen(); + reject(new WaitForTimeoutError(pattern, elapsed, currentScreen)); + } + }, 50); + }); + + // Fallback poll at 100ms intervals in case writes happen between checks. + const pollTimer = setInterval(() => { + check(); + }, 100); + + // Overall timeout. + const timeoutTimer = setTimeout(() => { + if (settled) return; + cleanup(); + const elapsed = Date.now() - start; + const currentScreen = this.readScreen(); + reject(new WaitForTimeoutError(pattern, elapsed, currentScreen)); + }, effectiveTimeout); + }); + } + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + /** + * Close the session, terminating the PTY process and cleaning up resources. + * + * If the process is already dead, returns immediately with the last known + * state. Otherwise, sends SIGTERM and waits up to 5 seconds. If the process + * does not exit within that window, sends SIGKILL. + * + * @param signal - The initial signal to send. Defaults to 'SIGTERM'. + * @returns The exit code, termination signal, and final screen state. + */ + async close(signal?: string): Promise { + if (!this._alive) { + // Already dead -- return last known state. + const finalScreen = this.readScreen(); + this.disposeAll(); + unregister(this._sessionId); + return { + exitCode: this._exitCode, + signal: this._exitSignal, + finalScreen, + }; + } + + // Capture the screen while the PTY is still alive. + const finalScreen = this.readScreen(); + + // Send the requested signal. + this.ptyProcess.kill(signal ?? 'SIGTERM'); + + // Wait up to 5 seconds for the process to exit. + const exitedCleanly = await this.waitForExit(5000); + + if (!exitedCleanly && this._alive) { + // Process did not respond to the initial signal. Force kill. + this.ptyProcess.kill('SIGKILL'); + await this.waitForExit(5000); + } + + this.disposeAll(); + unregister(this._sessionId); + + return { + exitCode: this._exitCode, + signal: this._exitSignal, + finalScreen, + }; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Assert that the session is still alive. + * + * @throws {Error} If the PTY process has exited. + */ + private assertAlive(): void { + if (!this._alive) { + throw new Error( + `Session ${this._sessionId} is not alive: ${this.command} ${this.args.join(' ')} (exitCode: ${this._exitCode})` + ); + } + } + + /** + * Wait for the PTY process to exit within a given timeout. + * + * @param timeoutMs - Maximum time to wait in milliseconds. + * @returns `true` if the process exited, `false` if the timeout was reached. + */ + private waitForExit(timeoutMs: number): Promise { + if (!this._alive) return Promise.resolve(true); + + return new Promise(resolve => { + const timer = setTimeout(() => { + listener.dispose(); + resolve(false); + }, timeoutMs); + + const listener = this.ptyProcess.onExit(() => { + clearTimeout(timer); + listener.dispose(); + resolve(true); + }); + }); + } + + /** + * Dispose all tracked resources (terminal, settling monitor, disposables). + */ + private disposeAll(): void { + this.settlingMonitor.dispose(); + for (const disposable of this.disposables) { + try { + disposable.dispose(); + } catch { + // Best-effort cleanup -- swallow errors from already-disposed resources. + } + } + this.terminal.dispose(); + } +} diff --git a/src/tui-harness/lib/availability.ts b/src/tui-harness/lib/availability.ts new file mode 100644 index 00000000..ad1f2953 --- /dev/null +++ b/src/tui-harness/lib/availability.ts @@ -0,0 +1,40 @@ +/** + * Runtime availability check for node-pty. + * + * This module attempts to load node-pty at module evaluation time and + * exports a boolean flag indicating whether it succeeded. Test files + * use this to skip TUI harness tests gracefully when node-pty is not + * installed or its native addon failed to compile. + * + * Import pattern (proven in Phase 1 proof-of-concept): + * import * as pty from 'node-pty'; + * + * We use createRequire here because the check must be synchronous (run + * at module load time) and because createRequire is the established + * pattern in this codebase for CJS interop under verbatimModuleSyntax. + */ +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); + +/** + * Whether node-pty is available and its native addon loaded successfully. + * + * When `true`, it is safe to `import * as pty from 'node-pty'` and use + * the PTY APIs. When `false`, check {@link unavailableReason} for details. + */ +export let isAvailable = false; + +/** + * Human-readable reason why node-pty is not available. + * + * Empty string when {@link isAvailable} is `true`. + */ +export let unavailableReason = ''; + +try { + require('node-pty'); + isAvailable = true; +} catch (err) { + unavailableReason = `node-pty not available: ${(err as Error).message}`; +} diff --git a/src/tui-harness/lib/key-map.ts b/src/tui-harness/lib/key-map.ts new file mode 100644 index 00000000..f79f9265 --- /dev/null +++ b/src/tui-harness/lib/key-map.ts @@ -0,0 +1,80 @@ +/** + * Mapping from human-readable special key names to xterm-256color escape sequences. + * + * The TUI test harness uses this module to translate high-level key names + * (e.g. "enter", "ctrl+c", "f5") into the raw byte sequences that a terminal + * emulator would send to a PTY. This allows test code to express key presses + * declaratively rather than embedding opaque escape codes. + * + * The `satisfies Record` constraint guarantees at compile + * time that every member of the SpecialKey union has a corresponding entry. + * Adding a new key to SpecialKey without updating KEY_MAP will produce a + * type error. + */ +import type { SpecialKey } from './types.js'; + +/** + * Exhaustive map from every {@link SpecialKey} to its xterm-256color byte sequence. + * + * Navigation keys use CSI (ESC [ ...) sequences. Function keys f1-f4 use + * SS3 (ESC O ...) sequences; f5-f12 use CSI with tilde-suffixed codes. + * Ctrl combos map to their traditional ASCII control characters. + */ +export const KEY_MAP = { + // Basic editing keys + enter: '\r', + tab: '\t', + escape: '\x1b', + backspace: '\x7f', + delete: '\x1b[3~', + space: ' ', + + // Arrow keys (CSI sequences) + up: '\x1b[A', + down: '\x1b[B', + right: '\x1b[C', + left: '\x1b[D', + + // Navigation keys + home: '\x1b[H', + end: '\x1b[F', + pageup: '\x1b[5~', + pagedown: '\x1b[6~', + + // Ctrl combinations (ASCII control characters) + 'ctrl+c': '\x03', + 'ctrl+d': '\x04', + 'ctrl+q': '\x11', + 'ctrl+g': '\x07', + 'ctrl+a': '\x01', + 'ctrl+e': '\x05', + 'ctrl+w': '\x17', + 'ctrl+u': '\x15', + 'ctrl+k': '\x0b', + + // Function keys f1-f4 (SS3 sequences) + f1: '\x1bOP', + f2: '\x1bOQ', + f3: '\x1bOR', + f4: '\x1bOS', + + // Function keys f5-f12 (CSI tilde sequences) + f5: '\x1b[15~', + f6: '\x1b[17~', + f7: '\x1b[18~', + f8: '\x1b[19~', + f9: '\x1b[20~', + f10: '\x1b[21~', + f11: '\x1b[23~', + f12: '\x1b[24~', +} as const satisfies Record; + +/** + * Resolve a special key name to its terminal escape sequence. + * + * @param key - A member of the {@link SpecialKey} union. + * @returns The raw byte sequence for the given key. + */ +export function resolveKey(key: SpecialKey): string { + return KEY_MAP[key]; +} diff --git a/src/tui-harness/lib/screen.ts b/src/tui-harness/lib/screen.ts new file mode 100644 index 00000000..71e7a261 --- /dev/null +++ b/src/tui-harness/lib/screen.ts @@ -0,0 +1,136 @@ +/** + * Screen reader utilities for extracting text content from an xterm Terminal buffer. + * + * This module provides functions that read the xterm buffer's internal line + * data and return plain-text representations of the terminal screen. It + * supports reading just the visible viewport, reading the full scrollback + * history, retrieving cursor position and buffer type, and composing a + * complete ScreenState snapshot. + * + * All functions accept a Terminal instance that must have been created with + * `allowProposedApi: true` (required to access `terminal.buffer`). + */ +import type { ReadOptions, ScreenState } from './types.js'; +import xtermHeadless from '@xterm/headless'; + +const { Terminal } = xtermHeadless; +type Terminal = InstanceType; + +// --------------------------------------------------------------------------- +// Individual readers +// --------------------------------------------------------------------------- + +/** + * Read the visible viewport lines from the active buffer. + * + * The viewport starts at `baseY` (the first visible row when scrolled to the + * bottom) and spans `terminal.rows` lines. + * + * @param terminal - An xterm Terminal instance with allowProposedApi enabled. + * @returns An array of strings, one per visible row, with trailing whitespace trimmed. + */ +export function readViewport(terminal: Terminal): string[] { + const buffer = terminal.buffer.active; + const start = buffer.baseY; + const lines: string[] = []; + + for (let i = start; i < start + terminal.rows; i++) { + lines.push(buffer.getLine(i)?.translateToString(true) ?? ''); + } + + return lines; +} + +/** + * Read all lines in the active buffer, including scrollback history. + * + * Returns every line from index 0 through `baseY + terminal.rows - 1`, + * covering the full scrollback plus the visible viewport. + * + * @param terminal - An xterm Terminal instance with allowProposedApi enabled. + * @returns An array of strings for every line in the buffer. + */ +export function readWithScrollback(terminal: Terminal): string[] { + const buffer = terminal.buffer.active; + const totalLines = buffer.baseY + terminal.rows; + const lines: string[] = []; + + for (let i = 0; i < totalLines; i++) { + lines.push(buffer.getLine(i)?.translateToString(true) ?? ''); + } + + return lines; +} + +/** + * Get the current cursor position in the active buffer. + * + * The coordinates are 0-indexed and relative to the viewport (not the + * scrollback). `cursorY` ranges from 0 to `terminal.rows - 1`. + * + * @param terminal - An xterm Terminal instance with allowProposedApi enabled. + * @returns An object with `x` and `y` properties. + */ +export function getCursor(terminal: Terminal): { x: number; y: number } { + const buffer = terminal.buffer.active; + return { x: buffer.cursorX, y: buffer.cursorY }; +} + +/** + * Determine whether the terminal is using the normal or alternate screen buffer. + * + * @param terminal - An xterm Terminal instance with allowProposedApi enabled. + * @returns `'normal'` or `'alternate'`. + */ +export function getBufferType(terminal: Terminal): 'normal' | 'alternate' { + return terminal.buffer.active.type; +} + +// --------------------------------------------------------------------------- +// Formatting +// --------------------------------------------------------------------------- + +/** + * Format an array of lines with right-aligned, 1-indexed line numbers. + * + * Example output for a 3-line array: + * ``` + * 1 | first line + * 2 | second line + * 3 | third line + * ``` + * + * @param lines - The lines to number. + * @returns A single string with newline-separated numbered lines. + */ +export function formatNumbered(lines: string[]): string { + const width = String(lines.length).length; + return lines.map((line, i) => `${String(i + 1).padStart(width)} | ${line}`).join('\n'); +} + +// --------------------------------------------------------------------------- +// Composite snapshot +// --------------------------------------------------------------------------- + +/** + * Build a complete ScreenState snapshot from the terminal. + * + * @param terminal - An xterm Terminal instance with allowProposedApi enabled. + * @param options - Optional ReadOptions controlling scrollback inclusion and numbering. + * @returns A ScreenState object containing lines, cursor, dimensions, and buffer type. + */ +export function buildScreenState(terminal: Terminal, options?: ReadOptions): ScreenState { + let lines = options?.includeScrollback ? readWithScrollback(terminal) : readViewport(terminal); + + if (options?.numbered) { + const width = String(lines.length).length; + lines = lines.map((line, i) => `${String(i + 1).padStart(width)} | ${line}`); + } + + return { + lines, + cursor: getCursor(terminal), + dimensions: { cols: terminal.cols, rows: terminal.rows }, + bufferType: getBufferType(terminal), + }; +} diff --git a/src/tui-harness/lib/session-manager.ts b/src/tui-harness/lib/session-manager.ts new file mode 100644 index 00000000..cddc1caf --- /dev/null +++ b/src/tui-harness/lib/session-manager.ts @@ -0,0 +1,150 @@ +/** + * Global session registry for TUI test harness sessions. + * + * Tracks all active TuiSession instances and ensures they are cleaned up + * on process exit or signal termination. Uses a module-level Map as the + * singleton registry — no class needed. + * + * To avoid circular dependencies, this module defines a {@link ManagedSession} + * interface that TuiSession (defined elsewhere) must implement. This module + * never imports TuiSession directly. + */ +import type { CloseResult, SessionInfo } from './types.js'; + +// --------------------------------------------------------------------------- +// Interfaces +// --------------------------------------------------------------------------- + +/** + * Interface for session objects managed by this registry. + * + * Avoids circular dependency with TuiSession (which imports session-manager). + * TuiSession must implement this interface to be registered here. + */ +export interface ManagedSession { + readonly sessionId: string; + readonly info: SessionInfo; + close(): Promise; +} + +// --------------------------------------------------------------------------- +// Module-level state (singleton registry) +// --------------------------------------------------------------------------- + +const sessions = new Map(); +let handlersRegistered = false; + +// --------------------------------------------------------------------------- +// Process exit handlers +// --------------------------------------------------------------------------- + +/** + * Registers process signal handlers (once) so that all tracked sessions are + * closed before the process terminates. The handlers are installed lazily on + * the first call to {@link register}. + * + * - SIGTERM / SIGINT: async cleanup via {@link closeAll}, then exit 0. + * - 'exit': synchronous — cannot await, so we just log a warning if sessions + * remain open. The OS will clean up child processes when the parent exits. + */ +function ensureProcessHandlers(): void { + if (handlersRegistered) return; + handlersRegistered = true; + + // Synchronous — cannot do async work here. If sessions are still open at + // this point they were not cleaned up by SIGTERM/SIGINT handlers or by an + // explicit closeAll() call. The OS will reap child processes automatically. + process.on('exit', () => { + if (sessions.size > 0) { + // Best-effort warning; the process is already exiting. + + console.warn( + `[tui-harness] ${sessions.size} session(s) still open at process exit. ` + + 'Child processes will be cleaned up by the OS.' + ); + } + }); + + const handleSignal = async (): Promise => { + await closeAll(); + process.exit(0); + }; + + process.on('SIGTERM', () => { + void handleSignal(); + }); + process.on('SIGINT', () => { + void handleSignal(); + }); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Register a session in the global registry. + * + * On the first registration, process signal handlers are installed to ensure + * cleanup on SIGTERM and SIGINT. + * + * @param session - The session object to track. Must satisfy {@link ManagedSession}. + */ +export function register(session: ManagedSession): void { + sessions.set(session.sessionId, session); + ensureProcessHandlers(); +} + +/** + * Remove a session from the global registry. + * + * This does **not** close the session — it simply stops tracking it. Callers + * are responsible for calling `session.close()` separately if needed. + * + * @param sessionId - The unique identifier of the session to unregister. + */ +export function unregister(sessionId: string): void { + sessions.delete(sessionId); +} + +/** + * Look up a session by its unique identifier. + * + * @param sessionId - The session ID to look up. + * @returns The managed session, or undefined if no session with that ID is registered. + */ +export function get(sessionId: string): ManagedSession | undefined { + return sessions.get(sessionId); +} + +/** + * Return metadata for all currently registered sessions. + * + * @returns An array of {@link SessionInfo} objects, one per registered session. + */ +export function listAll(): SessionInfo[] { + return Array.from(sessions.values()).map(s => s.info); +} + +/** + * Close all registered sessions and clear the registry. + * + * Each session's `close()` method is called concurrently. Errors from + * individual sessions are swallowed so that one failing session does not + * prevent others from being cleaned up. The registry is cleared after all + * close attempts have settled (whether resolved or rejected). + * + * This function is idempotent — calling it on an empty registry is a no-op. + */ +export async function closeAll(): Promise { + const promises = Array.from(sessions.values()).map(async session => { + try { + await session.close(); + } catch { + // Best-effort cleanup — swallow errors from dead or already-closed sessions. + } + }); + + await Promise.allSettled(promises); + sessions.clear(); +} diff --git a/src/tui-harness/lib/settling.ts b/src/tui-harness/lib/settling.ts new file mode 100644 index 00000000..b8f003d1 --- /dev/null +++ b/src/tui-harness/lib/settling.ts @@ -0,0 +1,175 @@ +/** + * Output settling monitor for the TUI test harness. + * + * Detects when terminal output has "settled" — meaning no more meaningful + * text changes are occurring. This is used to know when a TUI screen is + * fully rendered and ready for assertions or input. + * + * The core insight: cursor blink and other cosmetic writes (attribute + * changes, cursor repositioning) fire `onWriteParsed` but do NOT change + * the text content returned by `translateToString()`. By comparing text + * snapshots, we filter out cosmetic noise and only reset the silence + * timer when actual text changes occur. + * + * Import pattern (proven in Phase 1 proof-of-concept): + * import xtermHeadless from '@xterm/headless'; + * const { Terminal } = xtermHeadless; + * + * The package's "main" is a CJS bundle. With verbatimModuleSyntax + bundler + * resolution, a default import gets the module.exports object. + */ +import xtermHeadless from '@xterm/headless'; + +const { Terminal } = xtermHeadless; +type Terminal = InstanceType; + +/** Default number of milliseconds of text silence before considering output settled. */ +const DEFAULT_WAIT_MS = 300; + +/** Multiplier applied to the settle wait to compute the hard ceiling timeout. */ +const HARD_CEILING_MULTIPLIER = 3; + +/** + * Monitors a terminal for output settling — the point at which no more + * meaningful text changes are occurring. + * + * Usage: + * ```ts + * const monitor = new SettlingMonitor(terminal); + * const settled = await monitor.waitForSettle(); + * if (settled) { + * // Screen is stable, safe to read or interact + * } + * monitor.dispose(); + * ``` + */ +export class SettlingMonitor { + private terminal: Terminal; + private defaultWaitMs: number; + private disposed: boolean; + private disposeHandlers: { dispose(): void }[]; + + constructor(terminal: Terminal, options?: { defaultWaitMs?: number }) { + this.terminal = terminal; + this.defaultWaitMs = options?.defaultWaitMs ?? DEFAULT_WAIT_MS; + this.disposed = false; + this.disposeHandlers = []; + } + + /** + * Wait until terminal output settles (no text changes for `waitMs` + * milliseconds), or until the hard ceiling timeout is reached. + * + * @param waitMs - Milliseconds of text silence required. Defaults to + * the value provided in the constructor options (300ms). + * @returns `true` if output settled within the time limit, `false` if + * the hard ceiling was reached or the monitor was disposed. + */ + waitForSettle(waitMs?: number): Promise { + const effectiveWaitMs = waitMs ?? this.defaultWaitMs; + const hardCeilingMs = effectiveWaitMs * HARD_CEILING_MULTIPLIER; + + if (this.disposed) { + return Promise.resolve(false); + } + + return new Promise(resolve => { + let resolved = false; + + const cleanup = (): void => { + if (writeListener) { + writeListener.dispose(); + // Remove from disposeHandlers so we don't try to double-dispose + const idx = this.disposeHandlers.indexOf(disposeEntry); + if (idx !== -1) { + this.disposeHandlers.splice(idx, 1); + } + } + clearTimeout(silenceTimer); + clearTimeout(ceilingTimer); + }; + + const finish = (settled: boolean): void => { + if (resolved) return; + resolved = true; + cleanup(); + resolve(settled); + }; + + // Take the initial text snapshot of the viewport. + let lastSnapshot = this.takeSnapshot(); + + // Start the silence timer. If it fires without being reset, output + // has settled. + let silenceTimer = setTimeout(() => { + finish(true); + }, effectiveWaitMs); + + // Hard ceiling prevents infinite waiting when output keeps changing. + const ceilingTimer = setTimeout(() => { + finish(false); + }, hardCeilingMs); + + // Listen for parsed writes. On each write, compare the new text to + // the previous snapshot. Only reset the silence timer if text actually + // changed (filtering out cursor blink and other cosmetic writes). + const writeListener = this.terminal.onWriteParsed(() => { + if (resolved) return; + + const newSnapshot = this.takeSnapshot(); + + if (newSnapshot !== lastSnapshot) { + // Text changed — update baseline and reset silence timer. + lastSnapshot = newSnapshot; + clearTimeout(silenceTimer); + silenceTimer = setTimeout(() => { + finish(true); + }, effectiveWaitMs); + } + // If text is the same, this was a cosmetic write (cursor blink, + // attribute change). Do nothing — let the silence timer continue. + }); + + // Create a dispose entry that can abort this waitForSettle call + // if dispose() is called externally. + const disposeEntry = { + dispose(): void { + finish(false); + }, + }; + this.disposeHandlers.push(disposeEntry); + }); + } + + /** + * Clean up all listeners and timers. Any in-progress `waitForSettle` + * call will resolve with `false`. + */ + dispose(): void { + this.disposed = true; + + // Copy the array since finish() modifies disposeHandlers via splice. + const handlers = [...this.disposeHandlers]; + for (const handler of handlers) { + handler.dispose(); + } + this.disposeHandlers = []; + } + + /** + * Take a text snapshot of the current viewport. + * + * Reads each visible line from the active buffer using + * `translateToString(true)` (which trims trailing whitespace). + * The resulting lines are joined with newlines to form a single + * comparable string. + */ + private takeSnapshot(): string { + const buf = this.terminal.buffer.active; + const lines: string[] = []; + for (let i = buf.baseY; i < buf.baseY + this.terminal.rows; i++) { + lines.push(buf.getLine(i)?.translateToString(true) ?? ''); + } + return lines.join('\n'); + } +} diff --git a/src/tui-harness/lib/types.ts b/src/tui-harness/lib/types.ts new file mode 100644 index 00000000..8421f73d --- /dev/null +++ b/src/tui-harness/lib/types.ts @@ -0,0 +1,224 @@ +/** + * Type definitions and error classes for the TUI test harness. + * + * This module defines all interfaces, types, and custom error classes used + * throughout the harness. It has no runtime dependencies beyond the standard + * library, so it can be imported freely without pulling in node-pty or xterm. + */ + +// --------------------------------------------------------------------------- +// Interfaces +// --------------------------------------------------------------------------- + +/** + * Options for launching a TUI session. + * + * @property command - The executable to spawn (e.g. "/usr/bin/node", "agentcore"). + * @property args - Arguments passed to the command. Defaults to []. + * @property cwd - Working directory for the spawned process. + * @property cols - Terminal width in columns. Defaults to 100. + * @property rows - Terminal height in rows. Defaults to 30. + * @property env - Additional environment variables merged with process.env. + */ +export interface LaunchOptions { + command: string; + args?: string[]; + cwd?: string; + cols?: number; + rows?: number; + env?: Record; +} + +/** + * A snapshot of the terminal screen at a point in time. + * + * @property lines - Array of strings, one per terminal row. Each string is + * the text content of that row with trailing whitespace trimmed. + * @property cursor - The current cursor position (0-indexed). + * @property dimensions - The terminal dimensions at capture time. + * @property bufferType - Whether the terminal is using the normal or + * alternate screen buffer. + */ +export interface ScreenState { + lines: string[]; + cursor: { x: number; y: number }; + dimensions: { cols: number; rows: number }; + bufferType: 'normal' | 'alternate'; +} + +/** + * Options for reading the terminal screen. + * + * @property includeScrollback - When true, include lines above the visible + * viewport (scrollback history). Defaults to false. + * @property numbered - When true, prefix each line with its 1-indexed line + * number. Defaults to false. + */ +export interface ReadOptions { + includeScrollback?: boolean; + numbered?: boolean; +} + +/** + * Result returned when a TUI session is closed. + * + * @property exitCode - The process exit code, or null if terminated by signal. + * @property signal - The signal that terminated the process (e.g. "SIGTERM"), + * or null if it exited normally. + * @property finalScreen - The last screen state captured before the process + * exited. + */ +export interface CloseResult { + exitCode: number | null; + signal: string | null; + finalScreen: ScreenState; +} + +/** + * Metadata about an active (or recently closed) TUI session. + * + * @property sessionId - Unique identifier for this session. + * @property pid - Operating system process ID of the PTY child process. + * @property command - The command that was launched. + * @property cwd - The working directory of the spawned process. + * @property dimensions - Current terminal dimensions. + * @property bufferType - Current buffer type (normal or alternate). + * @property alive - Whether the PTY process is still running. + * @property created - Timestamp when the session was created. + */ +export interface SessionInfo { + sessionId: string; + pid: number; + command: string; + cwd: string; + dimensions: { cols: number; rows: number }; + bufferType: 'normal' | 'alternate'; + alive: boolean; + created: Date; +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * All special key names recognized by the TUI harness, as a compile-time + * constant array. This is the single source of truth for the key list. + * + * The {@link SpecialKey} type is derived from this array, and `key-map.ts` + * uses `satisfies Record` to guarantee exhaustive + * coverage at compile time. + */ +export const SPECIAL_KEY_VALUES = [ + 'enter', + 'tab', + 'escape', + 'backspace', + 'delete', + 'space', + 'up', + 'down', + 'left', + 'right', + 'home', + 'end', + 'pageup', + 'pagedown', + 'ctrl+c', + 'ctrl+d', + 'ctrl+q', + 'ctrl+g', + 'ctrl+a', + 'ctrl+e', + 'ctrl+w', + 'ctrl+u', + 'ctrl+k', + 'f1', + 'f2', + 'f3', + 'f4', + 'f5', + 'f6', + 'f7', + 'f8', + 'f9', + 'f10', + 'f11', + 'f12', +] as const; + +/** + * Union of all special key names recognized by the harness input methods. + * + * Derived from {@link SPECIAL_KEY_VALUES} so the array and the type are + * always in sync. + */ +export type SpecialKey = (typeof SPECIAL_KEY_VALUES)[number]; + +// --------------------------------------------------------------------------- +// Error Classes +// --------------------------------------------------------------------------- + +/** + * Thrown when a `waitFor` call exceeds its timeout without matching the + * expected pattern on screen. + * + * The error message includes the pattern that was being waited for, the + * elapsed time, and a dump of the non-empty screen lines at the time of + * failure to aid debugging. + */ +export class WaitForTimeoutError extends Error { + public readonly pattern: string | RegExp; + public readonly elapsed: number; + public readonly screen: ScreenState; + + constructor(pattern: string | RegExp, elapsed: number, screen: ScreenState) { + const nonEmptyLines = screen.lines.filter(line => line.trim() !== ''); + const screenDump = nonEmptyLines.join('\n'); + const patternStr = pattern instanceof RegExp ? pattern.toString() : `"${pattern}"`; + + super( + `Timed out after ${elapsed}ms waiting for ${patternStr}\n` + + `Screen (${nonEmptyLines.length} non-empty lines):\n${screenDump}` + ); + + this.name = 'WaitForTimeoutError'; + this.pattern = pattern; + this.elapsed = elapsed; + this.screen = screen; + } +} + +/** + * Thrown when a TUI process fails to launch or exits unexpectedly during + * session setup. + * + * The error message includes the command, arguments, working directory, + * exit code, and a dump of the non-empty screen lines to aid debugging. + */ +export class LaunchError extends Error { + public readonly command: string; + public readonly args: string[]; + public readonly cwd: string; + public readonly exitCode: number | null; + public readonly screen: ScreenState; + + constructor(command: string, args: string[], cwd: string, exitCode: number | null, screen: ScreenState) { + const nonEmptyLines = screen.lines.filter(line => line.trim() !== ''); + const screenDump = nonEmptyLines.join('\n'); + + super( + `Failed to launch: ${command} ${args.join(' ')}\n` + + ` cwd: ${cwd}\n` + + ` exitCode: ${exitCode}\n` + + `Screen (${nonEmptyLines.length} non-empty lines):\n${screenDump}` + ); + + this.name = 'LaunchError'; + this.command = command; + this.args = args; + this.cwd = cwd; + this.exitCode = exitCode; + this.screen = screen; + } +} diff --git a/src/tui-harness/mcp/index.ts b/src/tui-harness/mcp/index.ts new file mode 100644 index 00000000..7a3828fa --- /dev/null +++ b/src/tui-harness/mcp/index.ts @@ -0,0 +1,29 @@ +import { closeAllSessions, createServer } from './server.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + +async function main(): Promise { + const server = createServer(); + const transport = new StdioServerTransport(); + + // Graceful shutdown handler + const shutdown = async (): Promise => { + await closeAllSessions(); + await server.close(); + process.exit(0); + }; + + process.on('SIGTERM', () => { + void shutdown(); + }); + process.on('SIGINT', () => { + void shutdown(); + }); + + // Connect server to stdio transport + await server.connect(transport); +} + +main().catch((error: unknown) => { + console.error('MCP harness failed to start:', error); + process.exit(1); +}); diff --git a/src/tui-harness/mcp/server.ts b/src/tui-harness/mcp/server.ts new file mode 100644 index 00000000..ff11d1df --- /dev/null +++ b/src/tui-harness/mcp/server.ts @@ -0,0 +1,538 @@ +/** + * MCP server for the TUI harness. + * + * Creates and configures an MCP Server instance that exposes seven tools for + * interacting with TUI applications through headless pseudo-terminals: + * + * tui_launch - Spawn a TUI process in a PTY + * tui_send_keys - Send keystrokes (text or special keys) + * tui_read_screen - Read the current terminal screen + * tui_wait_for - Wait for a pattern to appear on screen + * tui_screenshot - Capture a bordered, numbered screenshot + * tui_close - Close a session and terminate its process + * tui_list_sessions - List all active sessions + * + * Tool schemas are defined inline as Zod raw shapes and registered via + * McpServer.registerTool(). This module owns the runtime dispatch logic that + * maps tool calls to TuiSession methods. + */ +import { LaunchError, TuiSession, WaitForTimeoutError, closeAll } from '../index.js'; +import type { SpecialKey } from '../index.js'; +import { LAUNCH_DEFAULTS, SPECIAL_KEY_ENUM, TOOL_NAMES } from './tools.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Maximum number of concurrent TUI sessions the server will manage. */ +const MAX_SESSIONS = 10; + +// --------------------------------------------------------------------------- +// Internal state +// --------------------------------------------------------------------------- + +/** Active TUI sessions keyed by session ID. */ +const sessions = new Map(); + +// --------------------------------------------------------------------------- +// Response helpers +// --------------------------------------------------------------------------- + +/** + * Build an MCP error response with the `isError` flag set. + * + * @param message - Human-readable error description. + */ +function errorResponse(message: string) { + return { + content: [{ type: 'text' as const, text: message }], + isError: true, + }; +} + +/** + * Build a successful MCP response containing a JSON-serialized payload. + * + * @param data - Arbitrary data to serialize as JSON. + */ +function jsonResponse(data: unknown) { + return { + content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }], + }; +} + +// --------------------------------------------------------------------------- +// Session lookup +// --------------------------------------------------------------------------- + +/** + * Look up a session by ID. + * + * Returns the session or `undefined` if no session with that ID exists. + */ +function getSession(sessionId: string): TuiSession | undefined { + return sessions.get(sessionId); +} + +// --------------------------------------------------------------------------- +// Tool handlers +// --------------------------------------------------------------------------- + +/** + * Handle the `tui_launch` tool call. + * + * Spawns a new TUI session in a pseudo-terminal and returns its initial screen + * state along with session metadata. + */ +async function handleLaunch(args: { + command?: string; + args?: string[]; + cwd?: string; + cols?: number; + rows?: number; + env?: Record; +}) { + if (sessions.size >= MAX_SESSIONS) { + return errorResponse( + `Maximum number of concurrent sessions (${MAX_SESSIONS}) reached. ` + + 'Close an existing session before launching a new one.' + ); + } + + const command = args.command ?? LAUNCH_DEFAULTS.command; + const commandArgs = args.args ?? [...LAUNCH_DEFAULTS.args]; + + try { + const session = await TuiSession.launch({ + command, + args: commandArgs, + cwd: args.cwd, + cols: args.cols, + rows: args.rows, + env: args.env, + }); + + sessions.set(session.sessionId, session); + + const screen = session.readScreen(); + const { sessionId } = session; + const { pid, dimensions } = session.info; + + return jsonResponse({ sessionId, pid, dimensions, screen }); + } catch (err) { + if (err instanceof LaunchError) { + return errorResponse( + `Launch failed: ${err.message}\n` + + `Command: ${err.command} ${err.args.join(' ')}\n` + + `CWD: ${err.cwd}\n` + + `Exit code: ${err.exitCode}` + ); + } + throw err; + } +} + +/** + * Handle the `tui_send_keys` tool call. + * + * Sends raw text or a named special key to the session's PTY and returns the + * screen state after output settles. + */ +async function handleSendKeys(args: { sessionId: string; keys?: string; specialKey?: SpecialKey; waitMs?: number }) { + const { sessionId } = args; + const session = getSession(sessionId); + if (!session) { + return errorResponse(`Session not found: ${sessionId}`); + } + + const { keys, specialKey, waitMs } = args; + + if (!keys && !specialKey) { + return errorResponse('Either keys or specialKey must be provided.'); + } + + try { + let screen; + if (keys !== undefined) { + screen = await session.sendKeys(keys, waitMs); + } + if (specialKey !== undefined) { + screen = await session.sendSpecialKey(specialKey, waitMs); + } + return jsonResponse({ screen }); + } catch (err) { + return errorResponse( + `Failed to send keys to session ${sessionId}: ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +/** + * Handle the `tui_read_screen` tool call. + * + * Reads the current terminal screen state. This is a safe, read-only operation. + */ +function handleReadScreen(args: { sessionId: string; includeScrollback?: boolean; numbered?: boolean }) { + const { sessionId } = args; + const session = getSession(sessionId); + if (!session) { + return errorResponse(`Session not found: ${sessionId}`); + } + + try { + const screen = session.readScreen({ + includeScrollback: args.includeScrollback, + numbered: args.numbered, + }); + + return jsonResponse({ screen }); + } catch (err) { + return errorResponse( + `Failed to read screen for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +/** + * Handle the `tui_wait_for` tool call. + * + * Waits for a text or regex pattern to appear on the terminal screen. A timeout + * is NOT treated as an error -- it is an expected outcome that returns + * `{ found: false }` so the agent can decide what to do next. + */ +async function handleWaitFor(args: { sessionId: string; pattern: string; timeoutMs?: number; isRegex?: boolean }) { + const { sessionId } = args; + const session = getSession(sessionId); + if (!session) { + return errorResponse(`Session not found: ${sessionId}`); + } + + const { isRegex, timeoutMs } = args; + const patternStr = args.pattern; + + let pattern: string | RegExp; + if (isRegex) { + try { + pattern = new RegExp(patternStr); + } catch (err) { + return errorResponse( + `Invalid regex pattern "${patternStr}": ${err instanceof Error ? err.message : String(err)}` + ); + } + } else { + pattern = patternStr; + } + + const start = Date.now(); + + try { + const screen = await session.waitFor(pattern, timeoutMs); + const elapsed = Date.now() - start; + return jsonResponse({ found: true, elapsed, screen }); + } catch (err) { + if (err instanceof WaitForTimeoutError) { + return jsonResponse({ + found: false, + elapsed: err.elapsed, + screen: err.screen, + }); + } + return errorResponse( + `Error waiting for pattern in session ${sessionId}: ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +/** + * Handle the `tui_screenshot` tool call. + * + * Captures the current screen with line numbers and renders it inside a + * Unicode-bordered box for easy visual inspection. + */ +function handleScreenshot(args: { sessionId: string }) { + const { sessionId } = args; + const session = getSession(sessionId); + if (!session) { + return errorResponse(`Session not found: ${sessionId}`); + } + + try { + const screen = session.readScreen({ numbered: true }); + const { dimensions, cursor, bufferType } = screen; + + // Build the bordered screenshot. + const header = `TUI Screenshot (${dimensions.cols}x${dimensions.rows})`; + const topBorder = `\u250C\u2500 ${header} ${'\u2500'.repeat(Math.max(0, dimensions.cols - header.length - 4))}\u2510`; + const bottomBorder = `\u2514${'\u2500'.repeat(Math.max(0, dimensions.cols + 2))}\u2518`; + + const body = screen.lines.map(line => ` ${line}`).join('\n'); + + const screenshot = `${topBorder}\n${body}\n${bottomBorder}`; + + return jsonResponse({ + screenshot, + metadata: { + cursor, + dimensions, + bufferType, + timestamp: new Date().toISOString(), + }, + }); + } catch (err) { + return errorResponse( + `Failed to capture screenshot for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +/** + * Handle the `tui_close` tool call. + * + * Closes a TUI session, terminates the PTY process, and removes the session + * from the active sessions map. + */ +async function handleClose(args: { sessionId: string; signal?: string }) { + const { sessionId } = args; + const session = getSession(sessionId); + if (!session) { + return errorResponse(`Session not found: ${sessionId}`); + } + + try { + const { signal } = args; + const result = await session.close(signal); + sessions.delete(sessionId); + + return jsonResponse({ + exitCode: result.exitCode, + signal: result.signal, + finalScreen: result.finalScreen, + }); + } catch (err) { + // Even if close throws, remove the session from the map to avoid leaks. + sessions.delete(sessionId); + return errorResponse(`Error closing session ${sessionId}: ${err instanceof Error ? err.message : String(err)}`); + } +} + +/** + * Handle the `tui_list_sessions` tool call. + * + * Returns metadata for all active sessions. + */ +function handleListSessions() { + const sessionList = Array.from(sessions.values()).map(session => session.info); + return jsonResponse({ sessions: sessionList }); +} + +// --------------------------------------------------------------------------- +// Server factory +// --------------------------------------------------------------------------- + +/** + * Create and configure an MCP Server instance with all TUI harness tools + * registered. + * + * The returned server is fully configured but not yet connected to a transport. + * Call `server.connect(transport)` to start serving requests. + * + * @returns A configured McpServer instance. + */ +export function createServer(): McpServer { + const server = new McpServer({ name: 'tui-harness', version: '1.0.0' }); + + // --- tui_launch --- + server.registerTool( + TOOL_NAMES.LAUNCH, + { + description: + 'Launch a TUI application in a pseudo-terminal. Returns session ID and initial screen state. ' + + 'Defaults to launching AgentCore CLI if no command is specified.', + inputSchema: { + command: z + .string() + .optional() + .describe('The executable to spawn (e.g. "vim", "htop", "agentcore"). Defaults to "node".'), + args: z + .array(z.string()) + .optional() + .describe('Arguments passed to the command. Defaults to ["dist/cli/index.mjs"] (AgentCore CLI).'), + cwd: z.string().optional().describe('Working directory for the spawned process.'), + cols: z.number().int().min(40).max(300).optional().describe('Terminal width in columns (default: 100).'), + rows: z.number().int().min(10).max(100).optional().describe('Terminal height in rows (default: 30).'), + env: z + .record(z.string(), z.string()) + .optional() + .describe('Additional environment variables merged with the default environment.'), + }, + }, + async args => { + return await handleLaunch(args); + } + ); + + // --- tui_send_keys --- + server.registerTool( + TOOL_NAMES.SEND_KEYS, + { + description: 'Send keystrokes to a TUI session. Returns updated screen state after rendering settles.', + inputSchema: { + sessionId: z.string().describe('The session ID returned by tui_launch.'), + keys: z + .string() + .optional() + .describe('Raw text to type into the terminal. For special keys, use the specialKey parameter instead.'), + specialKey: z + .enum(SPECIAL_KEY_ENUM) + .optional() + .describe('A named special key to send (e.g. "enter", "tab", "ctrl+c", "f1"). Mutually usable with keys.'), + waitMs: z + .number() + .int() + .min(0) + .max(10000) + .optional() + .describe('Milliseconds to wait for the screen to settle after sending keys (default: 300).'), + }, + }, + async args => { + return await handleSendKeys(args); + } + ); + + // --- tui_read_screen --- + server.registerTool( + TOOL_NAMES.READ_SCREEN, + { + description: 'Read the current terminal screen state. Safe read-only operation.', + inputSchema: { + sessionId: z.string().describe('The session ID returned by tui_launch.'), + includeScrollback: z + .boolean() + .optional() + .describe('When true, include lines above the visible viewport (scrollback history).'), + numbered: z.boolean().optional().describe('When true, prefix each line with its 1-indexed line number.'), + }, + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + args => { + return handleReadScreen(args); + } + ); + + // --- tui_wait_for --- + server.registerTool( + TOOL_NAMES.WAIT_FOR, + { + description: + 'Wait for a text pattern to appear on the terminal screen. Useful for synchronizing with async TUI operations.', + inputSchema: { + sessionId: z.string().describe('The session ID returned by tui_launch.'), + pattern: z + .string() + .describe( + 'The text or regex pattern to search for on screen. Interpreted as a plain substring unless isRegex is true.' + ), + timeoutMs: z + .number() + .int() + .min(100) + .max(30000) + .optional() + .describe('Maximum time in milliseconds to wait for the pattern to appear (default: 5000).'), + isRegex: z.boolean().optional().describe('When true, interpret the pattern as a regular expression.'), + }, + }, + async args => { + return await handleWaitFor(args); + } + ); + + // --- tui_screenshot --- + server.registerTool( + TOOL_NAMES.SCREENSHOT, + { + description: 'Capture a formatted screenshot of the terminal with line numbers and borders for debugging.', + inputSchema: { + sessionId: z.string().describe('The session ID returned by tui_launch.'), + }, + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + args => { + return handleScreenshot(args); + } + ); + + // --- tui_close --- + server.registerTool( + TOOL_NAMES.CLOSE, + { + description: 'Close a TUI session and terminate the process.', + inputSchema: { + sessionId: z.string().describe('The session ID returned by tui_launch.'), + signal: z + .enum(['SIGTERM', 'SIGKILL', 'SIGHUP']) + .optional() + .describe('The signal to send to the process (default: SIGTERM).'), + }, + annotations: { + destructiveHint: true, + }, + }, + async args => { + return await handleClose(args); + } + ); + + // --- tui_list_sessions --- + server.registerTool( + TOOL_NAMES.LIST_SESSIONS, + { + description: 'List all active TUI sessions.', + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + () => { + return handleListSessions(); + } + ); + + return server; +} + +// --------------------------------------------------------------------------- +// Cleanup +// --------------------------------------------------------------------------- + +/** + * Close all active sessions managed by this server and clear the session map. + * + * Also calls the session-manager's `closeAll()` to ensure sessions registered + * at the harness level are cleaned up as well. + */ +export async function closeAllSessions(): Promise { + // Close each session in the local map. + const closePromises = Array.from(sessions.values()).map(async session => { + try { + await session.close(); + } catch { + // Best-effort cleanup -- swallow errors from dead or already-closed sessions. + } + }); + + await Promise.allSettled(closePromises); + sessions.clear(); + + // Also close any sessions tracked by the harness-level session manager. + await closeAll(); +} diff --git a/src/tui-harness/mcp/tools.ts b/src/tui-harness/mcp/tools.ts new file mode 100644 index 00000000..0adad86c --- /dev/null +++ b/src/tui-harness/mcp/tools.ts @@ -0,0 +1,59 @@ +/** + * MCP tool constants for the TUI harness. + * + * This module exports the canonical tool names, launch defaults, and the + * special-key enum array used by the MCP server when registering tools and + * building Zod schemas. + * + * The JSON Schema tool definitions that previously lived here have been + * removed -- the MCP server defines its schemas inline via Zod. + */ +import { SPECIAL_KEY_VALUES } from '../index.js'; + +// --------------------------------------------------------------------------- +// Re-export: Special Key Enum +// --------------------------------------------------------------------------- + +/** + * All special key names recognized by the TUI harness. + * + * Re-exported from the harness types module so both the harness library and + * the MCP server share a single source of truth. The underlying constant is + * `SPECIAL_KEY_VALUES` in `src/tui-harness/lib/types.ts`. + */ +export { SPECIAL_KEY_VALUES as SPECIAL_KEY_ENUM }; + +// --------------------------------------------------------------------------- +// Tool Name Constants +// --------------------------------------------------------------------------- + +/** + * Canonical tool names used by the MCP server. + * + * Use these constants instead of raw strings to avoid typos and enable + * compile-time checking when wiring tool handlers. + */ +export const TOOL_NAMES = { + LAUNCH: 'tui_launch', + SEND_KEYS: 'tui_send_keys', + READ_SCREEN: 'tui_read_screen', + WAIT_FOR: 'tui_wait_for', + SCREENSHOT: 'tui_screenshot', + CLOSE: 'tui_close', + LIST_SESSIONS: 'tui_list_sessions', +} as const; + +// --------------------------------------------------------------------------- +// Launch Defaults +// --------------------------------------------------------------------------- + +/** + * Default command and args for `tui_launch` when not specified by the caller. + * + * This makes `tui_launch({})` a convenient shorthand for launching the + * AgentCore CLI TUI. + */ +export const LAUNCH_DEFAULTS = { + command: 'node', + args: ['dist/cli/index.mjs'], +} as const; diff --git a/vitest.config.ts b/vitest.config.ts index 8ed66077..fec90f1c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -47,6 +47,7 @@ export default defineConfig({ test: { name: 'integ', include: ['integ-tests/**/*.test.ts'], + exclude: ['integ-tests/tui/**/*.test.ts'], }, }, { @@ -58,6 +59,16 @@ export default defineConfig({ hookTimeout: 600000, }, }, + { + extends: true, + test: { + name: 'tui', + include: ['integ-tests/tui/**/*.test.ts'], + testTimeout: 30_000, + fileParallelism: false, + setupFiles: ['./integ-tests/tui/setup.ts'], + }, + }, ], testTimeout: 120000, hookTimeout: 120000,