Compare commits

..

3 Commits

Author SHA1 Message Date
Ben Brandt
fda22f227c prep release 2025-12-11 14:02:33 +01:00
Ben Brandt
7643799805 Allow agent to write plans and todos to its config directory 2025-12-11 14:00:24 +01:00
Ben Brandt
ce4ae06993 Use official sdk types for tools 2025-12-11 13:19:22 +01:00
19 changed files with 1130 additions and 4507 deletions

View File

@@ -32,3 +32,5 @@ jobs:
- name: Create Release
id: create_release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090
with:
draft: true

2
.gitignore vendored
View File

@@ -1,5 +1,3 @@
node_modules
dist
.DS_Store
.claude/*.local.*
CLAUDE.local.md

View File

@@ -1,55 +1,7 @@
# Changelog
## 0.15.0
- Update to @anthropic-ai/claude-agent-sdk@0.2.32 (adds support for Opus 4.6 and 1M context Opus)
## 0.14.0
- Update to @anthropic-ai/claude-agent-sdk@0.2.29
- Update to using the recommended `CLAUDE_CONFIG_DIR` env variable for setting where config files are kept
- Support /context command
- Fix incorrect context type mapping for tool calls
- Fix glob metching for file permissions on Windows
- Support the `IS_SANDBOX` env var for supporting bypass permissions in root mode
- Fix missing notification for entering plan mode
- Experimental unstable support for listing sessions
## 0.13.2
- Update to @anthropic-ai/claude-agent-sdk@0.2.22
- Fix: return content from ACP write tool to help with issues with alternate providers.
## 0.13.1
- Update to @anthropic-ai/claude-agent-sdk@0.2.7
- Add TypeScript declaration files for library users
- Fixed error handling in custom ACP focused MCP tools
## 0.13.0
- Update to @anthropic-ai/claude-agent-sdk@0.2.6
- Update to @agentclientprotocol/sdk@0.13.0
## 0.12.6
- Fix model selection
## 0.12.5
- Update to @anthropic-ai/claude-agent-sdk@v0.1.70
- Unstable implementation of resuming sessions
## 0.12.4
- Update to @anthropic-ai/claude-agent-sdk@v0.1.67
- Better respect permissions specified in settings files
- Unstable implementation of forking
## 0.12.3
- Update to @anthropic-ai/claude-agent-sdk@v0.1.65
- Update to @agentclientprotocol/sdk@0.9.0
- Allow agent to write plans and todos to its config directory
- Fix experimental resume ids

View File

@@ -4,7 +4,7 @@
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 Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview), supporting:
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
@@ -32,14 +32,20 @@ Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) suppo
### Other clients
Or try it with any of the other [ACP compatible clients](https://agentclientprotocol.com/overview/clients)!
- Emacs via [agent-shell.el](https://github.com/xenodium/agent-shell)
- [marimo notebook](https://github.com/marimo-team/marimo)
- Neovim
- via [CodeCompanion.nvim](https://codecompanion.olimorris.dev/configuration/adapters#setup-claude-code-via-acp)
- via [yetone/avante.nvim](https://github.com/yetone/avante.nvim)
[Submit a PR](https://github.com/zed-industries/claude-code-acp/pulls) to add yours!
#### Installation
Install the adapter from `npm`:
```bash
npm install -g @zed-industries/claude-code-acp
npm install @zed-industries/claude-code-acp
```
You can then use `claude-code-acp` as a regular ACP agent:

1049
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,24 +3,15 @@
"publishConfig": {
"access": "public"
},
"version": "0.15.0",
"version": "0.12.3",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
"bin": {
"claude-code-acp": "./dist/index.js"
},
"type": "module",
"exports": {
".": {
"types": "./dist/lib.d.ts",
"import": "./dist/lib.js"
},
"./*": "./*"
},
"files": [
"dist/",
"!dist/tests/",
"README.md",
"LICENSE",
"package.json"
@@ -60,23 +51,22 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.13.1",
"@anthropic-ai/claude-agent-sdk": "0.2.32",
"@modelcontextprotocol/sdk": "1.26.0",
"diff": "8.0.3",
"minimatch": "10.1.1"
"@agentclientprotocol/sdk": "0.8.0",
"@anthropic-ai/claude-agent-sdk": "0.1.61",
"@modelcontextprotocol/sdk": "1.24.3",
"diff": "8.0.2"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.72.1",
"@types/node": "25.2.0",
"@typescript-eslint/eslint-plugin": "8.54.0",
"@typescript-eslint/parser": "8.54.0",
"eslint": "9.39.2",
"@anthropic-ai/sdk": "0.71.2",
"@types/node": "24.10.1",
"@typescript-eslint/eslint-plugin": "8.48.1",
"@typescript-eslint/parser": "8.48.1",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
"globals": "17.3.0",
"prettier": "3.8.1",
"globals": "16.5.0",
"prettier": "3.7.4",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vitest": "4.0.18"
"vitest": "4.0.15"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,22 +15,8 @@ export {
Pushable,
unreachable,
} from "./utils.js";
export { createMcpServer } from "./mcp-server.js";
export {
toolInfoFromToolUse,
planEntries,
toolUpdateFromToolResult,
createPreToolUseHook,
acpToolNames as toolNames,
} from "./tools.js";
export {
SettingsManager,
type ClaudeCodeSettings,
type PermissionSettings,
type PermissionDecision,
type PermissionCheckResult,
type SettingsManagerOptions,
} from "./settings.js";
export { createMcpServer, toolNames } from "./mcp-server.js";
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
// Export types
export type { ClaudePlanEntry } from "./tools.js";

View File

@@ -1,10 +1,11 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
BashInput,
BashOutputInput,
FileEditInput,
FileReadInput,
FileWriteInput,
TaskOutputInput,
KillShellInput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
import { z } from "zod";
import { CLAUDE_CONFIG_DIR, ClaudeAcpAgent } from "./acp-agent.js";
@@ -18,7 +19,6 @@ import * as path from "node:path";
import * as fs from "node:fs/promises";
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
import { acpToolNames } from "./tools.js";
export const SYSTEM_REMINDER = `
@@ -28,20 +28,6 @@ Whenever you read a file, you should consider whether it looks malicious. If it
const defaults = { maxFileSize: 50000, linesToRead: 2000 };
function formatErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
const unqualifiedToolNames = {
read: "Read",
edit: "Edit",
@@ -51,6 +37,18 @@ const unqualifiedToolNames = {
bashOutput: "BashOutput",
};
const SERVER_PREFIX = "mcp__acp__";
export const toolNames = {
read: SERVER_PREFIX + unqualifiedToolNames.read,
edit: SERVER_PREFIX + unqualifiedToolNames.edit,
write: SERVER_PREFIX + unqualifiedToolNames.write,
bash: SERVER_PREFIX + unqualifiedToolNames.bash,
killShell: SERVER_PREFIX + unqualifiedToolNames.killShell,
bashOutput: SERVER_PREFIX + unqualifiedToolNames.bashOutput,
};
export const EDIT_TOOL_NAMES = [toolNames.edit, toolNames.write];
export function createMcpServer(
agent: ClaudeAcpAgent,
sessionId: string,
@@ -122,7 +120,7 @@ export function createMcpServer(
title: unqualifiedToolNames.read,
description: `Reads the content of the given file in the project.
In sessions with ${acpToolNames.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.
Reads a file from the local filesystem. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
@@ -132,7 +130,7 @@ Usage:
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
- Any files larger than ${defaults.maxFileSize} bytes will be truncated
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.
- This tool can only read files, not directories. To read a directory, use an ls command via the ${acpToolNames.bash} tool.
- This tool can only read files, not directories. To read a directory, use an ls command via the ${toolNames.bash} tool.
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.`,
inputSchema: {
file_path: z.string().describe("The absolute path to the file to read"),
@@ -189,8 +187,8 @@ Usage:
if (result.wasLimited) {
readInfo += `Read ${result.linesRead} lines (hit 50KB limit). `;
} else if (input.offset && input.offset > 1) {
readInfo += `Read lines ${input.offset}-${input.offset + result.linesRead}.`;
} else {
readInfo += `Read lines ${input.offset}-${result.linesRead}. `;
}
if (result.wasLimited) {
@@ -208,13 +206,12 @@ Usage:
},
],
};
} catch (error) {
} catch (error: any) {
return {
isError: true,
content: [
{
type: "text",
text: "Reading file failed: " + formatErrorMessage(error),
text: "Reading file failed: " + error.message,
},
],
};
@@ -230,12 +227,12 @@ Usage:
title: unqualifiedToolNames.write,
description: `Writes a file to the local filesystem..
In sessions with ${acpToolNames.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.
Usage:
- This tool will overwrite the existing file if there is one at the provided path.
- If this is an existing file, you MUST use the ${acpToolNames.read} tool first to read the file's contents. This tool will fail if you did not read the file first.
- If this is an existing file, you MUST use the ${toolNames.read} tool first to read the file's contents. This tool will fail if you did not read the file first.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.`,
@@ -269,20 +266,14 @@ Usage:
await writeTextFile(input);
return {
content: [
{
type: "text",
text: `The file ${input.file_path} has been updated successfully.`,
},
],
content: [],
};
} catch (error) {
} catch (error: any) {
return {
isError: true,
content: [
{
type: "text",
text: "Writing file failed: " + formatErrorMessage(error),
text: "Writing file failed: " + error.message,
},
],
};
@@ -296,11 +287,11 @@ Usage:
title: unqualifiedToolNames.edit,
description: `Performs exact string replacements in files.
In sessions with ${acpToolNames.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.
Usage:
- You must use your \`${acpToolNames.read}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
- You must use your \`${toolNames.read}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
@@ -368,13 +359,12 @@ Usage:
},
],
};
} catch (error) {
} catch (error: any) {
return {
isError: true,
content: [
{
type: "text",
text: "Editing file failed: " + formatErrorMessage(error),
text: "Editing file failed: " + (error?.message ?? String(error)),
},
],
};
@@ -390,7 +380,7 @@ Usage:
title: unqualifiedToolNames.bash,
description: `Executes a bash command
In sessions with ${acpToolNames.bash} always use it instead of Bash`,
In sessions with ${toolNames.bash} always use it instead of Bash`,
inputSchema: {
command: z.string().describe("The command to execute"),
timeout: z.number().describe(`Optional timeout in milliseconds (max ${2 * 60 * 1000})`),
@@ -411,137 +401,125 @@ Output: Create directory 'foo'`),
.boolean()
.default(false)
.describe(
`Set to true to run this command in the background. The tool returns an \`id\` that can be used with the \`${acpToolNames.bashOutput}\` tool to retrieve the current output, or the \`${acpToolNames.killShell}\` tool to stop it early.`,
`Set to true to run this command 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.killShell}\` tool to stop it early.`,
),
},
},
async (input: BashInput, extra) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const toolCallId = extra._meta?.["claudecode/toolUseId"];
if (typeof toolCallId !== "string") {
throw new Error("No tool call ID found");
}
if (!agent.clientCapabilities?.terminal || !agent.client.createTerminal) {
throw new Error("unreachable");
}
const handle = await agent.client.createTerminal({
command: input.command,
env: [{ name: "CLAUDECODE", value: "1" }],
sessionId,
outputByteLimit: 32_000,
});
await agent.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: "in_progress",
title: input.description,
content: [{ type: "terminal", terminalId: handle.id }],
},
});
const abortPromise = new Promise((resolve) => {
if (extra.signal.aborted) {
resolve(null);
} else {
extra.signal.addEventListener("abort", () => {
resolve(null);
});
}
});
const statusPromise = Promise.race([
handle.waitForExit().then((exitStatus) => ({ status: "exited" as const, exitStatus })),
abortPromise.then(() => ({ status: "aborted" as const, exitStatus: null })),
sleep(input.timeout ?? 2 * 60 * 1000).then(async () => {
if (agent.backgroundTerminals[handle.id]?.status === "started") {
await handle.kill();
}
return { status: "timedOut" as const, exitStatus: null };
}),
]);
if (input.run_in_background) {
agent.backgroundTerminals[handle.id] = {
handle,
lastOutput: null,
status: "started",
};
statusPromise.then(async ({ status, exitStatus }) => {
const bgTerm = agent.backgroundTerminals[handle.id];
if (bgTerm.status !== "started") {
return;
}
const currentOutput = await handle.currentOutput();
agent.backgroundTerminals[handle.id] = {
status,
pendingOutput: {
...currentOutput,
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
exitStatus: exitStatus ?? currentOutput.exitStatus,
},
};
return handle.release();
});
return {
content: [
{
type: "text",
text: `Command started in background with id: ${handle.id}`,
},
],
};
}
await using terminal = handle;
const { status } = await statusPromise;
if (status === "aborted") {
return {
content: [{ type: "text", text: "Tool cancelled by user" }],
};
}
const output = await terminal.currentOutput();
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [{ type: "text", text: toolCommandOutput(status, output) }],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: "Running bash command failed: " + formatErrorMessage(error),
text: "The user has left the building",
},
],
};
}
const toolCallId = extra._meta?.["claudecode/toolUseId"];
if (typeof toolCallId !== "string") {
throw new Error("No tool call ID found");
}
if (!agent.clientCapabilities?.terminal || !agent.client.createTerminal) {
throw new Error("unreachable");
}
const handle = await agent.client.createTerminal({
command: input.command,
env: [{ name: "CLAUDECODE", value: "1" }],
sessionId,
outputByteLimit: 32_000,
});
await agent.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: "in_progress",
title: input.description,
content: [{ type: "terminal", terminalId: handle.id }],
},
});
const abortPromise = new Promise((resolve) => {
if (extra.signal.aborted) {
resolve(null);
} else {
extra.signal.addEventListener("abort", () => {
resolve(null);
});
}
});
const statusPromise = Promise.race([
handle.waitForExit().then((exitStatus) => ({ status: "exited" as const, exitStatus })),
abortPromise.then(() => ({ status: "aborted" as const, exitStatus: null })),
sleep(input.timeout ?? 2 * 60 * 1000).then(async () => {
if (agent.backgroundTerminals[handle.id]?.status === "started") {
await handle.kill();
}
return { status: "timedOut" as const, exitStatus: null };
}),
]);
if (input.run_in_background) {
agent.backgroundTerminals[handle.id] = {
handle,
lastOutput: null,
status: "started",
};
statusPromise.then(async ({ status, exitStatus }) => {
const bgTerm = agent.backgroundTerminals[handle.id];
if (bgTerm.status !== "started") {
return;
}
const currentOutput = await handle.currentOutput();
agent.backgroundTerminals[handle.id] = {
status,
pendingOutput: {
...currentOutput,
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
exitStatus: exitStatus ?? currentOutput.exitStatus,
},
};
return handle.release();
});
return {
content: [
{
type: "text",
text: `Command started in background with id: ${handle.id}`,
},
],
};
}
await using terminal = handle;
const { status } = await statusPromise;
if (status === "aborted") {
return {
content: [{ type: "text", text: "Tool cancelled by user" }],
};
}
const output = await terminal.currentOutput();
return {
content: [{ type: "text", text: toolCommandOutput(status, output) }],
};
},
);
@@ -555,106 +533,45 @@ Output: Create directory 'foo'`),
- Returns stdout and stderr output along with shell status
- Use this tool when you need to monitor or check the output of a long-running shell
In sessions with ${acpToolNames.bashOutput} always use it for output from Bash commands instead of TaskOutput.`,
In sessions with ${toolNames.bashOutput} always use it instead of BashOutput.`,
inputSchema: {
task_id: z
bash_id: z
.string()
.describe(
`The id of the background bash command as returned by \`${acpToolNames.bash}\``,
),
block: z.boolean().describe("Whether to wait for completion"),
timeout: z.number().describe("Max wait time in ms"),
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
},
},
async (input: TaskOutputInput) => {
try {
const bgTerm = agent.backgroundTerminals[input.task_id];
async (input: BashOutputInput) => {
const bgTerm = agent.backgroundTerminals[input.bash_id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.task_id}`);
}
if (!bgTerm) {
throw new Error(`Unknown shell ${input.bash_id}`);
}
if (input.block && bgTerm.status === "started") {
const statusPromise = Promise.race([
bgTerm.handle
.waitForExit()
.then((exitStatus) => ({ status: "exited" as const, exitStatus })),
sleep(input.timeout ?? 2 * 60 * 1000).then(async () => {
if (bgTerm.status === "started") {
await bgTerm.handle.kill();
}
return { status: "timedOut" as const, exitStatus: null };
}),
]);
if (bgTerm.status === "started") {
const newOutput = await bgTerm.handle.currentOutput();
const strippedOutput = stripCommonPrefix(
bgTerm.lastOutput?.output ?? "",
newOutput.output,
);
bgTerm.lastOutput = newOutput;
const { status, exitStatus } = await statusPromise;
const currentOutput = await bgTerm.handle.currentOutput();
const strippedOutput = stripCommonPrefix(
bgTerm.lastOutput?.output ?? "",
currentOutput.output,
);
agent.backgroundTerminals[input.task_id] = {
status,
pendingOutput: {
...currentOutput,
output: strippedOutput,
exitStatus: exitStatus ?? currentOutput.exitStatus,
},
};
await bgTerm.handle.release();
return {
content: [
{
type: "text",
text: toolCommandOutput(status, {
...currentOutput,
output: strippedOutput,
exitStatus: exitStatus ?? currentOutput.exitStatus,
}),
},
],
};
}
if (bgTerm.status === "started") {
const newOutput = await bgTerm.handle.currentOutput();
const strippedOutput = stripCommonPrefix(
bgTerm.lastOutput?.output ?? "",
newOutput.output,
);
bgTerm.lastOutput = newOutput;
return {
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, {
...newOutput,
output: strippedOutput,
}),
},
],
};
} else {
return {
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, bgTerm.pendingOutput),
},
],
};
}
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: "Retrieving bash output failed: " + formatErrorMessage(error),
text: toolCommandOutput(bgTerm.status, {
...newOutput,
output: strippedOutput,
}),
},
],
};
} else {
return {
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, bgTerm.pendingOutput),
},
],
};
@@ -671,71 +588,57 @@ In sessions with ${acpToolNames.bashOutput} always use it for output from Bash c
- Returns a success or failure status
- Use this tool when you need to terminate a long-running shell
In sessions with ${acpToolNames.killShell} always use it instead of KillShell.`,
In sessions with ${toolNames.killShell} always use it instead of KillShell.`,
inputSchema: {
shell_id: z
.string()
.describe(
`The id of the background bash command as returned by \`${acpToolNames.bash}\``,
),
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
},
},
async (input) => {
try {
const bgTerm = agent.backgroundTerminals[input.shell_id];
async (input: KillShellInput) => {
const bgTerm = agent.backgroundTerminals[input.shell_id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.shell_id}`);
}
if (!bgTerm) {
throw new Error(`Unknown shell ${input.shell_id}`);
}
switch (bgTerm.status) {
case "started": {
await bgTerm.handle.kill();
const currentOutput = await bgTerm.handle.currentOutput();
agent.backgroundTerminals[bgTerm.handle.id] = {
status: "killed",
pendingOutput: {
...currentOutput,
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
},
};
await bgTerm.handle.release();
return {
content: [{ type: "text", text: "Command killed successfully." }],
};
}
case "aborted":
return {
content: [{ type: "text", text: "Command aborted by user." }],
};
case "exited":
return {
content: [{ type: "text", text: "Command had already exited." }],
};
case "killed":
return {
content: [{ type: "text", text: "Command was already killed." }],
};
case "timedOut":
return {
content: [{ type: "text", text: "Command killed by timeout." }],
};
default: {
unreachable(bgTerm);
throw new Error("Unexpected background terminal status");
}
}
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: "Killing shell failed: " + formatErrorMessage(error),
switch (bgTerm.status) {
case "started": {
await bgTerm.handle.kill();
const currentOutput = await bgTerm.handle.currentOutput();
agent.backgroundTerminals[bgTerm.handle.id] = {
status: "killed",
pendingOutput: {
...currentOutput,
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
},
],
};
};
await bgTerm.handle.release();
return {
content: [{ type: "text", text: "Command killed successfully." }],
};
}
case "aborted":
return {
content: [{ type: "text", text: "Command aborted by user." }],
};
case "exited":
return {
content: [{ type: "text", text: "Command had already exited." }],
};
case "killed":
return {
content: [{ type: "text", text: "Command was already killed." }],
};
case "timedOut":
return {
content: [{ type: "text", text: "Command killed by timeout." }],
};
default: {
unreachable(bgTerm);
throw new Error("Unexpected background terminal status");
}
}
},
);

View File

@@ -1,528 +0,0 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { minimatch } from "minimatch";
import { ACP_TOOL_NAME_PREFIX, acpToolNames } from "./tools.js";
import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
/**
* Permission rule format examples:
* - "Read" - matches all Read tool calls
* - "Read(./.env)" - matches specific path
* - "Read(./.env.*)" - glob pattern
* - "Read(./secrets/**)" - recursive glob
* - "Bash(npm run lint)" - exact command prefix
* - "Bash(npm run:*)" - command prefix with wildcard
*
* Docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules
*/
export interface PermissionSettings {
allow?: string[];
deny?: string[];
ask?: string[];
additionalDirectories?: string[];
defaultMode?: string;
}
export interface ClaudeCodeSettings {
permissions?: PermissionSettings;
env?: Record<string, string>;
model?: string;
}
export type PermissionDecision = "allow" | "deny" | "ask";
export interface PermissionCheckResult {
decision: PermissionDecision;
rule?: string;
source?: "allow" | "deny" | "ask";
}
interface ParsedRule {
toolName: string;
argument?: string;
isWildcard?: boolean;
}
/**
* Shell operators that can be used for command chaining/injection
* These should cause a prefix match to fail to prevent bypasses like:
* - "safe-cmd && malicious-cmd"
* - "safe-cmd; malicious-cmd"
* - "safe-cmd | malicious-cmd"
* - "safe-cmd || malicious-cmd"
* - "$(malicious-cmd)"
* - "`malicious-cmd`"
*/
const SHELL_OPERATORS = ["&&", "||", ";", "|", "$(", "`", "\n"];
/**
* Checks if a string contains shell operators that could allow command chaining
*/
function containsShellOperator(str: string): boolean {
return SHELL_OPERATORS.some((op) => str.includes(op));
}
/*
* Tools that modify files. Per Claude Code docs:
* "Edit rules apply to all built-in tools that edit files."
* This means an Edit(...) rule should match Write, MultiEdit, etc.
*/
const FILE_EDITING_TOOLS = [acpToolNames.edit, acpToolNames.write];
/**
* Tools that read files. Per Claude Code docs:
* "Claude will make a best-effort attempt to apply Read rules to all built-in tools
* that read files like Grep and Glob."
* This means a Read(...) rule should match Grep, Glob, etc.
*/
const FILE_READING_TOOLS = [acpToolNames.read];
/**
* Functions to extract the relevant argument from tool input for permission matching
*/
const TOOL_ARG_ACCESSORS: Record<string, (input: unknown) => string | undefined> = {
mcp__acp__Read: (input) => (input as { file_path?: string })?.file_path,
mcp__acp__Edit: (input) => (input as { file_path?: string })?.file_path,
mcp__acp__Write: (input) => (input as { file_path?: string })?.file_path,
mcp__acp__Bash: (input) => (input as { command?: string })?.command,
};
/**
* Parses a permission rule string into its components
* Examples:
* "Read" -> { toolName: "Read" }
* "Read(./.env)" -> { toolName: "Read", argument: "./.env" }
* "Bash(npm run:*)" -> { toolName: "Bash", argument: "npm run", isWildcard: true }
*/
function parseRule(rule: string): ParsedRule {
const match = rule.match(/^(\w+)(?:\((.+)\))?$/);
if (!match) {
return { toolName: rule };
}
const [, toolName, argument] = match;
if (argument && argument.endsWith(":*")) {
return {
toolName,
argument: argument.slice(0, -2),
isWildcard: true,
};
}
return { toolName, argument };
}
/**
* Normalizes a path for comparison:
* - Expands ~ to home directory
* - Resolves relative paths against cwd
* - Normalizes path separators
*/
function normalizePath(filePath: string, cwd: string): string {
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2));
} else if (filePath.startsWith("./")) {
filePath = path.join(cwd, filePath.slice(2));
} else if (!path.isAbsolute(filePath)) {
filePath = path.join(cwd, filePath);
}
// Convert backslashes to forward slashes for minimatch compatibility on Windows
return path.normalize(filePath).replace(/\\/g, "/");
}
/**
* Checks if a file path matches a glob pattern
*/
function matchesGlob(pattern: string, filePath: string, cwd: string): boolean {
const normalizedPattern = normalizePath(pattern, cwd);
const normalizedPath = normalizePath(filePath, cwd);
return minimatch(normalizedPath, normalizedPattern, {
dot: true,
matchBase: false,
nocase: process.platform === "win32",
});
}
/**
* Checks if a tool invocation matches a parsed permission rule
*/
function matchesRule(rule: ParsedRule, toolName: string, toolInput: unknown, cwd: string): boolean {
// Per Claude Code docs:
// - "Edit rules apply to all built-in tools that edit files."
// - "Claude will make a best-effort attempt to apply Read rules to all built-in tools
// that read files like Grep, Glob, and LS."
const ruleAppliesToTool =
(rule.toolName === "Bash" && toolName === acpToolNames.bash) ||
(rule.toolName === "Edit" && FILE_EDITING_TOOLS.includes(toolName)) ||
(rule.toolName === "Read" && FILE_READING_TOOLS.includes(toolName));
if (!ruleAppliesToTool) {
return false;
}
if (!rule.argument) {
return true;
}
const argAccessor = TOOL_ARG_ACCESSORS[toolName];
if (!argAccessor) {
return true;
}
const actualArg = argAccessor(toolInput);
if (!actualArg) {
return false;
}
if (toolName === acpToolNames.bash) {
// Per Claude Code docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules
// - Bash(npm run build) matches the EXACT command "npm run build"
// - Bash(npm run test:*) matches commands STARTING WITH "npm run test"
// The :* suffix enables prefix matching, without it the match is exact
//
// Also from docs: "Claude Code is aware of shell operators (like &&) so a prefix match
// rule like Bash(safe-cmd:*) won't give it permission to run the command safe-cmd && other-cmd"
if (rule.isWildcard) {
if (!actualArg.startsWith(rule.argument)) {
return false;
}
// Check that the matched prefix isn't followed by shell operators that could
// allow command chaining/injection
const remainder = actualArg.slice(rule.argument.length);
if (containsShellOperator(remainder)) {
return false;
}
return true;
}
return actualArg === rule.argument;
}
// For file-based tools (Read, Edit, Write), use glob matching
return matchesGlob(rule.argument, actualArg, cwd);
}
/**
* Reads and parses a JSON settings file, returning an empty object if not found or invalid
*/
async function loadSettingsFile(filePath: string | null): Promise<ClaudeCodeSettings> {
if (!filePath) {
return {};
}
try {
const content = await fs.promises.readFile(filePath, "utf-8");
return JSON.parse(content) as ClaudeCodeSettings;
} catch {
return {};
}
}
/**
* Gets the enterprise settings path based on the current platform
*/
export function getManagedSettingsPath(): string {
switch (process.platform) {
case "darwin":
return "/Library/Application Support/ClaudeCode/managed-settings.json";
case "linux":
return "/etc/claude-code/managed-settings.json";
case "win32":
return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
default:
return "/etc/claude-code/managed-settings.json";
}
}
export interface SettingsManagerOptions {
onChange?: () => void;
logger?: { log: (...args: any[]) => void; error: (...args: any[]) => void };
}
/**
* Manages Claude Code settings from multiple sources with proper precedence.
*
* Settings are loaded from (in order of increasing precedence):
* 1. User settings (~/.claude/settings.json)
* 2. Project settings (<cwd>/.claude/settings.json)
* 3. Local project settings (<cwd>/.claude/settings.local.json)
* 4. Enterprise managed settings (platform-specific path)
*
* The manager watches all settings files for changes and automatically reloads.
*/
export class SettingsManager {
private cwd: string;
private userSettings: ClaudeCodeSettings = {};
private projectSettings: ClaudeCodeSettings = {};
private localSettings: ClaudeCodeSettings = {};
private enterpriseSettings: ClaudeCodeSettings = {};
private mergedSettings: ClaudeCodeSettings = {};
private watchers: fs.FSWatcher[] = [];
private onChange?: () => void;
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
private initialized = false;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
constructor(cwd: string, options?: SettingsManagerOptions) {
this.cwd = cwd;
this.onChange = options?.onChange;
this.logger = options?.logger ?? console;
}
/**
* Initialize the settings manager by loading all settings and setting up file watchers
*/
async initialize(): Promise<void> {
if (this.initialized) {
return;
}
await this.loadAllSettings();
this.setupWatchers();
this.initialized = true;
}
/**
* Returns the path to the user settings file
*/
private getUserSettingsPath(): string {
return path.join(CLAUDE_CONFIG_DIR, "settings.json");
}
/**
* Returns the path to the project settings file
*/
private getProjectSettingsPath(): string {
return path.join(this.cwd, ".claude", "settings.json");
}
/**
* Returns the path to the local project settings file
*/
private getLocalSettingsPath(): string {
return path.join(this.cwd, ".claude", "settings.local.json");
}
/**
* Loads settings from all sources
*/
private async loadAllSettings(): Promise<void> {
const [userSettings, projectSettings, localSettings, enterpriseSettings] = await Promise.all([
loadSettingsFile(this.getUserSettingsPath()),
loadSettingsFile(this.getProjectSettingsPath()),
loadSettingsFile(this.getLocalSettingsPath()),
loadSettingsFile(getManagedSettingsPath()),
]);
this.userSettings = userSettings;
this.projectSettings = projectSettings;
this.localSettings = localSettings;
this.enterpriseSettings = enterpriseSettings;
this.mergeSettings();
}
/**
* Merges all settings sources with proper precedence.
* For permissions, rules from all sources are combined.
* Deny rules always take precedence during permission checks.
*/
private mergeSettings(): void {
const allSettings = [
this.userSettings,
this.projectSettings,
this.localSettings,
this.enterpriseSettings,
];
const merged: ClaudeCodeSettings = {
permissions: {
allow: [],
deny: [],
ask: [],
},
};
for (const settings of allSettings) {
if (settings.permissions) {
if (settings.permissions.allow) {
merged.permissions!.allow!.push(...settings.permissions.allow);
}
if (settings.permissions.deny) {
merged.permissions!.deny!.push(...settings.permissions.deny);
}
if (settings.permissions.ask) {
merged.permissions!.ask!.push(...settings.permissions.ask);
}
if (settings.permissions.additionalDirectories) {
merged.permissions!.additionalDirectories = [
...(merged.permissions!.additionalDirectories || []),
...settings.permissions.additionalDirectories,
];
}
if (settings.permissions.defaultMode) {
merged.permissions!.defaultMode = settings.permissions.defaultMode;
}
}
if (settings.env) {
merged.env = { ...merged.env, ...settings.env };
}
if (settings.model) {
merged.model = settings.model;
}
}
this.mergedSettings = merged;
}
/**
* Sets up file watchers for all settings files
*/
private setupWatchers(): void {
const paths = [
this.getUserSettingsPath(),
this.getProjectSettingsPath(),
this.getLocalSettingsPath(),
getManagedSettingsPath(),
];
for (const filePath of paths) {
if (!filePath) continue;
try {
const dir = path.dirname(filePath);
const filename = path.basename(filePath);
if (fs.existsSync(dir)) {
const watcher = fs.watch(dir, (eventType, changedFilename) => {
if (changedFilename === filename) {
this.handleSettingsChange();
}
});
watcher.on("error", (error) => {
this.logger.error(`Settings watcher error for ${filePath}:`, error);
});
this.watchers.push(watcher);
}
} catch (error) {
this.logger.error(`Failed to set up watcher for ${filePath}:`, error);
}
}
}
/**
* Handles settings file changes with debouncing to avoid rapid reloads
*/
private handleSettingsChange(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(async () => {
this.debounceTimer = null;
try {
await this.loadAllSettings();
this.onChange?.();
} catch (error) {
this.logger.error("Failed to reload settings:", error);
}
}, 100);
}
/**
* Checks if a tool invocation is allowed based on the loaded settings.
*
* @param toolName - The tool name (can be ACP-prefixed like mcp__acp__Read or plain like Read)
* @param toolInput - The tool input object
* @returns The permission decision and matching rule info
*/
checkPermission(toolName: string, toolInput: unknown): PermissionCheckResult {
if (!toolName.startsWith(ACP_TOOL_NAME_PREFIX)) {
return { decision: "ask" };
}
const permissions = this.mergedSettings.permissions;
if (!permissions) {
return { decision: "ask" };
}
// Check deny rules first (highest priority)
for (const rule of permissions.deny || []) {
const parsed = parseRule(rule);
if (matchesRule(parsed, toolName, toolInput, this.cwd)) {
return { decision: "deny", rule, source: "deny" };
}
}
// Check allow rules
for (const rule of permissions.allow || []) {
const parsed = parseRule(rule);
if (matchesRule(parsed, toolName, toolInput, this.cwd)) {
return { decision: "allow", rule, source: "allow" };
}
}
// Check ask rules
for (const rule of permissions.ask || []) {
const parsed = parseRule(rule);
if (matchesRule(parsed, toolName, toolInput, this.cwd)) {
return { decision: "ask", rule, source: "ask" };
}
}
// No matching rule - default to ask
return { decision: "ask" };
}
/**
* Returns the current merged settings
*/
getSettings(): ClaudeCodeSettings {
return this.mergedSettings;
}
/**
* Returns the current working directory
*/
getCwd(): string {
return this.cwd;
}
/**
* Updates the working directory and reloads project-specific settings
*/
async setCwd(cwd: string): Promise<void> {
if (this.cwd === cwd) {
return;
}
this.dispose();
this.cwd = cwd;
this.initialized = false;
await this.initialize();
}
/**
* Disposes of file watchers and cleans up resources
*/
dispose(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
for (const watcher of this.watchers) {
watcher.close();
}
this.watchers = [];
this.initialized = false;
}
}

View File

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

View File

@@ -21,13 +21,6 @@ import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from ".
import { toAcpNotifications, promptToClaude } from "../acp-agent.js";
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { randomUUID } from "crypto";
import type {
BetaToolResultBlockParam,
BetaToolSearchToolResultBlockParam,
BetaWebSearchToolResultBlockParam,
BetaWebFetchToolResultBlockParam,
BetaCodeExecutionToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
let child: ReturnType<typeof spawn>;
@@ -173,7 +166,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
expect(commands).toContainEqual({
name: "say-hello",
description: "Say hello (project)",
input: { hint: "name" },
input: { hint: "[name]" },
});
await connection.prompt({
@@ -258,7 +251,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "execute",
title: "`rm README.md.rm`",
content: [
@@ -283,7 +276,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "search",
title: "Find `*/**.ts`",
content: [],
@@ -304,7 +297,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "think",
title: "Handle user's work request",
content: [
@@ -329,7 +322,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "search",
title: "List the `/Users/test/github/claude-code-acp` directory's contents",
content: [],
@@ -347,7 +340,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "search",
title: 'grep ".*"',
content: [],
@@ -365,7 +358,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "edit",
title: "Write /Users/test/project/example.txt",
content: [
@@ -391,7 +384,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "edit",
title: "Write /Users/test/project/config.json",
content: [
@@ -416,7 +409,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "read",
title: "Read File",
content: [],
@@ -434,7 +427,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/data.json",
content: [],
@@ -453,7 +446,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: [],
@@ -473,7 +466,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: [],
@@ -492,7 +485,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: [],
@@ -500,6 +493,50 @@ describe("tool conversions", () => {
});
});
it("should handle WebFetch tool calls", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01LxEjDn8ci9SAc3qG7LbbXV",
name: "WebFetch",
input: {
url: "https://agentclientprotocol.com",
prompt:
"Please provide a comprehensive summary of the content on this page, including what the Agent Client Protocol is, its main features, documentation links, and any other relevant information.",
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "fetch",
title: "Fetch https://agentclientprotocol.com",
content: [
{
content: {
text: "Please provide a comprehensive summary of the content on this page, including what the Agent Client Protocol is, its main features, documentation links, and any other relevant information.",
type: "text",
},
type: "content",
},
],
});
});
it("should handle WebSearch tool calls", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01NYMwiZFbdoQFxYxuQDFZXQ",
name: "WebSearch",
input: {
query: "agentclientprotocol.com",
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "fetch",
title: '"agentclientprotocol.com"',
content: [],
});
});
it("should handle KillBash entries", () => {
const tool_use = {
type: "tool_use",
@@ -510,7 +547,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "execute",
title: `Kill Process`,
content: [],
@@ -527,7 +564,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "execute",
title: `Tail Logs`,
content: [],
@@ -620,6 +657,7 @@ describe("tool conversions", () => {
received.message.role,
"test",
{},
{},
{} as AgentSideConnection,
console,
),
@@ -741,200 +779,6 @@ describe("tool conversions", () => {
],
});
});
it("should transform tool_reference content to valid ACP content", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "ToolSearch",
input: { query: "test" },
};
const toolResult: BetaToolResultBlockParam = {
content: [
{
type: "tool_reference",
tool_name: "some_discovered_tool",
},
],
tool_use_id: "toolu_01MNO345",
is_error: false,
type: "tool_result",
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update).toEqual({
content: [
{
type: "content",
content: { type: "text", text: "Tool: some_discovered_tool" },
},
],
});
});
it("should transform web_search_result content to valid ACP content", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "WebSearch",
input: { query: "test" },
};
const toolResult: BetaWebSearchToolResultBlockParam = {
content: [
{
type: "web_search_result",
title: "Test Result",
url: "https://example.com",
encrypted_content: "...",
page_age: null,
},
],
tool_use_id: "toolu_01MNO345",
type: "web_search_tool_result",
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update).toEqual({
content: [
{
type: "content",
content: { type: "text", text: "Test Result (https://example.com)" },
},
],
});
});
it("should transform web_search_tool_result_error to valid ACP content", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "WebSearch",
input: { query: "test" },
};
const toolResult: BetaWebSearchToolResultBlockParam = {
content: {
type: "web_search_tool_result_error",
error_code: "unavailable",
},
tool_use_id: "toolu_01MNO345",
type: "web_search_tool_result",
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update).toEqual({
content: [
{
type: "content",
content: { type: "text", text: "Error: unavailable" },
},
],
});
});
it("should transform code_execution_result content to valid ACP content", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "CodeExecution",
input: {},
};
const toolResult: BetaCodeExecutionToolResultBlockParam = {
content: {
type: "code_execution_result",
stdout: "Hello World",
stderr: "",
return_code: 0,
content: [],
},
tool_use_id: "toolu_01MNO345",
type: "code_execution_tool_result",
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update).toEqual({
content: [
{
type: "content",
content: { type: "text", text: "Output: Hello World" },
},
],
});
});
it("should transform web_fetch_result content to valid ACP content", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "WebFetch",
input: { url: "https://example.com" },
};
const toolResult: BetaWebFetchToolResultBlockParam = {
content: {
type: "web_fetch_result",
url: "https://example.com",
content: {
type: "document",
citations: null,
title: null,
source: { type: "text", media_type: "text/plain", data: "Page content here" },
},
},
tool_use_id: "toolu_01MNO345",
type: "web_fetch_tool_result",
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update).toEqual({
content: [
{
type: "content",
content: { type: "text", text: "Fetched: https://example.com" },
},
],
});
});
it("should transform tool_search_tool_search_result to valid ACP content", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "ToolSearch",
input: { query: "test" },
};
const toolResult: BetaToolSearchToolResultBlockParam = {
content: {
type: "tool_search_tool_search_result",
tool_references: [
{ type: "tool_reference", tool_name: "tool_a" },
{ type: "tool_reference", tool_name: "tool_b" },
],
},
tool_use_id: "toolu_01MNO345",
type: "tool_search_tool_result",
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update).toEqual({
content: [
{
type: "content",
content: { type: "text", text: "Tools found: tool_a, tool_b" },
},
],
});
});
});
describe("escape markdown", () => {
@@ -1047,7 +891,7 @@ describe("permission requests", () => {
for (const testCase of testCases) {
// Get the tool info that would be used in requestPermission
const toolInfo = toolInfoFromToolUse(testCase.toolUse);
const toolInfo = toolInfoFromToolUse(testCase.toolUse, {});
// Verify toolInfo has a title
expect(toolInfo.title).toBeDefined();

View File

@@ -1,386 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { AgentSideConnection } from "@agentclientprotocol/sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
describe("unstable_listSessions", () => {
let tempDir: string;
let originalClaudeConfigDir: string | undefined;
let agent: ClaudeAcpAgentType;
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
// Helper to create a mock AgentSideConnection
function createMockClient(): AgentSideConnection {
return {
sessionUpdate: async () => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection;
}
// Helper to encode a path like Claude does:
// - Unix: "/Users/test" -> "-Users-test"
// - Windows: "C:\Users\test" -> "C-Users-test"
function encodePath(cwd: string): string {
// Handle Windows paths
if (/^[A-Za-z]:\\/.test(cwd)) {
return cwd.replace(/\\/g, "-").replace(":", "");
}
// Unix paths
return cwd.replace(/\//g, "-");
}
// Helper to write a session file
function writeSessionFile(
cwd: string,
sessionId: string,
options: {
userMessage?: string;
userMessageArray?: boolean;
mtime?: Date;
malformed?: boolean;
isAgentFile?: boolean;
} = {},
): void {
const encodedPath = encodePath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
const filename = options.isAgentFile ? `agent-${sessionId}.jsonl` : `${sessionId}.jsonl`;
const filePath = path.join(projectDir, filename);
if (options.malformed) {
fs.writeFileSync(filePath, "not valid json\n{also bad");
return;
}
const lines: string[] = [];
// First line with sessionId
lines.push(JSON.stringify({ sessionId, type: "init" }));
// User message if provided
if (options.userMessage) {
const content = options.userMessageArray
? [{ type: "text", text: options.userMessage }]
: options.userMessage;
lines.push(JSON.stringify({ type: "user", message: { content } }));
}
// Assistant response
lines.push(JSON.stringify({ type: "assistant", message: { content: "Hello!" } }));
fs.writeFileSync(filePath, lines.join("\n"));
// Set modification time if specified
if (options.mtime) {
fs.utimesSync(filePath, options.mtime, options.mtime);
}
}
beforeEach(async () => {
// Create temp directory to isolate tests from real filesystem.
// We set process.env.CLAUDE_CONFIG_DIR before importing the module so that
// CLAUDE_CONFIG_DIR is evaluated with our temp directory.
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-test-"));
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = tempDir;
// Reset modules to pick up the new CLAUDE_CONFIG_DIR env var
vi.resetModules();
// Dynamic import after setting env var
const acpAgent = await import("../acp-agent.js");
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
// Create agent instance
agent = new ClaudeAcpAgent(createMockClient());
});
afterEach(() => {
// Restore env and cleanup
if (originalClaudeConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
} else {
delete process.env.CLAUDE_CONFIG_DIR;
}
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("returns empty array when projects directory does not exist", async () => {
const result = await agent.unstable_listSessions({});
expect(result.sessions).toEqual([]);
expect(result.nextCursor).toBeUndefined();
});
it("returns empty array when projects directory is empty", async () => {
fs.mkdirSync(path.join(tempDir, "projects"), { recursive: true });
const result = await agent.unstable_listSessions({});
expect(result.sessions).toEqual([]);
});
it("parses session files and returns correct metadata", async () => {
const cwd = "/Users/test/myproject";
const sessionId = "sess-123";
const mtime = new Date("2025-01-15T10:30:00Z");
writeSessionFile(cwd, sessionId, {
userMessage: "Hello, can you help me?",
mtime,
});
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]).toMatchObject({
sessionId: "sess-123",
cwd: "/Users/test/myproject",
title: "Hello, can you help me?",
});
expect(result.sessions[0]!.updatedAt).toBeDefined();
});
it("extracts title from array-style user message content", async () => {
writeSessionFile("/Users/test/project", "sess-456", {
userMessage: "Fix the bug in auth",
userMessageArray: true,
});
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.title).toBe("Fix the bug in auth");
});
it("truncates long titles to 128 characters with ellipsis", async () => {
const longMessage = "A".repeat(150);
writeSessionFile("/Users/test/project", "sess-789", {
userMessage: longMessage,
});
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.title).toBe("A".repeat(127) + "…");
});
it("replaces newlines with spaces in titles", async () => {
const messageWithNewlines = "First line\nSecond line\r\nThird line";
writeSessionFile("/Users/test/project", "sess-newlines", {
userMessage: messageWithNewlines,
});
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.title).toBe("First line Second line Third line");
});
it("collapses multiple whitespace characters in titles", async () => {
const messageWithWhitespace = "Hello world \n\n test";
writeSessionFile("/Users/test/project", "sess-whitespace", {
userMessage: messageWithWhitespace,
});
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.title).toBe("Hello world test");
});
it("filters sessions by cwd parameter", async () => {
writeSessionFile("/Users/test/projectone", "sess-a", { userMessage: "Project A" });
writeSessionFile("/Users/test/projecttwo", "sess-b", { userMessage: "Project B" });
const result = await agent.unstable_listSessions({ cwd: "/Users/test/projectone" });
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("sess-a");
});
it("returns all sessions when cwd is not specified", async () => {
writeSessionFile("/Users/test/projectone", "sess-a", { userMessage: "A" });
writeSessionFile("/Users/test/projecttwo", "sess-b", { userMessage: "B" });
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(2);
});
it("sorts sessions by updatedAt descending (most recent first)", async () => {
writeSessionFile("/Users/test/project", "old-session", {
userMessage: "Old",
mtime: new Date("2025-01-01T00:00:00Z"),
});
writeSessionFile("/Users/test/project", "new-session", {
userMessage: "New",
mtime: new Date("2025-01-15T00:00:00Z"),
});
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.sessionId).toBe("new-session");
expect(result.sessions[1]!.sessionId).toBe("old-session");
});
it("skips files starting with agent-", async () => {
writeSessionFile("/Users/test/project", "regular-session", { userMessage: "Regular" });
writeSessionFile("/Users/test/project", "hidden-session", {
userMessage: "Hidden",
isAgentFile: true,
});
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("regular-session");
});
it("skips malformed session files gracefully", async () => {
writeSessionFile("/Users/test/project", "good-session", { userMessage: "Good" });
writeSessionFile("/Users/test/project", "bad-session", { malformed: true });
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("good-session");
});
it("uses filename as sessionId when not in file content", async () => {
const cwd = "/Users/test/project";
const encodedPath = encodePath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
// Write file without sessionId in content
const filePath = path.join(projectDir, "fallback-id.jsonl");
fs.writeFileSync(filePath, JSON.stringify({ type: "init" }) + "\n");
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.sessionId).toBe("fallback-id");
});
it("returns null title when no user message exists", async () => {
const cwd = "/Users/test/project";
const encodedPath = encodePath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
// Write file with only init and assistant message
const filePath = path.join(projectDir, "no-user.jsonl");
fs.writeFileSync(
filePath,
[
JSON.stringify({ sessionId: "no-user", type: "init" }),
JSON.stringify({ type: "assistant", message: { content: "Hi" } }),
].join("\n"),
);
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.title).toBeNull();
});
describe("pagination", () => {
it("returns nextCursor when there are more results", async () => {
// Create 60 sessions (more than PAGE_SIZE of 50)
for (let i = 0; i < 60; i++) {
writeSessionFile("/Users/test/project", `sess-${i.toString().padStart(3, "0")}`, {
userMessage: `Session ${i}`,
mtime: new Date(Date.now() - i * 1000), // Each older by 1 second
});
}
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(50);
expect(result.nextCursor).toBeDefined();
});
it("does not return nextCursor when all results fit in one page", async () => {
for (let i = 0; i < 10; i++) {
writeSessionFile("/Users/test/project", `sess-${i}`, { userMessage: `Session ${i}` });
}
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(10);
expect(result.nextCursor).toBeUndefined();
});
it("returns next page when cursor is provided", async () => {
// Create 60 sessions
for (let i = 0; i < 60; i++) {
writeSessionFile("/Users/test/project", `sess-${i.toString().padStart(3, "0")}`, {
userMessage: `Session ${i}`,
mtime: new Date(Date.now() - i * 1000),
});
}
// Get first page
const firstPage = await agent.unstable_listSessions({});
expect(firstPage.sessions).toHaveLength(50);
expect(firstPage.nextCursor).toBeDefined();
// Get second page
const secondPage = await agent.unstable_listSessions({ cursor: firstPage.nextCursor });
expect(secondPage.sessions).toHaveLength(10);
expect(secondPage.nextCursor).toBeUndefined();
});
it("handles invalid cursor gracefully (starts from beginning)", async () => {
writeSessionFile("/Users/test/project", "sess-1", { userMessage: "Test" });
const result = await agent.unstable_listSessions({ cursor: "invalid-base64!" });
expect(result.sessions).toHaveLength(1);
});
it("handles malformed cursor JSON gracefully", async () => {
writeSessionFile("/Users/test/project", "sess-1", { userMessage: "Test" });
// Valid base64 but not valid JSON
const badCursor = Buffer.from("not json").toString("base64");
const result = await agent.unstable_listSessions({ cursor: badCursor });
expect(result.sessions).toHaveLength(1);
});
});
describe("Windows path support", () => {
it("decodes Windows-style paths correctly", async () => {
// Simulate a Windows path encoded as "C-Users-test-project"
const windowsCwd = "C:\\Users\\test\\project";
writeSessionFile(windowsCwd, "win-session", { userMessage: "Windows test" });
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.cwd).toBe("C:\\Users\\test\\project");
expect(result.sessions[0]!.sessionId).toBe("win-session");
});
it("filters Windows paths by cwd correctly", async () => {
writeSessionFile("C:\\Users\\test\\projectone", "win-a", { userMessage: "A" });
writeSessionFile("C:\\Users\\test\\projecttwo", "win-b", { userMessage: "B" });
const result = await agent.unstable_listSessions({ cwd: "C:\\Users\\test\\projectone" });
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("win-a");
});
it("handles mixed Unix and Windows paths", async () => {
writeSessionFile("/Users/test/unixproject", "unix-session", { userMessage: "Unix" });
writeSessionFile("C:\\Users\\test\\winproject", "win-session", { userMessage: "Windows" });
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(2);
const cwds = result.sessions.map((s) => s.cwd);
expect(cwds).toContain("/Users/test/unixproject");
expect(cwds).toContain("C:\\Users\\test\\winproject");
});
});
});

