diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a35b21f..80cff72 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,5 @@ { "packages/core": "0.0.2", - "packages/ssh": "0.0.3" + "packages/ssh": "0.0.3", + "packages/docker": "0.0.1" } diff --git a/eslint.config.js b/eslint.config.js index 8f289c0..50bd6a5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -50,6 +50,7 @@ export default [ '@typescript-eslint/prefer-nullish-coalescing': 'error', '@typescript-eslint/prefer-promise-reject-errors': 'off', '@typescript-eslint/require-await': 'off', + '@typescript-eslint/no-unnecessary-condition': 'warn', }, }, { diff --git a/examples/README.md b/examples/README.md index be11116..ad4b62b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,62 +1,60 @@ # Examples This directory contains example projects demonstrating how to use -`@agent-remote/ssh` with the Claude Agent SDK. +`@agent-remote` tools with various AI frameworks. ## Available Examples ### [claude-agent-sdk](./claude-agent-sdk) -A basic example showing how to: +A complete example showing how to: -- Connect to a remote SSH server +- Connect to a remote system - Create an AI agent with remote execution tools - Execute multi-step tasks autonomously - Use the Claude Agent SDK with MCP tools -Perfect for getting started and understanding the fundamentals. +### [claude-code-ssh](./claude-code-ssh) + +Configuration examples for using Claude Code with remote systems: + +- MCP server configuration for SSH remotes +- MCP server configuration for Docker remotes +- Example configurations for both simultaneously +- Works with the included sandbox environment + +Perfect for using Claude Code to work on remote systems. ## Development Setup (Contributors) This guide is for developers working on the monorepo who want to test changes to -`@agent-remote/ssh` in the examples. +`@agent-remote/ssh` or `@agent-remote/docker` in the examples. ### Prerequisites 1. **Start the sandbox** (from monorepo root): - ```bash - cd sandbox - docker-compose up -d - cd .. - ``` +```bash +cd sandbox +docker compose up -d +cd .. +``` - This provides a Docker container with SSH access for testing. +This provides a Docker container accessible via SSH or Docker exec for testing. -2. **Build the SSH package** (from monorepo root): +2. **Build the packages** (from monorepo root): ```bash + # Build SSH package cd packages/ssh pnpm build cd ../.. - ``` - -### Linking the Local Package - -To use your local development version of `@agent-remote/ssh`: - -```bash -cd examples/claude-agent-sdk -pnpm link ../../packages/ssh -pnpm install -``` - -The `pnpm link` command: -- Creates a symlink from `node_modules/@agent-remote/ssh` to - `../../packages/ssh` -- Adds an override to `examples/pnpm-workspace.yaml` that persists across - installs + # Build Docker package + cd packages/docker + pnpm build + cd ../.. + ``` ### Development Workflow @@ -74,7 +72,7 @@ In another terminal, run the example: ```bash cd examples/claude-agent-sdk export ANTHROPIC_API_KEY=your_api_key_here -pnpm start +pnpm start:ssh # or pnpm start:docker ``` Now edit files in `packages/ssh/src/` - they'll automatically rebuild, and the @@ -89,22 +87,9 @@ pnpm build # Run the example cd ../examples/claude-agent-sdk -pnpm start +pnpm start:ssh # or pnpm start:docker ``` -### Unlinking (Reverting to npm Version) - -To stop using the local package and use the published npm version: - -1. Remove the `overrides:` section from `examples/pnpm-workspace.yaml` -2. Reinstall: - - ```bash - cd examples/claude-agent-sdk - rm pnpm-lock.yaml - pnpm install - ``` - ## Adding New Examples When adding a new example: diff --git a/examples/claude-agent-sdk/README.md b/examples/claude-agent-sdk/README.md index 60749a0..9fbe3fc 100644 --- a/examples/claude-agent-sdk/README.md +++ b/examples/claude-agent-sdk/README.md @@ -1,13 +1,13 @@ -# Claude Agent SDK with @agent-remote/ssh +# Claude Agent SDK with Remote Tools -A complete example demonstrating how to use `@agent-remote/ssh` with the Claude -Agent SDK to create an AI agent that can interact with remote systems via SSH. +A complete example demonstrating how to use @agent-remote tools with the Claude +Agent SDK to create an AI agent that can interact with remote systems. ## What This Example Does This example creates an autonomous AI agent that: -- Connects to a remote SSH server +- Connects to a remote system - Executes commands, reads files, and searches the filesystem remotely - Uses the Claude Agent SDK to run multi-step tasks - Provides all remote operations as MCP tools to Claude @@ -20,7 +20,9 @@ remote system, and work autonomously until the task is complete. - Node.js 20+ - An Anthropic API key (get one at [console.anthropic.com](https://console.anthropic.com)) -- Access to an SSH server +- Either: + - Access to an SSH server, or + - Docker with a running container ## Installation @@ -38,6 +40,8 @@ Set your Anthropic API key: export ANTHROPIC_API_KEY=your_api_key_here ``` +### SSH Configuration + Configure SSH connection using environment variables: | Variable | Default | Description | @@ -47,3 +51,91 @@ Configure SSH connection using environment variables: | `SSH_USERNAME` | `dev` | SSH username | | `SSH_PASSWORD` | `dev` | SSH password (if not using key) | | `SSH_KEY_PATH` | - | Path to private key file (optional) | + +### Docker Configuration + +Configure Docker connection using environment variables: + +| Variable | Default | Description | +| ------------------ | --------- | ---------------------------- | +| `DOCKER_CONTAINER` | `sandbox` | Docker container name/ID | +| `DOCKER_SHELL` | `sh` | Shell to use (sh, bash, zsh) | + +## Running the Example + +### Using SSH + +```bash +# Uses default SSH settings (localhost:2222, user 'dev', password 'dev') +pnpm start:ssh + +# Or with custom SSH settings +export SSH_HOST=myserver.com +export SSH_PORT=22 +export SSH_USERNAME=myuser +export SSH_KEY_PATH=~/.ssh/id_rsa +pnpm start:ssh +``` + +### Using Docker + +```bash +# Start a Docker container first +docker run -d --name my-container ubuntu:latest sleep infinity + +# Run the example with Docker remote +export DOCKER_CONTAINER=my-container +export DOCKER_SHELL=bash +pnpm start:docker +``` + +## How It Works + +The example accepts a command-line argument to choose the remote type: + +- **SSH mode** (`pnpm start:ssh`): Establishes an SSH connection to a remote + server and maintains it throughout the session +- **Docker mode** (`pnpm start:docker`): Executes commands directly in a Docker + container using `docker exec` + +Both modes expose the same set of tools to Claude: + +- `bash` - Execute commands in a persistent shell session +- `bash-output` - Retrieve output from background shells +- `kill-bash` - Kill running background shells +- `read` - Read files with optional line ranges +- `write` - Write content to files +- `edit` - Edit files by replacing text +- `grep` - Search for patterns in files +- `glob` - Find files matching glob patterns + +## Example Tasks + +The example includes a sample task that demonstrates the agent's capabilities. +You can modify the `task` variable in `index.ts` to try different operations. + +## Using with the Local Sandbox + +This repository includes a sandbox environment for testing. To use it: + +```bash +# Start the sandbox +cd ../../sandbox +docker compose up -d + +# Run the example with SSH (default configuration works with the sandbox) +cd ../examples/claude-agent-sdk +pnpm start:ssh +``` + +Or use the Docker sandbox directly: + +```bash +# The sandbox container is named 'sandbox' +cd ../../sandbox +docker compose up -d + +cd ../examples/claude-agent-sdk +export DOCKER_CONTAINER=sandbox +pnpm start:docker +``` diff --git a/examples/claude-agent-sdk/index.ts b/examples/claude-agent-sdk/index.ts index 7d94bc2..d3cf4ca 100644 --- a/examples/claude-agent-sdk/index.ts +++ b/examples/claude-agent-sdk/index.ts @@ -1,21 +1,22 @@ import { readFileSync } from 'fs'; import { query } from '@anthropic-ai/claude-agent-sdk'; -import type { - TextBlock, - ToolUseBlock, -} from '@anthropic-ai/sdk/resources/messages.mjs'; -import { Remote } from '@agent-remote/ssh'; +import type { TextBlock, ToolUseBlock } from '@anthropic-ai/sdk/resources'; +import { Remote as SSHRemote } from '@agent-remote/ssh'; +import { Remote as DockerRemote } from '@agent-remote/docker'; /** - * Simple example demonstrating how to use @agent-remote/ssh tools - * with the Claude Agent SDK. + * Simple example demonstrating how to use @agent-remote tools with the Claude + * Agent SDK. * - * This example connects to a remote SSH server and creates an AI agent + * This example connects to a remote system and creates an AI agent * that can execute commands, read/write files, and search the filesystem. */ -type RemoteConfig = { +type RemoteType = 'ssh' | 'docker'; + +type SshConfig = { + type: 'ssh'; host: string; port?: number; username: string; @@ -23,10 +24,35 @@ type RemoteConfig = { privateKeyPath?: string; }; -/** - * Loads SSH configuration from environment variables - */ +type DockerConfig = { + type: 'docker'; + container: string; + shell?: string; +}; + +type RemoteConfig = SshConfig | DockerConfig; + function getRemoteConfig(): RemoteConfig { + const args = process.argv.slice(2); + const remoteType = (args[0] ?? 'ssh') as RemoteType; + + if (!['ssh', 'docker'].includes(remoteType)) { + console.error(`Invalid remote type: ${remoteType}`); + console.error('Usage: tsx index.ts [ssh|docker]'); + process.exit(1); + } + + if (remoteType === 'docker') { + const container = process.env.DOCKER_CONTAINER ?? 'sandbox'; + const shell = process.env.DOCKER_SHELL; + + return { + type: 'docker', + container, + shell, + }; + } + const host = process.env.SSH_HOST ?? 'localhost'; const port = process.env.SSH_PORT ? parseInt(process.env.SSH_PORT) : 2222; const username = process.env.SSH_USERNAME ?? 'dev'; @@ -34,6 +60,7 @@ function getRemoteConfig(): RemoteConfig { const privateKeyPath = process.env.SSH_KEY_PATH; return { + type: 'ssh', host, port, username, @@ -46,33 +73,43 @@ function getRemoteConfig(): RemoteConfig { * Main function that demonstrates using remote tools with Claude */ async function main() { - // Load configuration const config = getRemoteConfig(); - console.log( - `Connecting to ${config.username}@${config.host}:${config.port}...`, - ); - - // Connect to remote server - const remote = await Remote.connect({ - host: config.host, - port: config.port, - username: config.username, - password: config.password, - privateKey: config.privateKeyPath - ? readFileSync(config.privateKeyPath) - : undefined, - }); + let remote: SSHRemote | DockerRemote; + let mcpServerName: string; + let needsDisconnect = false; - console.log('Connected successfully!\n'); + if (config.type === 'docker') { + console.log(`Using Docker container: ${config.container}...`); + remote = new DockerRemote({ + container: config.container, + shell: config.shell, + }); + mcpServerName = 'remote-docker'; + console.log('Remote ready!\n'); + } else { + console.log( + `Connecting to ${config.username}@${config.host}:${config.port}...`, + ); + remote = await SSHRemote.connect({ + host: config.host, + port: config.port, + username: config.username, + password: config.password, + privateKey: config.privateKeyPath + ? readFileSync(config.privateKeyPath) + : undefined, + }); + mcpServerName = 'remote-ssh'; + needsDisconnect = true; + console.log('Connected successfully!\n'); + } try { - // Create MCP server with all remote tools - const mcpServer = remote.createSdkMcpServer(); + const mcpServer = remote.createSdkMcpServer(mcpServerName); - // Example task: List files and count TypeScript files const task = ` - Please work on the REMOTE server (use the mcp__remote-ssh__ tools). + Please work on the REMOTE server (use the mcp__${mcpServerName}__ tools). The remote working directory is /home/dev. Do the following: @@ -85,25 +122,22 @@ async function main() { console.log('Task:', task); console.log('\n--- Agent Response ---\n'); - // Create agent query with remote tools const agentQuery = query({ prompt: task, options: { mcpServers: { - 'remote-ssh': mcpServer, + [mcpServerName]: mcpServer, }, permissionMode: 'bypassPermissions', }, }); - // Process agent messages for await (const message of agentQuery) { if (message.type === 'system' && message.subtype === 'init') { console.log(`Model: ${message.model}`); console.log(`Available tools: ${message.tools.join(', ')}`); console.log(''); } else if (message.type === 'assistant') { - // Display assistant message content for (const content of message.message.content) { if (content.type === 'text') { const textBlock = content as TextBlock; @@ -128,9 +162,10 @@ async function main() { } } } finally { - // Clean up connection - await remote.disconnect(); - console.log('\nDisconnected from remote server.'); + if (needsDisconnect) { + await (remote as SSHRemote).disconnect(); + console.log('\nDisconnected from remote server.'); + } } } diff --git a/examples/claude-agent-sdk/package.json b/examples/claude-agent-sdk/package.json index f4e4ff1..f9e44d8 100644 --- a/examples/claude-agent-sdk/package.json +++ b/examples/claude-agent-sdk/package.json @@ -1,17 +1,19 @@ { "name": "claude-agent-sdk-example", "version": "0.0.1", - "description": "Example of using @agent-remote/ssh with Claude Agent SDK", + "description": "Example of using @agent-remote with Claude Agent SDK", "license": "Apache-2.0", "type": "module", "private": true, "scripts": { - "start": "tsx index.ts" + "start:ssh": "tsx index.ts ssh", + "start:docker": "tsx index.ts docker" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.1.21", "@anthropic-ai/sdk": "^0.37.0", - "@agent-remote/ssh": "^0.0.1", + "@agent-remote/ssh": "^0.0.3", + "@agent-remote/docker": "^0.0.1", "zod": "^3.25.76" }, "devDependencies": { diff --git a/examples/claude-code-ssh/.mcp.json b/examples/claude-code-ssh/.mcp.json deleted file mode 100644 index 9d5b0b7..0000000 --- a/examples/claude-code-ssh/.mcp.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "mcpServers": { - "remote-ssh": { - "command": "remote-ssh-mcp", - "args": [ - "--debug", - "--host", - "localhost", - "--port", - "2222", - "--username", - "dev", - "--private-key", - "../../sandbox/ssh_key" - ] - } - } -} diff --git a/examples/claude-code-ssh/README.md b/examples/claude-code-ssh/README.md deleted file mode 100644 index 96b1a2a..0000000 --- a/examples/claude-code-ssh/README.md +++ /dev/null @@ -1,97 +0,0 @@ -# Claude Code with Remote SSH Tools - -A configuration example for using Claude Code with remote systems via SSH. All -file operations and command executions happen on the remote machine instead of -your local filesystem. - -## What This Example Does - -This example configures Claude Code to: - -- Connect to a remote SSH server via the MCP protocol -- Execute all bash commands on the remote system -- Read, write, and edit files remotely -- Search files with grep and glob patterns on the remote filesystem -- Disable local file/command tools to prevent accidental local operations - -## Prerequisites - -- Node.js 20+ -- Access to an SSH server - -## Installation - -```bash -pnpm install -# or -npm install -``` - -## Configuration - -This example is pre-configured to connect to the local sandbox server. To use -your own SSH server, edit `.mcp.json`: - -| Argument | Default | Description | -| --------------- | ----------------------- | ------------------------ | -| `--host` | `localhost` | SSH server hostname | -| `--port` | `2222` | SSH server port | -| `--username` | `dev` | SSH username | -| `--private-key` | `../../sandbox/ssh_key` | Path to private key file | - -You can also use `--password` for password authentication or `--agent` for SSH -agent. - -## Running the Example - -### Launch Claude Code - -```bash -pnpm start -# or -npm start -``` - -Claude Code will start with the remote SSH server connected. All file and -command operations will execute on the remote system. - -## Example Tasks - -Once Claude Code is running, try these prompts: - -**List files on the remote system:** - -> Show me what files are in /home/dev - -**Create and edit files remotely:** - -> Create a file called test.txt in /home/dev with some sample content, then read -> it back - -**Run commands:** - -> Run 'uname -a' to show the system information - -**Search files:** - -> Find all Python files under /home/dev/fixtures - -All operations happen on the remote machine, not your local filesystem. - -## How It Works - -The `.mcp.json` file configures an MCP server that connects via SSH: - -```json -{ - "mcpServers": { - "remote-ssh": { - "command": "remote-ssh-mcp", - "args": ["--host", "localhost", "--port", "2222", ...] - } - } -} -``` - -This provides access to all remote tools (bash, read, write, edit, grep, glob) -through the MCP protocol. diff --git a/examples/claude-code-ssh/package.json b/examples/claude-code-ssh/package.json deleted file mode 100644 index a26021c..0000000 --- a/examples/claude-code-ssh/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@agent-remote/claude-code-ssh-example", - "version": "0.0.1", - "description": "Example project for using Claude Code with SSH MCP server", - "license": "Apache-2.0", - "private": true, - "scripts": { - "start": "claude" - }, - "dependencies": { - "@anthropic-ai/claude-code": "^1.0.0", - "@agent-remote/ssh": "link:../../packages/ssh" - } -} diff --git a/examples/claude-code-ssh/.claude/settings.json b/examples/claude-code/.claude/settings.json similarity index 100% rename from examples/claude-code-ssh/.claude/settings.json rename to examples/claude-code/.claude/settings.json diff --git a/examples/claude-code/.mcp.json.docker-example b/examples/claude-code/.mcp.json.docker-example new file mode 100644 index 0000000..89f91e7 --- /dev/null +++ b/examples/claude-code/.mcp.json.docker-example @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "remote-docker": { + "command": "remote-docker-mcp", + "args": [ + "--container", "sandbox", + "--shell", "bash" + ] + } + } +} + diff --git a/examples/claude-code/.mcp.json.ssh-example b/examples/claude-code/.mcp.json.ssh-example new file mode 100644 index 0000000..43e7dd7 --- /dev/null +++ b/examples/claude-code/.mcp.json.ssh-example @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "remote-ssh": { + "command": "remote-ssh-mcp", + "args": [ + "--host", "localhost", + "--port", "2222", + "--username", "dev", + "--password", "dev" + ] + } + } +} + diff --git a/examples/claude-code/README.md b/examples/claude-code/README.md new file mode 100644 index 0000000..7dfecf1 --- /dev/null +++ b/examples/claude-code/README.md @@ -0,0 +1,203 @@ +# Claude Code with Remote Tools + +Configuration examples for using Claude Code with remote systems via SSH or +Docker. All file operations and command executions happen on the remote machine +instead of your local filesystem. + +## What This Example Does + +This example shows how to configure Claude Code to: + +- Connect to a remote system (SSH server or Docker container) via the MCP + protocol +- Execute all bash commands on the remote system +- Read, write, and edit files remotely +- Search files with grep and glob patterns on the remote filesystem +- Disable local file/command tools to prevent accidental local operations + +## Prerequisites + +- Node.js 20+ +- Claude Code CLI +- Either: + - Access to an SSH server, or + - Docker with a running container + +## Installation + +```bash +pnpm install +# or +npm install +``` + +## Configuration + +Choose one of the configuration options below based on your remote type. + +### Option 1: SSH Remote + +Create a `.mcp.json` file in this directory: + +```json +{ + "mcpServers": { + "remote-ssh": { + "command": "remote-ssh-mcp", + "args": [ + "--host", + "localhost", + "--port", + "2222", + "--username", + "dev", + "--password", + "dev" + ] + } + } +} +``` + +**SSH Arguments:** + +| Argument | Description | Required | +| --------------- | ------------------------ | -------- | +| `--host` | SSH server hostname | Yes | +| `--port` | SSH server port | No | +| `--username` | SSH username | Yes | +| `--password` | Password authentication | No\* | +| `--private-key` | Path to private key file | No\* | +| `--agent` | Use SSH agent | No\* | + +\* At least one authentication method is required + +**Example with SSH key:** + +```json +{ + "mcpServers": { + "remote-ssh": { + "command": "remote-ssh-mcp", + "args": [ + "--host", + "myserver.com", + "--username", + "myuser", + "--private-key", + "/path/to/id_rsa" + ] + } + } +} +``` + +### Option 2: Docker Remote + +Create a `.mcp.json` file in this directory: + +```json +{ + "mcpServers": { + "remote-docker": { + "command": "remote-docker-mcp", + "args": ["--container", "my-container", "--shell", "bash"] + } + } +} +``` + +**Docker Arguments:** + +| Argument | Description | Required | Default | +| ------------- | -------------------------- | -------- | ------- | +| `--container` | Container name or ID | Yes | - | +| `--shell` | Shell to use (sh/bash/zsh) | No | `sh` | + +## Running the Example + +After creating your `.mcp.json` file: + +```bash +pnpm start +# or +npm start +``` + +Claude Code will start with the configured remote server connected. All file and +command operations will execute on the remote system. + +## Example Tasks + +Once Claude Code is running, try these prompts: + +### With SSH Remote + +> Show me what files are in /home/dev + +> Create a file called test.txt in /home/dev with some sample content, then read +> it back + +> Run 'uname -a' to show the system information + +> Find all Python files under /home/dev/fixtures + +### With Docker Remote + +> List the contents of /app + +> Check which shell we're using by running 'echo $SHELL' + +> Create a simple Node.js app in /tmp/myapp with a package.json file + +> Search for TODO comments in /app + +All operations happen on the remote machine, not your local filesystem. + +## Using with the Local Sandbox + +This repository includes a sandbox environment for testing: + +### SSH Sandbox + +```bash +# Start the SSH sandbox +cd ../../sandbox +docker compose up -d + +# Create .mcp.json with sandbox SSH settings +cd ../examples/claude-code-ssh +cp .mcp.json.ssh-example .mcp.json + +pnpm start +``` + +### Docker Sandbox + +```bash +cp .mcp.json.docker-example .mcp.json + +pnpm start +``` + +## Available Tools + +Both SSH and Docker remotes provide these tools: + +- `bash` - Execute commands in a persistent shell session +- `bash-output` - Retrieve output from background shells +- `kill-bash` - Kill running background shells +- `read` - Read files with optional line ranges +- `write` - Write content to files +- `edit` - Edit files by replacing text +- `grep` - Search for patterns in files +- `glob` - Find files matching glob patterns + +## Notes + +- The `.mcp.json` file is gitignored to prevent accidentally committing + credentials +- Make sure the MCP server binaries (`remote-ssh-mcp` and `remote-docker-mcp`) + are built and in your PATH, or use absolute paths in the `command` field +- For Docker remote, ensure the container is running before starting Claude Code +- For SSH remote, ensure you have network access to the SSH server diff --git a/examples/claude-code/package.json b/examples/claude-code/package.json new file mode 100644 index 0000000..8267d7b --- /dev/null +++ b/examples/claude-code/package.json @@ -0,0 +1,15 @@ +{ + "name": "@agent-remote/claude-code-example", + "version": "0.0.1", + "description": "Example project for using Claude Code with remote tools via @agent-remote MCP servers", + "license": "Apache-2.0", + "private": true, + "scripts": { + "start": "claude" + }, + "dependencies": { + "@anthropic-ai/claude-code": "^1.0.0", + "@agent-remote/ssh": "^0.0.3", + "@agent-remote/docker": "^0.0.1" + } +} diff --git a/examples/pnpm-lock.yaml b/examples/pnpm-lock.yaml new file mode 100644 index 0000000..5cca257 --- /dev/null +++ b/examples/pnpm-lock.yaml @@ -0,0 +1,792 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + '@agent-remote/ssh': link:../packages/ssh + '@agent-remote/docker': link:../packages/docker + +importers: + + .: + dependencies: + '@agent-remote/ssh': + specifier: link:../packages/ssh + version: link:../packages/ssh + + claude-agent-sdk: + dependencies: + '@agent-remote/docker': + specifier: link:../../packages/docker + version: link:../../packages/docker + '@agent-remote/ssh': + specifier: link:../../packages/ssh + version: link:../../packages/ssh + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.1.21 + version: 0.1.28(zod@3.25.76) + '@anthropic-ai/sdk': + specifier: ^0.37.0 + version: 0.37.0 + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + tsx: + specifier: ^4.19.2 + version: 4.20.6 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + claude-code-ssh: + dependencies: + '@agent-remote/docker': + specifier: link:../../packages/docker + version: link:../../packages/docker + '@agent-remote/ssh': + specifier: link:../../packages/ssh + version: link:../../packages/ssh + '@anthropic-ai/claude-code': + specifier: ^1.0.0 + version: 1.0.128 + +packages: + + '@anthropic-ai/claude-agent-sdk@0.1.28': + resolution: {integrity: sha512-latceXkaJbtV4mn2kqnbZgTZYlve+dhCGCW2liWhYdUv6KuoUEY6DK1VDzzHGnd2996fIBO/7E5PAAEHpsDing==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.24.1 + + '@anthropic-ai/claude-code@1.0.128': + resolution: {integrity: sha512-uUg5cFMJfeQetQzFw76Vpbro6DAXst2Lpu8aoZWRFSoQVYu5ZSAnbBoxaWmW/IgnHSqIIvtMwzCoqmcA9j9rNQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + '@anthropic-ai/sdk@0.37.0': + resolution: {integrity: sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==} + + '@esbuild/aix-ppc64@0.25.11': + resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.11': + resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.11': + resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.11': + resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.11': + resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.11': + resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.11': + resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.11': + resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.11': + resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.11': + resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.11': + resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.11': + resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.11': + resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.11': + resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.11': + resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.11': + resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.11': + resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.11': + resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.11': + resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.11': + resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.11': + resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.11': + resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.11': + resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.11': + resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.11': + resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.11': + resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/sharp-darwin-arm64@0.33.5': + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.33.5': + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.33.5': + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.33.5': + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-x64@0.33.5': + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-win32-x64@0.33.5': + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.25.11: + resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} + engines: {node: '>=18'} + hasBin: true + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + + formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tsx@4.20.6: + resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + + web-streams-polyfill@4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@anthropic-ai/claude-agent-sdk@0.1.28(zod@3.25.76)': + dependencies: + zod: 3.25.76 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + + '@anthropic-ai/claude-code@1.0.128': + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + + '@anthropic-ai/sdk@0.37.0': + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + '@esbuild/aix-ppc64@0.25.11': + optional: true + + '@esbuild/android-arm64@0.25.11': + optional: true + + '@esbuild/android-arm@0.25.11': + optional: true + + '@esbuild/android-x64@0.25.11': + optional: true + + '@esbuild/darwin-arm64@0.25.11': + optional: true + + '@esbuild/darwin-x64@0.25.11': + optional: true + + '@esbuild/freebsd-arm64@0.25.11': + optional: true + + '@esbuild/freebsd-x64@0.25.11': + optional: true + + '@esbuild/linux-arm64@0.25.11': + optional: true + + '@esbuild/linux-arm@0.25.11': + optional: true + + '@esbuild/linux-ia32@0.25.11': + optional: true + + '@esbuild/linux-loong64@0.25.11': + optional: true + + '@esbuild/linux-mips64el@0.25.11': + optional: true + + '@esbuild/linux-ppc64@0.25.11': + optional: true + + '@esbuild/linux-riscv64@0.25.11': + optional: true + + '@esbuild/linux-s390x@0.25.11': + optional: true + + '@esbuild/linux-x64@0.25.11': + optional: true + + '@esbuild/netbsd-arm64@0.25.11': + optional: true + + '@esbuild/netbsd-x64@0.25.11': + optional: true + + '@esbuild/openbsd-arm64@0.25.11': + optional: true + + '@esbuild/openbsd-x64@0.25.11': + optional: true + + '@esbuild/openharmony-arm64@0.25.11': + optional: true + + '@esbuild/sunos-x64@0.25.11': + optional: true + + '@esbuild/win32-arm64@0.25.11': + optional: true + + '@esbuild/win32-ia32@0.25.11': + optional: true + + '@esbuild/win32-x64@0.25.11': + optional: true + + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + optional: true + + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.0.5': + optional: true + + '@img/sharp-libvips-linux-x64@1.0.4': + optional: true + + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + optional: true + + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + optional: true + + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + optional: true + + '@img/sharp-win32-x64@0.33.5': + optional: true + + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 18.19.130 + form-data: 4.0.4 + + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + + asynckit@0.4.0: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + delayed-stream@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.25.11: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.11 + '@esbuild/android-arm': 0.25.11 + '@esbuild/android-arm64': 0.25.11 + '@esbuild/android-x64': 0.25.11 + '@esbuild/darwin-arm64': 0.25.11 + '@esbuild/darwin-x64': 0.25.11 + '@esbuild/freebsd-arm64': 0.25.11 + '@esbuild/freebsd-x64': 0.25.11 + '@esbuild/linux-arm': 0.25.11 + '@esbuild/linux-arm64': 0.25.11 + '@esbuild/linux-ia32': 0.25.11 + '@esbuild/linux-loong64': 0.25.11 + '@esbuild/linux-mips64el': 0.25.11 + '@esbuild/linux-ppc64': 0.25.11 + '@esbuild/linux-riscv64': 0.25.11 + '@esbuild/linux-s390x': 0.25.11 + '@esbuild/linux-x64': 0.25.11 + '@esbuild/netbsd-arm64': 0.25.11 + '@esbuild/netbsd-x64': 0.25.11 + '@esbuild/openbsd-arm64': 0.25.11 + '@esbuild/openbsd-x64': 0.25.11 + '@esbuild/openharmony-arm64': 0.25.11 + '@esbuild/sunos-x64': 0.25.11 + '@esbuild/win32-arm64': 0.25.11 + '@esbuild/win32-ia32': 0.25.11 + '@esbuild/win32-x64': 0.25.11 + + event-target-shim@5.0.1: {} + + form-data-encoder@1.7.2: {} + + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formdata-node@4.4.1: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + ms@2.1.3: {} + + node-domexception@1.0.0: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + resolve-pkg-maps@1.0.0: {} + + tr46@0.0.3: {} + + tsx@4.20.6: + dependencies: + esbuild: 0.25.11 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@5.26.5: {} + + web-streams-polyfill@4.0.0-beta.3: {} + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + zod@3.25.76: {} diff --git a/examples/pnpm-workspace.yaml b/examples/pnpm-workspace.yaml index 0488690..df3f364 100644 --- a/examples/pnpm-workspace.yaml +++ b/examples/pnpm-workspace.yaml @@ -4,3 +4,4 @@ packages: overrides: '@agent-remote/ssh': link:../packages/ssh + '@agent-remote/docker': link:../packages/docker diff --git a/packages/docker/README.md b/packages/docker/README.md new file mode 100644 index 0000000..69f1f39 --- /dev/null +++ b/packages/docker/README.md @@ -0,0 +1,549 @@ +# @agent-remote/docker + +A TypeScript library for executing commands and managing files on Docker +containers. Designed for integration with the Claude Agent SDK to provide AI +agents with container access. + +## Features + +- **Bash execution** - Run commands in persistent shell sessions with background + execution support +- **File operations** - Read, write, and edit files remotely using shell + commands +- **Search tools** - Grep pattern search and glob file matching +- **Type-safe** - Full TypeScript support with Zod validation +- **Agent SDK integration** - Easy integration with Claude Agent SDK via MCP + server +- **Standalone MCP server** - Run as a standalone MCP server with stdio + transport +- **Zero setup** - Works with any running Docker container + +## Installation + +```bash +npm install @agent-remote/docker +``` + +## Quick Start + +### MCP Server (Standalone) + +The package includes a standalone MCP server executable that can be run from the +command line and communicates over stdio transport. The server is self-contained +with all dependencies bundled (except Node.js builtins), making it easy to +distribute and run without installing node_modules. + +**Installation:** + +```bash +npm install -g @agent-remote/docker +``` + +**Usage with command line arguments:** + +```bash +# With container name +remote-docker-mcp --container my-app + +# With custom shell +remote-docker-mcp --container my-app --shell bash +``` + +**Usage with environment variables:** + +```bash +export DOCKER_CONTAINER=my-app +export DOCKER_SHELL=bash +remote-docker-mcp +``` + +**Available options:** + +- `--container, -c` - Docker container name (or `DOCKER_CONTAINER` env var) +- `--shell, -s` - Shell to use for command execution, default 'sh' (or + `DOCKER_SHELL` env var) +- `--debug` - Enable debug output + +The server exposes all remote tools (bash, read, write, edit, grep, glob, etc.) +through the MCP protocol. + +**Configuration in Claude Desktop:** + +Add to your Claude Desktop configuration: + +```json +{ + "mcpServers": { + "docker": { + "command": "remote-docker-mcp", + "args": ["--container", "my-app"] + } + } +} +``` + +### Basic Usage + +```typescript +import { Remote } from '@agent-remote/docker'; + +// Create a remote instance +const remote = new Remote({ + container: 'my-app', + shell: 'bash', // optional, defaults to 'sh' +}); + +// Execute commands +const result = await remote.bash.handler({ + command: 'ls -la', +}); +console.log(result.content[0].text); + +// Read files +const fileContent = await remote.read.handler({ + file_path: '/app/config.json', +}); + +// Write files +await remote.write.handler({ + file_path: '/tmp/test.txt', + content: 'Hello, world!', +}); +``` + +### Using with Claude Agent SDK + +```typescript +import { Remote } from '@agent-remote/docker'; + +const remote = new Remote({ + container: 'my-app', + shell: 'bash', +}); + +// Create an MCP server with all tools +const server = remote.createSdkMcpServer(); + +// Use with Agent SDK... +``` + +### Using Individual Tools + +```typescript +const remote = new Remote({ container: 'my-app' }); + +// Each tool is accessible as a property with a handler +const tools = [ + remote.bash, + remote.bashOutput, + remote.killBash, + remote.grep, + remote.read, + remote.write, + remote.edit, + remote.glob, +]; + +// You can use individual tools in your own MCP server +import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'; + +const customServer = createSdkMcpServer({ + name: 'custom-remote-docker', + version: '1.0.0', + tools: [ + tool( + remote.bash.name, + remote.bash.description, + remote.bash.inputSchema, + remote.bash.handler, + ), + tool( + remote.read.name, + remote.read.description, + remote.read.inputSchema, + remote.read.handler, + ), + tool( + remote.write.name, + remote.write.description, + remote.write.inputSchema, + remote.write.handler, + ), + ], +}); +``` + +## API Reference + +### Remote Class + +The main class for managing Docker container access and tools. + +#### Constructor + +##### `new Remote(config: RemoteConfig)` + +Creates a new Remote instance for interacting with a Docker container. + +**Parameters:** + +- `config` - Docker container configuration (see Configuration below) + +**Returns:** A `Remote` instance + +**Example:** + +```typescript +const remote = new Remote({ + container: 'my-app', + shell: 'bash', +}); +``` + +#### Instance Methods + +##### `createSdkMcpServer(name?: string)` + +Creates an MCP server with all remote tools from this Remote instance. + +**Parameters:** + +- `name` - Optional name for the MCP server (defaults to 'remote-docker') + +**Returns:** An MCP server instance from the Claude Agent SDK + +**Example:** + +```typescript +const server = remote.createSdkMcpServer(); +``` + +#### Tool Properties + +Each tool is accessed via a getter property that returns a tool definition with +`name`, `description`, `inputSchema`, and `handler` properties. + +##### `bash: BashToolDefinition` + +Executes commands in a persistent shell session. + +**Input:** + +```typescript +{ + command: string; // The command to execute + timeout?: number; // Optional timeout in milliseconds (max 600000) + description?: string; // Description of what the command does + run_in_background?: boolean; // Run in background +} +``` + +**Output:** + +```typescript +{ + output: string; // Combined stdout and stderr + exitCode?: number; // Exit code (optional) + signal?: string; // Signal used to terminate the command + killed?: boolean; // Whether the command was killed due to timeout + shellId?: string; // Shell ID if background execution +} +``` + +##### `bashOutput: BashOutputToolDefinition` + +Retrieves output from a running or completed background bash shell. + +**Input:** + +```typescript +{ + shell_id: string; // Shell ID to retrieve output from +} +``` + +**Output:** + +```typescript +{ + output: string; // New output since last check + status: 'running' | 'completed'; // Shell status + exitCode?: number; // Exit code (when completed) + signal?: string; // Signal (when completed) +} +``` + +##### `killBash: KillBashToolDefinition` + +Kills a running background shell by its ID. + +**Input:** + +```typescript +{ + shell_id: string; // Shell ID to kill + signal?: string; // Signal to send (e.g., 'SIGTERM', 'SIGKILL') +} +``` + +**Output:** + +```typescript +{ + killed: boolean; // Whether the shell was killed +} +``` + +##### `grep: GrepToolDefinition` + +Searches for patterns in files or directories. + +**Input:** + +```typescript +{ + pattern: string; // Regular expression pattern + path: string; // File or directory to search + glob?: string; // Glob pattern to filter files + output_mode?: 'content' | 'files_with_matches' | 'count'; + '-B'?: number; // Lines of context before match + '-A'?: number; // Lines of context after match + '-C'?: number; // Lines of context before and after + '-n'?: boolean; // Show line numbers + '-i'?: boolean; // Case insensitive + head_limit?: number; // Limit output lines +} +``` + +**Output:** + +```typescript +{ + mode: 'content' | 'files_with_matches' | 'count'; + content?: string; // Matching lines (if mode is 'content') + filenames?: string[]; // Matching files (if mode is 'files_with_matches') + numFiles?: number; // Number of files (if mode is 'files_with_matches') + numMatches?: number; // Number of matches (if mode is 'count') +} +``` + +##### `read: ReadToolDefinition` + +Reads files from the container filesystem using shell commands. + +**Input:** + +```typescript +{ + file_path: string; // Absolute path to file + offset?: number; // Line number to start reading from + limit?: number; // Number of lines to read +} +``` + +**Output:** + +```typescript +{ + content: string; // File content + numLines: number; // Number of lines read + startLine: number; // Starting line number + totalLines: number; // Total lines in file +} +``` + +**Implementation:** + +Uses `cat` command to read file contents from the container. + +##### `write: WriteToolDefinition` + +Writes content to files on the container filesystem using shell commands. + +**Input:** + +```typescript +{ + file_path: string; // Absolute path to file + content: string; // Content to write +} +``` + +**Output:** + +```typescript +{ + content: string; // Content that was written +} +``` + +**Implementation:** + +Uses `printf '%s' '...'` with single-quote escaping to safely handle special +characters, newlines, and unicode. + +##### `edit: EditToolDefinition` + +Edits files by replacing text on the container filesystem using shell commands. + +**Input:** + +```typescript +{ + file_path: string; // Absolute path to file + old_string: string; // Text to find + new_string: string; // Text to replace with + replace_all?: boolean; // Replace all occurrences +} +``` + +**Output:** + +```typescript +{ + replacements: number; // Number of replacements made + diff: StructuredPatch; // Unified diff of changes +} +``` + +**Implementation:** + +Uses `cat` to read, performs replacement locally, then uses `printf` to write +back. + +##### `glob: GlobToolDefinition` + +Searches for files matching glob patterns. + +**Input:** + +```typescript +{ + base_path: string; // Absolute base path to search from + pattern: string; // Glob pattern (e.g., '**/*.ts') + include_hidden?: boolean; // Include hidden files +} +``` + +**Output:** + +```typescript +{ + matches: string[]; // List of matching file paths + count: number; // Number of matches +} +``` + +## Configuration + +### RemoteConfig + +The configuration object for creating a Remote instance: + +```typescript +{ + container: string; // Name of the Docker container (required) + shell?: string; // Shell to use (default: 'sh') +} +``` + +**Shell options:** + +- `'sh'` - Default, uses whatever shell is symlinked as `/bin/sh` in the + container +- `'bash'` - Bash shell (must be available in container) +- `'zsh'` - Zsh shell (must be available in container) +- `'dash'` - Dash shell (must be available in container) + +**Example:** + +```typescript +const remote = new Remote({ + container: 'my-app', + shell: 'bash', +}); +``` + +## Examples + +### Error Handling + +```typescript +const remote = new Remote({ container: 'my-app' }); + +const result = await remote.bash.handler({ + command: 'some-command', +}); + +if (result.isError) { + console.error('Command failed:', result.content[0].text); +} else { + console.log('Success:', result.content[0].text); +} +``` + +### Background Command Execution + +```typescript +const remote = new Remote({ container: 'my-app' }); + +// Start a long-running command in the background +const startResult = await remote.bash.handler({ + command: 'npm run build', + run_in_background: true, + description: 'Building project', +}); + +const { shellId } = startResult.structuredContent; + +// Check output periodically +const checkOutput = async () => { + const output = await remote.bashOutput.handler({ shell_id: shellId }); + console.log(output.content[0].text); + + if (output.structuredContent.status === 'running') { + setTimeout(checkOutput, 1000); + } +}; + +checkOutput(); +``` + +### File Search and Edit + +```typescript +const remote = new Remote({ container: 'my-app' }); + +// Find all TypeScript files +const files = await remote.glob.handler({ + base_path: '/app', + pattern: '**/*.ts', +}); + +// Search for a pattern +const matches = await remote.grep.handler({ + pattern: 'TODO', + path: '/app/src', + glob: '*.ts', + output_mode: 'content', + '-n': true, +}); + +// Edit a file +await remote.edit.handler({ + file_path: '/app/config.ts', + old_string: 'localhost', + new_string: 'example.com', + replace_all: true, +}); +``` + +## Requirements + +- Docker must be installed and accessible via the `docker` command +- The target container must be running +- Basic Unix tools should be available in the container (sh, cat, find, grep) + +## License + +Apache-2.0 diff --git a/packages/docker/package.json b/packages/docker/package.json new file mode 100644 index 0000000..bfa70bd --- /dev/null +++ b/packages/docker/package.json @@ -0,0 +1,73 @@ +{ + "name": "@agent-remote/docker", + "version": "0.0.1", + "description": "Docker remote tools for AI agents", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/lib/index.ts" + }, + "publishConfig": { + "access": "public", + "main": "dist/cjs/index.cjs", + "module": "dist/esm/index.js", + "types": "dist/index.d.ts", + "bin": { + "remote-docker-mcp": "./dist/server.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.cjs" + } + } + }, + "files": [ + "dist", + "src", + "!src/**/*.test.ts" + ], + "scripts": { + "build": "tsdown", + "build:watch": "tsdown --watch", + "test": "vitest run", + "test:watch": "vitest", + "test:unit": "vitest run --project=unit", + "test:integration": "vitest run --project=integration", + "test:e2e": "vitest run --project=e2e", + "test:watch:unit": "vitest --project=unit", + "test:watch:integration": "vitest --project=integration", + "test:watch:e2e": "vitest --project=e2e", + "server": "tsx src/server/server.ts", + "server:watch": "tsx --watch src/server/server.ts", + "clean": "rimraf dist" + }, + "dependencies": { + "diff": "^8.0.2", + "minimatch": "^10.0.3", + "nanoid": "^5.1.6", + "pino": "^10.1.0", + "pino-pretty": "^13.1.2", + "yargs": "^18.0.0", + "zod": "^3.25.76", + "zod-to-json-schema": "^3.24.6", + "zod-validation-error": "^4.0.2" + }, + "peerDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.1.0", + "@modelcontextprotocol/sdk": "^1.20.0" + }, + "devDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.1.21", + "@agent-remote/core": "workspace:*", + "@modelcontextprotocol/sdk": "^1.20.0", + "@types/yargs": "^17.0.33", + "rimraf": "^6.0.1", + "tsdown": "^0.15.9", + "tslib": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/docker/src/lib/bash.integration.test.ts b/packages/docker/src/lib/bash.integration.test.ts new file mode 100644 index 0000000..4795806 --- /dev/null +++ b/packages/docker/src/lib/bash.integration.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { BashTool } from './bash'; + +/** + * Unit tests specific to Docker BashTool implementation + */ + +describe('Docker BashTool - Unit Tests', () => { + describe('Event Listener Management', () => { + it('should not leak event listeners after multiple executions', async () => { + const bashTool = new BashTool({ container: 'sandbox', shell: 'sh' }); + + // Execute init to get the shell process + const shell = await bashTool.init(); + + // Get initial listener counts + const initialStdoutListeners = shell.stdout.listenerCount('data'); + const initialStderrListeners = shell.stderr.listenerCount('data'); + const initialErrorListeners = shell.listenerCount('error'); + + // Run multiple commands + for (let i = 0; i < 5; i++) { + await bashTool.execute({ command: `echo "test ${i}"` }); + } + + // Verify listener counts haven't increased + expect(shell.stdout.listenerCount('data')).toBe(initialStdoutListeners); + expect(shell.stderr.listenerCount('data')).toBe(initialStderrListeners); + expect(shell.listenerCount('error')).toBe(initialErrorListeners); + }); + }); +}); diff --git a/packages/docker/src/lib/bash.ts b/packages/docker/src/lib/bash.ts new file mode 100644 index 0000000..3bfd211 --- /dev/null +++ b/packages/docker/src/lib/bash.ts @@ -0,0 +1,314 @@ +import type { + ChildProcess, + ChildProcessWithoutNullStreams, +} from 'node:child_process'; +import { spawn } from 'node:child_process'; + +import type { + BashInput, + BashOutput, + BashOutputInput, + BashOutputOutput, + KillShellInput, + KillShellOutput, +} from '@agent-remote/core'; +import { + bashInputSchema, + bashOutputInputSchema, + killShellInputSchema, +} from '@agent-remote/core'; +import { customAlphabet } from 'nanoid'; + +import { ToolConfig } from './remote'; + +const nanoid = customAlphabet( + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', + 8, +); + +type Shell = BashOutputOutput & { + process: ChildProcess; +}; + +export class BashTool { + private readonly container: string; + private readonly shellCommand: string; + private shellProcess: ChildProcessWithoutNullStreams | null = null; + private readonly shells = new Map(); + + constructor(config: ToolConfig) { + this.container = config.container; + this.shellCommand = config.shell; + } + + public async execute(input: BashInput): Promise { + bashInputSchema.parse(input); + + if (input.run_in_background) { + return this.executeInBackground(input); + } + + return this.executeInForeground(input); + } + + public async init(): Promise { + if (!this.shellProcess || this.shellProcess.exitCode !== null) { + this.shellProcess = spawn('docker', [ + 'exec', + '-i', + this.container, + this.shellCommand, + ]); + + this.shellProcess.on('close', () => { + this.shellProcess = null; + }); + + this.shellProcess.on('error', () => { + this.shellProcess = null; + }); + } + + return this.shellProcess; + } + + private async executeInForeground(input: BashInput): Promise { + const abortSignal = AbortSignal.timeout(input.timeout ?? 60000); + const shell = await this.init(); + + return new Promise((resolve, reject) => { + const startMarker = `__CMD_START_${Date.now()}_${nanoid()}__`; + const endMarker = `__CMD_END_${Date.now()}_${nanoid()}__`; + const command = + `echo "${startMarker}"; ` + input.command + `; echo "${endMarker}:$?"`; + + let buffer = ''; + let timedOut = false; + + const onStdout = (data: Buffer) => { + // Don't accumulate output after timeout, just consume it + if (timedOut) { + return; + } + + // Accumulate data into buffer + buffer += data.toString(); + + // Check if we've received the start marker first + const startIndex = buffer.indexOf(startMarker); + if (startIndex === -1) { + return; + } + + // Then look for end marker AFTER the start marker + const endIndex = buffer.indexOf(endMarker, startIndex); + if (endIndex === -1) { + return; + } + + // Wait for newline after end marker + // The end marker format is: __CMD_END_xxx__:exit_code\n + const markerEnd = endIndex + endMarker.length; + const newlineIndex = buffer.indexOf('\n', markerEnd); + if (newlineIndex === -1) { + return; // Full end marker hasn't arrived yet + } + + // Extract output between start and end markers + // Skip the start marker line and any newlines after it + const afterStart = startIndex + startMarker.length; + const nextLineAfterStart = buffer.indexOf('\n', afterStart); + const outputStart = + nextLineAfterStart !== -1 ? nextLineAfterStart + 1 : afterStart; + const output = buffer.slice(outputStart, endIndex); + + // Extract exit code between marker and newline (e.g., ":0") + const exitCodePart = buffer.slice(markerEnd, newlineIndex); + const colonIndex = exitCodePart.indexOf(':'); + const exitCode = + colonIndex !== -1 + ? Number.parseInt(exitCodePart.slice(colonIndex + 1)) + : undefined; + + shell.stdout.removeListener('data', onStdout); + shell.stderr.removeListener('data', onStderr); + shell.removeListener('error', onError); + + const trimmed = output.trim(); + resolve({ + output: trimmed ? trimmed + '\n' : '', + exitCode: Number.isNaN(exitCode) ? undefined : exitCode, + }); + }; + + const onStderr = (data: Buffer) => { + // Stderr data - include it in the output + if (!timedOut) { + buffer += data.toString(); + } + }; + + const onError = (err: unknown) => { + shell.stdout.removeListener('data', onStdout); + shell.stderr.removeListener('data', onStderr); + reject(err); + }; + + shell.stdout.on('data', onStdout); + shell.stderr.on('data', onStderr); + shell.on('error', onError); + + abortSignal.addEventListener('abort', () => { + // Mark as timed out + timedOut = true; + + // Clean up listeners immediately + shell.stdout.removeListener('data', onStdout); + shell.stderr.removeListener('data', onStderr); + shell.removeListener('error', onError); + + // Kill the persistent shell - it's in an unknown state after timeout + shell.kill(); + this.shellProcess = null; + + // Extract output before the interrupt + const output = buffer.slice(0, buffer.lastIndexOf('\n') + 1); + + resolve({ + output, + killed: true, + }); + }); + + shell.stdin.write(command + '\n'); + }); + } + + private async executeInBackground(input: BashInput): Promise { + const abortSignal = AbortSignal.timeout(input.timeout ?? 60000); + const shellId = nanoid(); + + const process = spawn('docker', [ + 'exec', + this.container, + this.shellCommand, + '-c', + input.command, + ]); + + this.shells.set(shellId, { + output: '', + status: 'running', + process, + }); + + process.stdout.on('data', (data: Buffer) => { + const shell = this.shells.get(shellId); + if (!shell) { + return; + } + const text = data.toString(); + shell.output += text; + }); + + process.stderr.on('data', (data: Buffer) => { + const shell = this.shells.get(shellId); + if (!shell) { + return; + } + const text = data.toString(); + shell.output += text; + }); + + process.on('exit', (code: number | null, signal: NodeJS.Signals | null) => { + const shell = this.shells.get(shellId); + if (!shell) { + return; + } + shell.exitCode = code ?? undefined; + shell.signal = signal ?? undefined; + }); + + process.on('close', () => { + const shell = this.shells.get(shellId); + if (!shell) { + return; + } + shell.status = 'completed'; + shell.process.removeAllListeners(); + }); + + abortSignal.addEventListener('abort', () => { + const shell = this.shells.get(shellId); + if (!shell) { + return; + } + void this.killShell({ shell_id: shellId }); + }); + + return { output: '', shellId }; + } + + public async getOutput(input: BashOutputInput): Promise { + bashOutputInputSchema.parse(input); + + const shell = this.shells.get(input.shell_id); + if (!shell) { + throw new Error('Shell not found'); + } + let { output } = shell; + if (input.filter) { + const regex = new RegExp(input.filter); + output = output + .split('\n') + .filter((line) => regex.test(line)) + .join('\n'); + } + shell.output = ''; + return { + output, + status: shell.status, + exitCode: shell.exitCode, + signal: shell.signal, + }; + } + + public async getStatus( + input: BashOutputInput, + ): Promise { + const shell = this.shells.get(input.shell_id); + if (!shell) { + throw new Error('Shell not found'); + } + return shell.status; + } + + public async killShell(input: KillShellInput): Promise { + killShellInputSchema.parse(input); + + const shell = this.shells.get(input.shell_id); + if (!shell) { + return { killed: false }; + } + + return new Promise((resolve) => { + shell.process.on('close', () => { + resolve({ killed: true }); + }); + + shell.process.on('error', () => { + resolve({ killed: false }); + }); + + // Node.js expects 'SIGTERM', 'SIGKILL', etc. + // SSH2 uses 'TERM', 'KILL', etc. + // Normalize the signal name + const baseSignal = input.signal ?? 'KILL'; + const signal = ( + baseSignal.startsWith('SIG') ? baseSignal : `SIG${baseSignal}` + ) as NodeJS.Signals; + + shell.process.kill(signal); + }); + } +} diff --git a/packages/docker/src/lib/file.ts b/packages/docker/src/lib/file.ts new file mode 100644 index 0000000..913c9ad --- /dev/null +++ b/packages/docker/src/lib/file.ts @@ -0,0 +1,180 @@ +import { spawn } from 'node:child_process'; + +import type { + FileEditInput, + FileEditOutput, + FileReadInput, + FileReadOutput, + FileWriteInput, + FileWriteOutput, +} from '@agent-remote/core'; +import { + fileEditInputSchema, + fileReadInputSchema, + fileWriteInputSchema, +} from '@agent-remote/core'; +import { structuredPatch } from 'diff'; + +import { ToolConfig } from './remote'; + +/** + * File tool that uses docker exec for file operations + */ +export class FileTool { + private readonly container: string; + private readonly shell: string; + + constructor(config: ToolConfig) { + this.container = config.container; + this.shell = config.shell; + } + + private async execCommand(args: string[]): Promise { + return new Promise((resolve, reject) => { + const process = spawn('docker', ['exec', '-i', this.container, ...args]); + + let output = ''; + let errorOutput = ''; + + process.stdout.on('data', (data: Buffer) => { + output += data.toString(); + }); + + process.stderr.on('data', (data: Buffer) => { + errorOutput += data.toString(); + }); + + process.on('error', (err: unknown) => { + reject(err); + }); + + process.on('close', (code: number | null) => { + if (code !== 0) { + reject(new Error(errorOutput || `Command failed with code ${code}`)); + return; + } + resolve(output); + }); + }); + } + + public async read(input: FileReadInput): Promise { + fileReadInputSchema.parse(input); + + const content = await this.execCommand(['cat', input.file_path]); + + const allLines = content.split('\n'); + const totalLines = allLines.length; + + let startLine = 1; + let lines = allLines; + + if (input.offset !== undefined) { + startLine = Math.max(1, Math.trunc(input.offset)); + lines = allLines.slice(startLine - 1); + } + + if (input.limit !== undefined) { + const limit = Math.max(0, Math.trunc(input.limit)); + lines = lines.slice(0, limit); + } + + const resultContent = lines.join('\n'); + const numLines = lines.length; + + return { + content: resultContent, + numLines, + startLine, + totalLines, + }; + } + + public async write(input: FileWriteInput): Promise { + fileWriteInputSchema.parse(input); + + return new Promise((resolve, reject) => { + // Escape single quotes by replacing ' with '\'' + const escapedContent = input.content.replace(/'/g, "'\\''"); + // Use printf %s to write content without adding newlines + const command = `printf '%s' '${escapedContent}' > ${JSON.stringify(input.file_path)}`; + + const process = spawn('docker', [ + 'exec', + '-i', + this.container, + this.shell, + '-c', + command, + ]); + + let errorOutput = ''; + + process.stderr.on('data', (data: Buffer) => { + errorOutput += data.toString(); + }); + + process.on('error', (err: unknown) => { + reject(err); + }); + + process.on('close', (code: number | null) => { + if (code !== 0) { + reject(new Error(errorOutput || `Command failed with code ${code}`)); + return; + } + resolve({ + content: input.content, + }); + }); + }); + } + + /** + * Edits a file by replacing text and generates a unified diff + */ + public async edit(input: FileEditInput): Promise { + fileEditInputSchema.parse(input); + + const oldContent = await this.execCommand(['cat', input.file_path]); + + // Count replacements and perform the replacement + let replacements = 0; + let newContent: string; + + if (input.replace_all) { + // Count how many times the string appears + const regex = new RegExp( + input.old_string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + 'g', + ); + const matches = oldContent.match(regex); + replacements = matches ? matches.length : 0; + newContent = oldContent.replaceAll(input.old_string, input.new_string); + } else { + // Replace only the first occurrence + if (oldContent.includes(input.old_string)) { + replacements = 1; + newContent = oldContent.replace(input.old_string, input.new_string); + } else { + replacements = 0; + newContent = oldContent; + } + } + + await this.write({ file_path: input.file_path, content: newContent }); + + // Generate unified diff + const diff = structuredPatch( + input.file_path, + input.file_path, + oldContent, + newContent, + undefined, + undefined, + { context: 3 }, + ); + + return { replacements, diff }; + } +} diff --git a/packages/docker/src/lib/glob.ts b/packages/docker/src/lib/glob.ts new file mode 100644 index 0000000..02f61b1 --- /dev/null +++ b/packages/docker/src/lib/glob.ts @@ -0,0 +1,93 @@ +import { spawn } from 'node:child_process'; + +import type { GlobInput, GlobOutput } from '@agent-remote/core'; +import { globInputSchema } from '@agent-remote/core'; +import { minimatch } from 'minimatch'; + +import { ToolConfig } from './remote'; + +export class GlobTool { + private readonly container: string; + + constructor(config: ToolConfig) { + this.container = config.container; + } + + /** + * Searches for files matching a glob pattern + * Uses find command with local minimatch filtering for reliable cross-platform behavior + * + * When include_hidden is true: + * - Wildcards match hidden files/directories + * - Recurses into hidden subdirectories + */ + public async glob(input: GlobInput): Promise { + globInputSchema.parse(input); + + const isRecursive = input.pattern.includes('**'); + const hasPathSeparator = input.pattern.includes('/'); + + // Build find command - recurse if pattern has ** or contains path separators + const args = [ + 'find', + JSON.stringify(input.base_path).slice(1, -1), + '-mindepth', + '1', + ]; + if (!isRecursive && !hasPathSeparator) { + // Only search one level deep for simple patterns like *.txt + args.push('-maxdepth', '1'); + } + + return new Promise((resolve, reject) => { + const process = spawn('docker', ['exec', '-i', this.container, ...args]); + + let output = ''; + + process.stdout.on('data', (data: Buffer) => { + output += data.toString(); + }); + + process.on('error', (err: unknown) => { + reject(err); + }); + + process.on( + 'close', + (code: number | null, signal: NodeJS.Signals | null) => { + if (code !== 0) { + reject( + new Error( + `Command failed with code ${code}` + + (signal ? ` and signal ${signal}` : ''), + ), + ); + return; + } + + output = output.trim(); + const allFiles = + output === '' ? [] : output.split('\n').filter((f) => f.length > 0); + + // Filter files using minimatch + // Convert absolute paths to relative for matching + // Note: dot option controls whether wildcards match hidden files + const matches = allFiles.filter((filePath) => { + const relativePath = filePath.startsWith(input.base_path) + ? filePath.slice(input.base_path.length + 1) + : filePath; + return minimatch(relativePath, input.pattern, { + matchBase: false, + dot: input.include_hidden ?? false, + }); + }); + + resolve({ + matches, + count: matches.length, + }); + }, + ); + }); + } +} diff --git a/packages/docker/src/lib/grep.ts b/packages/docker/src/lib/grep.ts new file mode 100644 index 0000000..d7b3a75 --- /dev/null +++ b/packages/docker/src/lib/grep.ts @@ -0,0 +1,108 @@ +import { spawn } from 'node:child_process'; + +import type { GrepInput, GrepOutput } from '@agent-remote/core'; +import { grepInputSchema } from '@agent-remote/core'; + +import { ToolConfig } from './remote'; + +export class GrepTool { + private readonly container: string; + + constructor(config: ToolConfig) { + this.container = config.container; + } + + public async grep(input: GrepInput): Promise { + grepInputSchema.parse(input); + + const args: string[] = ['grep', '-r']; + + if (input.glob) { + args.push('--include', JSON.stringify(input.glob)); + } + + if (input.output_mode === 'content') { + if (input['-B'] !== undefined) { + args.push('-B', Math.trunc(input['-B']).toString()); + } + if (input['-A'] !== undefined) { + args.push('-A', Math.trunc(input['-A']).toString()); + } + if (input['-C'] !== undefined) { + args.push('-C', Math.trunc(input['-C']).toString()); + } + if (input['-n']) { + args.push('-n'); + } + } else if (input.output_mode !== 'count') { + args.push('-l'); + } + + if (input['-i']) { + args.push('-i'); + } + + args.push(JSON.stringify(input.pattern), JSON.stringify(input.path)); + + if (input.head_limit) { + args.push(`| head -${Math.trunc(input.head_limit)}`); + } + + if (input.output_mode === 'count') { + args.push('| wc -l'); + } + + const command = args.join(' '); + + return new Promise((resolve, reject) => { + const process = spawn('docker', [ + 'exec', + '-i', + this.container, + 'sh', + '-c', + command, + ]); + + let output = ''; + + process.stdout.on('data', (data: Buffer) => { + output += data.toString(); + }); + + process.on('error', (err: unknown) => { + reject(err); + }); + + process.on('close', () => { + output = output.trim(); + switch (input.output_mode) { + case 'content': + resolve({ + mode: 'content', + content: output, + numLines: output === '' ? 0 : output.split('\n').length, + }); + break; + case 'count': + resolve({ + mode: 'count', + numMatches: output === '' ? 0 : Number.parseInt(output), + }); + break; + case 'files_with_matches': + default: + { + const filenames = output === '' ? [] : output.split('\n'); + resolve({ + mode: 'files_with_matches', + numFiles: filenames.length, + filenames, + }); + } + break; + } + }); + }); + } +} diff --git a/packages/docker/src/lib/index.ts b/packages/docker/src/lib/index.ts new file mode 100644 index 0000000..1d2ef31 --- /dev/null +++ b/packages/docker/src/lib/index.ts @@ -0,0 +1,6 @@ +export { Remote } from './remote'; +export type { RemoteConfig } from './remote'; +export { BashTool } from './bash'; +export { FileTool } from './file'; +export { GrepTool } from './grep'; +export { GlobTool } from './glob'; diff --git a/packages/docker/src/lib/remote.ts b/packages/docker/src/lib/remote.ts new file mode 100644 index 0000000..a4ce5ec --- /dev/null +++ b/packages/docker/src/lib/remote.ts @@ -0,0 +1,546 @@ +import type { + BashInput, + BashOutputInput, + BashOutputToolDefinition, + BashToolDefinition, + EditToolDefinition, + FileEditInput, + FileReadInput, + FileWriteInput, + GlobInput, + GlobToolDefinition, + GrepInput, + GrepToolDefinition, + KillBashToolDefinition, + KillShellInput, + ReadToolDefinition, + WriteToolDefinition, +} from '@agent-remote/core'; +import { + bashInputSchema, + bashOutputInputSchema, + fileEditInputSchema, + fileReadInputSchema, + fileWriteInputSchema, + globInputSchema, + grepInputSchema, + killShellInputSchema, +} from '@agent-remote/core'; +import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'; +import { formatPatch } from 'diff'; +import { ZodError } from 'zod'; +import { fromError } from 'zod-validation-error/v3'; + +import packageJson from '../../package.json'; + +import { BashTool } from './bash'; +import { FileTool } from './file'; +import { GlobTool } from './glob'; +import { GrepTool } from './grep'; + +function formatErrorMessage(error: unknown): string { + if (error instanceof ZodError) { + return fromError(error).toString(); + } else if (error instanceof Error) { + return error.message; + } + return String(error); +} + +/** + * Configuration for Docker remote + */ +export type RemoteConfig = { + /** + * Name of the Docker container to execute commands on + */ + container: string; + /** + * Shell to use for command execution (default: 'sh') + * Common options: 'sh', 'bash', 'zsh', 'dash' + */ + shell?: string; +}; + +export type ToolConfig = Required; + +/** + * Docker remote manager that provides tools for executing commands and + * managing files on Docker containers. + * + * @example + * ```typescript + * const remote = new Remote({ + * container: 'my-app', + * shell: 'bash', // optional, defaults to 'sh' + * }); + * + * // Use individual tools + * const bashResult = await remote.bash.handler({ command: 'ls -la' }); + * const fileResult = await remote.read.handler({ file_path: '/etc/hosts' }); + * + * // Or create an "MCP server" for the Claude Agent SDK + * const server = remote.createSdkMcpServer(); + * ``` + */ +export class Remote { + private readonly container: string; + private readonly shell: string; + private bashTool?: BashTool; + private grepTool?: GrepTool; + private globTool?: GlobTool; + private fileTool?: FileTool; + + constructor(config: RemoteConfig) { + this.container = config.container; + this.shell = config.shell ?? 'sh'; + } + + /** + * Bash command execution tool. + * Executes commands in a persistent shell session with optional timeout. + * For terminal operations like git, npm, docker, etc. + */ + get bash(): BashToolDefinition { + this.bashTool ??= new BashTool({ + container: this.container, + shell: this.shell, + }); + const tool = this.bashTool; + + return { + name: 'bash', + description: + 'Executes a bash command in a persistent shell session with optional timeout. For terminal operations like git, npm, docker, etc. DO NOT use for file operations - use specialized tools instead.', + inputSchema: bashInputSchema, + handler: async (input: BashInput) => { + try { + const output = await tool.execute(input); + return { + content: [ + { + type: 'text', + text: output.output, + }, + ], + structuredContent: output, + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Failed to execute bash command: ${formatErrorMessage(error)}`, + }, + ], + isError: true, + }; + } + }, + }; + } + + /** + * Bash output retrieval tool. + * Retrieves output from a running or completed background bash shell. + */ + get bashOutput(): BashOutputToolDefinition { + this.bashTool ??= new BashTool({ + container: this.container, + shell: this.shell, + }); + const tool = this.bashTool; + + return { + name: 'bash-output', + description: + 'Retrieves output from a running or completed background bash shell. Takes a shell_id parameter identifying the shell. Always returns only new output since the last check. Returns command output along with shell status.', + inputSchema: bashOutputInputSchema, + handler: async (input: BashOutputInput) => { + try { + const output = await tool.getOutput(input); + return { + content: [ + { + type: 'text', + text: output.output, + }, + ], + structuredContent: output, + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Failed to retrieve bash output: ${formatErrorMessage(error)}`, + }, + ], + isError: true, + }; + } + }, + }; + } + + /** + * Bash shell kill tool. + * Kills a running background shell by its ID. + */ + get killBash(): KillBashToolDefinition { + this.bashTool ??= new BashTool({ + container: this.container, + shell: this.shell, + }); + const tool = this.bashTool; + + return { + name: 'kill-bash', + description: + 'Kills a running background shell by its ID. Takes a shell_id parameter identifying the shell to kill, and an optional signal parameter to send to the shell. Returns a success or failure status.', + inputSchema: killShellInputSchema, + handler: async (input: KillShellInput) => { + try { + const output = await tool.killShell(input); + return { + content: [ + { + type: 'text', + text: `Shell ${input.shell_id} ${output.killed ? 'killed' : 'not found'}`, + }, + ], + structuredContent: output, + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Failed to kill bash shell: ${formatErrorMessage(error)}`, + }, + ], + isError: true, + }; + } + }, + }; + } + + /** + * Grep search tool. + * Searches for patterns in files or directories. + */ + get grep(): GrepToolDefinition { + this.grepTool ??= new GrepTool({ + container: this.container, + shell: this.shell, + }); + const tool = this.grepTool; + + return { + name: 'grep', + description: + 'Searches for a pattern in a file or directory. Takes a pattern parameter to search for, and an optional path parameter to search in. Returns a list of files that match the pattern.', + inputSchema: grepInputSchema, + handler: async (input: GrepInput) => { + try { + const output = await tool.grep(input); + let text = ''; + switch (output.mode) { + case 'content': + text = output.content; + break; + case 'files_with_matches': + text = `Found ${output.numFiles} files\n${output.filenames.join('\n')}`; + break; + case 'count': + text = `Found ${output.numMatches} matches`; + break; + } + return { + content: [ + { + type: 'text', + text, + }, + ], + structuredContent: output, + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Failed to grep: ${formatErrorMessage(error)}`, + }, + ], + isError: true, + }; + } + }, + }; + } + + /** + * File read tool. + * Reads files from the remote filesystem. + */ + get read(): ReadToolDefinition { + this.fileTool ??= new FileTool({ + container: this.container, + shell: this.shell, + }); + const tool = this.fileTool; + + return { + name: 'read', + description: + 'Reads a file from the filesystem. Takes an absolute file path and optional offset/limit parameters for partial reads. Returns content with line numbers in cat -n format.', + inputSchema: fileReadInputSchema, + handler: async (input: FileReadInput) => { + try { + const output = await tool.read(input); + const lines = output.content.split('\n'); + const maxLineNumber = output.startLine + lines.length - 1; + const padding = maxLineNumber.toString().length; + const numberedLines = lines.map((line, index) => { + const lineNumber = output.startLine + index; + return `${lineNumber.toString().padStart(padding)}\t${line}`; + }); + const text = numberedLines.join('\n'); + return { + content: [ + { + type: 'text', + text, + }, + ], + structuredContent: output, + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Failed to read file: ${formatErrorMessage(error)}`, + }, + ], + isError: true, + }; + } + }, + }; + } + + /** + * File write tool. + * Writes content to files on the remote filesystem. + */ + get write(): WriteToolDefinition { + this.fileTool ??= new FileTool({ + container: this.container, + shell: this.shell, + }); + const tool = this.fileTool; + + return { + name: 'write', + description: + 'Writes content to a file on the filesystem. Takes an absolute file path and content to write. Creates or overwrites the file.', + inputSchema: fileWriteInputSchema, + handler: async (input: FileWriteInput) => { + try { + const output = await tool.write(input); + return { + content: [ + { + type: 'text', + text: `Successfully wrote to ${input.file_path}`, + }, + ], + structuredContent: output, + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Failed to write file: ${formatErrorMessage(error)}`, + }, + ], + isError: true, + }; + } + }, + }; + } + + /** + * File edit tool. + * Edits files by replacing text on the remote filesystem. + */ + get edit(): EditToolDefinition { + this.fileTool ??= new FileTool({ + container: this.container, + shell: this.shell, + }); + const tool = this.fileTool; + + return { + name: 'edit', + description: + 'Edits a file by replacing text. Takes an absolute file path, old_string to find, new_string to replace with, and optional replace_all flag. Returns a unified diff of the changes.', + inputSchema: fileEditInputSchema, + handler: async (input: FileEditInput) => { + try { + const output = await tool.edit(input); + let text = `Made ${output.replacements} replacement${output.replacements === 1 ? '' : 's'} in ${input.file_path}`; + + if (output.diff.hunks.length > 0) { + text += '\n\n'; + text += formatPatch(output.diff).split('\n').slice(2).join('\n'); + } + + return { + content: [ + { + type: 'text', + text, + }, + ], + structuredContent: output, + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Failed to edit file: ${formatErrorMessage(error)}`, + }, + ], + isError: true, + }; + } + }, + }; + } + + /** + * Glob file search tool. + * Searches for files matching glob patterns. + */ + get glob(): GlobToolDefinition { + this.globTool ??= new GlobTool({ + container: this.container, + shell: this.shell, + }); + const tool = this.globTool; + + return { + name: 'glob', + description: + 'Searches for files matching a glob pattern. Takes an absolute base path and a glob pattern supporting modern features like brace expansion, globstar (**), and POSIX character classes. Returns a list of matching file paths.', + inputSchema: globInputSchema, + handler: async (input: GlobInput) => { + try { + const output = await tool.glob(input); + const text = `Found ${output.count} match${output.count === 1 ? '' : 'es'}\n${output.matches.join('\n')}`; + return { + content: [ + { + type: 'text', + text, + }, + ], + structuredContent: output, + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Failed to glob: ${formatErrorMessage(error)}`, + }, + ], + isError: true, + }; + } + }, + }; + } + + /** + * Creates an MCP server with all remote tools from this Remote instance. + * This is a convenience method that integrates with the Claude Agent SDK. + * + * @param name - Optional name for the MCP server (defaults to 'remote-docker') + * @returns An MCP server instance from the Agent SDK + * + * @example + * ```typescript + * const remote = new Remote({ + * container: 'my-app', + * }); + * + * const server = remote.createSdkMcpServer(); + * + * // Use with Agent SDK... + * ``` + */ + createSdkMcpServer(name: string = 'remote-docker') { + return createSdkMcpServer({ + name, + version: packageJson.version, + tools: [ + tool( + this.bash.name, + this.bash.description, + this.bash.inputSchema.shape, + (args: BashInput) => this.bash.handler(args), + ), + tool( + this.bashOutput.name, + this.bashOutput.description, + this.bashOutput.inputSchema.shape, + (args: BashOutputInput) => this.bashOutput.handler(args), + ), + tool( + this.killBash.name, + this.killBash.description, + this.killBash.inputSchema.shape, + this.killBash.handler, + ), + tool( + this.grep.name, + this.grep.description, + this.grep.inputSchema.shape, + this.grep.handler, + ), + tool( + this.read.name, + this.read.description, + this.read.inputSchema.shape, + this.read.handler, + ), + tool( + this.write.name, + this.write.description, + this.write.inputSchema.shape, + this.write.handler, + ), + tool( + this.edit.name, + this.edit.description, + this.edit.inputSchema.shape, + this.edit.handler, + ), + tool( + this.glob.name, + this.glob.description, + this.glob.inputSchema.shape, + this.glob.handler, + ), + ], + }); + } +} diff --git a/packages/docker/src/server/server.e2e.test.ts b/packages/docker/src/server/server.e2e.test.ts new file mode 100644 index 0000000..b0a1edf --- /dev/null +++ b/packages/docker/src/server/server.e2e.test.ts @@ -0,0 +1,174 @@ +/** + * End-to-end tests for the MCP server executable. + * These tests run the actual built server script and validate outputs. + * + * By default, these tests are SKIPPED because they take ~30s to build and run. + * + * To run them, use: + * pnpm test:e2e + */ + +import { execFile } from 'child_process'; +import { promisify } from 'util'; + +import { build } from 'tsdown'; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { serverTarget } from '../../tsdown.config'; + +const execFileAsync = promisify(execFile); + +/** + * Helper to run the server with args and capture output. + * The spawned process inherits the current process.env. + */ +async function runServer( + args: string[], +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + try { + const { stdout, stderr } = await execFileAsync( + 'node', + ['dist/server.js', ...args], + { + env: process.env, + timeout: 5000, + }, + ); + return { stdout, stderr, exitCode: 0 }; + } catch (error: unknown) { + if ( + error && + typeof error === 'object' && + 'code' in error && + 'stdout' in error && + 'stderr' in error + ) { + const execError = error as { + code: number; + stdout: string; + stderr: string; + }; + return { + stdout: execError.stdout, + stderr: execError.stderr, + exitCode: execError.code, + }; + } + throw error; + } +} + +describe('MCP Server Executable - End-to-End Tests', () => { + /** + * Build the server to a temporary location before running tests. + * This ensures we're testing the latest code without interfering + * with the user's actual build in dist/. + */ + beforeAll(async () => { + try { + // Build the project + await build({ ...serverTarget, logLevel: 'silent', config: false }); + } catch (error) { + const execError = error as { stdout?: string; stderr?: string }; + const errorMessage = [ + 'Failed to build server:', + execError.stdout, + execError.stderr, + ] + .filter(Boolean) + .join('\n'); + throw new Error(errorMessage); + } + }, 120000); + + /** + * Unset DOCKER_CONTAINER before each test to ensure clean environment. + * Individual tests can stub it back if needed. + */ + beforeEach(() => { + vi.stubEnv('DOCKER_CONTAINER', undefined); + }); + + /** + * Clean up environment stubs after each test. + */ + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe('Help and Version', () => { + it('should show help with --help', async () => { + const { stdout, stderr, exitCode } = await runServer(['--help']); + const output = stdout + stderr; + + expect(exitCode).toBe(0); + expect(output).toContain('remote-docker-mcp [OPTIONS]'); + expect(output).toContain('Docker Connection Options:'); + expect(output).toContain('--container'); + expect(output).toContain('--shell'); + expect(output).toContain('env: DOCKER_CONTAINER'); + expect(output).toContain('env: DOCKER_SHELL'); + }); + + it('should show help with -h', async () => { + const { stdout, stderr, exitCode } = await runServer(['-h']); + const output = stdout + stderr; + + expect(exitCode).toBe(0); + expect(output).toContain('remote-docker-mcp [OPTIONS]'); + }); + + it('should show version with --version', async () => { + const { stdout, exitCode } = await runServer(['--version']); + + expect(exitCode).toBe(0); + expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('should show version with -V', async () => { + const { stdout, exitCode } = await runServer(['-V']); + + expect(exitCode).toBe(0); + expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/); + }); + }); + + describe('Validation Errors', () => { + it('should error when no arguments provided', async () => { + const { stdout, stderr, exitCode } = await runServer([]); + const output = stdout + stderr; + + expect(exitCode).not.toBe(0); + expect(output).toContain('Missing required argument: container'); + }); + + it('should error when container is not provided', async () => { + const { stdout, stderr, exitCode } = await runServer(['--shell', 'bash']); + const output = stdout + stderr; + + expect(exitCode).not.toBe(0); + expect(output).toContain('container'); + }); + }); + + describe('Environment Variable Support', () => { + it('should accept container from DOCKER_CONTAINER env var', async () => { + vi.stubEnv('DOCKER_CONTAINER', 'test-container'); + + // Test help still works with env var set + const { stdout, stderr, exitCode } = await runServer(['--help']); + const output = stdout + stderr; + + expect(exitCode).toBe(0); + expect(output).toContain('remote-docker-mcp'); + }); + }); +}); diff --git a/packages/docker/src/server/server.ts b/packages/docker/src/server/server.ts new file mode 100644 index 0000000..85702ab --- /dev/null +++ b/packages/docker/src/server/server.ts @@ -0,0 +1,225 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import pino from 'pino'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import packageJson from '../../package.json'; + +import { Remote } from '~/lib/remote'; + +const logger = pino({ + transport: { + target: 'pino-pretty', + options: { + destination: 2, + }, + }, +}); + +/** + * Parse Docker configuration from command line arguments and environment variables. + * Command line arguments take precedence over environment variables. + */ +function parseDockerConfig(): { container: string; shell?: string } { + const argv = yargs(hideBin(process.argv)) + .usage('remote-docker-mcp [OPTIONS]') + .version(packageJson.version) + .help('help') + .alias('help', 'h') + .alias('version', 'V') + .option('debug', { + type: 'boolean', + description: 'Enable debug output', + default: false, + }) + .env('DOCKER') + .option('container', { + alias: 'c', + type: 'string', + description: 'Docker container name (env: DOCKER_CONTAINER)', + demandOption: true, + }) + .option('shell', { + alias: 's', + type: 'string', + description: + 'Shell to use for command execution (env: DOCKER_SHELL, default: sh)', + default: 'sh', + }) + .group(['container', 'shell'], 'Docker Connection Options:') + .epilogue( + 'Usage:\n' + + ' Specify the container name to execute commands on.\n' + + ' Optionally specify a shell to use (sh, bash, zsh, etc.).\n' + + ' Shell executable must be available in the container.\n\n', + ) + .parseSync(); + + if (argv.debug) { + logger.level = 'debug'; + } + + return { + container: argv.container, + shell: argv.shell, + }; +} + +async function main() { + // Parse Docker configuration + const config = parseDockerConfig(); + + logger.info( + { + container: config.container, + shell: config.shell, + }, + 'Connecting to Docker container:', + ); + + // Create remote instance + const remote = new Remote(config); + logger.info('Connected to Docker container'); + + // Create MCP server + const server = new Server( + { + name: 'docker-remote', + version: packageJson.version, + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + // Register tool list handler + server.setRequestHandler(ListToolsRequestSchema, async (req) => { + logger.debug({ params: req.params }, 'Received list tools request'); + return { + tools: [ + { + name: remote.bash.name, + description: remote.bash.description, + inputSchema: zodToJsonSchema(remote.bash.inputSchema), + }, + { + name: remote.bashOutput.name, + description: remote.bashOutput.description, + inputSchema: zodToJsonSchema(remote.bashOutput.inputSchema), + }, + { + name: remote.killBash.name, + description: remote.killBash.description, + inputSchema: zodToJsonSchema(remote.killBash.inputSchema), + }, + { + name: remote.grep.name, + description: remote.grep.description, + inputSchema: zodToJsonSchema(remote.grep.inputSchema), + }, + { + name: remote.read.name, + description: remote.read.description, + inputSchema: zodToJsonSchema(remote.read.inputSchema), + }, + { + name: remote.write.name, + description: remote.write.description, + inputSchema: zodToJsonSchema(remote.write.inputSchema), + }, + { + name: remote.edit.name, + description: remote.edit.description, + inputSchema: zodToJsonSchema(remote.edit.inputSchema), + }, + { + name: remote.glob.name, + description: remote.glob.description, + inputSchema: zodToJsonSchema(remote.glob.inputSchema), + }, + ], + }; + }); + + // Register tool call handler + server.setRequestHandler(CallToolRequestSchema, async (request) => { + logger.debug({ params: request.params }, 'Received tool call request'); + const { name, arguments: args } = request.params; + + try { + let result; + + switch (name) { + case 'bash': + result = await remote.bash.handler(args as never); + break; + case 'bash-output': + result = await remote.bashOutput.handler(args as never); + break; + case 'kill-bash': + result = await remote.killBash.handler(args as never); + break; + case 'grep': + result = await remote.grep.handler(args as never); + break; + case 'read': + result = await remote.read.handler(args as never); + break; + case 'write': + result = await remote.write.handler(args as never); + break; + case 'edit': + result = await remote.edit.handler(args as never); + break; + case 'glob': + result = await remote.glob.handler(args as never); + break; + default: + throw new Error(`Unknown tool: ${name}`); + } + + logger.debug({ result }, 'Tool call result'); + return result; + } catch (error) { + logger.error({ error }, 'Error in tool call'); + return { + content: [ + { + type: 'text', + text: `Error: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } + }); + + // Connect with stdio transport + const transport = new StdioServerTransport(); + await server.connect(transport); + logger.info('MCP server connected to stdio'); + + // Handle cleanup on exit + process.on('SIGINT', () => { + logger.info('Received SIGINT, shutting down'); + process.exit(0); + }); + + process.on('SIGTERM', () => { + logger.info('Received SIGTERM, shutting down'); + process.exit(0); + }); +} + +main().catch((error) => { + logger.error(error, 'Failed to start MCP server'); + process.exit(1); +}); diff --git a/packages/docker/tsconfig.json b/packages/docker/tsconfig.json new file mode 100644 index 0000000..9e0ee8d --- /dev/null +++ b/packages/docker/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "paths": { + "~/*": ["./src/*"] + } + }, + "references": [{ "path": "../core" }], + "include": ["src", "./package.json", "./*.config.ts", "./*.setup.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/docker/tsdown.config.ts b/packages/docker/tsdown.config.ts new file mode 100644 index 0000000..68fa64a --- /dev/null +++ b/packages/docker/tsdown.config.ts @@ -0,0 +1,72 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import type { Options } from 'tsdown'; +import { defineConfig } from 'tsdown'; + +export const esmTarget: Options = { + entry: 'src/lib/index.ts', + format: 'esm', + outDir: 'dist/esm', + unbundle: true, + noExternal: ['@agent-remote/core'], + dts: false, + shims: false, + clean: true, + sourcemap: true, +}; + +export const cjsTarget: Options = { + entry: 'src/lib/index.ts', + format: 'cjs', + outDir: 'dist/cjs', + unbundle: true, + noExternal: ['@agent-remote/core'], + dts: false, + shims: false, + clean: true, + sourcemap: true, +}; + +export const serverTarget: Options = { + entry: 'src/server/server.ts', + format: 'esm', + banner: `#!/usr/bin/env node`, + outDir: 'dist', + noExternal: ['@agent-remote/core'], + plugins: [ + { + name: 'chmod', + writeBundle: async (options, bundle) => { + for (const [_, output] of Object.entries(bundle)) { + if (output.type === 'chunk' && output.isEntry) { + await fs.chmod(path.join(options.dir!, output.fileName), 0o755); + } + } + }, + }, + ], + dts: false, + shims: false, + clean: false, +}; + +export const typeDeclarationsTarget: Options = { + entry: 'src/lib/index.ts', + outDir: 'dist', + dts: { + emitDtsOnly: true, + build: true, + }, +}; + +export default defineConfig([ + // Build library as ESM + esmTarget, + // Build library as CJS + cjsTarget, + // Build server executable + serverTarget, + // Build type declarations + typeDeclarationsTarget, +]); diff --git a/packages/docker/vitest.config.ts b/packages/docker/vitest.config.ts new file mode 100644 index 0000000..15217c5 --- /dev/null +++ b/packages/docker/vitest.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from 'vitest/config'; + +const test = { + globals: true, +}; + +export default defineConfig({ + test: { + slowTestThreshold: 20000, + projects: [ + { + test: { + ...test, + name: 'unit', + include: ['src/**/*.unit.test.ts'], + }, + }, + { + test: { + ...test, + name: 'integration', + include: ['src/**/*.integration.test.ts'], + }, + }, + { + test: { + ...test, + name: 'e2e', + include: ['src/**/*.e2e.test.ts'], + }, + }, + ], + }, +}); diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 85e5006..854c552 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -20,10 +20,13 @@ "test": "vitest run", "test:watch": "vitest", "test:ssh": "vitest run --testNamePattern ssh", - "test:watch:ssh": "vitest --testNamePattern ssh" + "test:watch:ssh": "vitest --testNamePattern ssh", + "test:docker": "vitest run --testNamePattern docker", + "test:watch:docker": "vitest --testNamePattern docker" }, "dependencies": { "@agent-remote/core": "workspace:*", + "@agent-remote/docker": "workspace:*", "@agent-remote/ssh": "workspace:*", "ssh2": "^1.17.0" }, diff --git a/packages/integration-tests/src/bash-background.test.ts b/packages/integration-tests/src/bash-background.test.ts index c6da6b7..d6e6c92 100644 --- a/packages/integration-tests/src/bash-background.test.ts +++ b/packages/integration-tests/src/bash-background.test.ts @@ -1,7 +1,13 @@ -import { BashTool } from '@agent-remote/ssh'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { BashTool as DockerBashTool } from '@agent-remote/docker'; +import { BashOutputOutput, BashTool as SSHBashTool } from '@agent-remote/ssh'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { getSSHClient, setupSSH, teardownSSH } from './setup'; +import { + getDockerContainer, + getSSHClient, + setupSSH, + teardownSSH, +} from './setup'; describe('Integration Tests', () => { beforeAll(async () => { @@ -14,18 +20,37 @@ describe('Integration Tests', () => { const implementations: Array<{ name: string; - createBashTool: () => BashTool; + createBashTool: () => SSHBashTool | DockerBashTool; }> = [ { name: 'ssh', - createBashTool: () => new BashTool(getSSHClient()), + createBashTool: () => new SSHBashTool(getSSHClient()), + }, + { + name: 'docker', + createBashTool: () => + new DockerBashTool({ container: getDockerContainer(), shell: 'bash' }), }, ]; describe.each(implementations)( 'BashTool Background ($name)', ({ name: _name, createBashTool }) => { - let bashTool: BashTool; + let bashTool: SSHBashTool | DockerBashTool; + + const waitForStatus = async ( + shellId: string, + status: BashOutputOutput['status'] = 'completed', + waitUntilOptions?: Parameters[1], + ): Promise => { + return vi.waitUntil(async () => { + const currentStatus = await bashTool.getStatus({ shell_id: shellId }); + if (currentStatus !== status) { + return false; + } + return currentStatus; + }, waitUntilOptions); + }; beforeAll(() => { bashTool = createBashTool(); @@ -41,13 +66,12 @@ describe('Integration Tests', () => { expect(result.shellId).toHaveLength(8); expect(result.output).toBe(''); - // Give it time to complete - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitForStatus(result.shellId!); const output = await bashTool.getOutput({ shell_id: result.shellId! }); + expect(output.exitCode).toBe(0); expect(output.output).toContain('Hello, Background!'); expect(output.status).toBe('completed'); - expect(output.exitCode).toBe(0); }); it('should not echo the command in output', async () => { @@ -59,9 +83,10 @@ describe('Integration Tests', () => { expect(result.shellId).toBeDefined(); - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitForStatus(result.shellId!); const output = await bashTool.getOutput({ shell_id: result.shellId! }); + // Should contain the actual output expect(output.output).toContain('test output for no echo'); // Should NOT contain the command itself @@ -78,10 +103,10 @@ describe('Integration Tests', () => { expect(result.shellId).toBeDefined(); - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitForStatus(result.shellId!); const output = await bashTool.getOutput({ shell_id: result.shellId! }); - // Output should be completely empty for a command with no output + expect(output.output).toBe(''); expect(output.status).toBe('completed'); expect(output.exitCode).toBe(0); @@ -101,7 +126,7 @@ describe('Integration Tests', () => { expect(output1.exitCode).toBeUndefined(); // Wait for completion - await new Promise((resolve) => setTimeout(resolve, 2500)); + await waitForStatus(result.shellId!, 'completed', { timeout: 2500 }); const output2 = await bashTool.getOutput({ shell_id: result.shellId! }); expect(output2.status).toBe('completed'); @@ -147,7 +172,7 @@ describe('Integration Tests', () => { expect(result.shellId).toBeDefined(); - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitForStatus(result.shellId!); const output = await bashTool.getOutput({ shell_id: result.shellId! }); expect(output.output).toContain('stdout message'); @@ -164,7 +189,7 @@ describe('Integration Tests', () => { expect(result.shellId).toBeDefined(); - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitForStatus(result.shellId!); const output = await bashTool.getOutput({ shell_id: result.shellId! }); expect(output.output).toContain('No such file or directory'); @@ -181,13 +206,12 @@ describe('Integration Tests', () => { expect(result.shellId).toBeDefined(); - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitForStatus(result.shellId!); const output = await bashTool.getOutput({ shell_id: result.shellId!, - filter: '^found', + filter: 'found', }); - expect(output.output).toContain('found1'); expect(output.output).toContain('found2'); expect(output.output).not.toContain('skipped'); @@ -216,24 +240,20 @@ describe('Integration Tests', () => { expect(result1.shellId).not.toBe(result2.shellId); expect(result2.shellId).not.toBe(result3.shellId); - await new Promise((resolve) => setTimeout(resolve, 600)); - - const output1 = await bashTool.getOutput({ - shell_id: result1.shellId!, - }); - const output2 = await bashTool.getOutput({ - shell_id: result2.shellId!, - }); - const output3 = await bashTool.getOutput({ - shell_id: result3.shellId!, - }); - + await Promise.all([ + waitForStatus(result1.shellId!), + waitForStatus(result2.shellId!), + waitForStatus(result3.shellId!), + ]); + + const [output1, output2, output3] = await Promise.all([ + bashTool.getOutput({ shell_id: result1.shellId! }), + bashTool.getOutput({ shell_id: result2.shellId! }), + bashTool.getOutput({ shell_id: result3.shellId! }), + ]); expect(output1.output).toContain('first done'); expect(output2.output).toContain('second done'); expect(output3.output).toContain('third done'); - expect(output1.status).toBe('completed'); - expect(output2.status).toBe('completed'); - expect(output3.status).toBe('completed'); }); it('should kill a running background command', async () => { @@ -245,7 +265,6 @@ describe('Integration Tests', () => { expect(result.shellId).toBeDefined(); // Verify it's running - await new Promise((resolve) => setTimeout(resolve, 100)); const output1 = await bashTool.getOutput({ shell_id: result.shellId! }); expect(output1.status).toBe('running'); @@ -256,7 +275,6 @@ describe('Integration Tests', () => { expect(killResult.killed).toBeTrue(); // Verify it's no longer running - await new Promise((resolve) => setTimeout(resolve, 100)); const output2 = await bashTool.getOutput({ shell_id: result.shellId! }); expect(output2.status).toBe('completed'); expect(output2.output).not.toContain('should not see this'); @@ -272,7 +290,6 @@ describe('Integration Tests', () => { expect(result.shellId).toBeDefined(); // Verify it's running - await new Promise((resolve) => setTimeout(resolve, 100)); const output1 = await bashTool.getOutput({ shell_id: result.shellId! }); expect(output1.status).toBe('running'); @@ -284,7 +301,6 @@ describe('Integration Tests', () => { expect(killResult.killed).toBe(true); // Verify it was terminated with the correct signal - await new Promise((resolve) => setTimeout(resolve, 100)); const output2 = await bashTool.getOutput({ shell_id: result.shellId! }); expect(output2.status).toBe('completed'); expect(output2.signal).toBe('SIGTERM'); @@ -328,7 +344,7 @@ describe('Integration Tests', () => { expect(result.shellId).toBeDefined(); - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitForStatus(result.shellId!); const output = await bashTool.getOutput({ shell_id: result.shellId! }); expect(output.output).toContain('HELLO WORLD'); @@ -338,19 +354,20 @@ describe('Integration Tests', () => { it('should clear output after each getOutput call', async () => { const result = await bashTool.execute({ - command: 'echo "message1" && sleep 0.3 && echo "message2"', + command: 'echo "message1" && sleep 0.5 && echo "message2"', run_in_background: true, }); expect(result.shellId).toBeDefined(); - // First read - await new Promise((resolve) => setTimeout(resolve, 100)); + // First read - wait for first message + await new Promise((resolve) => setTimeout(resolve, 200)); const output1 = await bashTool.getOutput({ shell_id: result.shellId! }); expect(output1.output).toContain('message1'); - // Second read - should only have new output - await new Promise((resolve) => setTimeout(resolve, 400)); + // Second read - wait for command to complete and get second message + // The first message was cleared by the first getOutput call + await waitForStatus(result.shellId!); const output2 = await bashTool.getOutput({ shell_id: result.shellId! }); expect(output2.output).not.toContain('message1'); expect(output2.output).toContain('message2'); @@ -364,7 +381,7 @@ describe('Integration Tests', () => { expect(result.shellId).toBeDefined(); - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitForStatus(result.shellId!); const output = await bashTool.getOutput({ shell_id: result.shellId! }); expect(output.output).toContain('background test'); @@ -380,7 +397,7 @@ describe('Integration Tests', () => { expect(result.shellId).toBeDefined(); - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitForStatus(result.shellId!); const output = await bashTool.getOutput({ shell_id: result.shellId! }); expect(output.output).toContain('background content'); @@ -398,7 +415,6 @@ describe('Integration Tests', () => { expect(result.shellId).toBeDefined(); // Check immediately - should be running - await new Promise((resolve) => setTimeout(resolve, 100)); const output1 = await bashTool.getOutput({ shell_id: result.shellId! }); expect(output1.status).toBe('running'); @@ -456,7 +472,7 @@ describe('Integration Tests', () => { expect(result.shellId).toBeDefined(); - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitForStatus(result.shellId!); const output = await bashTool.getOutput({ shell_id: result.shellId! }); expect(output.output).toContain('line1 stdout'); @@ -475,7 +491,7 @@ describe('Integration Tests', () => { expect(result.shellId).toBeDefined(); - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitForStatus(result.shellId!); const output = await bashTool.getOutput({ shell_id: result.shellId! }); // Verify all content is captured diff --git a/packages/integration-tests/src/bash-foreground.test.ts b/packages/integration-tests/src/bash-foreground.test.ts index 245cc7c..735dfc4 100644 --- a/packages/integration-tests/src/bash-foreground.test.ts +++ b/packages/integration-tests/src/bash-foreground.test.ts @@ -1,7 +1,13 @@ -import { BashTool } from '@agent-remote/ssh'; +import { BashTool as DockerBashTool } from '@agent-remote/docker'; +import { BashTool as SSHBashTool } from '@agent-remote/ssh'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getSSHClient, setupSSH, teardownSSH } from './setup'; +import { + getDockerContainer, + getSSHClient, + setupSSH, + teardownSSH, +} from './setup'; describe('Integration Tests', () => { beforeAll(async () => { @@ -14,18 +20,23 @@ describe('Integration Tests', () => { const implementations: Array<{ name: string; - createBashTool: () => BashTool; + createBashTool: () => SSHBashTool | DockerBashTool; }> = [ { name: 'ssh', - createBashTool: () => new BashTool(getSSHClient()), + createBashTool: () => new SSHBashTool(getSSHClient()), + }, + { + name: 'docker', + createBashTool: () => + new DockerBashTool({ container: getDockerContainer(), shell: 'bash' }), }, ]; describe.each(implementations)( 'BashTool Foreground ($name)', ({ name: _name, createBashTool }) => { - let bashTool: BashTool; + let bashTool: SSHBashTool | DockerBashTool; beforeAll(() => { bashTool = createBashTool(); @@ -220,23 +231,6 @@ describe('Integration Tests', () => { ); }); - it('should not leak event listeners after multiple executions', async () => { - const shell = await bashTool.init(); - - // Get initial listener counts - const initialDataListeners = shell.listenerCount('data'); - const initialErrorListeners = shell.listenerCount('error'); - - // Run multiple commands - for (let i = 0; i < 5; i++) { - await bashTool.execute({ command: `echo "test ${i}"` }); - } - - // Verify listener counts haven't increased - expect(shell.listenerCount('data')).toBe(initialDataListeners); - expect(shell.listenerCount('error')).toBe(initialErrorListeners); - }); - it('should timeout a long-running foreground command', async () => { const result = await bashTool.execute({ command: 'sleep 3', diff --git a/packages/integration-tests/src/file.test.ts b/packages/integration-tests/src/file.test.ts index 85077a4..593301c 100644 --- a/packages/integration-tests/src/file.test.ts +++ b/packages/integration-tests/src/file.test.ts @@ -1,9 +1,16 @@ import fs from 'fs/promises'; -import { FileTool } from '@agent-remote/ssh'; +import { FileTool as DockerFileTool } from '@agent-remote/docker'; +import { FileTool as SSHFileTool } from '@agent-remote/ssh'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { getSSHClient, getSSHSFTP, setupSSH, teardownSSH } from './setup'; +import { + getDockerContainer, + getSSHClient, + getSSHSFTP, + setupSSH, + teardownSSH, +} from './setup'; describe('Integration Tests', () => { let fileContent: string; @@ -25,22 +32,27 @@ describe('Integration Tests', () => { const implementations: Array<{ name: string; - createFileTool: () => FileTool; + createFileTool: () => SSHFileTool | DockerFileTool; }> = [ { name: 'ssh-sftp', - createFileTool: () => new FileTool(getSSHSFTP()), + createFileTool: () => new SSHFileTool(getSSHSFTP()), }, { name: 'ssh-bash', - createFileTool: () => new FileTool(getSSHClient()), + createFileTool: () => new SSHFileTool(getSSHClient()), + }, + { + name: 'docker', + createFileTool: () => + new DockerFileTool({ container: getDockerContainer(), shell: 'sh' }), }, ]; describe.each(implementations)( 'FileTool ($name)', ({ name, createFileTool }) => { - let fileTool: FileTool; + let fileTool: SSHFileTool | DockerFileTool; beforeAll(() => { fileTool = createFileTool(); @@ -143,7 +155,7 @@ describe('Integration Tests', () => { await new Promise((resolve, reject) => { getSSHSFTP().mkdir(testDir, { mode: 0o755 }, (err) => { // Ignore error if directory already exists - if (err && err.message?.includes('Failure')) { + if (err && err.message.includes('Failure')) { resolve(); return; } diff --git a/packages/integration-tests/src/glob.test.ts b/packages/integration-tests/src/glob.test.ts index 365db93..c516a1c 100644 --- a/packages/integration-tests/src/glob.test.ts +++ b/packages/integration-tests/src/glob.test.ts @@ -1,7 +1,13 @@ -import { GlobTool } from '@agent-remote/ssh'; +import { GlobTool as DockerGlobTool } from '@agent-remote/docker'; +import { GlobTool as SSHGlobTool } from '@agent-remote/ssh'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getSSHClient, setupSSH, teardownSSH } from './setup'; +import { + getDockerContainer, + getSSHClient, + setupSSH, + teardownSSH, +} from './setup'; describe('Integration Tests', () => { beforeAll(async () => { @@ -14,18 +20,23 @@ describe('Integration Tests', () => { const implementations: Array<{ name: string; - createGlobTool: () => GlobTool; + createGlobTool: () => SSHGlobTool | DockerGlobTool; }> = [ { name: 'ssh', - createGlobTool: () => new GlobTool(getSSHClient()), + createGlobTool: () => new SSHGlobTool(getSSHClient()), + }, + { + name: 'docker', + createGlobTool: () => + new DockerGlobTool({ container: getDockerContainer(), shell: 'sh' }), }, ]; describe.each(implementations)( 'GlobTool ($name)', ({ name: _name, createGlobTool }) => { - let globTool: GlobTool; + let globTool: SSHGlobTool | DockerGlobTool; beforeAll(() => { globTool = createGlobTool(); diff --git a/packages/integration-tests/src/grep.test.ts b/packages/integration-tests/src/grep.test.ts index 7d12af8..8785626 100644 --- a/packages/integration-tests/src/grep.test.ts +++ b/packages/integration-tests/src/grep.test.ts @@ -1,7 +1,13 @@ -import { GrepTool } from '@agent-remote/ssh'; +import { GrepTool as DockerGrepTool } from '@agent-remote/docker'; +import { GrepTool as SSHGrepTool } from '@agent-remote/ssh'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getSSHClient, setupSSH, teardownSSH } from './setup'; +import { + getDockerContainer, + getSSHClient, + setupSSH, + teardownSSH, +} from './setup'; describe('Integration Tests', () => { beforeAll(async () => { @@ -14,18 +20,23 @@ describe('Integration Tests', () => { const implementations: Array<{ name: string; - createGrepTool: () => GrepTool; + createGrepTool: () => SSHGrepTool | DockerGrepTool; }> = [ { name: 'ssh', - createGrepTool: () => new GrepTool(getSSHClient()), + createGrepTool: () => new SSHGrepTool(getSSHClient()), + }, + { + name: 'docker', + createGrepTool: () => + new DockerGrepTool({ container: getDockerContainer(), shell: 'sh' }), }, ]; describe.each(implementations)( 'GrepTool ($name)', ({ name: _name, createGrepTool }) => { - let grepTool: GrepTool; + let grepTool: SSHGrepTool | DockerGrepTool; beforeAll(() => { grepTool = createGrepTool(); diff --git a/packages/integration-tests/src/setup.ts b/packages/integration-tests/src/setup.ts index 681139b..89822aa 100644 --- a/packages/integration-tests/src/setup.ts +++ b/packages/integration-tests/src/setup.ts @@ -38,8 +38,8 @@ export async function setupSSH(): Promise { * Tear down SSH connection */ export function teardownSSH(): void { - sftp?.end(); - client?.end(); + sftp.end(); + client.end(); } /** @@ -55,3 +55,10 @@ export function getSSHClient(): Client { export function getSSHSFTP(): SFTPWrapper { return sftp; } + +/** + * Get the Docker container name for testing + */ +export function getDockerContainer(): string { + return 'sandbox'; +} diff --git a/packages/ssh/src/lib/bash.ts b/packages/ssh/src/lib/bash.ts index cd2327b..e7dab7f 100644 --- a/packages/ssh/src/lib/bash.ts +++ b/packages/ssh/src/lib/bash.ts @@ -265,6 +265,16 @@ export class BashTool { }; } + public async getStatus( + input: BashOutputInput, + ): Promise { + const shell = this.shells.get(input.shell_id); + if (!shell) { + throw new Error('Shell not found'); + } + return shell.status; + } + public async killShell(input: KillShellInput): Promise { killShellInputSchema.parse(input); diff --git a/packages/ssh/src/lib/bash.unit.test.ts b/packages/ssh/src/lib/bash.unit.test.ts new file mode 100644 index 0000000..1f60ded --- /dev/null +++ b/packages/ssh/src/lib/bash.unit.test.ts @@ -0,0 +1,62 @@ +import { Client } from 'ssh2'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { BashTool } from './bash'; + +/** + * Unit tests specific to SSH BashTool implementation + */ + +let client: Client; + +async function setupSSH(): Promise { + client = new Client(); + await new Promise((resolve, reject) => { + client.on('ready', () => { + resolve(); + }); + client.on('error', (err) => { + reject(err); + }); + client.connect({ + host: 'localhost', + port: 2222, + username: 'dev', + password: 'dev', + }); + }); +} + +function teardownSSH(): void { + client.end(); +} + +describe('SSH BashTool - Unit Tests', () => { + beforeAll(async () => { + await setupSSH(); + }); + + afterAll(() => { + teardownSSH(); + }); + + describe('Event Listener Management', () => { + it('should not leak event listeners after multiple executions', async () => { + const bashTool = new BashTool(client); + const shell = await bashTool.init(); + + // Get initial listener counts + const initialDataListeners = shell.listenerCount('data'); + const initialErrorListeners = shell.listenerCount('error'); + + // Run multiple commands + for (let i = 0; i < 5; i++) { + await bashTool.execute({ command: `echo "test ${i}"` }); + } + + // Verify listener counts haven't increased + expect(shell.listenerCount('data')).toBe(initialDataListeners); + expect(shell.listenerCount('error')).toBe(initialErrorListeners); + }); + }); +}); diff --git a/packages/ssh/src/lib/remote.ts b/packages/ssh/src/lib/remote.ts index 6487d48..7ac3676 100644 --- a/packages/ssh/src/lib/remote.ts +++ b/packages/ssh/src/lib/remote.ts @@ -293,12 +293,16 @@ export class Remote { try { const output = await tool.grep(input); let text = ''; - if (output.mode === 'content') { - text = output.content; - } else if (output.mode === 'files_with_matches') { - text = `Found ${output.numFiles} files\n${output.filenames.join('\n')}`; - } else if (output.mode === 'count') { - text = `Found ${output.numMatches} matches`; + switch (output.mode) { + case 'content': + text = output.content; + break; + case 'files_with_matches': + text = `Found ${output.numFiles} files\n${output.filenames.join('\n')}`; + break; + case 'count': + text = `Found ${output.numMatches} matches`; + break; } return { content: [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e9ff02..e0381fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,7 +40,7 @@ importers: version: 24.9.1 '@vitest/eslint-plugin': specifier: ^1.3.24 - version: 1.3.25(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)(vitest@3.2.4(@types/node@24.9.1)(jiti@2.6.1)(tsx@4.20.6)) + version: 1.3.26(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)(vitest@3.2.4(@types/node@24.9.1)(jiti@2.6.1)(tsx@4.20.6)) eslint: specifier: ^9.38.0 version: 9.38.0(jiti@2.6.1) @@ -103,11 +103,75 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/docker: + dependencies: + diff: + specifier: ^8.0.2 + version: 8.0.2 + minimatch: + specifier: ^10.0.3 + version: 10.0.3 + nanoid: + specifier: ^5.1.6 + version: 5.1.6 + pino: + specifier: ^10.1.0 + version: 10.1.0 + pino-pretty: + specifier: ^13.1.2 + version: 13.1.2 + yargs: + specifier: ^18.0.0 + version: 18.0.0 + zod: + specifier: ^3.25.76 + version: 3.25.76 + zod-to-json-schema: + specifier: ^3.24.6 + version: 3.24.6(zod@3.25.76) + zod-validation-error: + specifier: ^4.0.2 + version: 4.0.2(zod@3.25.76) + devDependencies: + '@agent-remote/core': + specifier: workspace:* + version: link:../core + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.1.21 + version: 0.1.28(zod@3.25.76) + '@modelcontextprotocol/sdk': + specifier: ^1.20.0 + version: 1.20.2 + '@types/yargs': + specifier: ^17.0.33 + version: 17.0.34 + rimraf: + specifier: ^6.0.1 + version: 6.0.1 + tsdown: + specifier: ^0.15.9 + version: 0.15.11(typescript@5.9.3) + tslib: + specifier: 'catalog:' + version: 2.8.1 + tsx: + specifier: 'catalog:' + version: 4.20.6 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 3.2.4(@types/node@24.9.1)(jiti@2.6.1)(tsx@4.20.6) + packages/integration-tests: dependencies: '@agent-remote/core': specifier: workspace:* version: link:../core + '@agent-remote/docker': + specifier: workspace:* + version: link:../docker '@agent-remote/ssh': specifier: workspace:* version: link:../ssh @@ -172,7 +236,7 @@ importers: version: link:../core '@anthropic-ai/claude-agent-sdk': specifier: ^0.1.21 - version: 0.1.27(zod@3.25.76) + version: 0.1.28(zod@3.25.76) '@modelcontextprotocol/sdk': specifier: ^1.20.0 version: 1.20.2 @@ -187,7 +251,7 @@ importers: version: 6.0.1 tsdown: specifier: ^0.15.9 - version: 0.15.10(typescript@5.9.3) + version: 0.15.11(typescript@5.9.3) tslib: specifier: 'catalog:' version: 2.8.1 @@ -203,8 +267,8 @@ importers: packages: - '@anthropic-ai/claude-agent-sdk@0.1.27': - resolution: {integrity: sha512-HuMPW6spj2q8FODiP/WBCqUZAYGwDPoI1EpicP9KUXvuYk+2MZQYSaD7oiN6iNPupR2T5oJ2HY/D9OzjyCD2Mw==} + '@anthropic-ai/claude-agent-sdk@0.1.28': + resolution: {integrity: sha512-latceXkaJbtV4mn2kqnbZgTZYlve+dhCGCW2liWhYdUv6KuoUEY6DK1VDzzHGnd2996fIBO/7E5PAAEHpsDing==} engines: {node: '>=18.0.0'} peerDependencies: zod: ^3.24.1 @@ -586,91 +650,91 @@ packages: '@quansync/fs@0.1.5': resolution: {integrity: sha512-lNS9hL2aS2NZgNW7BBj+6EBl4rOf8l+tQ0eRY6JWCI8jI2kc53gSoqbjojU0OnAWhzoXiOjFyGsHcDGePB3lhA==} - '@rolldown/binding-android-arm64@1.0.0-beta.44': - resolution: {integrity: sha512-g9ejDOehJFhxC1DIXQuZQ9bKv4lRDioOTL42cJjFjqKPl1L7DVb9QQQE1FxokGEIMr6FezLipxwnzOXWe7DNPg==} + '@rolldown/binding-android-arm64@1.0.0-beta.45': + resolution: {integrity: sha512-bfgKYhFiXJALeA/riil908+2vlyWGdwa7Ju5S+JgWZYdR4jtiPOGdM6WLfso1dojCh+4ZWeiTwPeV9IKQEX+4g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-beta.44': - resolution: {integrity: sha512-PxAW1PXLPmCzfhfKIS53kwpjLGTUdIfX4Ht+l9mj05C3lYCGaGowcNsYi2rdxWH24vSTmeK+ajDNRmmmrK0M7g==} + '@rolldown/binding-darwin-arm64@1.0.0-beta.45': + resolution: {integrity: sha512-xjCv4CRVsSnnIxTuyH1RDJl5OEQ1c9JYOwfDAHddjJDxCw46ZX9q80+xq7Eok7KC4bRSZudMJllkvOKv0T9SeA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-beta.44': - resolution: {integrity: sha512-/CtQqs1oO9uSb5Ju60rZvsdjE7Pzn8EK2ISAdl2jedjMzeD/4neNyCbwyJOAPzU+GIQTZVyrFZJX+t7HXR1R/g==} + '@rolldown/binding-darwin-x64@1.0.0-beta.45': + resolution: {integrity: sha512-ddcO9TD3D/CLUa/l8GO8LHzBOaZqWg5ClMy3jICoxwCuoz47h9dtqPsIeTiB6yR501LQTeDsjA4lIFd7u3Ljfw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-beta.44': - resolution: {integrity: sha512-V5Q5W9c4+2GJ4QabmjmVV6alY97zhC/MZBaLkDtHwGy3qwzbM4DYgXUbun/0a8AH5hGhuU27tUIlYz6ZBlvgOA==} + '@rolldown/binding-freebsd-x64@1.0.0-beta.45': + resolution: {integrity: sha512-MBTWdrzW9w+UMYDUvnEuh0pQvLENkl2Sis15fHTfHVW7ClbGuez+RWopZudIDEGkpZXdeI4CkRXk+vdIIebrmg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.44': - resolution: {integrity: sha512-X6adjkHeFqKsTU0FXdNN9HY4LDozPqIfHcnXovE5RkYLWIjMWuc489mIZ6iyhrMbCqMUla9IOsh5dvXSGT9o9A==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.45': + resolution: {integrity: sha512-4YgoCFiki1HR6oSg+GxxfzfnVCesQxLF1LEnw9uXS/MpBmuog0EOO2rYfy69rWP4tFZL9IWp6KEfGZLrZ7aUog==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.44': - resolution: {integrity: sha512-kRRKGZI4DXWa6ANFr3dLA85aSVkwPdgXaRjfanwY84tfc3LncDiIjyWCb042e3ckPzYhHSZ3LmisO+cdOIYL6Q==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.45': + resolution: {integrity: sha512-LE1gjAwQRrbCOorJJ7LFr10s5vqYf5a00V5Ea9wXcT2+56n5YosJkcp8eQ12FxRBv2YX8dsdQJb+ZTtYJwb6XQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.44': - resolution: {integrity: sha512-hMtiN9xX1NhxXBa2U3Up4XkVcsVp2h73yYtMDY59z9CDLEZLrik9RVLhBL5QtoX4zZKJ8HZKJtWuGYvtmkCbIQ==} + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.45': + resolution: {integrity: sha512-tdy8ThO/fPp40B81v0YK3QC+KODOmzJzSUOO37DinQxzlTJ026gqUSOM8tzlVixRbQJltgVDCTYF8HNPRErQTA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.44': - resolution: {integrity: sha512-rd1LzbpXQuR8MTG43JB9VyXDjG7ogSJbIkBpZEHJ8oMKzL6j47kQT5BpIXrg3b5UVygW9QCI2fpFdMocT5Kudg==} + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.45': + resolution: {integrity: sha512-lS082ROBWdmOyVY/0YB3JmsiClaWoxvC+dA8/rbhyB9VLkvVEaihLEOr4CYmrMse151C4+S6hCw6oa1iewox7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.0-beta.44': - resolution: {integrity: sha512-qI2IiPqmPRW25exXkuQr3TlweCDc05YvvbSDRPCuPsWkwb70dTiSoXn8iFxT4PWqTi71wWHg1Wyta9PlVhX5VA==} + '@rolldown/binding-linux-x64-musl@1.0.0-beta.45': + resolution: {integrity: sha512-Hi73aYY0cBkr1/SvNQqH8Cd+rSV6S9RB5izCv0ySBcRnd/Wfn5plguUoGYwBnhHgFbh6cPw9m2dUVBR6BG1gxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-openharmony-arm64@1.0.0-beta.44': - resolution: {integrity: sha512-+vHvEc1pL5iJRFlldLC8mjm6P4Qciyfh2bh5ZI6yxDQKbYhCHRKNURaKz1mFcwxhVL5YMYsLyaqM3qizVif9MQ==} + '@rolldown/binding-openharmony-arm64@1.0.0-beta.45': + resolution: {integrity: sha512-fljEqbO7RHHogNDxYtTzr+GNjlfOx21RUyGmF+NrkebZ8emYYiIqzPxsaMZuRx0rgZmVmliOzEp86/CQFDKhJQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-beta.44': - resolution: {integrity: sha512-XSgLxRrtFj6RpTeMYmmQDAwHjKseYGKUn5LPiIdW4Cq+f5SBSStL2ToBDxkbdxKPEbCZptnLPQ/nfKcAxrC8Xg==} + '@rolldown/binding-wasm32-wasi@1.0.0-beta.45': + resolution: {integrity: sha512-ZJDB7lkuZE9XUnWQSYrBObZxczut+8FZ5pdanm8nNS1DAo8zsrPuvGwn+U3fwU98WaiFsNrA4XHngesCGr8tEQ==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.44': - resolution: {integrity: sha512-cF1LJdDIX02cJrFrX3wwQ6IzFM7I74BYeKFkzdcIA4QZ0+2WA7/NsKIgjvrunupepWb1Y6PFWdRlHSaz5AW1Wg==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45': + resolution: {integrity: sha512-zyzAjItHPUmxg6Z8SyRhLdXlJn3/D9KL5b9mObUrBHhWS/GwRH4665xCiFqeuktAhhWutqfc+rOV2LjK4VYQGQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.44': - resolution: {integrity: sha512-5uaJonDafhHiMn+iEh7qUp3QQ4Gihv3lEOxKfN8Vwadpy0e+5o28DWI42DpJ9YBYMrVy4JOWJ/3etB/sptpUwA==} + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45': + resolution: {integrity: sha512-wODcGzlfxqS6D7BR0srkJk3drPwXYLu7jPHN27ce2c4PUnVVmJnp9mJzUQGT4LpmHmmVdMZ+P6hKvyTGBzc1CA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.44': - resolution: {integrity: sha512-vsqhWAFJkkmgfBN/lkLCWTXF1PuPhMjfnAyru48KvF7mVh2+K7WkKYHezF3Fjz4X/mPScOcIv+g6cf6wnI6eWg==} + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.45': + resolution: {integrity: sha512-wiU40G1nQo9rtfvF9jLbl79lUgjfaD/LTyUEw2Wg/gdF5OhjzpKMVugZQngO+RNdwYaNj+Fs+kWBWfp4VXPMHA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-beta.44': - resolution: {integrity: sha512-g6eW7Zwnr2c5RADIoqziHoVs6b3W5QTQ4+qbpfjbkMJ9x+8Og211VW/oot2dj9dVwaK/UyC6Yo+02gV+wWQVNg==} + '@rolldown/pluginutils@1.0.0-beta.45': + resolution: {integrity: sha512-Le9ulGCrD8ggInzWw/k2J8QcbPz7eGIOWqfJ2L+1R0Opm7n6J37s2hiDWlh6LJN0Lk9L5sUzMvRHKW7UxBZsQA==} '@rollup/rollup-android-arm-eabi@4.52.5': resolution: {integrity: sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==} @@ -979,8 +1043,8 @@ packages: cpu: [x64] os: [win32] - '@vitest/eslint-plugin@1.3.25': - resolution: {integrity: sha512-7qM/FrA2VyUmrorP0TQ/Oqhn6wsAcktg6euBn0XmpgF0yT2mDxjziu2QLy86i2mOJ41Wtt55z6aUWo+bfmyAeg==} + '@vitest/eslint-plugin@1.3.26': + resolution: {integrity: sha512-oP+Vyqgp+kLuMagG0tRkcT7e2tUoE+XWgti1OFxqdTpmMlSZJ6BWSC3rv8vzhtDXReXNyAJI1eojuc7N0QqbNQ==} engines: {node: '>=18'} peerDependencies: eslint: '>=8.57.0' @@ -2253,8 +2317,8 @@ packages: engines: {node: 20 || >=22} hasBin: true - rolldown-plugin-dts@0.17.1: - resolution: {integrity: sha512-dQfoYD9kwSau7UQPg0UubprCDcwWeEKYd9SU9O2MpOdKy3VHy3/DaDF+x6w9+KE/w6J8qxkHVjwG1K2QmmQAFA==} + rolldown-plugin-dts@0.17.2: + resolution: {integrity: sha512-tbLm7FoDvZAhAY33wJbq0ACw+srToKZ5xFqwn/K4tayGloZPXQHyOEPEYi7whEfTCaMndZWaho9+oiQTlwIe6Q==} engines: {node: '>=20.18.0'} peerDependencies: '@ts-macro/tsc': ^0.3.6 @@ -2272,8 +2336,8 @@ packages: vue-tsc: optional: true - rolldown@1.0.0-beta.44: - resolution: {integrity: sha512-gcqgyCi3g93Fhr49PKvymE8PoaGS0sf6ajQrsYaQ8o5de6aUEbD6rJZiJbhOfpcqOnycgsAsUNPYri1h25NgsQ==} + rolldown@1.0.0-beta.45: + resolution: {integrity: sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -2543,8 +2607,8 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} - tsdown@0.15.10: - resolution: {integrity: sha512-8zbSN4GW7ZzhjIYl/rWrruGzl1cJiDtAjb8l5XVF2cVme1+aDLVcExw+Ph4gNcfdGg6ZfYPh5kmcpIfh5xHisw==} + tsdown@0.15.11: + resolution: {integrity: sha512-7k2OglWWt6LzvJKwEf1izbGvETvVfPYRBr9JgEYVRnz/R9LeJSp+B51FUMO46wUeEGtZ1jA3E3PtWWLlq3iygA==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: @@ -2632,8 +2696,8 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} - unrun@0.2.0: - resolution: {integrity: sha512-iaCxWG/6kmjP3wUTBheowjFm6LuI8fd/A3Uz7DbMoz8HvQsJThh7tWZKWJfVltOSK3LuIJFzepr7g6fbuhUasw==} + unrun@0.2.1: + resolution: {integrity: sha512-1HpwmlCKrAOP3jPxFisPR0sYpPuiNtyYKJbmKu9iugIdvCte3DH1uJ1p1DBxUWkxW2pjvkUguJoK9aduK8ak3Q==} engines: {node: '>=20.19.0'} hasBin: true @@ -2800,7 +2864,7 @@ packages: snapshots: - '@anthropic-ai/claude-agent-sdk@0.1.27(zod@3.25.76)': + '@anthropic-ai/claude-agent-sdk@0.1.28(zod@3.25.76)': dependencies: zod: 3.25.76 optionalDependencies: @@ -3124,51 +3188,51 @@ snapshots: dependencies: quansync: 0.2.11 - '@rolldown/binding-android-arm64@1.0.0-beta.44': + '@rolldown/binding-android-arm64@1.0.0-beta.45': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-beta.44': + '@rolldown/binding-darwin-arm64@1.0.0-beta.45': optional: true - '@rolldown/binding-darwin-x64@1.0.0-beta.44': + '@rolldown/binding-darwin-x64@1.0.0-beta.45': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-beta.44': + '@rolldown/binding-freebsd-x64@1.0.0-beta.45': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.44': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.45': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.44': + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.45': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.44': + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.45': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.44': + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.45': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-beta.44': + '@rolldown/binding-linux-x64-musl@1.0.0-beta.45': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-beta.44': + '@rolldown/binding-openharmony-arm64@1.0.0-beta.45': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-beta.44': + '@rolldown/binding-wasm32-wasi@1.0.0-beta.45': dependencies: '@napi-rs/wasm-runtime': 1.0.7 optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.44': + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45': optional: true - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.44': + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.44': + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.45': optional: true - '@rolldown/pluginutils@1.0.0-beta.44': {} + '@rolldown/pluginutils@1.0.0-beta.45': {} '@rollup/rollup-android-arm-eabi@4.52.5': optional: true @@ -3430,7 +3494,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitest/eslint-plugin@1.3.25(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)(vitest@3.2.4(@types/node@24.9.1)(jiti@2.6.1)(tsx@4.20.6))': + '@vitest/eslint-plugin@1.3.26(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)(vitest@3.2.4(@types/node@24.9.1)(jiti@2.6.1)(tsx@4.20.6))': dependencies: '@typescript-eslint/scope-manager': 8.46.2 '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) @@ -4826,7 +4890,7 @@ snapshots: glob: 11.0.3 package-json-from-dist: 1.0.1 - rolldown-plugin-dts@0.17.1(rolldown@1.0.0-beta.44)(typescript@5.9.3): + rolldown-plugin-dts@0.17.2(rolldown@1.0.0-beta.45)(typescript@5.9.3): dependencies: '@babel/generator': 7.28.5 '@babel/parser': 7.28.5 @@ -4837,32 +4901,32 @@ snapshots: dts-resolver: 2.1.2 get-tsconfig: 4.13.0 magic-string: 0.30.21 - rolldown: 1.0.0-beta.44 + rolldown: 1.0.0-beta.45 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver - supports-color - rolldown@1.0.0-beta.44: + rolldown@1.0.0-beta.45: dependencies: '@oxc-project/types': 0.95.0 - '@rolldown/pluginutils': 1.0.0-beta.44 + '@rolldown/pluginutils': 1.0.0-beta.45 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-beta.44 - '@rolldown/binding-darwin-arm64': 1.0.0-beta.44 - '@rolldown/binding-darwin-x64': 1.0.0-beta.44 - '@rolldown/binding-freebsd-x64': 1.0.0-beta.44 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.44 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.44 - '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.44 - '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.44 - '@rolldown/binding-linux-x64-musl': 1.0.0-beta.44 - '@rolldown/binding-openharmony-arm64': 1.0.0-beta.44 - '@rolldown/binding-wasm32-wasi': 1.0.0-beta.44 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.44 - '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.44 - '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.44 + '@rolldown/binding-android-arm64': 1.0.0-beta.45 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.45 + '@rolldown/binding-darwin-x64': 1.0.0-beta.45 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.45 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.45 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.45 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.45 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.45 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.45 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.45 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.45 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.45 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.45 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.45 rollup@4.52.5: dependencies: @@ -5182,7 +5246,7 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 - tsdown@0.15.10(typescript@5.9.3): + tsdown@0.15.11(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -5191,14 +5255,14 @@ snapshots: diff: 8.0.2 empathic: 2.0.0 hookable: 5.5.3 - rolldown: 1.0.0-beta.44 - rolldown-plugin-dts: 0.17.1(rolldown@1.0.0-beta.44)(typescript@5.9.3) + rolldown: 1.0.0-beta.45 + rolldown-plugin-dts: 0.17.2(rolldown@1.0.0-beta.45)(typescript@5.9.3) semver: 7.7.3 tinyexec: 1.0.1 tinyglobby: 0.2.15 tree-kill: 1.2.2 unconfig: 7.3.3 - unrun: 0.2.0 + unrun: 0.2.1 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -5319,10 +5383,10 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - unrun@0.2.0: + unrun@0.2.1: dependencies: '@oxc-project/runtime': 0.95.0 - rolldown: 1.0.0-beta.44 + rolldown: 1.0.0-beta.45 synckit: 0.11.11 uri-js@4.4.1: