Compare commits

..

1 Commits

Author SHA1 Message Date
Cole Miller
abae47de80 read output in a code block 2025-08-28 21:08:26 -04:00
16 changed files with 1234 additions and 2764 deletions

View File

@@ -1,45 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: "ci-${{ github.ref }}"
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build:
name: Build
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Use Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: latest
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Check formatting
run: npm run format:check
- name: Run linter
run: npm run lint
- name: Build
run: npm run build
- name: Run tests
run: npm run test:run

View File

@@ -1,23 +0,0 @@
name: Publish Package to npmjs
on:
push:
tags:
- "v*.*.*"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "lts/*"
registry-url: "https://registry.npmjs.org"
- run: npm ci
# Add back provenance once repo is public
- run: npm publish # --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -1,4 +0,0 @@
{
"printWidth": 100,
"tabWidth": 2
}

View File

@@ -1,55 +1,3 @@
# ACP adapter for Claude Code
An ACP-compatible coding agent powered by the Claude Code SDK
[![npm](https://img.shields.io/npm/v/%40zed-industries%2Fclaude-code-acp)](https://www.npmjs.com/package/@zed-industries/claude-code-acp)
Use [Claude Code](https://www.anthropic.com/claude-code) from [ACP-compatible](https://agentclientprotocol.com) clients such as [Zed](https://zed.dev)!
This tool implements an ACP agent by using the official [Claude Code SDK](https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-overview), supporting:
- Context @-mentions
- Images
- Tool calls (with permission requests)
- Following
- Edit review
- TODO lists
- Interactive (and background) terminals
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
- Client MCP servers
Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
## How to use
### Zed
The latest version of Zed can already use this adapter out of the box.
To use Claude Code, open the Agent Panel and click "New Claude Code Thread" from the `+` button menu in the top-right:
https://github.com/user-attachments/assets/ddce66c7-79ac-47a3-ad59-4a6a3ca74903
Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) support.
### Other clients
- Neovim via [CodeCompanion.nvim](https://codecompanion.olimorris.dev/configuration/adapters#setup-claude-code-via-acp)
[Submit a PR](https://github.com/zed-industries/claude-code-acp/pulls) to add yours!
#### Installation
Install the adapter from `npm`:
```bash
npm install @zed-industries/claude-code-acp
```
You can then use `claude-code-acp` as a regular ACP agent:
```
ANTHROPIC_API_KEY=sk-... claude-code-acp
```
## License
Apache-2.0
This wrapper lets you use the real version of Claude Code from the IDE of your choice.

View File

@@ -1,48 +0,0 @@
import js from "@eslint/js";
import tseslint from "@typescript-eslint/eslint-plugin";
import tsparser from "@typescript-eslint/parser";
import prettierConfig from "eslint-config-prettier";
import globals from "globals";
export default [
{
ignores: ["node_modules/", "dist/", "coverage/", "*.min.js", "*.config.js", ".github/"],
},
js.configs.recommended,
{
files: ["src/**/*.ts"],
languageOptions: {
ecmaVersion: 2020,
sourceType: "module",
parser: tsparser,
parserOptions: {
project: "./tsconfig.json",
},
globals: {
...globals.node,
},
},
plugins: {
"@typescript-eslint": tseslint,
},
rules: {
...tseslint.configs.recommended.rules,
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
},
],
"no-console": "off",
"no-constant-condition": "off",
"default-case": "error",
"prefer-const": "error",
"no-var": "error",
eqeqeq: ["error", "always"],
curly: ["error", "all"],
},
},
prettierConfig,
];

1381
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,33 +3,19 @@
"publishConfig": {
"access": "public"
},
"bin": {
"claude-code-acp": "./dist/index.js"
},
"version": "0.4.0",
"version": "0.1.0",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/index.js",
"main": "dist/acp-agent.js",
"type": "module",
"files": [
"dist/",
"README.md",
"LICENSE",
"package.json"
],
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "npm run build && npm run start",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"check": "npm run lint && npm run format:check",
"test": "vitest",
"test:integration": "RUN_INTEGRATION_TESTS=true vitest run",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"prepublishOnly": "npm run build"
"test:coverage": "vitest run --coverage"
},
"keywords": [
"claude",
@@ -43,28 +29,22 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/claude-code": "1.0.111",
"@modelcontextprotocol/sdk": "1.17.4",
"@zed-industries/agent-client-protocol": "0.2.0-alpha.8",
"diff": "8.0.2",
"express": "5.1.0",
"minimist": "1.2.8",
"@anthropic-ai/claude-code": "^1.0.96",
"@modelcontextprotocol/sdk": "^1.17.4",
"@types/express": "^5.0.3",
"@zed-industries/agent-client-protocol": "0.1.2",
"express": "^5.1.0",
"minimist": "^1.2.8",
"uuid": "11.1.0"
},
"devDependencies": {
"@types/express": "5.0.3",
"@types/minimist": "1.2.5",
"@types/node": "24.3.1",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.43.0",
"@typescript-eslint/parser": "8.43.0",
"@vitest/ui": "3.2.4",
"eslint": "9.35.0",
"eslint-config-prettier": "10.1.8",
"globals": "16.4.0",
"prettier": "3.6.2",
"ts-node": "10.9.2",
"typescript": "5.9.2",
"vitest": "3.2.4"
"@types/minimist": "^1.2.5",
"@types/node": "^24.3.0",
"@types/uuid": "^10.0.0",
"@vitest/ui": "^3.2.4",
"eslint": "^8.56.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.0",
"vitest": "^3.2.4"
}
}

View File