View File

@@ -1,652 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { SettingsManager } from "../settings.js";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
describe("SettingsManager", () => {
let tempDir: string;
let settingsManager: SettingsManager;
beforeEach(async () => {
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-test-"));
});
afterEach(async () => {
settingsManager?.dispose();
await fs.promises.rm(tempDir, { recursive: true, force: true });
});
describe("permission checking", () => {
it("should return 'ask' when no settings exist", async () => {
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const result = settingsManager.checkPermission("mcp__acp__Read", {
file_path: "/some/file.txt",
});
expect(result.decision).toBe("ask");
});
it("should return 'ask' for non-ACP tools (permission checks only apply to mcp__acp__* tools)", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
deny: ["Read"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
// Non-ACP tools should always return 'ask' regardless of rules
const result = settingsManager.checkPermission("Read", { file_path: "/some/file.txt" });
expect(result.decision).toBe("ask");
});
it("should allow tool use when matching allow rule exists", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Read"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const result = settingsManager.checkPermission("mcp__acp__Read", {
file_path: "/some/file.txt",
});
expect(result.decision).toBe("allow");
expect(result.rule).toBe("Read");
});
it("should deny tool use when matching deny rule exists", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
deny: ["Read(./.env)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const result = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, ".env"),
});
expect(result.decision).toBe("deny");
expect(result.rule).toBe("Read(./.env)");
});
it("should prioritize deny rules over allow rules", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Read"],
deny: ["Read(./.env)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
// .env should be denied
const envResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, ".env"),
});
expect(envResult.decision).toBe("deny");
// other files should be allowed
const otherResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, "other.txt"),
});
expect(otherResult.decision).toBe("allow");
});
it("should handle ACP-prefixed tool names", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Read"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const result = settingsManager.checkPermission("mcp__acp__Read", {
file_path: "/some/file.txt",
});
expect(result.decision).toBe("allow");
});
});
describe("Bash permission rules", () => {
it("should match exact Bash commands without :* wildcard", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
// Per docs: Bash(npm run build) matches the EXACT command "npm run build"
allow: ["Bash(npm run lint)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
// Exact match should be allowed
const exactResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "npm run lint",
});
expect(exactResult.decision).toBe("allow");
// Command with extra arguments should NOT match (exact match only)
const withArgsResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "npm run lint --fix",
});
expect(withArgsResult.decision).toBe("ask");
// Similar command should NOT match (exact match only)
const similarResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "npm run linting",
});
expect(similarResult.decision).toBe("ask");
// Different command should not match
const differentResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "npm run test",
});
expect(differentResult.decision).toBe("ask");
});
it("should match Bash commands with :* wildcard suffix (prefix matching)", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
// The :* suffix is a convention to make prefix matching explicit
allow: ["Bash(npm run:*)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
// Any command starting with "npm run" should match (prefix matching with :*)
const lintResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "npm run lint",
});
expect(lintResult.decision).toBe("allow");
const testResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "npm run test",
});
expect(testResult.decision).toBe("allow");
// Commands with additional args also match
const withArgsResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "npm run test --watch",
});
expect(withArgsResult.decision).toBe("allow");
// Non-matching command
const installResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "npm install",
});
expect(installResult.decision).toBe("ask");
});
it("should not allow shell operators to bypass prefix matching", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Bash(safe-cmd:*)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
// Normal prefix match should work
const normalResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "safe-cmd --flag",
});
expect(normalResult.decision).toBe("allow");
// Shell operators should NOT be allowed (per docs: Claude Code is aware of shell operators)
const andResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "safe-cmd && malicious-cmd",
});
expect(andResult.decision).toBe("ask");
const orResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "safe-cmd || malicious-cmd",
});
expect(orResult.decision).toBe("ask");
const semicolonResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "safe-cmd; malicious-cmd",
});
expect(semicolonResult.decision).toBe("ask");
const pipeResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "safe-cmd | malicious-cmd",
});
expect(pipeResult.decision).toBe("ask");
const subshellResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "safe-cmd $(malicious-cmd)",
});
expect(subshellResult.decision).toBe("ask");
const backtickResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "safe-cmd `malicious-cmd`",
});
expect(backtickResult.decision).toBe("ask");
const newlineResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "safe-cmd\nmalicious-cmd",
});
expect(newlineResult.decision).toBe("ask");
});
it("should deny dangerous Bash commands", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
deny: ["Bash(curl:*)", "Bash(wget:*)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const curlResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "curl https://example.com",
});
expect(curlResult.decision).toBe("deny");
const wgetResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "wget https://example.com",
});
expect(wgetResult.decision).toBe("deny");
const lsResult = settingsManager.checkPermission("mcp__acp__Bash", { command: "ls -la" });
expect(lsResult.decision).toBe("ask");
});
});
describe("file path glob matching", () => {
it("should match exact file paths", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
deny: ["Read(./.env)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const envResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, ".env"),
});
expect(envResult.decision).toBe("deny");
const otherResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, ".envrc"),
});
expect(otherResult.decision).toBe("ask");
});
it("should match glob patterns with single wildcard", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
deny: ["Read(./.env.*)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const envLocalResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, ".env.local"),
});
expect(envLocalResult.decision).toBe("deny");
const envProdResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, ".env.production"),
});
expect(envProdResult.decision).toBe("deny");
// Plain .env should not match .env.*
const plainEnvResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, ".env"),
});
expect(plainEnvResult.decision).toBe("ask");
});
it("should match glob patterns with double wildcard (recursive)", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
deny: ["Read(./secrets/**)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const secretResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, "secrets", "api-key.txt"),
});
expect(secretResult.decision).toBe("deny");
const nestedSecretResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, "secrets", "deep", "nested", "key.txt"),
});
expect(nestedSecretResult.decision).toBe("deny");
const otherResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, "public", "file.txt"),
});
expect(otherResult.decision).toBe("ask");
});
it("should handle home directory expansion", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Read(~/.zshrc)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const zshrcResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(os.homedir(), ".zshrc"),
});
expect(zshrcResult.decision).toBe("allow");
});
});
describe("settings merging", () => {
it("should merge project and local settings", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
// Project settings
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Read"],
},
}),
);
// Local settings
await fs.promises.writeFile(
path.join(claudeDir, "settings.local.json"),
JSON.stringify({
permissions: {
deny: ["Read(./.env)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
// Read should be allowed in general
const readResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, "file.txt"),
});
expect(readResult.decision).toBe("allow");
// But .env should be denied (local settings take precedence)
const envResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, ".env"),
});
expect(envResult.decision).toBe("deny");
});
it("should merge model setting with later sources taking precedence", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
// Project settings with one model
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
model: "claude-3-5-sonnet",
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
let settings = settingsManager.getSettings();
expect(settings.model).toBe("claude-3-5-sonnet");
// Add local settings that override the model
await fs.promises.writeFile(
path.join(claudeDir, "settings.local.json"),
JSON.stringify({
model: "claude-3-5-haiku",
}),
);
// Re-initialize to pick up local settings
settingsManager.dispose();
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
settings = settingsManager.getSettings();
expect(settings.model).toBe("claude-3-5-haiku");
});
});
describe("ask rules", () => {
it("should return 'ask' for matching ask rules", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
ask: ["Bash(git push:*)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const result = settingsManager.checkPermission("mcp__acp__Bash", {
command: "git push origin main",
});
expect(result.decision).toBe("ask");
expect(result.rule).toBe("Bash(git push:*)");
});
});
describe("Edit and Write tools", () => {
it("should handle Edit tool permissions", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
deny: ["Edit(./package-lock.json)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const lockFileResult = settingsManager.checkPermission("mcp__acp__Edit", {
file_path: path.join(tempDir, "package-lock.json"),
});
expect(lockFileResult.decision).toBe("deny");
const otherResult = settingsManager.checkPermission("mcp__acp__Edit", {
file_path: path.join(tempDir, "package.json"),
});
expect(otherResult.decision).toBe("ask");
});
it("should handle mcp__acp__Edit and mcp__acp__Write tool names", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Edit", "Write"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const editResult = settingsManager.checkPermission("mcp__acp__Edit", {
file_path: "/some/file.ts",
});
expect(editResult.decision).toBe("allow");
const writeResult = settingsManager.checkPermission("mcp__acp__Write", {
file_path: "/some/file.ts",
});
expect(writeResult.decision).toBe("allow");
});
});
describe("getSettings", () => {
it("should return merged settings", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Read", "Bash(npm:*)"],
deny: ["Read(./.env)"],
},
env: {
FOO: "bar",
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const settings = settingsManager.getSettings();
expect(settings.permissions?.allow).toContain("Read");
expect(settings.permissions?.allow).toContain("Bash(npm:*)");
expect(settings.permissions?.deny).toContain("Read(./.env)");
expect(settings.env?.FOO).toBe("bar");
});
});
describe("setCwd", () => {
it("should reload settings when cwd changes", async () => {
const claudeDir1 = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir1, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir1, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Read"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
let result = settingsManager.checkPermission("mcp__acp__Read", { file_path: "/file.txt" });
expect(result.decision).toBe("allow");
// Create a new temp directory with different settings
const tempDir2 = await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-test-2-"));
const claudeDir2 = path.join(tempDir2, ".claude");
await fs.promises.mkdir(claudeDir2, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir2, "settings.json"),
JSON.stringify({
permissions: {
deny: ["Read"],
},
}),
);
await settingsManager.setCwd(tempDir2);
result = settingsManager.checkPermission("mcp__acp__Read", { file_path: "/file.txt" });
expect(result.decision).toBe("deny");
// Cleanup second temp dir
await fs.promises.rm(tempDir2, { recursive: true, force: true });
});
});
});

