Compare commits

...

69 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
Agus Zubiaga
8d14d9dd1d 0.2.1 2025-09-03 09:25:44 -03:00
Agus Zubiaga
98b6cd5c07 Fix edit location reporting (#15)
When reporting the edit tool uses, we were accessing a field with a typo
(we should start using Zod, btw), and that caused locations not to be
reported correctly. We also now report all line numbers.

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-09-03 09:25:15 -03:00
Agus Zubiaga
a4c9ba3c7a 0.2.0 2025-09-03 09:05:24 -03:00
Agus Zubiaga
a08ce88f30 Improve edit tool quality (#14)
- Return a unified diff after applying changes
- Fail if the `old_string` isn't found
- Fail if the `old_string` is empty
- Throw instead of returning succesful content on error

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-09-03 09:04:06 -03:00
Agus Zubiaga
f717175cdc 0.1.11 2025-09-02 23:16:46 -03:00
Agus Zubiaga
d1fd794ecb Add backticks around glob pattern (#13)
Looks nicer and prevents potential markdown issues
2025-09-02 23:13:27 -03:00
Agus Zubiaga
f54da50bf5 Update README.md 2025-09-02 22:35:45 -03:00
Agus Zubiaga
55c61986e8 0.1.10 2025-09-02 20:03:22 -03:00
Agus Zubiaga
46f643b9f4 Improve background terminal output 2025-09-02 20:03:14 -03:00
Agus Zubiaga
07316e2eb0 Redirect console.log et al to stderr 2025-09-02 19:46:30 -03:00
Agus Zubiaga
9dc15bd190 0.1.9 2025-09-02 19:26:40 -03:00
Agus Zubiaga
df04c41d5a Only include relevant files 2025-09-02 19:14:05 -03:00
Agus Zubiaga
a3038d383f Setup linter (#12)
Fixes basic issues. For now, we're still allowing `any`, but it'd be
nice to move away from it.
2025-09-02 19:06:32 -03:00
Agus Zubiaga
28f36d7474 Format README 2025-09-02 18:24:51 -03:00
Agus Zubiaga
a07f132a9a Improve "Other clients" section 2025-09-02 18:23:05 -03:00
Agus Zubiaga
7d969fd9ff Update README.md 2025-09-02 18:17:17 -03:00
Conrad Irwin
a4e8ba0b14 Remove more unsupported /slash commands 2025-09-02 13:18:13 -07:00
Conrad Irwin
532daf0e7e v0.1.8 2025-09-02 12:33:40 -07:00
Conrad Irwin
d6e5910631 Fix load errors when SDK is busted 2025-09-02 12:33:11 -07:00
Agus Zubiaga
0d5e78f1ac Fix info for builtin tools (#11) 2025-09-02 15:50:21 -03:00
Agus Zubiaga
02fac67f52 Do not send tool_call_update for TodoWrite (#10)
When we receive a `TodoWrite` tool use, we send a plan update instead of
a tool call. However, we were still trying to send a tool call update
when we received the `TodoWrite` tool result, which caused an ACP error.

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-09-02 11:53:54 -03:00
Agus Zubiaga
1ef3ba327d Unpin SDK now that entrypoint is fixed (#9)
Co-authored-by: Bennet <bennet@zed.dev>
2025-09-02 11:38:45 -03:00
Agus Zubiaga
617233d693 0.1.7 2025-09-02 11:25:56 -03:00
Bennet Bo Fenner
0dc5a4b366 Add support for slash commands (#7)
Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Agus Zubiaga <agus@zed.dev>
2025-09-02 10:29:45 +02:00
Agus Zubiaga
6d1711cfa4 Use ACP terminal support when available (#8)
When the `terminal` client capability is available, we'll now replace
Claude Code's bash tools with our own that go over ACP. This includes
support for background terminals.

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Bennet <bennet@zed.dev>
2025-09-01 12:01:50 -03:00
Ben Brandt
f79a121c9c Remove provenance flag 2025-08-29 12:18:27 -07:00
Ben Brandt
ace8b0b5e5 0.1.6 2025-08-29 12:13:56 -07:00
Ben Brandt
1d14d35779 Add publish step on tags (#6) 2025-08-29 21:13:27 +02:00
Ben Brandt
d39c2adb21 Add CI workflow and Prettier formatting (#5) 2025-08-29 20:35:31 +02:00
Conrad Irwin
48a31df673 v0.1.5 2025-08-29 10:01:57 -06:00
Conrad Irwin
63d243deef Add a bin field 2025-08-29 10:01:45 -06:00
Conrad Irwin
048d5c562c Add bin 2025-08-29 09:57:27 -06:00
Bennet Bo Fenner
d6b805a77b Update tests to reflect new behavior 2025-08-29 10:36:42 +02:00
Conrad Irwin
b83c250f17 Only enable read/write tools if supported by the client 2025-08-28 23:02:59 -06:00
Conrad Irwin
d7511ac542 v0.1.4 2025-08-28 22:42:48 -06:00
Conrad Irwin
f18bfeaf8d Render read files in code blocks 2025-08-28 22:34:56 -06:00
Cole Miller
c4674e901a Change the format of LS tool's title to match Zed agent (#4) 2025-08-29 00:20:51 -04:00
Ben Brandt
dd088b39e3 Cleanup tool updates a bit 2025-08-28 20:01:34 -07:00
Conrad Irwin
a066b838a2 v0.1.3 2025-08-28 20:59:09 -06:00
Conrad Irwin
b875e24ad5 Fix showing of diffs in permission prompts 2025-08-28 20:58:45 -06:00
Conrad Irwin
77f667c619 Fix publishing 2025-08-28 19:33:14 -06:00
Conrad Irwin
1f2953a4ce v0.1.1 2025-08-28 18:20:21 -06:00
Ben Brandt
ce0e1a30ac Only overwrite content if we have something 2025-08-28 16:48:30 -07:00
16 changed files with 2752 additions and 1211 deletions

45
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
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

23
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
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 }}

4
.prettierrc.json Normal file
View File

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

View File

@@ -1,3 +1,55 @@
An ACP-compatible coding agent powered by the Claude Code SDK
# ACP adapter for Claude Code
This wrapper lets you use the real version of Claude Code from the IDE of your choice.
[![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

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,
];

1383
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,19 +3,33 @@
"publishConfig": {
"access": "public"
},
"version": "0.1.0",
"bin": {
"claude-code-acp": "./dist/index.js"
},
"version": "0.4.0",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/acp-agent.js",
"main": "dist/index.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"
"test:coverage": "vitest run --coverage",
"prepublishOnly": "npm run build"
},
"keywords": [
"claude",
@@ -29,22 +43,28 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@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",
"@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",
"@vitest/ui": "^3.2.4",
"eslint": "^8.56.0",
"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

@@ -2,49 +2,64 @@ import {
Agent,
AgentSideConnection,
AuthenticateRequest,
AvailableCommand,
CancelNotification,
Client,
ClientCapabilities,
InitializeRequest,
InitializeResponse,
LoadSessionRequest,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
ReadTextFileRequest,
ReadTextFileResponse,
RequestError,
ToolCallContent,
ToolKind,
SetSessionModeRequest,
SetSessionModeResponse,
TerminalHandle,
TerminalOutputResponse,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@zed-industries/agent-client-protocol";
import {
McpServerConfig,
Options,
PermissionMode,
Query,
query,
SDKAssistantMessage,
SDKMessage,
SDKUserMessage,
} from "@anthropic-ai/claude-code";
import * as readline from "node:readline";
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,
unreachable,
} from "./utils.js";
import { ContentBlock } from "@zed-industries/agent-client-protocol";
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";
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
@@ -52,56 +67,80 @@ export class ClaudeAcpAgent implements Agent {
sessions: {
[key: string]: Session;
};
client: Client;
toolUseCache: { [key: string]: any };
client: AgentSideConnection;
toolUseCache: ToolUseCache;
fileContentCache: { [key: string]: string };
backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
clientCapabilities?: ClientCapabilities;
constructor(client: Client) {
constructor(client: AgentSideConnection) {
this.sessions = {};
this.client = client;
this.toolUseCache = {};
this.fileContentCache = {};
}
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
this.clientCapabilities = request.clientCapabilities;
return {
protocolVersion: 1,
// todo!()
agentCapabilities: {
promptCapabilities: { image: true, embeddedContext: true },
promptCapabilities: {
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> {
let sessionId = uuidv7();
let input = new Pushable<SDKUserMessage>();
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,
};
}
}
}
// todo!() auth
let server = await createMcpServer(this, sessionId);
let address = server.address() as AddressInfo;
const server = await createMcpServer(this, sessionId, this.clientCapabilities);
const address = server.address() as AddressInfo;
mcpServers["acp"] = {
type: "http",
url: "http://127.0.0.1:" + address.port + "/mcp",
@@ -110,30 +149,90 @@ export class ClaudeAcpAgent implements Agent {
},
};
let q = query({
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({
prompt: input,
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),
},
options,
});
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.");
}
@@ -148,7 +247,7 @@ export class ClaudeAcpAgent implements Agent {
input.push(promptToClaude(params));
while (true) {
let { value: message, done } = await query.next();
const { value: message, done } = await query.next();
if (done || !message) {
if (this.sessions[params.sessionId].cancelled) {
return { stopReason: "cancelled" };
@@ -186,8 +285,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();
@@ -196,13 +295,14 @@ export class ClaudeAcpAgent implements Agent {
message,
params.sessionId,
this.toolUseCache,
this.fileContentCache,
)) {
await this.client.sessionUpdate(notification);
}
break;
}
default:
unreachable(message);
unreachable(message as never);
}
}
throw new Error("Session did not end in result");
@@ -215,6 +315,97 @@ 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 {
@@ -312,13 +503,14 @@ function promptToClaude(prompt: PromptRequest): SDKUserMessage {
export function toAcpNotifications(
message: SDKAssistantMessage | SDKUserMessage,
sessionId: string,
toolUseCache: { [key: string]: any },
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
): SessionNotification[] {
let chunks = message.message.content as ContentChunk[];
let output = [];
const chunks = message.message.content as ContentChunk[];
const output = [];
// Only handle the first chunk for streaming; extend as needed for batching
for (const chunk of chunks) {
let update: SessionNotification["update"];
let update: SessionNotification["update"] | null = null;
switch (chunk.type) {
case "text":
update = {
@@ -335,8 +527,7 @@ 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,
},
};
@@ -352,7 +543,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),
@@ -363,24 +554,37 @@ export function toAcpNotifications(
sessionUpdate: "tool_call",
rawInput: chunk.input,
status: "pending",
...toolInfoFromToolUse(chunk),
...toolInfoFromToolUse(chunk, fileContentCache),
};
}
break;
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]),
};
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]),
};
}
break;
}
default:
throw new Error("unhandled chunk type: " + chunk.type);
}
output.push({ sessionId, update });
if (update) {
output.push({ sessionId, update });
}
}
return output;
@@ -396,7 +600,7 @@ export function runAcp() {
type ContentChunk =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: any } // input is serde_json::Value, so use any or unknown
| { type: "tool_use"; id: string; name: string; input: any }
| {
type: "tool_result";
content: string;

View File

@@ -1,3 +1,24 @@
#!/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

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

View File

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

View File

@@ -2,8 +2,10 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { spawn, spawnSync } from "child_process";
import {
Agent,
AvailableCommand,
Client,
ClientSideConnection,
NewSessionResponse,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionRequest,
@@ -13,89 +15,183 @@ import {
WriteTextFileResponse,
} from "@zed-industries/agent-client-protocol";
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import { toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
import { markdownEscape, 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 () => {
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);
});
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,
});
afterAll(() => {
child.kill();
child.on("error", (error) => {
console.error("Error starting subprocess:", error);
});
child.on("exit", (exit) => {
console.error("Exited with", exit);
});
});
class TestClient implements Client {
agent: Agent;
afterAll(() => {
child.kill();
});
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.",
};
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;
});
}
takeReceivedText() {
const text = this.receivedText;
this.receivedText = "";
return text;
}
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
const optionId = params.options.find((p) => p.kind === "allow_once")!.optionId;
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;
}
}
it("should connect to the ACP subprocess", async () => {
const connection = new ClientSideConnection(
(agent) => {
return new TestClient(agent);
},
nodeToWebWritable(child.stdin!),
nodeToWebReadable(child.stdout!),
);
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
this.files.set(params.path, params.content);
return null;
}
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);
},
);
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,
},
},
});
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);
});
describe("tool conversions", () => {
it("should handle Bash nicely", () => {
@@ -109,7 +205,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "execute",
title: "`rm README.md.rm`",
content: [
@@ -134,9 +230,9 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "search",
title: "Find */**.ts",
title: "Find `*/**.ts`",
content: [],
locations: [],
});
@@ -155,7 +251,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "think",
title: "Handle user's work request",
content: [
@@ -176,15 +272,15 @@ describe("tool conversions", () => {
id: "toolu_01EEqsX7Eb9hpx87KAHVPTey",
name: "LS",
input: {
path: "/Users/benbrandt/github/claude-code-acp",
path: "/Users/test/github/claude-code-acp",
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "search",
title: "/Users/benbrandt/github/claude-code-acp",
title: "List the `/Users/test/github/claude-code-acp` directory's contents",
content: [],
locations: [{ path: "/Users/benbrandt/github/claude-code-acp" }],
locations: [],
});
});
@@ -198,7 +294,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "search",
title: 'grep ".*"',
content: [],
@@ -211,12 +307,12 @@ describe("tool conversions", () => {
id: "toolu_01ABC123XYZ789",
name: "Write",
input: {
abs_path: "/Users/test/project/example.txt",
file_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: [
@@ -242,7 +338,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "edit",
title: "Write /Users/test/project/config.json",
content: [
@@ -263,19 +359,14 @@ describe("tool conversions", () => {
id: "toolu_01MNO456PQR789",
name: "Read",
input: {
abs_path: "/Users/test/project/readme.md",
file_path: "/Users/test/project/readme.md",
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "read",
title: "Read File",
content: [
{
type: "content",
content: { type: "text", text: "/Users/test/project/readme.md" },
},
],
content: [],
locations: [{ path: "/Users/test/project/readme.md", line: 0 }],
});
});
@@ -290,7 +381,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/data.json",
content: [],
@@ -309,7 +400,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: [],
@@ -329,7 +420,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: [],
@@ -348,7 +439,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: [],
@@ -368,7 +459,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "fetch",
title: "Fetch https://agentclientprotocol.com",
content: [
@@ -393,7 +484,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "fetch",
title: '"agentclientprotocol.com"',
content: [],
@@ -401,7 +492,7 @@ describe("tool conversions", () => {
});
it("should handle KillBash entries", () => {
let tool_use = {
const tool_use = {
type: "tool_use",
id: "toolu_01PhLms5fuvmdjy2bb6dfUKT",
name: "KillBash",
@@ -410,7 +501,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "execute",
title: `Kill Process`,
content: [],
@@ -418,7 +509,7 @@ describe("tool conversions", () => {
});
it("should handle BashOutput entries", () => {
let tool_use = {
const tool_use = {
type: "tool_use",
id: "toolu_01SJUWPtj1QspgANgtpqGPuN",
name: "BashOutput",
@@ -427,7 +518,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "execute",
title: `Tail Logs`,
content: [],
@@ -475,8 +566,7 @@ 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",
},
@@ -512,7 +602,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: {
@@ -564,140 +654,80 @@ describe("tool conversions", () => {
]);
});
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", () => {
it("should show full diff for multi edit tool", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01DEF456",
name: "multi-edit",
name: "mcp__acp__multi-edit",
input: {
file_path: "/Users/test/project/config.json",
edits: [
{
old_string: "version: 1.0",
new_string: "version: 2.0",
old_string: 'version": 1',
new_string: 'version": 2',
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,
),
};
const toolResult = {
expect(toolInfoFromToolUse(toolUse, fileCache)).toEqual({
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers: [2, 8, 12, 20] }),
newText: `{
"version": 2,
"filler": "filler",
"enabled": true
}`,
oldText: `{
"version": 1,
"filler": "filler",
"enabled": false
}`,
path: "/Users/test/project/config.json",
type: "diff",
},
],
};
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 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: [
kind: "edit",
locations: [
{
type: "text",
text: JSON.stringify({ lineNumbers: [42] }),
path: "/Users/test/project/config.json",
line: 1,
},
],
};
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: [] }),
path: "/Users/test/project/config.json",
line: 3,
},
],
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([]);
title: "Edit /Users/test/project/config.json",
});
});
it("should handle malformed tool results gracefully", () => {
it("should return empty update for successful edit result", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "edit",
name: "mcp__acp__edit",
input: {
abs_path: "/Users/test/project/test.txt",
old_text: "old",
new_text: "new",
old_string: "old",
new_string: "new",
},
};
@@ -708,6 +738,9 @@ describe("tool conversions", () => {
text: "not valid json",
},
],
tool_use_id: "test",
is_error: false,
type: "tool_result" as const,
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
@@ -715,4 +748,50 @@ 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,13 +4,9 @@ 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)
});
@@ -20,20 +16,15 @@ 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";
const result = replaceAndCalculateLocation(content, [
{ oldText: "notfound", newText: "replaced" },
]);
expect(result.newContent).toBe(content);
expect(result.lineNumbers).toEqual([]);
expect(() => {
replaceAndCalculateLocation(content, [{ oldText: "notfound", newText: "replaced" }]);
}).toThrow('The provided `old_string` does not appear in the file: "notfound"');
});
it("should handle Windows line endings (CRLF)", () => {
@@ -54,9 +45,7 @@ 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]);
});
@@ -66,17 +55,13 @@ 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)
@@ -84,9 +69,7 @@ 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)
@@ -98,53 +81,41 @@ 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", () => {
const content = "";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
expect(result.newContent).toBe("");
expect(result.lineNumbers).toEqual([]);
expect(() => {
replaceAndCalculateLocation("", [{ oldText: "text", newText: "replaced" }]);
}).toThrowError(
'The provided `old_string` does not appear in the file: "text".\n\nNo edits were applied.',
);
});
it("should handle empty search text", () => {
// Test with replaceAll false (default)
const content1 = "line 1\nline 2\nline 3";
const result1 = replaceAndCalculateLocation(content1, [
{ oldText: "", newText: "replaced" },
]);
expect(result1.newContent).toBe(content1);
expect(result1.lineNumbers).toEqual([]);
expect(() => {
replaceAndCalculateLocation(content1, [{ oldText: "", newText: "replaced" }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
// Test with replaceAll true on single line
const content2 = "abc";
const result2 = replaceAndCalculateLocation(content2, [
{ oldText: "", newText: "X", replaceAll: true },
]);
expect(result2.newContent).toBe(content2);
expect(result2.lineNumbers).toEqual([]);
expect(() => {
replaceAndCalculateLocation(content2, [{ oldText: "", newText: "X", replaceAll: true }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
// Test with replaceAll true on multiline content
const content3 = "ab\ncd";
const result3 = replaceAndCalculateLocation(content3, [
{ oldText: "", newText: "X", replaceAll: true },
]);
expect(result3.newContent).toBe(content3);
expect(result3.lineNumbers).toEqual([]);
expect(() => {
replaceAndCalculateLocation(content3, [{ oldText: "", newText: "X", replaceAll: true }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
});
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)
@@ -183,9 +154,7 @@ 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`,
@@ -198,9 +167,7 @@ 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");
@@ -224,9 +191,7 @@ 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
@@ -260,9 +225,7 @@ 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]);
@@ -302,9 +265,7 @@ 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([
@@ -326,9 +287,7 @@ 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([
@@ -340,17 +299,14 @@ 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,7 +19,10 @@ interface ToolUpdate {
locations?: ToolCallLocation[];
}
export function toolInfoFromToolUse(toolUse: any): ToolInfo {
export function toolInfoFromToolUse(
toolUse: any,
cachedFileContent: { [key: string]: string },
): ToolInfo {
const name = toolUse.name;
const input = toolUse.input;
@@ -41,30 +44,15 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
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:
input && input.notebook_path
? [
{
type: "content",
content: {
type: "text",
text: input.notebook_path.toString(),
},
},
]
: [],
content: [],
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
@@ -79,13 +67,12 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
};
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.command
input && input.description
? [
{
type: "content",
@@ -96,6 +83,7 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
};
case "BashOutput":
case toolNames.bashOutput:
return {
title: "Tail Logs",
kind: "execute",
@@ -103,21 +91,18 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
};
case "KillBash":
case toolNames.killBash:
return {
title: "Kill Process",
kind: "execute",
content: [],
};
case "mcp__acp__read": {
case toolNames.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) + ")";
}
@@ -138,65 +123,118 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
return {
title: "Read File",
kind: "read",
content:
input && input.abs_path
? [
{
type: "content",
content: { type: "text", text: input.abs_path.toString() },
},
]
: [],
locations: [{ path: input.abs_path, line: input.offset ?? 0 }],
content: [],
locations: [{ path: input.file_path, line: input.offset ?? 0 }],
};
case "LS":
return {
title: input?.path ? input.path : "Current Directory",
title: `List the ${input?.path ? "`" + input.path + "`" : "current"} directory's contents`,
kind: "search",
content: [],
locations: input?.path ? [{ path: input.path }] : [],
locations: [],
};
case "mcp__acp__edit":
case "Edit":
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);
}
}
return {
title: input?.abs_path ? `Edit ${input.abs_path}` : "Edit",
title: path ? `Edit \`${path}\`` : "Edit",
kind: "edit",
content:
input && input.abs_path
input && path
? [
{
type: "diff",
path: input.abs_path,
oldText: input.old_text || null,
newText: input.new_text,
path,
oldText,
newText,
},
]
: [],
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
locations: path
? affectedLines.length > 0
? affectedLines.map((line) => ({ line, path }))
: [{ path }]
: [],
};
}
case "mcp__acp__multi-edit":
case "MultiEdit":
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);
}
// 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:
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 }] : [],
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 }]
: [],
};
case "mcp__acp__write":
}
case toolNames.write: {
let content: ToolCallContent[] = [];
if (input && input.abs_path) {
content = [
@@ -221,32 +259,33 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
content,
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
};
}
case "Write":
return {
title: input?.abs_path ? `Write ${input.abs_path}` : "Write",
title: input?.file_path ? `Write ${input.file_path}` : "Write",
kind: "edit",
content:
input && input.abs_path
input && input.file_path
? [
{
type: "diff",
path: input.abs_path,
path: input.file_path,
oldText: null,
newText: input.content,
},
]
: [],
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
locations: input?.file_path ? [{ path: input.file_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,
@@ -285,6 +324,7 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
label += " -c";
break;
case "Content":
default:
break;
}
}
@@ -360,10 +400,10 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
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 } }]
@@ -394,64 +434,71 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
}
}
type ToolResult = {
type: "tool_result";
content: any;
tool_use_id: string;
is_error: boolean;
};
export function toolUpdateFromToolResult(
toolResult: any,
toolResult: ToolResult,
toolUse: any | undefined,
): ToolUpdate {
switch (toolUse?.name) {
case "mcp__acp__edit":
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 "edit":
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 "mcp__acp__multi-edit":
case "Edit":
case toolNames.edit:
case toolNames.multiEdit:
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 {};
}
case "MultiEdit":
case toolNames.write:
case "Write": {
if (toolResult.is_error && toolResult.content?.length > 0) {
// Only return errors
return toAcpContentUpdate(toolResult.content);
}
return {};
}
case "ExitPlanMode": {
return { title: "Exited Plan Mode" };
}
case "Task":
case "NotebookEdit":
case "NotebookRead":
case "mcp__acp__write":
case "Write":
case "TodoWrite":
case "exit_plan_mode":
case "Bash":
@@ -464,30 +511,35 @@ export function toolUpdateFromToolResult(
case "WebSearch":
case "Other":
default: {
// This happens for the mcp__acp__read tool,
// but may also for others...
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 {};
return toAcpContentUpdate(toolResult.content);
}
}
}
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";
@@ -501,3 +553,13 @@ 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,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> {
@@ -45,24 +47,23 @@ 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) => {
@@ -77,3 +78,47 @@ export function nodeToWebReadable(
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;
}
}
}