Compare commits

...

26 Commits

Author SHA1 Message Date
Richard Feldman
55532ecc57 Re-upgrade to SDK version 1.0.111 (#44)
There's apparently a RCE on 1.0.100, so we shouldn't stay on that
version.
2025-09-11 12:50:19 -04:00
Richard Feldman
f4711e96b0 Pin npm dependencies (#43)
We discussed this and decided we'll manually upgrade on our own cadence.
Don't want surprises like
https://github.com/zed-industries/zed/issues/37976
2025-09-11 12:27:52 -04:00
Richard Feldman
f691024350 Load managed-settings.json (#42)
The SDK doesn't currently load [Claude Code's
`managed-settings.json`](https://docs.anthropic.com/en/docs/claude-code/settings#settings-files)
- now the adapter does (following [the rules in the
docs](https://docs.anthropic.com/en/docs/claude-code/settings#settings-files)),
but we can remove this once the SDK supports it.
2025-09-11 08:47:33 -04:00
Agus Zubiaga
d6c91b1ec1 0.4.0 2025-09-09 11:49:37 -03:00
Agus Zubiaga
0289dd9de7 Support plan mode and all other permission modes (#40)
Exposes all Claude Code permission modes as [ACP Session
modes](https://github.com/zed-industries/agent-client-protocol/pull/67).
This allows ACP clients to build mode selectors and to be notified when
the mode changes as a result of `ExitPlanMode`.

See a demo in the [Zed
PR](https://github.com/zed-industries/zed/pull/37632).

Closes #31

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
Co-authored-by: Bennet <bennet@zed.dev>
2025-09-09 11:48:26 -03:00
Agus Zubiaga
5cd3ac91e8 Removing "Coming soon" 2025-09-08 13:18:28 -03:00
Agus Zubiaga
6b5873f005 0.3.0 2025-09-08 13:13:25 -03:00
Agus Zubiaga
539f94de45 Support HTTP/SSE MCP servers (#38)
HTTP/SSE MCP servers can be now included in `session/new`
2025-09-08 13:12:19 -03:00
Jie Wang
70441b0aa3 Move @types/express to dev dependencies (#34) 2025-09-05 06:19:09 +02:00
Agus Zubiaga
de514862f8 0.2.6 2025-09-04 18:01:30 -03:00
Ben Brandt
9ebdfffd48 Remove option for using an API Key, just support /login
Co-authored-by: Agus Zubiaga <agus@zed.dev>
2025-09-04 08:41:45 -07:00
Agus Zubiaga
e6bf33cd30 0.2.5 2025-09-03 22:02:40 -03:00
Xiangyi Li
96261a5b50 Remove dollar signs from CLI commands in installation section (#22)
Makes it easier to copy and paste the installation commands without
having to manually remove the dollar signs. This is a common convention
in documentation to improve user experience when following installation
instructions.
2025-09-03 22:01:24 -03:00
Agus Zubiaga
003ae41d18 Send available commands over notifications (#20)
See https://github.com/zed-industries/agent-client-protocol/pull/62

---------

Co-authored-by: Cole Miller <cole@zed.dev>
2025-09-03 22:00:54 -03:00
Danilo Leal
0ba0ec1017 Update Claude Code auth button label (#16)
We were using "Claude CLI" which is not the correct product name.
2025-09-03 16:37:50 -04:00
Oli
f70730f1ab Update README for CodeCompanion.nvim (#17)
Added CodeCompanion.nvim to the README, linking to my own setup
instructions.
2025-09-03 12:53:40 -07:00
Agus Zubiaga
13cd9214d7 0.2.4 2025-09-03 15:15:13 -03:00
Agus Zubiaga
78975d75f5 Provide more helpful message on init error 2025-09-03 15:15:03 -03:00
Agus Zubiaga
bbd0774ae5 0.2.3 2025-09-03 15:07:22 -03:00
Agus Zubiaga
76331017b2 Pass node executable path 2025-09-03 15:07:13 -03:00
Agus Zubiaga
90f1447e99 0.2.2 2025-09-03 14:04:10 -03:00
Agus Zubiaga
dae4c9956e Raise timeout 2025-09-03 14:03:47 -03:00
Agus Zubiaga
eb282abb50 Fix readme format 2025-09-03 11:39:33 -03:00
Agus Zubiaga
74c0be057b Add npm badge 2025-09-03 11:36:39 -03:00
Agus Zubiaga
e66091f723 Add permission request note 2025-09-03 11:02:33 -03:00
Agus Zubiaga
1b99ab96a0 Fix test 2025-09-03 09:28:11 -03:00
12 changed files with 985 additions and 700 deletions

View File

@@ -1,6 +0,0 @@
node_modules/
dist/
coverage/
*.min.js
*.config.js
.github/

View File

@@ -1,33 +0,0 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"env": {
"node": true,
"es2020": true
},
"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"]
},
"ignorePatterns": ["dist", "node_modules", "*.js"]
}

View File

@@ -1,12 +1,14 @@
# ACP adapter for Claude Code
[![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
- 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

48
eslint.config.js Normal file
View File

@@ -0,0 +1,48 @@
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,
];

1076
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",
@@ -43,27 +43,28 @@
"author": "Zed Industries",
"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",
"diff": "^8.0.2",
"express": "^5.1.0",
"minimist": "^1.2.8",
"@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",
"uuid": "11.1.0"
},
"devDependencies": {
"@types/minimist": "^1.2.5",
"@types/node": "^24.3.0",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.42.0",
"@typescript-eslint/parser": "^8.42.0",
"@vitest/ui": "^3.2.4",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.6.2",
"ts-node": "^10.9.2",
"typescript": "^5.4.0",
"vitest": "^3.2.4"
"@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"
}
}

View File

@@ -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",
},
],
},
};
}
@@ -245,7 +302,7 @@ export class ClaudeAcpAgent implements Agent {
break;
}
default:
unreachable(message);
unreachable(message as never);
}
}
throw new Error("Session did not end in result");
@@ -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 }) => {

View File

@@ -1,5 +1,13 @@
#!/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;
@@ -7,6 +15,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();

View File

@@ -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());

View File

@@ -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",

View File

@@ -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":

View File

@@ -2,6 +2,8 @@
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> {
@@ -80,3 +82,43 @@ export function unreachable(value: never): never {
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;
}
}
}