View File

@@ -1,318 +0,0 @@
import { describe, it, expect } from "vitest";
import { AgentSideConnection } from "@agentclientprotocol/sdk";
import { ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
import {
BetaMCPToolResultBlock,
BetaTextBlock,
BetaWebSearchResultBlock,
BetaWebSearchToolResultBlock,
BetaBashCodeExecutionToolResultBlock,
BetaBashCodeExecutionResultBlock,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import { toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
describe("rawOutput in tool call updates", () => {
const mockClient = {} as AgentSideConnection;
const mockLogger: Logger = { log: () => {}, error: () => {} };
it("should include rawOutput with string content for tool_result", () => {
const toolUseCache: ToolUseCache = {
toolu_123: {
type: "tool_use",
id: "toolu_123",
name: "Bash",
input: { command: "echo hello" },
},
};
const toolResult: ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "toolu_123",
content: "hello\n",
is_error: false,
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_123",
status: "completed",
rawOutput: "hello\n",
});
});
it("should include rawOutput with array content for tool_result", () => {
const toolUseCache: ToolUseCache = {
toolu_456: {
type: "tool_use",
id: "toolu_456",
name: "Read",
input: { file_path: "/test/file.txt" },
},
};
// ToolResultBlockParam content can be string or array of TextBlockParam
const toolResult: ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "toolu_456",
content: [{ type: "text", text: "Line 1\nLine 2\nLine 3" }],
is_error: false,
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_456",
status: "completed",
rawOutput: [{ type: "text", text: "Line 1\nLine 2\nLine 3" }],
});
});
it("should include rawOutput for mcp_tool_result with string content", () => {
const toolUseCache: ToolUseCache = {
toolu_789: {
type: "tool_use",
id: "toolu_789",
name: "mcp__server__tool",
input: { query: "test" },
},
};
// BetaMCPToolResultBlock content can be string or Array<BetaTextBlock>
const toolResult: BetaMCPToolResultBlock = {
type: "mcp_tool_result",
tool_use_id: "toolu_789",
content: '{"result": "success", "data": [1, 2, 3]}',
is_error: false,
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_789",
status: "completed",
rawOutput: '{"result": "success", "data": [1, 2, 3]}',
});
});
it("should include rawOutput for mcp_tool_result with array content", () => {
const toolUseCache: ToolUseCache = {
toolu_abc: {
type: "tool_use",
id: "toolu_abc",
name: "mcp__server__search",
input: { term: "test" },
},
};
// BetaTextBlock requires citations field
const arrayContent: BetaTextBlock[] = [
{ type: "text", text: "Result 1", citations: null },
{ type: "text", text: "Result 2", citations: null },
];
const toolResult: BetaMCPToolResultBlock = {
type: "mcp_tool_result",
tool_use_id: "toolu_abc",
content: arrayContent,
is_error: false,
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_abc",
status: "completed",
rawOutput: arrayContent,
});
});
it("should include rawOutput for web_search_tool_result", () => {
const toolUseCache: ToolUseCache = {
toolu_web: {
type: "tool_use",
id: "toolu_web",
name: "WebSearch",
input: { query: "test search" },
},
};
// BetaWebSearchResultBlock from SDK
const searchResults: BetaWebSearchResultBlock[] = [
{
type: "web_search_result",
url: "https://example.com",
title: "Example",
encrypted_content: "encrypted content here",
page_age: "2 days ago",
},
];
const toolResult: BetaWebSearchToolResultBlock = {
type: "web_search_tool_result",
tool_use_id: "toolu_web",
content: searchResults,
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_web",
status: "completed",
rawOutput: searchResults,
});
});
it("should include rawOutput for bash_code_execution_tool_result", () => {
const toolUseCache: ToolUseCache = {
toolu_bash: {
type: "tool_use",
id: "toolu_bash",
name: "Bash",
input: { command: "ls -la" },
},
};
// BetaBashCodeExecutionResultBlock from SDK
const bashResult: BetaBashCodeExecutionResultBlock = {
type: "bash_code_execution_result",
stdout: "file1.txt\nfile2.txt",
stderr: "",
return_code: 0,
content: [],
};
const toolResult: BetaBashCodeExecutionToolResultBlock = {
type: "bash_code_execution_tool_result",
tool_use_id: "toolu_bash",
content: bashResult,
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_bash",
status: "completed",
rawOutput: bashResult,
});
});
it("should set status to failed when is_error is true", () => {
const toolUseCache: ToolUseCache = {
toolu_err: {
type: "tool_use",
id: "toolu_err",
name: "Bash",
input: { command: "invalid_command" },
},
};
const toolResult: ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "toolu_err",
content: "command not found: invalid_command",
is_error: true,
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_err",
status: "failed",
rawOutput: "command not found: invalid_command",
});
});
it("should not emit tool_call_update for TodoWrite (emits plan instead)", () => {
const toolUseCache: ToolUseCache = {
toolu_todo: {
type: "tool_use",
id: "toolu_todo",
name: "TodoWrite",
input: { todos: [{ content: "Test task", status: "pending" }] },
},
};
const toolResult: ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "toolu_todo",
content: "Todos updated successfully",
is_error: false,
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
// TodoWrite should not emit tool_call_update - it emits plan updates instead
expect(notifications).toHaveLength(0);
});
});