@@ -2,64 +2,49 @@ import {
Agent,
AgentSideConnection,
AuthenticateRequest,
AvailableCommand,
CancelNotification,
ClientCapabilities,
Client,
InitializeRequest,
InitializeResponse,
LoadSessionRequest,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
ReadTextFileRequest,
ReadTextFileResponse,
RequestError,
SetSessionModeRequest,
SetSessionModeResponse,
TerminalHandle,
TerminalOutputResponse,
WriteTextFileRequest,
WriteTextFileResponse,
ToolCallContent,
ToolKind,
} from "@zed-industries/agent-client-protocol";
import {
McpServerConfig,
Options,
PermissionMode,
Query,
query,
SDKAssistantMessage,
SDKMessage,
SDKUserMessage,
} from "@anthropic-ai/claude-code";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import * as readline from "node:readline";
import { v7 as uuidv7 } from "uuid";
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
import {
nodeToWebReadable,
nodeToWebWritable,
Pushable,
unreachable,
} from "./utils.js";
import { ContentBlock } from "@zed-industries/agent-client-protocol";
import { SessionNotification } from "@zed-industries/agent-client-protocol";
import { createMcpServer, toolNames } from "./mcp-server.js";
import { createMcpServer } from "./mcp-server.js";
import { AddressInfo } from "node:net";
import { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
import {
toolInfoFromToolUse,
planEntries,
toolUpdateFromToolResult,
} from "./tools.js";
type Session = {
query: Query;
input: Pushable<SDKUserMessage>;
cancelled: boolean;
permissionMode: PermissionMode;
};
type BackgroundTerminal =
| {
handle: TerminalHandle;
status: "started";
lastOutput: TerminalOutputResponse | null;
}
| {
status: "aborted" | "exited" | "killed" | "timedOut";
pendingOutput: TerminalOutputResponse;
};
type ToolUseCache = {
[key: string]: { type: "tool_use"; id: string; name: string; input: any };
};
// Implement the ACP Agent interface
@@ -67,80 +52,56 @@ export class ClaudeAcpAgent implements Agent {
sessions: {
[key: string]: Session;
};
client: AgentSideConnection;
toolUseCache: ToolUseCache;
fileContentCache: { [key: string]: string };
backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
clientCapabilities?: ClientCapabilities;
client: Client;
toolUseCache: { [key: string]: any };
constructor(client: AgentSideConnection) {
constructor(client: Client) {
this.sessions = {};
this.client = client;
this.toolUseCache = {};
this.fileContentCache = {};
}
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
this.clientCapabilities = request.clientCapabilities;
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
return {
protocolVersion: 1,
// todo!()
agentCapabilities: {
promptCapabilities: {
image: true,
embeddedContext: true,
},
mcpCapabilities: {
http: true,
sse: true,
},
promptCapabilities: { image: true, embeddedContext: true },
},
authMethods: [
{
description: "Run `claude /login` in the terminal",
name: "Log in with Claude Code",
name: "Login with Claude CLI",
id: "claude-login",
},
{
description: "Anthropic API KEY",
name: "Use Anthropic API Key",
id: "anthropic-api-key",
},
],
};
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
if (
fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
!fs.existsSync(path.resolve(os.homedir(), ".claude.json"))
) {
throw RequestError.authRequired();
}
const sessionId = uuidv7();
const input = new Pushable<SDKUserMessage>();
let sessionId = uuidv7();
let input = new Pushable<SDKUserMessage>();
const mcpServers: Record<string, McpServerConfig> = {};
if (Array.isArray(params.mcpServers)) {
for (const server of params.mcpServers) {
if ("type" in server) {
mcpServers[server.name] = {
type: server.type,
url: server.url,
headers: server.headers
? Object.fromEntries(server.headers.map((e) => [e.name, e.value]))
: undefined,
};
} else {
mcpServers[server.name] = {
type: "stdio",
command: server.command,
args: server.args,
env: server.env
? Object.fromEntries(server.env.map((e) => [e.name, e.value]))
: undefined,
};
}
mcpServers[server.name] = {
type: "stdio",
command: server.command,
args: server.args,
env: server.env
? Object.fromEntries(server.env.map((e) => [e.name, e.value]))
: undefined,
};
}
}
const server = await createMcpServer(this, sessionId, this.clientCapabilities);
const address = server.address() as AddressInfo;
// todo!() auth
let server = await createMcpServer(this, sessionId);
let address = server.address() as AddressInfo;
mcpServers["acp"] = {
type: "http",
url: "http://127.0.0.1:" + address.port + "/mcp",
@@ -149,90 +110,30 @@ export class ClaudeAcpAgent implements Agent {
},
};
const options: Options = {
cwd: params.cwd,
mcpServers,
permissionPromptToolName: toolNames.permission,
stderr: (err) => console.error(err),
// note: although not documented by the types, passing an absolute path
// here works to find zed's managed node version.
executable: process.execPath as any,
};
const allowedTools = [];
const disallowedTools = [];
if (this.clientCapabilities?.fs?.readTextFile) {
allowedTools.push(toolNames.read);
disallowedTools.push("Read");
}
if (this.clientCapabilities?.fs?.writeTextFile) {
allowedTools.push(toolNames.write);
disallowedTools.push("Write", "Edit", "MultiEdit");
}
if (this.clientCapabilities?.terminal) {
allowedTools.push(toolNames.bashOutput, toolNames.killBash);
disallowedTools.push("Bash", "BashOutput", "KillBash");
}
if (allowedTools.length > 0) {
options.allowedTools = allowedTools;
}
if (disallowedTools.length > 0) {
options.disallowedTools = disallowedTools;
}
const q = query({
let q = query({
prompt: input,
options,
options: {
cwd: params.cwd,
mcpServers,
allowedTools: ["mcp__acp__read"],
disallowedTools: ["Read", "Write", "Edit", "MultiEdit"],
strictMcpConfig: true,
permissionPromptToolName: "mcp__acp__permission",
stderr: (err) => console.error(err),
},
});
this.sessions[sessionId] = {
query: q,
input: input,
cancelled: false,
permissionMode: "default",
};
getAvailableSlashCommands(q).then((availableCommands) => {
this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands,
},
});
});
return {
sessionId,
modes: {
currentModeId: "default",
availableModes: [
{
id: "default",
name: "Always Ask",
description: "Prompts for permission on first use of each tool",
},
{
id: "acceptEdits",
name: "Accept Edits",
description: "Automatically accepts file edit permissions for the session",
},
{
id: "bypassPermissions",
name: "Bypass Permissions",
description: "Skips all permission prompts",
},
{
id: "plan",
name: "Plan Mode",
description: "Claude can analyze but not modify files or execute commands",
},
],
},
};
}
async authenticate(_params: AuthenticateRequest): Promise<void> {
async authenticate(params: AuthenticateRequest): Promise<void> {
throw new Error("Method not implemented.");
}
@@ -247,7 +148,7 @@ export class ClaudeAcpAgent implements Agent {
input.push(promptToClaude(params));
while (true) {
const { value: message, done } = await query.next();
let { value: message, done } = await query.next();
if (done || !message) {
if (this.sessions[params.sessionId].cancelled) {
return { stopReason: "cancelled" };
@@ -285,8 +186,8 @@ export class ClaudeAcpAgent implements Agent {
}
if (
message.message.model === "<synthetic>" &&
message.message.content.length === 1 &&
message.message.model == "<synthetic>" &&
message.message.content.length == 1 &&
message.message.content[0].text.includes("Please run /login")
) {
throw RequestError.authRequired();
@@ -295,14 +196,13 @@ export class ClaudeAcpAgent implements Agent {
message,
params.sessionId,
this.toolUseCache,
this.fileContentCache,
)) {
await this.client.sessionUpdate(notification);
}
break;
}
default:
unreachable(message as never);
unreachable(message);
}
}
throw new Error("Session did not end in result");
@@ -315,97 +215,6 @@ export class ClaudeAcpAgent implements Agent {
this.sessions[params.sessionId].cancelled = true;
await this.sessions[params.sessionId].query.interrupt();
}
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
if (!this.sessions[params.sessionId]) {
throw new Error("Session not found");
}
switch (params.modeId) {
case "default":
case "acceptEdits":
case "plan":
this.sessions[params.sessionId].permissionMode = params.modeId;
await this.sessions[params.sessionId].query.setPermissionMode(params.modeId);
return {};
case "bypassPermissions":
// For some reason, the SDK doesn't support setting the mode to `bypassPermissions`
// so we do it ourselves
this.sessions[params.sessionId].permissionMode = "bypassPermissions";
await this.sessions[params.sessionId].query.setPermissionMode("acceptEdits");
return {};
default:
throw new Error("Invalid mode");
}
}
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
const response = await this.client.readTextFile(params);
if (!params.limit && !params.line) {
this.fileContentCache[params.path] = response.content;
}
return response;
}
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
const response = await this.client.writeTextFile(params);
this.fileContentCache[params.path] = params.content;
return response;
}
}
async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand[]> {
const UNSUPPORTED_COMMANDS = [
"add-dir",
"agents", // Modal
"bashes", // Modal
"bug", // Modal
"clear", // Escape Codes
"compact", // Not supported via SDK?
"config", // Modal
"context", // Escape Codes
"cost", // Escape Codes
"doctor", // Escape Codes
"exit",
"export", // Modal
"help", // Modal
"hooks", // Modal
"ide", // Modal
"install-github-app", // Modal
"login",
"logout",
"memory",
"mcp",
"migrate-installer", // Modal
"model", // Not supported via SDK?
"output-style", // Modal
"output-style:new", // Modal
"permissions", // Modal
"privacy-settings",
"release-notes", // Escape Codes
"resume",
"status", // Not supported via SDK?
"statusline", // Not needed
"terminal-setup", // Not needed
"todos", // Escape Codes
"vim", // Not needed
];
//todo: Do not use `as any` once `supportedCommands` is exposed via the typescript interface
const commands = await (query as any).supportedCommands();
return commands
.map((command: { name: string; description: string; argumentHint: string }) => {
const input = command.argumentHint ? { hint: command.argumentHint } : null;
return {
name: command.name,
description: command.description || "",
input,
};
})
.filter(
(command: AvailableCommand) =>
!(command.name.match(/\(MCP\)/) || UNSUPPORTED_COMMANDS.includes(command.name)),
);
}
function formatUriAsLink(uri: string): string {
@@ -503,14 +312,13 @@ function promptToClaude(prompt: PromptRequest): SDKUserMessage {
export function toAcpNotifications(
message: SDKAssistantMessage | SDKUserMessage,
sessionId: string,
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
toolUseCache: { [key: string]: any },
): SessionNotification[] {
const chunks = message.message.content as ContentChunk[];
const output = [];
let chunks = message.message.content as ContentChunk[];
let output = [];
// Only handle the first chunk for streaming; extend as needed for batching
for (const chunk of chunks) {
let update: SessionNotification["update"] | null = null;
let update: SessionNotification["update"];
switch (chunk.type) {
case "text":
update = {
@@ -527,7 +335,8 @@ export function toAcpNotifications(
content: {
type: "image",
data: chunk.source.type === "base64" ? chunk.source.data : "",
mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
mimeType:
chunk.source.type === "base64" ? chunk.source.media_type : "",
uri: chunk.source.type === "url" ? chunk.source.url : undefined,
},
};
@@ -543,7 +352,7 @@ export function toAcpNotifications(
break;
case "tool_use":
toolUseCache[chunk.id] = chunk;
if (chunk.name === "TodoWrite") {
if (chunk.name == "TodoWrite") {
update = {
sessionUpdate: "plan",
entries: planEntries(chunk.input),
@@ -554,37 +363,24 @@ export function toAcpNotifications(
sessionUpdate: "tool_call",
rawInput: chunk.input,
status: "pending",
...toolInfoFromToolUse(chunk, fileContentCache),
...toolInfoFromToolUse(chunk),
};
}
break;
case "tool_result": {
const toolUse = toolUseCache[chunk.tool_use_id];
if (!toolUse) {
console.error(
`[claude-code-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`,
);
break;
}
if (toolUse.name !== "TodoWrite") {
update = {
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update",
status: chunk.is_error ? "failed" : "completed",
...toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id]),
};
}
case "tool_result":
update = {
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update",
status: chunk.is_error ? "failed" : "completed",
...toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id]),
};
break;
}
default:
throw new Error("unhandled chunk type: " + chunk.type);
}
if (update) {
output.push({ sessionId, update });
}
output.push({ sessionId, update });
}
return output;
@@ -600,7 +396,7 @@ export function runAcp() {
type ContentChunk =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: any }
| { type: "tool_use"; id: string; name: string; input: any } // input is serde_json::Value, so use any or unknown
| {
type: "tool_result";
content: string;

View File

@@ -1,24 +1,3 @@
#!/usr/bin/env node
// Load managed settings and apply environment variables
import { loadManagedSettings, applyEnvironmentSettings } from "./utils.js";
const managedSettings = loadManagedSettings();
if (managedSettings) {
applyEnvironmentSettings(managedSettings);
}
// stdout is used to send messages to the client
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
console.log = console.error;
console.info = console.error;
console.warn = console.error;
console.debug = console.error;
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
});
import { runAcp as runAcp } from "./acp-agent.js";
runAcp();

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +0,0 @@
---
description: 10 * 3 = 30
---
What is 10 \* 3. Only respond with the number.

View File

@@ -1,6 +0,0 @@
---
description: Say hello
argument-hint: [name]
---
Respond with "Hello $1" and nothing else.

View File

@@ -2,10 +2,8 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { spawn, spawnSync } from "child_process";
import {
Agent,
AvailableCommand,
Client,
ClientSideConnection,
NewSessionResponse,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionRequest,
@@ -15,183 +13,89 @@ import {
WriteTextFileResponse,
} from "@zed-industries/agent-client-protocol";
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
import { toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
import { toAcpNotifications } from "../acp-agent.js";
import { SDKAssistantMessage } from "@anthropic-ai/claude-code";
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
let child: ReturnType<typeof spawn>;
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
"ACP subprocess integration",
() => {
let child: ReturnType<typeof spawn>;
beforeAll(async () => {
const valid = spawnSync("tsc", { stdio: "inherit" });
if (valid.status) {
throw new Error("failed to compile");
}
// Start the subprocess
child = spawn("npm", ["run", "--silent", "dev"], {
stdio: ["pipe", "pipe", "inherit"],
env: process.env,
});
child.on("error", (error) => {
console.error("Error starting subprocess:", error);
});
child.on("exit", (exit) => {
console.error("Exited with", exit);
});
});
afterAll(() => {
child.kill();
});
class TestClient implements Client {
agent: Agent;
files: Map<string, string> = new Map();
receivedText: string = "";
resolveAvailableCommands: (commands: AvailableCommand[]) => void;
availableCommandsPromise: Promise<AvailableCommand[]>;
constructor(agent: Agent) {
this.agent = agent;
this.resolveAvailableCommands = () => {};
this.availableCommandsPromise = new Promise((resolve) => {
this.resolveAvailableCommands = resolve;
beforeAll(async () => {
let valid = spawnSync("tsc", { stdio: "inherit" });
if (valid.status) {
throw new Error("failed to compile");
}
// Start the subprocess
child = spawn("npm", ["run", "--silent", "dev"], {
stdio: ["pipe", "pipe", "inherit"],
env: process.env,
});
}
child.on("error", (error) => {
console.error("Error starting subprocess:", error);
});
child.on("exit", (exit) => {
console.error("Exited with", exit);
});
});
takeReceivedText() {
const text = this.receivedText;
this.receivedText = "";
return text;
}
afterAll(() => {
child.kill();
});
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
const optionId = params.options.find((p) => p.kind === "allow_once")!.optionId;
class TestClient implements Client {
agent: Agent;
return { outcome: { outcome: "selected", optionId } };
}
async sessionUpdate(params: SessionNotification): Promise<void> {
console.error("RECEIVED", JSON.stringify(params, null, 4));
switch (params.update.sessionUpdate) {
case "agent_message_chunk": {
if (params.update.content.type === "text") {
this.receivedText += params.update.content.text;
}
break;
}
case "available_commands_update":
this.resolveAvailableCommands(params.update.availableCommands);
break;
default:
break;
constructor(agent: Agent) {
this.agent = agent;
}
requestPermission(
params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
throw new Error("Method not implemented.");
}
async sessionUpdate(params: SessionNotification): Promise<void> {
console.error("RECEVIED", params);
}
writeTextFile(
params: WriteTextFileRequest,
): Promise<WriteTextFileResponse> {
throw new Error("Method not implemented.");
}
async readTextFile(
params: ReadTextFileRequest,
): Promise<ReadTextFileResponse> {
return {
content:
"This project attempts to take over the world by solving the Goldbach conjecture.",
};
}
}
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
this.files.set(params.path, params.content);
return null;
}
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
const content = this.files.get(params.path) ?? "";
return {
content,
};
}
}
async function setupTestSession(cwd: string): Promise<{
client: TestClient;
connection: ClientSideConnection;
newSessionResponse: NewSessionResponse;
}> {
let client;
const connection = new ClientSideConnection(
(agent) => {
client = new TestClient(agent);
return client;
},
nodeToWebWritable(child.stdin!),
nodeToWebReadable(child.stdout!),
);
await connection.initialize({
protocolVersion: 1,
clientCapabilities: {
fs: {
readTextFile: true,
writeTextFile: true,
it("should connect to the ACP subprocess", async () => {
const connection = new ClientSideConnection(
(agent) => {
return new TestClient(agent);
},
},
});
nodeToWebWritable(child.stdin!),
nodeToWebReadable(child.stdout!),
);
const newSessionResponse = await connection.newSession({
cwd,
mcpServers: [],
});
return { client: client!, connection, newSessionResponse };
}
it("should connect to the ACP subprocess", async () => {
const { client, connection, newSessionResponse } = await setupTestSession("./");
await connection.prompt({
prompt: [
{
type: "text",
text: "Hello",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).not.toEqual("");
}, 30000);
it("should include available commands", async () => {
const { client, connection, newSessionResponse } = await setupTestSession(__dirname);
const commands = await client.availableCommandsPromise;
expect(commands).toContainEqual({
name: "quick-math",
description: "10 * 3 = 30 (project)",
input: null,
});
expect(commands).toContainEqual({
name: "say-hello",
description: "Say hello (project)",
input: { hint: "[name]" },
});
await connection.prompt({
prompt: [
{
type: "text",
text: "/quick-math",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toContain("30");
await connection.prompt({
prompt: [
{
type: "text",
text: "/say-hello GPT-5",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toContain("Hello GPT-5");
}, 30000);
});
let session = await connection.newSession({ cwd: "./", mcpServers: [] });
await connection.prompt({
prompt: [
{
type: "text",
text: "What does this project do? Read the README.md file.",
},
],
sessionId: session.sessionId,
});
console.log("DONE AWAITING");
}, 20000);
},
);
describe("tool conversions", () => {
it("should handle Bash nicely", () => {
@@ -205,7 +109,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: "`rm README.md.rm`",
content: [
@@ -230,9 +134,9 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "search",
title: "Find `*/**.ts`",
title: "Find */**.ts",
content: [],
locations: [],
});
@@ -251,7 +155,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "think",
title: "Handle user's work request",
content: [
@@ -272,15 +176,15 @@ describe("tool conversions", () => {
id: "toolu_01EEqsX7Eb9hpx87KAHVPTey",
name: "LS",
input: {
path: "/Users/test/github/claude-code-acp",
path: "/Users/benbrandt/github/claude-code-acp",
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "search",
title: "List the `/Users/test/github/claude-code-acp` directory's contents",
title: "/Users/benbrandt/github/claude-code-acp",
content: [],
locations: [],
locations: [{ path: "/Users/benbrandt/github/claude-code-acp" }],
});
});
@@ -294,7 +198,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "search",
title: 'grep ".*"',
content: [],
@@ -307,12 +211,12 @@ describe("tool conversions", () => {
id: "toolu_01ABC123XYZ789",
name: "Write",
input: {
file_path: "/Users/test/project/example.txt",
abs_path: "/Users/test/project/example.txt",
content: "Hello, World!\nThis is test content.",
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "edit",
title: "Write /Users/test/project/example.txt",
content: [
@@ -338,7 +242,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "edit",
title: "Write /Users/test/project/config.json",
content: [
@@ -359,14 +263,19 @@ describe("tool conversions", () => {
id: "toolu_01MNO456PQR789",
name: "Read",
input: {
file_path: "/Users/test/project/readme.md",
abs_path: "/Users/test/project/readme.md",
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read File",
content: [],
content: [
{
type: "content",
content: { type: "text", text: "/Users/test/project/readme.md" },
},
],
locations: [{ path: "/Users/test/project/readme.md", line: 0 }],
});
});
@@ -381,7 +290,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/data.json",
content: [],
@@ -400,7 +309,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (1 - 100)",
content: [],
@@ -420,7 +329,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (51 - 150)",
content: [],
@@ -439,7 +348,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (from line 201)",
content: [],
@@ -459,7 +368,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "fetch",
title: "Fetch https://agentclientprotocol.com",
content: [
@@ -484,7 +393,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "fetch",
title: '"agentclientprotocol.com"',
content: [],
@@ -492,7 +401,7 @@ describe("tool conversions", () => {
});
it("should handle KillBash entries", () => {
const tool_use = {
let tool_use = {
type: "tool_use",
id: "toolu_01PhLms5fuvmdjy2bb6dfUKT",
name: "KillBash",
@@ -501,7 +410,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: `Kill Process`,
content: [],
@@ -509,7 +418,7 @@ describe("tool conversions", () => {
});
it("should handle BashOutput entries", () => {
const tool_use = {
let tool_use = {
type: "tool_use",
id: "toolu_01SJUWPtj1QspgANgtpqGPuN",
name: "BashOutput",
@@ -518,7 +427,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: `Tail Logs`,
content: [],
@@ -566,7 +475,8 @@ describe("tool conversions", () => {
activeForm: "Adding concurrent access tests",
},
{
content: "Add tests for Each function with various data types",
content:
"Add tests for Each function with various data types",
status: "pending",
activeForm: "Adding Each function tests",
},
@@ -602,7 +512,7 @@ describe("tool conversions", () => {
session_id: "d056596f-e328-41e9-badd-b07122ae5227",
uuid: "b7c3330c-de8f-4bba-ac53-68c7f76ffeb5",
};
expect(toAcpNotifications(received, "test", {}, {})).toStrictEqual([
expect(toAcpNotifications(received, "test", {})).toStrictEqual([
{
sessionId: "test",
update: {
@@ -654,80 +564,140 @@ describe("tool conversions", () => {
]);
});
it("should show full diff for multi edit tool", () => {
it("should extract locations from edit tool results", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01ABC123",
name: "edit",
input: {
abs_path: "/Users/test/project/example.txt",
old_text: "old content",
new_text: "new content",
},
};
const toolResult = {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers: [5, 10, 15] }),
},
],
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([
{ path: "/Users/test/project/example.txt", line: 5 },
{ path: "/Users/test/project/example.txt", line: 10 },
{ path: "/Users/test/project/example.txt", line: 15 },
]);
});
it("should extract locations from multi-edit tool results", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01DEF456",
name: "mcp__acp__multi-edit",
name: "multi-edit",
input: {
file_path: "/Users/test/project/config.json",
edits: [
{
old_string: 'version": 1',
new_string: 'version": 2',
old_string: "version: 1.0",
new_string: "version: 2.0",
replace_all: false,
},
{
old_string: 'enabled": false',
new_string: 'enabled": true',
old_string: "enabled: false",
new_string: "enabled: true",
replace_all: true,
},
],
},
};
const fileCache: { [key: string]: string } = {
"/Users/test/project/config.json": JSON.stringify(
{
version: 1,
filler: "filler",
enabled: false,
},
null,
4,
),
};
expect(toolInfoFromToolUse(toolUse, fileCache)).toEqual({
const toolResult = {
content: [
{
newText: `{
"version": 2,
"filler": "filler",
"enabled": true
}`,
oldText: `{
"version": 1,
"filler": "filler",
"enabled": false
}`,
path: "/Users/test/project/config.json",
type: "diff",
type: "text",
text: JSON.stringify({ lineNumbers: [2, 8, 12, 20] }),
},
],
kind: "edit",
locations: [
{
path: "/Users/test/project/config.json",
line: 1,
},
{
path: "/Users/test/project/config.json",
line: 3,
},
],
title: "Edit /Users/test/project/config.json",
});
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([
{ path: "/Users/test/project/config.json", line: 2 },
{ path: "/Users/test/project/config.json", line: 8 },
{ path: "/Users/test/project/config.json", line: 12 },
{ path: "/Users/test/project/config.json", line: 20 },
]);
});
it("should return empty update for successful edit result", () => {
it("should handle mcp__acp__edit tool results", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01GHI789",
name: "mcp__acp__edit",
input: {
abs_path: "/Users/test/project/main.py",
old_text: "def hello():",
new_text: "def hello_world():",
},
};
const toolResult = {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers: [42] }),
},
],
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([
{ path: "/Users/test/project/main.py", line: 42 },
]);
});
it("should handle empty locations from edit tools", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01JKL012",
name: "edit",
input: {
abs_path: "/Users/test/project/notfound.txt",
old_text: "nonexistent",
new_text: "replacement",
},
};
const toolResult = {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers: [] }),
},
],
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([]);
});
it("should handle malformed tool results gracefully", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "mcp__acp__edit",
name: "edit",
input: {
abs_path: "/Users/test/project/test.txt",
old_string: "old",
new_string: "new",
old_text: "old",
new_text: "new",
},
};
@@ -738,9 +708,6 @@ describe("tool conversions", () => {
text: "not valid json",
},
],
tool_use_id: "test",
is_error: false,
type: "tool_result" as const,
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
@@ -748,50 +715,4 @@ describe("tool conversions", () => {
// Should return empty object when parsing fails
expect(update).toEqual({});
});
it("should return content update for edit failure", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "mcp__acp__edit",
input: {
abs_path: "/Users/test/project/test.txt",
old_string: "old",
new_string: "new",
},
};
const toolResult = {
content: [
{
type: "text",
text: "Failed to find `old_string`",
},
],
tool_use_id: "test",
is_error: true,
type: "tool_result" as const,
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
// Should return empty object when parsing fails
expect(update).toEqual({
content: [
{ content: { type: "text", text: "Failed to find `old_string`" }, type: "content" },
],
});
});
});
describe("escape markdown", () => {
it("should escape markdown characters", () => {
let text = "Hello *world*!";
let escaped = markdownEscape(text);
expect(escaped).toEqual("```\nHello *world*!\n```");
text = "for example:\n```markdown\nHello *world*!\n```\n";
escaped = markdownEscape(text);
expect(escaped).toEqual("````\nfor example:\n```markdown\nHello *world*!\n```\n````");
});
});

View File

@@ -4,9 +4,13 @@ import { replaceAndCalculateLocation } from "../mcp-server.js";
describe("replaceAndCalculateLocation", () => {
it("should replace first occurrence and return correct line number", () => {
const content = "line 1\nline 2 with text\nline 3 with text\nline 4";
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
expect(result.newContent).toBe("line 1\nline 2 with replaced\nline 3 with text\nline 4");
expect(result.newContent).toBe(
"line 1\nline 2 with replaced\nline 3 with text\nline 4",
);
expect(result.lineNumbers).toEqual([1]); // Line 2 (0-based indexing)
});
@@ -16,15 +20,20 @@ describe("replaceAndCalculateLocation", () => {
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe("line 1\nline 2 with replaced\nline 3 with replaced\nline 4");
expect(result.newContent).toBe(
"line 1\nline 2 with replaced\nline 3 with replaced\nline 4",
);
expect(result.lineNumbers).toEqual([1, 2]); // Lines 2 and 3 (0-based)
});
it("should return empty line numbers array when no match found", () => {
const content = "line 1\nline 2\nline 3";
expect(() => {
replaceAndCalculateLocation(content, [{ oldText: "notfound", newText: "replaced" }]);
}).toThrow('The provided `old_string` does not appear in the file: "notfound"');
const result = replaceAndCalculateLocation(content, [
{ oldText: "notfound", newText: "replaced" },
]);
expect(result.newContent).toBe(content);
expect(result.lineNumbers).toEqual([]);
});
it("should handle Windows line endings (CRLF)", () => {
@@ -45,7 +54,9 @@ describe("replaceAndCalculateLocation", () => {
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe("line 1\rline 2 with replaced\rline 3 with replaced\rline 4");
expect(result.newContent).toBe(
"line 1\rline 2 with replaced\rline 3 with replaced\rline 4",
);
expect(result.lineNumbers).toEqual([1, 2]);
});
@@ -55,13 +66,17 @@ describe("replaceAndCalculateLocation", () => {
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe("line 1\nline 2 with replaced\r\nline 3 with replaced\rline 4");
expect(result.newContent).toBe(
"line 1\nline 2 with replaced\r\nline 3 with replaced\rline 4",
);
expect(result.lineNumbers).toEqual([1, 2]);
});
it("should handle text at the beginning of file", () => {
const content = "text at start\nline 2\nline 3";
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
expect(result.newContent).toBe("replaced at start\nline 2\nline 3");
expect(result.lineNumbers).toEqual([0]); // First line (0-based)
@@ -69,7 +84,9 @@ describe("replaceAndCalculateLocation", () => {
it("should handle text at the end of file", () => {
const content = "line 1\nline 2\nlast line with text";
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
expect(result.newContent).toBe("line 1\nline 2\nlast line with replaced");
expect(result.lineNumbers).toEqual([2]); // Last line (0-based)
@@ -81,41 +98,53 @@ describe("replaceAndCalculateLocation", () => {
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe("line 1\nreplaced replaced replaced\nline 3");
expect(result.newContent).toBe(
"line 1\nreplaced replaced replaced\nline 3",
);
expect(result.lineNumbers).toEqual([1]); // All on line 2 (0-based), deduplicated
});
it("should handle empty file content", () => {
expect(() => {
replaceAndCalculateLocation("", [{ oldText: "text", newText: "replaced" }]);
}).toThrowError(
'The provided `old_string` does not appear in the file: "text".\n\nNo edits were applied.',
);
const content = "";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
expect(result.newContent).toBe("");
expect(result.lineNumbers).toEqual([]);
});
it("should handle empty search text", () => {
// Test with replaceAll false (default)
const content1 = "line 1\nline 2\nline 3";
expect(() => {
replaceAndCalculateLocation(content1, [{ oldText: "", newText: "replaced" }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
const result1 = replaceAndCalculateLocation(content1, [
{ oldText: "", newText: "replaced" },
]);
expect(result1.newContent).toBe(content1);
expect(result1.lineNumbers).toEqual([]);
// Test with replaceAll true on single line
const content2 = "abc";
expect(() => {
replaceAndCalculateLocation(content2, [{ oldText: "", newText: "X", replaceAll: true }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
const result2 = replaceAndCalculateLocation(content2, [
{ oldText: "", newText: "X", replaceAll: true },
]);
expect(result2.newContent).toBe(content2);
expect(result2.lineNumbers).toEqual([]);
// Test with replaceAll true on multiline content
const content3 = "ab\ncd";
expect(() => {
replaceAndCalculateLocation(content3, [{ oldText: "", newText: "X", replaceAll: true }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
const result3 = replaceAndCalculateLocation(content3, [
{ oldText: "", newText: "X", replaceAll: true },
]);
expect(result3.newContent).toBe(content3);
expect(result3.lineNumbers).toEqual([]);
});
it("should handle single line content", () => {
const content = "single line with text here";
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
expect(result.newContent).toBe("single line with replaced here");
expect(result.lineNumbers).toEqual([0]); // Line 1 (0-based)
@@ -154,7 +183,9 @@ describe("replaceAndCalculateLocation", () => {
it("should handle very long lines", () => {
const longLine = "a".repeat(10000) + "text" + "b".repeat(10000);
const content = `line 1\n${longLine}\nline 3`;
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
expect(result.newContent).toBe(
`line 1\n${"a".repeat(10000)}replaced${"b".repeat(10000)}\nline 3`,
@@ -167,7 +198,9 @@ describe("replaceAndCalculateLocation", () => {
lines[500] = "line with text";
const content = lines.join("\n");
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
expect(result.lineNumbers).toEqual([500]);
expect(result.newContent).includes("line with replaced");
@@ -191,7 +224,9 @@ describe("replaceAndCalculateLocation", () => {
const result1 = replaceAndCalculateLocation(content, [
{ oldText: "short", newText: "very very long text" },
]);
expect(result1.newContent).toBe("very very long text\nmedium line\nlong line here");
expect(result1.newContent).toBe(
"very very long text\nmedium line\nlong line here",
);
expect(result1.lineNumbers).toEqual([0]);
// Replacing with shorter text
@@ -225,7 +260,9 @@ describe("replaceAndCalculateLocation", () => {
it("should handle replacement at exact end of line", () => {
const content = "line ends with text\nnext line";
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
expect(result.newContent).toBe("line ends with replaced\nnext line");
expect(result.lineNumbers).toEqual([0]);
@@ -265,7 +302,9 @@ describe("replaceAndCalculateLocation", () => {
// - All "foo" occurrences (lines 1, 3, 5 in original)
// - First "bar" occurrence (line 1 after first edit)
// - "line 3" (line 2 after previous edits)
expect(result.newContent).toBe("line 1\nFOO BAR\nLINE THREE\nFOO baz\nline 5\nbar FOO\nline 7");
expect(result.newContent).toBe(
"line 1\nFOO BAR\nLINE THREE\nFOO baz\nline 5\nbar FOO\nline 7",
);
// Line numbers reflect position in final content, deduplicated and sorted
expect(result.lineNumbers).toEqual([
@@ -287,7 +326,9 @@ describe("replaceAndCalculateLocation", () => {
// First edit replaces first "hello" only
// Second edit replaces all "world" in the modified content
// Third edit replaces remaining "hello"s in the modified content
expect(result.newContent).toBe("hi earth\ngreetings greetings\nearth greetings earth");
expect(result.newContent).toBe(
"hi earth\ngreetings greetings\nearth greetings earth",
);
// Line numbers reflect position in final content, deduplicated and sorted
expect(result.lineNumbers).toEqual([
@@ -299,14 +340,17 @@ describe("replaceAndCalculateLocation", () => {
it("should handle complex scenarios with marker-like text", () => {
// Test that our marker approach doesn't get confused by similar text
const content = "line 1\n__MARKER__text\nline 3\n__REPLACE_MARKER_abc__\nline 5";
const content =
"line 1\n__MARKER__text\nline 3\n__REPLACE_MARKER_abc__\nline 5";
const result = replaceAndCalculateLocation(content, [
{ oldText: "__MARKER__", newText: "REPLACED", replaceAll: true },
{ oldText: "text", newText: "content" },
{ oldText: "__REPLACE_MARKER_abc__", newText: "DONE" },
]);
expect(result.newContent).toBe("line 1\nREPLACEDcontent\nline 3\nDONE\nline 5");
expect(result.newContent).toBe(
"line 1\nREPLACEDcontent\nline 3\nDONE\nline 5",
);
// Line numbers should be correct even with marker-like text
expect(result.lineNumbers).toEqual([

View File

@@ -1,10 +1,10 @@
import { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
import {
PlanEntry,
ToolCallContent,
ToolCallLocation,
ToolKind,
} from "@zed-industries/agent-client-protocol";
import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js";
interface ToolInfo {
title: string;
@@ -19,10 +19,7 @@ interface ToolUpdate {
locations?: ToolCallLocation[];
}
export function toolInfoFromToolUse(
toolUse: any,
cachedFileContent: { [key: string]: string },
): ToolInfo {
export function toolInfoFromToolUse(toolUse: any): ToolInfo {
const name = toolUse.name;
const input = toolUse.input;
@@ -44,15 +41,30 @@ export function toolInfoFromToolUse(
case "NotebookRead":
return {
title: input?.notebook_path ? `Read Notebook ${input.notebook_path}` : "Read Notebook",
title: input?.notebook_path
? `Read Notebook ${input.notebook_path}`
: "Read Notebook",
kind: "read",
content: [],
content:
input && input.notebook_path
? [
{
type: "content",
content: {
type: "text",
text: input.notebook_path.toString(),
},
},
]
: [],
locations: input?.notebook_path ? [{ path: input.notebook_path }] : [],
};
case "NotebookEdit":
return {
title: input?.notebook_path ? `Edit Notebook ${input.notebook_path}` : "Edit Notebook",
title: input?.notebook_path
? `Edit Notebook ${input.notebook_path}`
: "Edit Notebook",
kind: "edit",
content:
input && input.new_source
@@ -67,12 +79,13 @@ export function toolInfoFromToolUse(
};
case "Bash":
case toolNames.bash:
return {
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
title: input?.command
? "`" + input.command.replaceAll("`", "\\`") + "`"
: "Terminal",
kind: "execute",
content:
input && input.description
input && input.command
? [
{
type: "content",
@@ -83,7 +96,6 @@ export function toolInfoFromToolUse(
};
case "BashOutput":
case toolNames.bashOutput:
return {
title: "Tail Logs",
kind: "execute",
@@ -91,18 +103,21 @@ export function toolInfoFromToolUse(
};
case "KillBash":
case toolNames.killBash:
return {
title: "Kill Process",
kind: "execute",
content: [],
};
case toolNames.read: {
case "mcp__acp__read": {
let limit = "";
if (input.limit) {
limit =
" (" + ((input.offset ?? 0) + 1) + " - " + ((input.offset ?? 0) + input.limit) + ")";
" (" +
((input.offset ?? 0) + 1) +
" - " +
((input.offset ?? 0) + input.limit) +
")";
} else if (input.offset) {
limit = " (from line " + (input.offset + 1) + ")";
}
@@ -123,118 +138,65 @@ export function toolInfoFromToolUse(
return {
title: "Read File",
kind: "read",
content: [],
locations: [{ path: input.file_path, line: input.offset ?? 0 }],
content:
input && input.abs_path
? [
{
type: "content",
content: { type: "text", text: input.abs_path.toString() },
},
]
: [],
locations: [{ path: input.abs_path, line: input.offset ?? 0 }],
};
case "LS":
return {
title: `List the ${input?.path ? "`" + input.path + "`" : "current"} directory's contents`,
title: input?.path ? input.path : "Current Directory",
kind: "search",
content: [],
locations: [],
locations: input?.path ? [{ path: input.path }] : [],
};
case toolNames.edit:
case "Edit": {
const path = input?.abs_path ?? input?.file_path;
let oldText = input.old_string ?? null;
let newText = input.new_string ?? "";
let affectedLines: number[] = [];
if (path && oldText) {
try {
const oldContent = cachedFileContent[path] || "";
const newContent = replaceAndCalculateLocation(oldContent, [
{
oldText,
newText,
replaceAll: false,
},
]);
oldText = oldContent;
newText = newContent.newContent;
affectedLines = newContent.lineNumbers;
} catch (e) {
console.error(e);
}
}
case "mcp__acp__edit":
case "Edit":
return {
title: path ? `Edit \`${path}\`` : "Edit",
title: input?.abs_path ? `Edit ${input.abs_path}` : "Edit",
kind: "edit",
content:
input && path
input && input.abs_path
? [
{
type: "diff",
path,
oldText,
newText,
path: input.abs_path,
oldText: input.old_text || null,
newText: input.new_text,
},
]
: [],
locations: path
? affectedLines.length > 0
? affectedLines.map((line) => ({ line, path }))
: [{ path }]
: [],
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
};
}
case toolNames.multiEdit:
case "MultiEdit": {
const multiInput = input as {
file_path: string;
edits: {
old_string: string;
new_string: string;
replace_all?: boolean;
}[];
};
let oldTextMulti = multiInput.edits.map((edit: any) => edit.old_string).join("\n");
let newTextMulti = multiInput.edits.map((edit: any) => edit.new_string).join("\n");
let affectedLines: number[] = [];
try {
if (multiInput.edits && multiInput.file_path) {
const oldContent =
cachedFileContent[multiInput.file_path] ||
multiInput.edits.map((edit: any) => edit.oldText).join("\n");
const newContent = replaceAndCalculateLocation(
oldContent,
multiInput.edits.map((edit) => ({
oldText: edit.old_string,
newText: edit.new_string,
replaceAll: edit.replace_all,
})),
);
oldTextMulti = oldContent;
newTextMulti = newContent.newContent;
affectedLines = newContent.lineNumbers;
}
} catch (e) {
console.error(e);
}
case "mcp__acp__multi-edit":
case "MultiEdit":
// Display it as a normal edit, because end users don't care about
// the distinction between edits and multi-edits.
return {
title: input?.file_path ? `Edit ${input.file_path}` : "Edit",
kind: "edit",
content: [
{
type: "diff" as const,
path: input.file_path,
oldText: oldTextMulti,
newText: newTextMulti,
},
],
locations: input?.file_path
? affectedLines.length > 0
? affectedLines.map((line) => ({ line, path: input.file_path }))
: [{ path: input.file_path }]
: [],
content:
input && input.edits && input.edits.length > 0
? input.edits.map((edit: any) => ({
type: "diff" as const,
path: input.file_path,
oldText: edit.old_string || null,
newText: edit.new_string,
}))
: [],
locations: input?.file_path ? [{ path: input.file_path }] : [],
};
}
case toolNames.write: {
case "mcp__acp__write":
let content: ToolCallContent[] = [];
if (input && input.abs_path) {
content = [
@@ -259,33 +221,32 @@ export function toolInfoFromToolUse(
content,
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
};
}
case "Write":
return {
title: input?.file_path ? `Write ${input.file_path}` : "Write",
title: input?.abs_path ? `Write ${input.abs_path}` : "Write",
kind: "edit",
content:
input && input.file_path
input && input.abs_path
? [
{
type: "diff",
path: input.file_path,
path: input.abs_path,
oldText: null,
newText: input.content,
},
]
: [],
locations: input?.file_path ? [{ path: input.file_path }] : [],
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
};
case "Glob": {
let label = "Find";
if (input.path) {
label += ` \`${input.path}\``;
label += ` ${input.path}`;
}
if (input.pattern) {
label += ` \`${input.pattern}\``;
label += ` ${input.pattern}`;
}
return {
title: label,
@@ -324,7 +285,6 @@ export function toolInfoFromToolUse(
label += " -c";
break;
case "Content":
default:
break;
}
}
@@ -400,10 +360,10 @@ export function toolInfoFromToolUse(
content: [],
};
case "ExitPlanMode":
case "exit_plan_mode":
return {
title: "Ready to code?",
kind: "switch_mode",
title: "Exit Plan Mode",
kind: "think",
content:
input && input.plan
? [{ type: "content", content: { type: "text", text: input.plan } }]
@@ -434,71 +394,64 @@ export function toolInfoFromToolUse(
}
}
type ToolResult = {
type: "tool_result";
content: any;
tool_use_id: string;
is_error: boolean;
};
export function toolUpdateFromToolResult(
toolResult: ToolResult,
toolResult: any,
toolUse: any | undefined,
): ToolUpdate {
switch (toolUse?.name) {
case "Read":
case toolNames.read:
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
return {
content: toolResult.content.map((content: any) => ({
type: "content",
content:
content.type === "text"
? {
type: "text",
text: markdownEscape(content.text.replace(SYSTEM_REMINDER, "")),
}
: content,
})),
};
} else if (typeof toolResult.content === "string" && toolResult.content.length > 0) {
return {
content: [
{
type: "content",
content: {
type: "text",
text: markdownEscape(toolResult.content.replace(SYSTEM_REMINDER, "")),
},
},
],
};
}
return {};
case toolNames.bash:
case "mcp__acp__edit":
case "edit":
case "Edit":
case toolNames.edit:
case toolNames.multiEdit:
case "multi-edit":
case "MultiEdit":
case toolNames.write:
case "Write": {
if (toolResult.is_error && toolResult.content?.length > 0) {
// Only return errors
return toAcpContentUpdate(toolResult.content);
case "Edit": {
// Parse the line numbers from the tool result
if (toolResult.content && toolResult.content.length > 0) {
try {
const firstContent = toolResult.content[0];
const data = JSON.parse(firstContent.text || firstContent);
if (data.lineNumbers && Array.isArray(data.lineNumbers)) {
const locations: ToolCallLocation[] = data.lineNumbers.map(
(line: number) => ({
path: toolUse?.input?.abs_path || toolUse?.input?.file_path,
line: line,
}),
);
return { locations };
}
} catch (e) {
// If parsing fails, return empty object
return {};
}
}
return {};
}
case "ExitPlanMode": {
return { title: "Exited Plan Mode" };
case "mcp__acp__multi-edit":
case "multi-edit":
case "MultiEdit": {
// Parse the line numbers from the tool result
if (toolResult.content && toolResult.content.length > 0) {
try {
const firstContent = toolResult.content[0];
const data = JSON.parse(firstContent.text || firstContent);
if (data.lineNumbers && Array.isArray(data.lineNumbers)) {
const locations: ToolCallLocation[] = data.lineNumbers.map(
(line: number) => ({
path: toolUse?.input?.file_path || toolUse?.input?.abs_path,
line: line,
}),
);
return { locations };
}
} catch (e) {
// If parsing fails, return empty object
return {};
}
}
return {};
}
case "Task":
case "NotebookEdit":
case "NotebookRead":
case "mcp__acp__write":
case "Write":
case "TodoWrite":
case "exit_plan_mode":
case "Bash":
@@ -510,36 +463,42 @@ export function toolUpdateFromToolResult(
case "WebFetch":
case "WebSearch":
case "Other":
case "mcp__acp__read":
if (Array.isArray(toolResult.content)) {
return {
content: toolResult.content.map((content: any) => ({
type: "content",
content: {
type: "text",
text: `\`\`\`${toolUse.input?.abs_path ?? ""}\n${content.text ?? ""}\n\`\`\``,
},
})),
};
}
return {};
default: {
return toAcpContentUpdate(toolResult.content);
if (Array.isArray(toolResult.content)) {
return {
content: toolResult.content.map((content: any) => ({
type: "content",
content,
})),
};
} else if (typeof toolResult.content === "string") {
return {
content: [
{
type: "content",
content: { type: "text", text: toolResult.content },
},
],
};
}
return {};
}
}
}
function toAcpContentUpdate(content: any): { content?: ToolCallContent[] } {
if (Array.isArray(content) && content.length > 0) {
return {
content: content.map((content: any) => ({
type: "content",
content,
})),
};
} else if (typeof content === "string" && content.length > 0) {
return {
content: [
{
type: "content",
content: {
type: "text",
text: content,
},
},
],
};
}
return {};
}
type ClaudePlanEntry = {
content: string;
status: "pending" | "in_progress" | "completed";
@@ -553,13 +512,3 @@ export function planEntries(input: { todos: ClaudePlanEntry[] }): PlanEntry[] {
priority: "medium",
}));
}
export function markdownEscape(text: string): string {
let escape = "```";
for (const [m] of text.matchAll(/^```+/gm)) {
while (m.length >= escape.length) {
escape += "`";
}
}
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
}

View File

@@ -2,8 +2,6 @@
import { Readable, Writable } from "node:stream";
import { WritableStream, ReadableStream } from "node:stream/web";
import { readFileSync } from "node:fs";
import { platform } from "node:os";
// Useful for bridging push-based and async-iterator-based code.
export class Pushable<T> implements AsyncIterable<T> {
@@ -47,23 +45,24 @@ export class Pushable<T> implements AsyncIterable<T> {
}
// Helper to convert Node.js streams to Web Streams
export function nodeToWebWritable(nodeStream: Writable): WritableStream<Uint8Array> {
export function nodeToWebWritable(
nodeStream: Writable,
): WritableStream<Uint8Array> {
return new WritableStream<Uint8Array>({
write(chunk) {
return new Promise<void>((resolve, reject) => {
nodeStream.write(Buffer.from(chunk), (err) => {
if (err) {
reject(err);
} else {
resolve();
}
if (err) reject(err);
else resolve();
});
});
},
});
}
export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Array> {
export function nodeToWebReadable(
nodeStream: Readable,
): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
nodeStream.on("data", (chunk: Buffer) => {
@@ -78,47 +77,3 @@ export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Arr
export function unreachable(value: never): never {
throw new Error(`Unexpected case: ${value}`);
}
export function sleep(time: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, time));
}
interface ManagedSettings {
permissions?: {
allow?: string[];
deny?: string[];
};
env?: Record<string, string>;
}
// Following the rules in https://docs.anthropic.com/en/docs/claude-code/settings#settings-files
// This can be removed once the SDK supports it natively.
function getManagedSettingsPath(): string {
const os = platform();
switch (os) {
case "darwin":
return "/Library/Application Support/ClaudeCode/managed-settings.json";
case "linux": // including WSL
return "/etc/claude-code/managed-settings.json";
case "win32":
return "C:\\ProgramData\\ClaudeCode\\managed-settings.json";
default:
return "/etc/claude-code/managed-settings.json";
}
}
export function loadManagedSettings(): ManagedSettings | null {
try {
return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ManagedSettings;
} catch {
return null;
}
}
export function applyEnvironmentSettings(settings: ManagedSettings): void {
if (settings.env) {
for (const [key, value] of Object.entries(settings.env)) {
process.env[key] = value;
}
}
}