mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6c91b1ec1 | ||
|
|
0289dd9de7 | ||
|
|
5cd3ac91e8 | ||
|
|
6b5873f005 | ||
|
|
539f94de45 | ||
|
|
70441b0aa3 | ||
|
|
de514862f8 | ||
|
|
9ebdfffd48 | ||
|
|
e6bf33cd30 | ||
|
|
96261a5b50 | ||
|
|
003ae41d18 | ||
|
|
0ba0ec1017 | ||
|
|
f70730f1ab | ||
|
|
13cd9214d7 | ||
|
|
78975d75f5 | ||
|
|
bbd0774ae5 | ||
|
|
76331017b2 | ||
|
|
90f1447e99 | ||
|
|
dae4c9956e | ||
|
|
eb282abb50 | ||
|
|
74c0be057b | ||
|
|
e66091f723 | ||
|
|
1b99ab96a0 |
12
README.md
12
README.md
@@ -1,12 +1,14 @@
|
||||
# ACP adapter for Claude Code
|
||||
|
||||
[](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
|
||||
- Tool calls (with permission requests)
|
||||
- Following
|
||||
- Edit review
|
||||
- TODO lists
|
||||
@@ -30,20 +32,22 @@ Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) suppo
|
||||
|
||||
### Other clients
|
||||
|
||||
Setup instructions for other clients are coming soon. Feel free to [submit a PR](https://github.com/zed-industries/claude-code-acp/pulls) to add yours!
|
||||
- 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
|
||||
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
|
||||
ANTHROPIC_API_KEY=sk-... claude-code-acp
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
26
package-lock.json
generated
26
package-lock.json
generated
@@ -1,18 +1,17 @@
|
||||
{
|
||||
"name": "@zed-industries/claude-code-acp",
|
||||
"version": "0.2.1",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@zed-industries/claude-code-acp",
|
||||
"version": "0.2.1",
|
||||
"version": "0.4.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "^1.0.100",
|
||||
"@modelcontextprotocol/sdk": "^1.17.4",
|
||||
"@types/express": "^5.0.3",
|
||||
"@zed-industries/agent-client-protocol": "0.2.0-alpha.4",
|
||||
"@zed-industries/agent-client-protocol": "0.2.0-alpha.8",
|
||||
"diff": "^8.0.2",
|
||||
"express": "^5.1.0",
|
||||
"minimist": "^1.2.8",
|
||||
@@ -22,6 +21,7 @@
|
||||
"claude-code-acp": "dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/minimist": "^1.2.5",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
@@ -1277,6 +1277,7 @@
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
@@ -1297,6 +1298,7 @@
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@@ -1320,6 +1322,7 @@
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz",
|
||||
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
@@ -1331,6 +1334,7 @@
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz",
|
||||
"integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
@@ -1343,12 +1347,14 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/minimist": {
|
||||
@@ -1362,6 +1368,7 @@
|
||||
"version": "24.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
|
||||
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.10.0"
|
||||
@@ -1371,18 +1378,21 @@
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "0.17.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
|
||||
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mime": "^1",
|
||||
@@ -1393,6 +1403,7 @@
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz",
|
||||
"integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
@@ -1797,9 +1808,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@zed-industries/agent-client-protocol": {
|
||||
"version": "0.2.0-alpha.4",
|
||||
"resolved": "https://registry.npmjs.org/@zed-industries/agent-client-protocol/-/agent-client-protocol-0.2.0-alpha.4.tgz",
|
||||
"integrity": "sha512-l/MdK0MrvEb55/U3wAtAN/MC04YK+N6vNyakNgLsK2JTcmG54n45gXa0OZ08lYUmEvLe0shdu1dDtzlOV/Luxw==",
|
||||
"version": "0.2.0-alpha.8",
|
||||
"resolved": "https://registry.npmjs.org/@zed-industries/agent-client-protocol/-/agent-client-protocol-0.2.0-alpha.8.tgz",
|
||||
"integrity": "sha512-60DDfM5ghChYkbz0Nk2nWDuWQ/BOpU+/4BcIgzHYhEQvCcJRbcs5piVjTXx7TryHNTOTyCmLmoX/WRpAZtva/Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"zod": "^3.0.0"
|
||||
@@ -4430,6 +4441,7 @@
|
||||
"version": "7.10.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
|
||||
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"bin": {
|
||||
"claude-code-acp": "./dist/index.js"
|
||||
},
|
||||
"version": "0.2.1",
|
||||
"version": "0.4.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
@@ -45,14 +45,14 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "^1.0.100",
|
||||
"@modelcontextprotocol/sdk": "^1.17.4",
|
||||
"@types/express": "^5.0.3",
|
||||
"@zed-industries/agent-client-protocol": "0.2.0-alpha.4",
|
||||
"@zed-industries/agent-client-protocol": "0.2.0-alpha.8",
|
||||
"diff": "^8.0.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.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
|
||||
142
src/acp-agent.ts
142
src/acp-agent.ts
@@ -14,6 +14,8 @@ import {
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
RequestError,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
TerminalHandle,
|
||||
TerminalOutputResponse,
|
||||
WriteTextFileRequest,
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
import {
|
||||
McpServerConfig,
|
||||
Options,
|
||||
PermissionMode,
|
||||
Query,
|
||||
query,
|
||||
SDKAssistantMessage,
|
||||
@@ -31,9 +34,9 @@ import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { nodeToWebReadable, nodeToWebWritable, Pushable, sleep, unreachable } from "./utils.js";
|
||||
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
||||
import { SessionNotification } from "@zed-industries/agent-client-protocol";
|
||||
import { createMcpServer } from "./mcp-server.js";
|
||||
import { createMcpServer, toolNames } from "./mcp-server.js";
|
||||
import { AddressInfo } from "node:net";
|
||||
import { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
|
||||
|
||||
@@ -41,6 +44,7 @@ type Session = {
|
||||
query: Query;
|
||||
input: Pushable<SDKUserMessage>;
|
||||
cancelled: boolean;
|
||||
permissionMode: PermissionMode;
|
||||
};
|
||||
|
||||
type BackgroundTerminal =
|
||||
@@ -86,36 +90,52 @@ export class ClaudeAcpAgent implements Agent {
|
||||
image: true,
|
||||
embeddedContext: true,
|
||||
},
|
||||
mcpCapabilities: {
|
||||
http: true,
|
||||
sse: true,
|
||||
},
|
||||
},
|
||||
authMethods: [
|
||||
{
|
||||
description: "Run `claude /login` in the terminal",
|
||||
name: "Login with Claude CLI",
|
||||
name: "Log in with Claude Code",
|
||||
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>();
|
||||
|
||||
const mcpServers: Record<string, McpServerConfig> = {};
|
||||
if (Array.isArray(params.mcpServers)) {
|
||||
for (const server of params.mcpServers) {
|
||||
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,
|
||||
};
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,22 +152,25 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const options: Options = {
|
||||
cwd: params.cwd,
|
||||
mcpServers,
|
||||
permissionPromptToolName: "mcp__acp__permission",
|
||||
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("mcp__acp__read");
|
||||
allowedTools.push(toolNames.read);
|
||||
disallowedTools.push("Read");
|
||||
}
|
||||
if (this.clientCapabilities?.fs?.writeTextFile) {
|
||||
allowedTools.push("mcp__acp__write");
|
||||
allowedTools.push(toolNames.write);
|
||||
disallowedTools.push("Write", "Edit", "MultiEdit");
|
||||
}
|
||||
if (this.clientCapabilities?.terminal) {
|
||||
allowedTools.push("mcp__acp__BashOutput", "mcp__acp__KillBash");
|
||||
allowedTools.push(toolNames.bashOutput, toolNames.killBash);
|
||||
disallowedTools.push("Bash", "BashOutput", "KillBash");
|
||||
}
|
||||
|
||||
@@ -166,12 +189,46 @@ export class ClaudeAcpAgent implements Agent {
|
||||
query: q,
|
||||
input: input,
|
||||
cancelled: false,
|
||||
permissionMode: "default",
|
||||
};
|
||||
|
||||
const availableCommands = await availableSlashCommands(q);
|
||||
getAvailableSlashCommands(q).then((availableCommands) => {
|
||||
this.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
availableCommands,
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,6 +316,29 @@ export class ClaudeAcpAgent implements Agent {
|
||||
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) {
|
||||
@@ -274,7 +354,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
async function availableSlashCommands(query: Query): Promise<AvailableCommand[]> {
|
||||
async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand[]> {
|
||||
const UNSUPPORTED_COMMANDS = [
|
||||
"add-dir",
|
||||
"agents", // Modal
|
||||
@@ -310,20 +390,8 @@ async function availableSlashCommands(query: Query): Promise<AvailableCommand[]>
|
||||
"todos", // Escape Codes
|
||||
"vim", // Not needed
|
||||
];
|
||||
|
||||
const commands = await Promise.race([
|
||||
//todo: Do not use `as any` once `supportedCommands` is exposed via the typescript interface
|
||||
(query as any).supportedCommands(),
|
||||
sleep(5000).then(() => {
|
||||
if (
|
||||
fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
|
||||
!fs.existsSync(path.resolve(os.homedir(), ".claude.json"))
|
||||
) {
|
||||
throw RequestError.authRequired();
|
||||
}
|
||||
throw new Error("can't load supported slash commands");
|
||||
}),
|
||||
]);
|
||||
//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 }) => {
|
||||
|
||||
@@ -7,6 +7,10 @@ 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();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ClientCapabilities, TerminalOutputResponse } from "@zed-industries/agen
|
||||
import * as diff from "diff";
|
||||
|
||||
import { sleep, unreachable } from "./utils.js";
|
||||
import { PermissionResult } from "@anthropic-ai/claude-code";
|
||||
|
||||
export const SYSTEM_REMINDER = `
|
||||
|
||||
@@ -16,6 +17,31 @@ export const SYSTEM_REMINDER = `
|
||||
Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.
|
||||
</system-reminder>`;
|
||||
|
||||
const unqualifiedToolNames = {
|
||||
read: "read",
|
||||
edit: "edit",
|
||||
write: "write",
|
||||
multiEdit: "multi-edit",
|
||||
bash: "Bash",
|
||||
killBash: "KillBash",
|
||||
bashOutput: "BashOutput",
|
||||
permission: "permission",
|
||||
};
|
||||
|
||||
const SERVER_PREFIX = "mcp__acp__";
|
||||
export const toolNames = {
|
||||
read: SERVER_PREFIX + unqualifiedToolNames.read,
|
||||
edit: SERVER_PREFIX + unqualifiedToolNames.edit,
|
||||
write: SERVER_PREFIX + unqualifiedToolNames.write,
|
||||
multiEdit: SERVER_PREFIX + unqualifiedToolNames.multiEdit,
|
||||
bash: SERVER_PREFIX + unqualifiedToolNames.bash,
|
||||
killBash: SERVER_PREFIX + unqualifiedToolNames.killBash,
|
||||
bashOutput: SERVER_PREFIX + unqualifiedToolNames.bashOutput,
|
||||
permission: SERVER_PREFIX + unqualifiedToolNames.permission,
|
||||
};
|
||||
|
||||
const editToolNames = [toolNames.edit, toolNames.multiEdit, toolNames.write];
|
||||
|
||||
export function createMcpServer(
|
||||
agent: ClaudeAcpAgent,
|
||||
sessionId: string,
|
||||
@@ -29,14 +55,14 @@ export function createMcpServer(
|
||||
|
||||
if (clientCapabilities?.fs?.readTextFile) {
|
||||
server.registerTool(
|
||||
"read",
|
||||
unqualifiedToolNames.read,
|
||||
{
|
||||
title: "Read",
|
||||
description: `Reads the content of the given file in the project.
|
||||
|
||||
Never attempt to read a path that hasn't been previously mentioned.
|
||||
|
||||
In sessions with mcp__acp__read always use it instead of Read as it contains the most up-to-date contents.`,
|
||||
In sessions with ${toolNames.read} always use it instead of Read as it contains the most up-to-date contents.`,
|
||||
inputSchema: {
|
||||
abs_path: z.string().describe("The absolute path to the file to read."),
|
||||
offset: z
|
||||
@@ -97,12 +123,12 @@ In sessions with mcp__acp__read always use it instead of Read as it contains the
|
||||
|
||||
if (clientCapabilities?.fs?.writeTextFile) {
|
||||
server.registerTool(
|
||||
"write",
|
||||
unqualifiedToolNames.write,
|
||||
{
|
||||
title: "Write",
|
||||
description: `Writes content to the specified file in the project.
|
||||
|
||||
In sessions with mcp__acp__write always use it instead of Write as it will
|
||||
In sessions with ${toolNames.write} always use it instead of Write as it will
|
||||
allow the user to conveniently review changes.`,
|
||||
inputSchema: {
|
||||
abs_path: z.string().describe("The absolute path to the file to write"),
|
||||
@@ -152,12 +178,12 @@ allow the user to conveniently review changes.`,
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"edit",
|
||||
unqualifiedToolNames.edit,
|
||||
{
|
||||
title: "Edit",
|
||||
description: `Edit a file.
|
||||
|
||||
In sessions with mcp__acp__edit always use it instead of Edit as it will
|
||||
In sessions with ${toolNames.edit} always use it instead of Edit as it will
|
||||
allow the user to conveniently review changes.
|
||||
|
||||
File editing instructions:
|
||||
@@ -229,12 +255,12 @@ File editing instructions:
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"multi-edit",
|
||||
unqualifiedToolNames.multiEdit,
|
||||
{
|
||||
title: "Multi Edit",
|
||||
description: `Edit a file with multiple sequential edits.
|
||||
|
||||
In sessions with mcp__acp__multi-edit always use it instead of MultiEdit as it will
|
||||
In sessions with ${toolNames.multiEdit} always use it instead of MultiEdit as it will
|
||||
allow the user to conveniently review changes.
|
||||
|
||||
File editing instructions:
|
||||
@@ -319,7 +345,7 @@ File editing instructions:
|
||||
|
||||
if (agent.clientCapabilities?.terminal) {
|
||||
server.registerTool(
|
||||
"Bash",
|
||||
unqualifiedToolNames.bash,
|
||||
{
|
||||
title: "Bash",
|
||||
description: "Executes a bash command",
|
||||
@@ -333,7 +359,7 @@ File editing instructions:
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
"When set to true, the command is started in the background. The tool returns an `id` that can be used with the `mcp__acp__BashOutput` tool to retrieve the current output, or the `mcp__acp__KillBash` tool to stop it early.",
|
||||
`When set to true, the command is started in the background. The tool returns an \`id\` that can be used with the \`${toolNames.bashOutput}\` tool to retrieve the current output, or the \`${toolNames.killBash}\` tool to stop it early.`,
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -454,7 +480,7 @@ File editing instructions:
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"BashOutput",
|
||||
unqualifiedToolNames.bashOutput,
|
||||
{
|
||||
title: "BashOutput",
|
||||
description:
|
||||
@@ -462,7 +488,7 @@ File editing instructions:
|
||||
inputSchema: {
|
||||
id: z
|
||||
.string()
|
||||
.describe("The id of the background bash command as returned by `mcp__acp__Bash`"),
|
||||
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
@@ -505,14 +531,14 @@ File editing instructions:
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"KillBash",
|
||||
unqualifiedToolNames.killBash,
|
||||
{
|
||||
title: "KillBash",
|
||||
description: "Stops a background command by its id",
|
||||
inputSchema: {
|
||||
id: z
|
||||
.string()
|
||||
.describe("The id of the background bash command as returned by `mcp__acp__Bash`"),
|
||||
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
@@ -565,7 +591,7 @@ File editing instructions:
|
||||
|
||||
const alwaysAllowedTools: { [key: string]: boolean } = {};
|
||||
server.registerTool(
|
||||
"permission",
|
||||
unqualifiedToolNames.permission,
|
||||
{
|
||||
title: "Permission Tool",
|
||||
description: "Used to request tool permissions",
|
||||
@@ -576,42 +602,37 @@ File editing instructions:
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
behavior: "deny",
|
||||
message: "Session not found",
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (alwaysAllowedTools[input.tool_name]) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
behavior: "allow",
|
||||
updatedInput: input.input,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const result = await canUseTool(input);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
async function canUseTool(input: {
|
||||
tool_use_id?: string;
|
||||
tool_name: string;
|
||||
input?: any;
|
||||
}): Promise<PermissionResult> {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "Session not found",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.tool_name === "ExitPlanMode") {
|
||||
const response = await agent.client.requestPermission({
|
||||
options: [
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Always Allow",
|
||||
optionId: "allow_always",
|
||||
name: "Yes, and auto-accept edits",
|
||||
optionId: "acceptEdits",
|
||||
},
|
||||
{ kind: "allow_once", name: "Allow", optionId: "allow" },
|
||||
{ kind: "reject_once", name: "Reject", optionId: "reject" },
|
||||
{ kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
|
||||
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
|
||||
],
|
||||
sessionId,
|
||||
toolCall: {
|
||||
@@ -619,39 +640,80 @@ File editing instructions:
|
||||
rawInput: input.input,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
response.outcome?.outcome === "selected" &&
|
||||
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
|
||||
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")
|
||||
) {
|
||||
if (response.outcome.optionId === "allow_always") {
|
||||
alwaysAllowedTools[input.tool_name] = true;
|
||||
}
|
||||
session.permissionMode = response.outcome.optionId;
|
||||
await agent.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: response.outcome.optionId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
behavior: "allow",
|
||||
updatedInput: input.input,
|
||||
}),
|
||||
},
|
||||
behavior: "allow",
|
||||
updatedInput: input.input,
|
||||
updatedPermissions: [
|
||||
{ type: "setMode", mode: response.outcome.optionId, destination: "session" },
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
behavior: "deny",
|
||||
message: "User refused permission to run tool",
|
||||
}),
|
||||
},
|
||||
],
|
||||
behavior: "deny",
|
||||
message: "User rejected request to exit plan mode.",
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
session.permissionMode === "bypassPermissions" ||
|
||||
(session.permissionMode === "acceptEdits" && editToolNames.includes(input.tool_name)) ||
|
||||
alwaysAllowedTools[input.tool_name]
|
||||
) {
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: input.input,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await agent.client.requestPermission({
|
||||
options: [
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Always Allow",
|
||||
optionId: "allow_always",
|
||||
},
|
||||
{ kind: "allow_once", name: "Allow", optionId: "allow" },
|
||||
{ kind: "reject_once", name: "Reject", optionId: "reject" },
|
||||
],
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: input.tool_use_id!,
|
||||
rawInput: input.input,
|
||||
},
|
||||
});
|
||||
if (
|
||||
response.outcome?.outcome === "selected" &&
|
||||
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
|
||||
) {
|
||||
if (response.outcome.optionId === "allow_always") {
|
||||
alwaysAllowedTools[input.tool_name] = true;
|
||||
}
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: input.input,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "User refused permission to run tool",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import {
|
||||
Agent,
|
||||
AvailableCommand,
|
||||
Client,
|
||||
ClientSideConnection,
|
||||
NewSessionResponse,
|
||||
@@ -47,9 +48,15 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
takeReceivedText() {
|
||||
@@ -67,11 +74,18 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
async sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
console.error("RECEIVED", JSON.stringify(params, null, 4));
|
||||
|
||||
if (
|
||||
params.update.sessionUpdate === "agent_message_chunk" &&
|
||||
params.update.content.type === "text"
|
||||
) {
|
||||
this.receivedText += params.update.content.text;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,12 +154,14 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
it("should include available commands", async () => {
|
||||
const { client, connection, newSessionResponse } = await setupTestSession(__dirname);
|
||||
|
||||
expect(newSessionResponse.availableCommands).toContainEqual({
|
||||
const commands = await client.availableCommandsPromise;
|
||||
|
||||
expect(commands).toContainEqual({
|
||||
name: "quick-math",
|
||||
description: "10 * 3 = 30 (project)",
|
||||
input: null,
|
||||
});
|
||||
expect(newSessionResponse.availableCommands).toContainEqual({
|
||||
expect(commands).toContainEqual({
|
||||
name: "say-hello",
|
||||
description: "Say hello (project)",
|
||||
input: { hint: "[name]" },
|
||||
@@ -692,6 +708,11 @@ describe("tool conversions", () => {
|
||||
locations: [
|
||||
{
|
||||
path: "/Users/test/project/config.json",
|
||||
line: 1,
|
||||
},
|
||||
{
|
||||
path: "/Users/test/project/config.json",
|
||||
line: 3,
|
||||
},
|
||||
],
|
||||
title: "Edit /Users/test/project/config.json",
|
||||
|
||||
36
src/tools.ts
36
src/tools.ts
@@ -4,7 +4,7 @@ import {
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from "@zed-industries/agent-client-protocol";
|
||||
import { replaceAndCalculateLocation, SYSTEM_REMINDER } from "./mcp-server.js";
|
||||
import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js";
|
||||
|
||||
interface ToolInfo {
|
||||
title: string;
|
||||
@@ -67,7 +67,7 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
|
||||
case "Bash":
|
||||
case "mcp__acp__Bash":
|
||||
case toolNames.bash:
|
||||
return {
|
||||
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
|
||||
kind: "execute",
|
||||
@@ -83,7 +83,7 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
|
||||
case "BashOutput":
|
||||
case "mcp__acp__BashOutput":
|
||||
case toolNames.bashOutput:
|
||||
return {
|
||||
title: "Tail Logs",
|
||||
kind: "execute",
|
||||
@@ -91,14 +91,14 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
|
||||
case "KillBash":
|
||||
case "mcp__acp__KillBash":
|
||||
case toolNames.killBash:
|
||||
return {
|
||||
title: "Kill Process",
|
||||
kind: "execute",
|
||||
content: [],
|
||||
};
|
||||
|
||||
case "mcp__acp__read": {
|
||||
case toolNames.read: {
|
||||
let limit = "";
|
||||
if (input.limit) {
|
||||
limit =
|
||||
@@ -135,7 +135,7 @@ export function toolInfoFromToolUse(
|
||||
locations: [],
|
||||
};
|
||||
|
||||
case "mcp__acp__edit":
|
||||
case toolNames.edit:
|
||||
case "Edit": {
|
||||
const path = input?.abs_path ?? input?.file_path;
|
||||
let oldText = input.old_string ?? null;
|
||||
@@ -181,7 +181,7 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "mcp__acp__multi-edit":
|
||||
case toolNames.multiEdit:
|
||||
case "MultiEdit": {
|
||||
const multiInput = input as {
|
||||
file_path: string;
|
||||
@@ -234,7 +234,7 @@ export function toolInfoFromToolUse(
|
||||
: [],
|
||||
};
|
||||
}
|
||||
case "mcp__acp__write": {
|
||||
case toolNames.write: {
|
||||
let content: ToolCallContent[] = [];
|
||||
if (input && input.abs_path) {
|
||||
content = [
|
||||
@@ -400,10 +400,10 @@ export function toolInfoFromToolUse(
|
||||
content: [],
|
||||
};
|
||||
|
||||
case "exit_plan_mode":
|
||||
case "ExitPlanMode":
|
||||
return {
|
||||
title: "Exit Plan Mode",
|
||||
kind: "think",
|
||||
title: "Ready to code?",
|
||||
kind: "switch_mode",
|
||||
content:
|
||||
input && input.plan
|
||||
? [{ type: "content", content: { type: "text", text: input.plan } }]
|
||||
@@ -447,7 +447,7 @@ export function toolUpdateFromToolResult(
|
||||
): ToolUpdate {
|
||||
switch (toolUse?.name) {
|
||||
case "Read":
|
||||
case "mcp__acp__read":
|
||||
case toolNames.read:
|
||||
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
|
||||
return {
|
||||
content: toolResult.content.map((content: any) => ({
|
||||
@@ -476,14 +476,14 @@ export function toolUpdateFromToolResult(
|
||||
}
|
||||
return {};
|
||||
|
||||
case "mcp__acp__Bash":
|
||||
case "mcp__acp__edit":
|
||||
case toolNames.bash:
|
||||
case "edit":
|
||||
case "Edit":
|
||||
case "mcp__acp__multi-edit":
|
||||
case toolNames.edit:
|
||||
case toolNames.multiEdit:
|
||||
case "multi-edit":
|
||||
case "MultiEdit":
|
||||
case "mcp__acp__write":
|
||||
case toolNames.write:
|
||||
case "Write": {
|
||||
if (toolResult.is_error && toolResult.content?.length > 0) {
|
||||
// Only return errors
|
||||
@@ -492,6 +492,10 @@ export function toolUpdateFromToolResult(
|
||||
return {};
|
||||
}
|
||||
|
||||
case "ExitPlanMode": {
|
||||
return { title: "Exited Plan Mode" };
|
||||
}
|
||||
|
||||
case "Task":
|
||||
case "NotebookEdit":
|
||||
case "NotebookRead":
|
||||
|
||||
Reference in New Issue
Block a user