View File

@@ -1,558 +0,0 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { spawnSync } from "child_process";
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
"TypeScript declaration files integration",
() => {
let tempDir: string;
let tarballPath: string;
const projectRoot = path.resolve(__dirname, "../..");
// Base configuration templates
const basePackageJson = {
name: "ts-declaration-test",
version: "1.0.0",
type: "module",
dependencies: {},
devDependencies: {
typescript: "5.9.3",
"@types/node": "25.0.3",
},
};
const baseTsConfig = {
compilerOptions: {
target: "ES2020",
module: "NodeNext",
moduleResolution: "NodeNext",
lib: ["ES2020"],
strict: true,
esModuleInterop: true,
skipLibCheck: true, // Skip checking dependency internals, focus on our types
noEmit: true,
declaration: false,
},
include: ["*.ts"],
};
// Build and pack the package once for all tests
beforeAll(async () => {
// Step 1: Clean dist folder to ensure fresh build
const distPath = path.join(projectRoot, "dist");
await fs.promises.rm(distPath, { recursive: true, force: true });
console.log("Cleaned dist folder");
console.log("Building package...");
// Step 2: Build the package
const buildResult = spawnSync("npm", ["run", "build"], {
cwd: projectRoot,
stdio: "pipe",
encoding: "utf-8",
});
if (buildResult.status !== 0) {
throw new Error(`Build failed: ${buildResult.stderr || buildResult.stdout}`);
}
console.log("Packing package...");
// Step 3: Pack to create tarball
const packResult = spawnSync("npm", ["pack", "--pack-destination", os.tmpdir()], {
cwd: projectRoot,
stdio: "pipe",
encoding: "utf-8",
});
if (packResult.status !== 0) {
throw new Error(`Pack failed: ${packResult.stderr || packResult.stdout}`);
}
// Get the tarball filename from stdout (npm pack outputs the filename)
const tarballName = packResult.stdout.trim().split("\n").pop();
if (!tarballName) {
throw new Error("Failed to get tarball name from npm pack output");
}
tarballPath = path.join(os.tmpdir(), tarballName);
console.log(`Tarball created at: ${tarballPath}`);
}, 60000); // 60 second timeout for build
// Clean up the tarball after all tests
afterAll(async () => {
if (tarballPath && fs.existsSync(tarballPath)) {
await fs.promises.unlink(tarballPath);
console.log("Cleaned up tarball");
}
});
// Create fresh temp directory for each test
beforeEach(async () => {
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "ts-declaration-test-"));
});
// Clean up temp directory after each test
afterEach(async () => {
if (tempDir) {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
});
// Helper function to set up a test TypeScript project
async function setupTestProject(
packageJson: object = basePackageJson,
tsconfig: object = baseTsConfig,
): Promise<void> {
// Write package.json
await fs.promises.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify(packageJson, null, 2),
);
// Write tsconfig.json
await fs.promises.writeFile(
path.join(tempDir, "tsconfig.json"),
JSON.stringify(tsconfig, null, 2),
);
// Install all dependencies (TypeScript, @types/node, and the tarball)
console.log(`Installing dependencies in ${tempDir}...`);
const installResult = spawnSync("npm", ["install", tarballPath], {
cwd: tempDir,
stdio: "pipe",
encoding: "utf-8",
timeout: 60000, // 60 second timeout
});
if (installResult.status !== 0) {
throw new Error(`npm install failed: ${installResult.stderr || installResult.stdout}`);
}
}
// Helper function to run TypeScript type checking
function runTypeCheck(srcDir: string = tempDir): {
success: boolean;
output: string;
} {
const result = spawnSync("npx", ["tsc", "--noEmit"], {
cwd: srcDir,
stdio: "pipe",
encoding: "utf-8",
timeout: 30000, // 30 second timeout
});
return {
success: result.status === 0,
output: result.stdout + result.stderr,
};
}
// Helper function to write a test TypeScript file
async function writeTestFile(filename: string, content: string): Promise<void> {
await fs.promises.writeFile(path.join(tempDir, filename), content);
}
// Test 1: Main exports import verification
it("should successfully type-check main exports", async () => {
await setupTestProject();
await writeTestFile(
"test-main-exports.ts",
`
import {
ClaudeAcpAgent,
runAcp,
toAcpNotifications,
streamEventToAcpNotifications,
SettingsManager,
createMcpServer,
loadManagedSettings,
applyEnvironmentSettings,
nodeToWebReadable,
nodeToWebWritable,
Pushable,
unreachable,
toolInfoFromToolUse,
planEntries,
toolUpdateFromToolResult,
createPreToolUseHook,
toolNames,
} from "@zed-industries/claude-code-acp";
// Type-only imports
import type {
ToolUpdateMeta,
NewSessionMeta,
ClaudeCodeSettings,
PermissionSettings,
PermissionDecision,
PermissionCheckResult,
SettingsManagerOptions,
ClaudePlanEntry,
} from "@zed-industries/claude-code-acp";
// Verify exports exist and have expected types
const _runAcp: typeof runAcp = runAcp;
const _createMcpServer: typeof createMcpServer = createMcpServer;
const _toolNames: typeof toolNames = toolNames;
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 2: Deep imports verification (backwards compatibility)
it("should successfully type-check deep imports", async () => {
await setupTestProject();
await writeTestFile(
"test-deep-imports.ts",
`
// Deep import from dist/tools.js
import {
acpToolNames,
EDIT_TOOL_NAMES,
ACP_TOOL_NAME_PREFIX,
toolInfoFromToolUse,
planEntries,
} from "@zed-industries/claude-code-acp/dist/tools.js";
// Deep import from dist/settings.js
import {
SettingsManager,
getManagedSettingsPath,
} from "@zed-industries/claude-code-acp/dist/settings.js";
// Deep import from dist/utils.js
import {
Pushable,
nodeToWebReadable,
nodeToWebWritable,
loadManagedSettings,
} from "@zed-industries/claude-code-acp/dist/utils.js";
// Verify types work
const prefix: string = ACP_TOOL_NAME_PREFIX;
const editTools: readonly string[] = EDIT_TOOL_NAMES;
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 3: SettingsManager type shape verification
it("should verify SettingsManager has correct type shape", async () => {
await setupTestProject();
await writeTestFile(
"test-settings-manager.ts",
`
import {
SettingsManager,
ClaudeCodeSettings,
PermissionCheckResult,
SettingsManagerOptions
} from "@zed-industries/claude-code-acp";
// Test constructor signature
const options: SettingsManagerOptions = {
onChange: () => {},
logger: { log: console.log, error: console.error },
};
declare const cwd: string;
const manager = new SettingsManager(cwd, options);
// Test method signatures
async function testMethods() {
// initialize returns Promise<void>
await manager.initialize();
// checkPermission returns PermissionCheckResult
const result: PermissionCheckResult = manager.checkPermission(
"mcp__acp__Read",
{ file_path: "/some/path" }
);
// Verify decision type
const decision: "allow" | "deny" | "ask" = result.decision;
const rule: string | undefined = result.rule;
const source: "allow" | "deny" | "ask" | undefined = result.source;
// getSettings returns ClaudeCodeSettings
const settings: ClaudeCodeSettings = manager.getSettings();
// getCwd returns string
const currentCwd: string = manager.getCwd();
// setCwd returns Promise<void>
await manager.setCwd("/new/path");
// dispose returns void
manager.dispose();
}
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 4: ClaudeAcpAgent instantiation and type verification
it("should verify ClaudeAcpAgent has correct type shape", async () => {
await setupTestProject();
await writeTestFile(
"test-claude-acp-agent.ts",
`
import { ClaudeAcpAgent } from "@zed-industries/claude-code-acp";
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
// ClaudeAcpAgent should be a class that can be instantiated
declare const mockConnection: AgentSideConnection;
declare const mockLogger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
// Test constructor - accepts AgentSideConnection and optional logger
const agent = new ClaudeAcpAgent(mockConnection, mockLogger);
// Verify it has sessions property
const sessions: Record<string, any> = agent.sessions;
// Verify it has client property
const client: AgentSideConnection = agent.client;
// Verify it has logger property
const logger = agent.logger;
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 5: Type-only exports work correctly
it("should verify type-only exports are usable", async () => {
await setupTestProject();
await writeTestFile(
"test-type-exports.ts",
`
import type {
ClaudeCodeSettings,
PermissionSettings,
PermissionDecision,
PermissionCheckResult,
SettingsManagerOptions,
ClaudePlanEntry,
ToolUpdateMeta,
NewSessionMeta,
} from "@zed-industries/claude-code-acp";
// Test ClaudeCodeSettings shape
const settings: ClaudeCodeSettings = {
permissions: {
allow: ["Read"],
deny: ["Read(./.env)"],
ask: ["Bash"],
additionalDirectories: ["/extra"],
defaultMode: "default",
},
env: {
API_KEY: "secret",
},
};
// Test PermissionSettings shape
const perms: PermissionSettings = {
allow: ["Read"],
deny: ["Write"],
};
// Test PermissionDecision
const decisions: PermissionDecision[] = ["allow", "deny", "ask"];
// Test ClaudePlanEntry shape
const planEntry: ClaudePlanEntry = {
content: "Do something",
status: "pending",
activeForm: "Doing something",
};
// Test valid status values
const validStatuses: ClaudePlanEntry["status"][] = [
"pending",
"in_progress",
"completed",
];
// Test ToolUpdateMeta shape
const toolMeta: ToolUpdateMeta = {
claudeCode: {
toolName: "Read",
toolResponse: { success: true },
},
};
// Test NewSessionMeta shape
const sessionMeta: NewSessionMeta = {
claudeCode: {
options: {},
},
};
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 6: Function signatures verification
it("should verify function signatures are correct", async () => {
await setupTestProject();
await writeTestFile(
"test-function-signatures.ts",
`
import {
runAcp,
createMcpServer,
toolInfoFromToolUse,
planEntries,
createPreToolUseHook,
loadManagedSettings,
applyEnvironmentSettings,
SettingsManager,
} from "@zed-industries/claude-code-acp";
import type { ClaudeCodeSettings } from "@zed-industries/claude-code-acp";
// runAcp should be a function with no parameters that returns void
const runAcpType: () => void = runAcp;
// toolInfoFromToolUse should accept any and return object with title and kind
const info = toolInfoFromToolUse({ name: "Read", input: {} });
const title: string = info.title;
const kind: string = info.kind;
// planEntries should accept todos array and return array
const entries = planEntries({
todos: [
{ content: "test", status: "pending", activeForm: "testing" }
]
});
// entries should be an array
const entriesArray: any[] = entries;
// createPreToolUseHook should accept SettingsManager and optional logger
declare const settingsManager: SettingsManager;
const hook = createPreToolUseHook(settingsManager, console);
// loadManagedSettings should return ClaudeCodeSettings | null
const managedSettings: ClaudeCodeSettings | null = loadManagedSettings();
// applyEnvironmentSettings should accept ClaudeCodeSettings and return void
const applyResult: void = applyEnvironmentSettings({ permissions: {} });
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 7: Pushable generic class verification
it("should verify Pushable class works correctly", async () => {
await setupTestProject();
await writeTestFile(
"test-pushable.ts",
`
import { Pushable } from "@zed-industries/claude-code-acp";
// Pushable should be a generic class
const pushable = new Pushable<string>();
// Should have push method
pushable.push("test");
// Should have end method
pushable.end();
// Should implement AsyncIterable
async function consume() {
for await (const item of pushable) {
const str: string = item;
}
}
// Generic type parameter should work
const numPushable = new Pushable<number>();
numPushable.push(42);
interface MyType { id: number; name: string; }
const customPushable = new Pushable<MyType>();
customPushable.push({ id: 1, name: "test" });
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 8: Verify incorrect types fail
it("should fail type-check with incorrect types", async () => {
await setupTestProject();
await writeTestFile(
"test-invalid-types.ts",
`
import { SettingsManager, ClaudeCodeSettings } from "@zed-industries/claude-code-acp";
// This should fail - SettingsManager constructor requires string cwd
// @ts-expect-error - Testing that wrong argument type fails
const badManager = new SettingsManager(123);
// This should fail - ClaudeCodeSettings permissions must be an object
const badSettings: ClaudeCodeSettings = {
// @ts-expect-error - Testing that wrong type fails
permissions: "not-an-object",
};
`,
);
// This test should PASS because we expect tsc to catch these errors
// with @ts-expect-error directives
const result = runTypeCheck();
if (!result.success) {
// If it fails, it means @ts-expect-error didn't catch the error
// which could mean the types are too permissive
console.error("TypeScript errors (expected @ts-expect-error to catch):", result.output);
}
expect(result.success).toBe(true);
}, 120000);
},
);

View File

@@ -1,92 +1,17 @@
import {
ContentBlock,
PlanEntry,
ToolCallContent,
ToolCallLocation,
ToolKind,
} from "@agentclientprotocol/sdk";
import { SYSTEM_REMINDER } from "./mcp-server.js";
import * as diff from "diff";
import {
ImageBlockParam,
TextBlockParam,
ToolResultBlockParam,
WebSearchResultBlock,
WebSearchToolResultBlockParam,
WebSearchToolResultError,
} from "@anthropic-ai/sdk/resources";
import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js";
import { ToolResultBlockParam, WebSearchToolResultBlockParam } from "@anthropic-ai/sdk/resources";
import {
BetaBashCodeExecutionToolResultBlockParam,
BetaBashCodeExecutionResultBlock,
BetaBashCodeExecutionToolResultError,
BetaCodeExecutionToolResultBlockParam,
BetaCodeExecutionResultBlock,
BetaCodeExecutionToolResultError,
BetaRequestMCPToolResultBlockParam,
BetaTextEditorCodeExecutionToolResultBlockParam,
BetaTextEditorCodeExecutionViewResultBlock,
BetaTextEditorCodeExecutionCreateResultBlock,
BetaTextEditorCodeExecutionStrReplaceResultBlock,
BetaTextEditorCodeExecutionToolResultError,
BetaToolResultBlockParam,
BetaToolSearchToolResultBlockParam,
BetaToolReferenceBlock,
BetaToolSearchToolSearchResultBlock,
BetaToolSearchToolResultError,
BetaWebFetchToolResultBlockParam,
BetaWebFetchBlock,
BetaWebFetchToolResultErrorBlock,
BetaWebSearchToolResultBlockParam,
BetaImageBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
const acpUnqualifiedToolNames = {
read: "Read",
edit: "Edit",
write: "Write",
bash: "Bash",
killShell: "KillShell",
bashOutput: "BashOutput",
};
export const ACP_TOOL_NAME_PREFIX = "mcp__acp__";
export const acpToolNames = {
read: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.read,
edit: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.edit,
write: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.write,
bash: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.bash,
killShell: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.killShell,
bashOutput: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.bashOutput,
};
export const EDIT_TOOL_NAMES = [acpToolNames.edit, acpToolNames.write];
/**
* Union of all possible content types that can appear in tool results from the Anthropic SDK.
* These are transformed to valid ACP ContentBlock types by toValidAcpContent().
*/
type ToolResultContent =
| TextBlockParam
| ImageBlockParam
| BetaImageBlockParam
| BetaToolReferenceBlock
| BetaToolSearchToolSearchResultBlock
| BetaToolSearchToolResultError
| WebSearchResultBlock
| WebSearchToolResultError
| BetaWebFetchBlock
| BetaWebFetchToolResultErrorBlock
| BetaCodeExecutionResultBlock
| BetaCodeExecutionToolResultError
| BetaBashCodeExecutionResultBlock
| BetaBashCodeExecutionToolResultError
| BetaTextEditorCodeExecutionViewResultBlock
| BetaTextEditorCodeExecutionCreateResultBlock
| BetaTextEditorCodeExecutionStrReplaceResultBlock
| BetaTextEditorCodeExecutionToolResultError;
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
import { Logger } from "./acp-agent.js";
import { SettingsManager } from "./settings.js";
interface ToolInfo {
title: string;
@@ -101,7 +26,11 @@ interface ToolUpdate {
locations?: ToolCallLocation[];
}
export function toolInfoFromToolUse(toolUse: any): ToolInfo {
export function toolInfoFromToolUse(
toolUse: any,
cachedFileContent: { [key: string]: string },
logger: Logger = console,
): ToolInfo {
const name = toolUse.name;
const input = toolUse.input;
@@ -146,7 +75,7 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
};
case "Bash":
case acpToolNames.bash:
case toolNames.bash:
return {
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
kind: "execute",
@@ -162,7 +91,7 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
};
case "BashOutput":
case acpToolNames.bashOutput:
case toolNames.bashOutput:
return {
title: "Tail Logs",
kind: "execute",
@@ -170,14 +99,14 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
};
case "KillShell":
case acpToolNames.killShell:
case toolNames.killShell:
return {
title: "Kill Process",
kind: "execute",
content: [],
};
case acpToolNames.read: {
case toolNames.read: {
let limit = "";
if (input.limit) {
limit =
@@ -223,10 +152,30 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
locations: [],
};
case acpToolNames.edit:
case toolNames.edit:
case "Edit": {
const path = input?.file_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) {
logger.error(e);
}
}
return {
title: path ? `Edit \`${path}\`` : "Edit",
kind: "edit",
@@ -236,16 +185,20 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
{
type: "diff",
path,
oldText: input.old_string ?? null,
newText: input.new_string ?? "",
oldText,
newText,
},
]
: [],
locations: path ? [{ path }] : undefined,
locations: path
? affectedLines.length > 0
? affectedLines.map((line) => ({ line, path }))
: [{ path }]
: [],
};
}
case acpToolNames.write: {
case toolNames.write: {
let content: ToolCallContent[] = [];
if (input && input.file_path) {
content = [
@@ -356,9 +309,7 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
label += " -P";
}
if (input.pattern) {
label += ` "${input.pattern}"`;
}
label += ` "${input.pattern}"`;
if (input.path) {
label += ` ${input.path}`;
@@ -457,7 +408,6 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
export function toolUpdateFromToolResult(
toolResult:
| ToolResultBlockParam
| BetaToolResultBlockParam
| BetaWebSearchToolResultBlockParam
| BetaWebFetchToolResultBlockParam
| WebSearchToolResultBlockParam
@@ -468,19 +418,9 @@ export function toolUpdateFromToolResult(
| BetaToolSearchToolResultBlockParam,
toolUse: any | undefined,
): ToolUpdate {
if (
"is_error" in toolResult &&
toolResult.is_error &&
toolResult.content &&
toolResult.content.length > 0
) {
// Only return errors
return toAcpContentUpdate(toolResult.content, true);
}
switch (toolUse?.name) {
case "Read":
case acpToolNames.read:
case toolNames.read:
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
return {
content: toolResult.content.map((content: any) => ({
@@ -509,60 +449,21 @@ export function toolUpdateFromToolResult(
}
return {};
case acpToolNames.edit: {
const content: ToolCallContent[] = [];
const locations: ToolCallLocation[] = [];
if (
Array.isArray(toolResult.content) &&
toolResult.content.length > 0 &&
"text" in toolResult.content[0] &&
typeof toolResult.content[0].text === "string"
) {
const patches = diff.parsePatch(toolResult.content[0].text);
console.error(JSON.stringify(patches));
for (const { oldFileName, newFileName, hunks } of patches) {
for (const { lines, newStart } of hunks) {
const oldText = [];
const newText = [];
for (const line of lines) {
if (line.startsWith("-")) {
oldText.push(line.slice(1));
} else if (line.startsWith("+")) {
newText.push(line.slice(1));
} else {
oldText.push(line.slice(1));
newText.push(line.slice(1));
}
}
if (oldText.length > 0 || newText.length > 0) {
locations.push({ path: newFileName || oldFileName, line: newStart });
content.push({
type: "diff",
path: newFileName || oldFileName,
oldText: oldText.join("\n") || null,
newText: newText.join("\n"),
});
}
}
}
}
const result: ToolUpdate = {};
if (content.length > 0) {
result.content = content;
}
if (locations.length > 0) {
result.locations = locations;
}
return result;
}
case acpToolNames.bash:
case toolNames.bash:
case "edit":
case "Edit":
case acpToolNames.write:
case toolNames.edit:
case toolNames.write:
case "Write": {
if (
"is_error" in toolResult &&
toolResult.is_error &&
toolResult.content &&
toolResult.content.length > 0
) {
// Only return errors
return toAcpContentUpdate(toolResult.content, true);
}
return {};
}
@@ -599,20 +500,17 @@ function toAcpContentUpdate(
): { content?: ToolCallContent[] } {
if (Array.isArray(content) && content.length > 0) {
return {
content: content.map((c: any) => ({
type: "content" as const,
content: toAcpContentBlock(c, isError),
content: content.map((content: any) => ({
type: "content",
content:
isError && content.type === "text"
? {
...content,
text: `\`\`\`\n${content.text}\n\`\`\``,
}
: content,
})),
};
} else if (typeof content === "object" && content !== null && "type" in content) {
return {
content: [
{
type: "content" as const,
content: toAcpContentBlock(content, isError),
},
],
};
} else if (typeof content === "string" && content.length > 0) {
return {
content: [
@@ -629,74 +527,6 @@ function toAcpContentUpdate(
return {};
}
function toAcpContentBlock(content: ToolResultContent, isError: boolean): ContentBlock {
const wrapText = (text: string): ContentBlock => ({
type: "text" as const,
text: isError ? `\`\`\`\n${text}\n\`\`\`` : text,
});
switch (content.type) {
case "text":
return {
type: "text" as const,
text: isError ? `\`\`\`\n${content.text}\n\`\`\`` : content.text,
};
case "image":
if (content.source.type === "base64") {
return {
type: "image" as const,
data: content.source.data,
mimeType: content.source.media_type,
};
}
// URL and file-based images can't be converted to ACP format (requires data)
return wrapText(
content.source.type === "url"
? `[image: ${content.source.url}]`
: "[image: file reference]",
);
case "tool_reference":
return wrapText(`Tool: ${content.tool_name}`);
case "tool_search_tool_search_result":
return wrapText(
`Tools found: ${content.tool_references.map((r) => r.tool_name).join(", ") || "none"}`,
);
case "tool_search_tool_result_error":
return wrapText(
`Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`,
);
case "web_search_result":
return wrapText(`${content.title} (${content.url})`);
case "web_search_tool_result_error":
return wrapText(`Error: ${content.error_code}`);
case "web_fetch_result":
return wrapText(`Fetched: ${content.url}`);
case "web_fetch_tool_result_error":
return wrapText(`Error: ${content.error_code}`);
case "code_execution_result":
return wrapText(`Output: ${content.stdout || content.stderr || ""}`);
case "bash_code_execution_result":
return wrapText(`Output: ${content.stdout || content.stderr || ""}`);
case "code_execution_tool_result_error":
case "bash_code_execution_tool_result_error":
return wrapText(`Error: ${content.error_code}`);
case "text_editor_code_execution_view_result":
return wrapText(content.content);
case "text_editor_code_execution_create_result":
return wrapText(content.is_file_update ? "File updated" : "File created");
case "text_editor_code_execution_str_replace_result":
return wrapText(content.lines?.join("\n") || "");
case "text_editor_code_execution_tool_result_error":
return wrapText(
`Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`,
);
default:
return wrapText(JSON.stringify(content));
}
}
export type ClaudePlanEntry = {
content: string;
status: "pending" | "in_progress" | "completed";
@@ -752,80 +582,17 @@ export const registerHookCallback = (
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
export const createPostToolUseHook =
(
logger: Logger = console,
options?: {
onEnterPlanMode?: () => Promise<void>;
},
): HookCallback =>
(logger: Logger = console): HookCallback =>
async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => {
if (input.hook_event_name === "PostToolUse") {
// Handle EnterPlanMode tool - notify client of mode change after successful execution
if (input.tool_name === "EnterPlanMode" && options?.onEnterPlanMode) {
await options.onEnterPlanMode();
}
if (toolUseID) {
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
if (onPostToolUseHook) {
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
} else {
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
delete toolUseCallbacks[toolUseID];
}
if (input.hook_event_name === "PostToolUse" && toolUseID) {
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
if (onPostToolUseHook) {
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
} else {
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
delete toolUseCallbacks[toolUseID];
}
}
return { continue: true };
};
/**
* Creates a PreToolUse hook that checks permissions using the SettingsManager.
* This runs before the SDK's built-in permission rules, allowing us to enforce
* our own permission settings for ACP-prefixed tools.
*/
export const createPreToolUseHook =
(settingsManager: SettingsManager, logger: Logger = console): HookCallback =>
async (input: any, _toolUseID: string | undefined) => {
if (input.hook_event_name !== "PreToolUse") {
return { continue: true };
}
const toolName = input.tool_name;
const toolInput = input.tool_input;
const permissionCheck = settingsManager.checkPermission(toolName, toolInput);
if (permissionCheck.decision !== "ask") {
logger.log(
`[PreToolUseHook] Tool: ${toolName}, Decision: ${permissionCheck.decision}, Rule: ${permissionCheck.rule}`,
);
}
switch (permissionCheck.decision) {
case "allow":
return {
continue: true,
hookSpecificOutput: {
hookEventName: "PreToolUse" as const,
permissionDecision: "allow" as const,
permissionDecisionReason: `Allowed by settings rule: ${permissionCheck.rule}`,
},
};
case "deny":
return {
continue: true,
hookSpecificOutput: {
hookEventName: "PreToolUse" as const,
permissionDecision: "deny" as const,
permissionDecisionReason: `Denied by settings rule: ${permissionCheck.rule}`,
},
};
case "ask":
default:
// Let the normal permission flow continue
return { continue: true };
}
};

View File

@@ -3,8 +3,8 @@
import { Readable, Writable } from "node:stream";
import { WritableStream, ReadableStream } from "node:stream/web";
import { readFileSync } from "node:fs";
import { platform } from "node:os";
import { Logger } from "./acp-agent.js";
import { ClaudeCodeSettings, getManagedSettingsPath } from "./settings.js";
// Useful for bridging push-based and async-iterator-based code.
export class Pushable<T> implements AsyncIterable<T> {
@@ -90,15 +90,39 @@ export function sleep(time: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, time));
}
export function loadManagedSettings(): ClaudeCodeSettings | null {
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 ClaudeCodeSettings;
return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ManagedSettings;
} catch {
return null;
}
}
export function applyEnvironmentSettings(settings: ClaudeCodeSettings): void {
export function applyEnvironmentSettings(settings: ManagedSettings): void {
if (settings.env) {
for (const [key, value] of Object.entries(settings.env)) {
process.env[key] = value;

View File

@@ -9,9 +9,7 @@
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true
"rootDir": "src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]