Compare commits

..

2 Commits

Author SHA1 Message Date
Agus Zubiaga
0df3dea08d 0.1.11 2025-09-02 23:13:44 -03:00
Agus Zubiaga
182a40ccae Add backticks around glob tool label 2025-09-02 23:12:08 -03:00
21 changed files with 2381 additions and 4562 deletions

6
.eslintignore Normal file
View File

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

33
.eslintrc.json Normal file
View File

@@ -0,0 +1,33 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"env": {
"node": true,
"es2020": true
},
"rules": {
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
],
"no-console": "off",
"no-constant-condition": "off",
"default-case": "error",
"prefer-const": "error",
"no-var": "error",
"eqeqeq": ["error", "always"],
"curly": ["error", "all"]
},
"ignorePatterns": ["dist", "node_modules", "*.js"]
}

View File

@@ -4,9 +4,8 @@ on:
tags:
- "v*.*.*"
jobs:
publish-npm:
build:
runs-on: ubuntu-latest
environment: release # Optional: for enhanced security
permissions:
contents: read
id-token: write
@@ -17,18 +16,8 @@ jobs:
with:
node-version: "lts/*"
registry-url: "https://registry.npmjs.org"
- name: Update npm
run: npm install -g npm@latest
- run: npm ci
- run: npm run build
- run: npm publish
release:
runs-on: ubuntu-latest
needs: [publish-npm]
permissions:
contents: write
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Create Release
id: create_release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090
# Add back provenance once repo is public
- run: npm publish # --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

1
.gitignore vendored
View File

@@ -1,3 +1,2 @@
node_modules
dist
.DS_Store

View File

@@ -1,229 +0,0 @@
# Changelog
## 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
## 0.12.2
- Fix duplicate tool use IDs error
## 0.12.1
- Update to @anthropic-ai/claude-agent-sdk@v0.1.61
- Update to @agentclientprotocol/sdk@0.8.0
## 0.12.0
- Update to @anthropic-ai/claude-agent-sdk@v0.1.59
- Brings Opus to Claude Pro plans
- Support "Don't Ask" profile
- Unify ACP + Claude Code session ids
## 0.11.0
- Update to @anthropic-ai/claude-agent-sdk@v0.1.57
- Removed dependency on @anthropic-ai/claude-code since this is no longer needed
## 0.10.10
- Update to @agentclientprotocol/sdk@0.7.0
## 0.10.9
- Update to @anthropic-ai/claude-agent-sdk@v0.1.55
- Allow defining a custom logger when used as a library
- Allow specifying custom options when used as a library
- Add `CLAUDECODE=1` to terminal invocations to match default Claude Code behavior
## 0.10.8
- Update to @anthropic-ai/claude-agent-sdk@v0.1.51 (adds support for Opus 4.5)
## 0.10.7
- Fix read/edit tool error handling so upstream errors surface
- Update to @anthropic-ai/claude-agent-sdk@v0.1.50
## 0.10.6
- Disable experimental terminal auth support for now, as it was causing issues on Windows. Will revisit with a fix later.
- Update to @anthropic-ai/claude-agent-sdk@v0.1.46
## 0.10.5
- Better error messages at end of turn if there were any
- Add experimental support for disabling built-in tools via \_meta flag
- Update to @anthropic-ai/claude-agent-sdk@v0.1.44
## 0.10.4
- Fix tool call titles not appearing during approval in some cases
- Update to @anthropic-ai/claude-agent-sdk@v0.1.42
## 0.10.3
- Fix for experimental terminal auth support
## 0.10.2
- Fix incorrect stop reason for tool call refusals
## 0.10.1
- Add additional structured metadata to tool calls
- Update to @anthropic-ai/claude-agent-sdk@v0.1.37
## 0.10.0
- Update to @anthropic-ai/claude-agent-sdk@v0.1.30
- Use `canUseTool` callback instead of launching an HTTP MCP server for permission checks.
## 0.9.0
- Support slash commands coming from MCP servers (Prompts)
## 0.8.0
- Revert changes to filename for cli entrypoint
- Provide library entrypoint via lib.ts
## 0.7.0
- Allow importing from this package as a library in addition to running it as a CLI. Allows for easier integration into existing node applications.
- Update to @anthropic-ai/claude-agent-sdk@v0.1.27
## 0.6.10
- Provide `agentInfo` on initialization response.
- Update to @agentclientprotocol/sdk@0.5.1
- Fix crash when receiving a hook_response event
- Fix for invalid locations when read call has no path
## 0.6.9
- Update to @anthropic-ai/claude-agent-sdk@v0.1.26
- Update to @agentclientprotocol/sdk@0.5.0
## 0.6.8
- Fix for duplicate tokens appearing in thread with streaming enabled
- Update to @anthropic-ai/claude-agent-sdk@v0.1.23
- Update to @agentclientprotocol/sdk@0.4.9
## 0.6.7
- Fix for invalid plan input from the model introduced in latest agent-sdk
## 0.6.6
- Do not enable bypassPermissions mode if in root/sudo mode, because Claude Code will not start
## 0.6.5
- Fix for duplicated text content after streaming
## 0.6.4
- Support streaming partial messages!
- Update to @anthropic-ai/claude-agent-sdk@v0.1.21
## 0.6.3
- Fix issue where slash commands were loaded before initialization was complete.
## 0.6.2
- Fix bug where mode selection would sometimes fire before initialization was complete.
- Update to @anthropic-ai/claude-agent-sdk@v0.1.19
## 0.6.1
- Fix to allow bypassPermissions mode to be selected (it wasn't permitted previously)
## 0.6.0
- Provide a model selector. We use the "default" model by default, and the user can change it via the client.
- Make sure writes require permissions when necessary: https://github.com/zed-industries/claude-code-acp/pull/92
- Add support for appending or overriding the system prompt: https://github.com/zed-industries/claude-code-acp/pull/91
- Update to @anthropic-ai/claude-agent-sdk@v0.1.15
- Update to @agentclientprotocol/sdk@0.4.8
## 0.5.5
- Migrate to @agentclientprotocol/sdk@0.4.5
- Update to @anthropic-ai/claude-agent-sdk@v0.1.13
## 0.5.4
- Update to @anthropic-ai/claude-agent-sdk@v0.1.11
- Enable setting CLAUDE_CODE_EXECUTABLE to override the executable used by the SDK https://github.com/zed-industries/claude-code-acp/pull/86
## 0.5.3
- Update to @anthropic-ai/claude-agent-sdk@v0.1.8
- Update to @zed-industries/agent-client-protocol@v0.4.5
## 0.5.2
- Add back @anthropic-ai/claude-code@2.0.1 as runtime dependency
## 0.5.1
- Update to @anthropic-ai/claude-agent-sdk@v0.1.1
- Make improvements to ACP tools provided to the model
## 0.5.0
- Migrate to @anthropic-ai/claude-agent-sdk@v0.1.0
## v0.4.7
- More efficient file reads from the client.
## v0.4.6
- Update to @anthropic-ai/claude-code@v1.0.128
## v0.4.5
- Update to @anthropic-ai/claude-code@v1.0.124
- Update to @zed-industries/agent-client-protocol@v0.4.3
## v0.4.4
- Update to @anthropic-ai/claude-code@v1.0.123
- Update to @zed-industries/agent-client-protocol@v0.4.2
## v0.4.3
- Move ACP tools over MCP from an "http" MCP server to an "sdk" one so more tool calls can stay in-memory.
- Update to @anthropic-ai/claude-code@v1.0.119
- Update to @zed-industries/agent-client-protocol@v0.4.0
## v0.4.2
- Fix missing package.json metadata
## v0.4.1
- Add support for /compact command [ecfd36a](https://github.com/zed-industries/claude-code-acp/commit/ecfd36afa6c4e31f12e1daf9b8a2bdc12dda1794)
- Add default limits to read tool [7bd1638](https://github.com/zed-industries/claude-code-acp/commit/7bd163818bb959b11fd2c933eff73ad83c57abb8)
- Better rendering of Tool errors [491efe3](https://github.com/zed-industries/claude-code-acp/commit/491efe32e8547075842e448d873fc01b2ffabf3a)
- Load managed-settings.json [f691024](https://github.com/zed-industries/claude-code-acp/commit/f691024350362858e00b97248ac68e356d2331c2)
- Update to @anthropic-ai/claude-code@v1.0.113
- Update to @zed-industries/agent-client-protocol@v0.3.1

View File

@@ -1,14 +1,12 @@
# ACP adapter for Claude Code
[![npm](https://img.shields.io/npm/v/%40zed-industries%2Fclaude-code-acp)](https://www.npmjs.com/package/@zed-industries/claude-code-acp)
Use [Claude Code](https://www.anthropic.com/claude-code) from [ACP-compatible](https://agentclientprotocol.com) clients such as [Zed](https://zed.dev)!
This tool implements an ACP agent by using the official [Claude Code SDK](https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-overview), supporting:
- Context @-mentions
- Images
- Tool calls (with permission requests)
- Tool calls
- Following
- Edit review
- TODO lists
@@ -28,30 +26,24 @@ To use Claude Code, open the Agent Panel and click "New Claude Code Thread" from
https://github.com/user-attachments/assets/ddce66c7-79ac-47a3-ad59-4a6a3ca74903
Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) support.
[Read the docs](https://zed.dev/docs/ai/external-agents) on External Agent support.
### Other 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!
Setup instructions for other clients are coming soon. Feel free to [submit a PR](https://github.com/zed-industries/claude-code-acp/pulls) to add yours!
#### Installation
Install the adapter from `npm`:
```bash
npm install @zed-industries/claude-code-acp
$ npm install @zed-industries/claude-code-acp
```
You can then use `claude-code-acp` as a regular ACP agent:
```
ANTHROPIC_API_KEY=sk-... claude-code-acp
$ ANTHROPIC_API_KEY=sk-... claude-code-acp
```
## License

View File

@@ -1,24 +0,0 @@
## How to publish a new release
1. Create a new branch prep-vX.X.X
```sh
git checkout -b prep-vX.X.X
```
2. Bump the version in package.json
```sh
npm version vX.X.X
```
3. Add a new entry in CHANGELOG.md
4. Create a PR for `prev-vX.X.X` & merge it
5. Create tag and push it
```sh
git tag vX.X.X
git push origin vX.X.X
```
6. Paste CHANGELOG into release notes and publish the version on GitHub

View File

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

2507
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,12 +3,12 @@
"publishConfig": {
"access": "public"
},
"version": "0.12.5",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/lib.js",
"bin": {
"claude-code-acp": "./dist/index.js"
},
"version": "0.1.11",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/index.js",
"type": "module",
"files": [
"dist/",
@@ -40,34 +40,29 @@
"sdk",
"code"
],
"homepage": "https://github.com/zed-industries/claude-code-acp#readme",
"bugs": {
"url": "https://github.com/zed-industries/claude-code-acp/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/zed-industries/claude-code-acp.git"
},
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.12.0",
"@anthropic-ai/claude-agent-sdk": "0.1.70",
"@modelcontextprotocol/sdk": "1.25.0",
"diff": "8.0.2",
"minimatch": "^10.1.1"
"@anthropic-ai/claude-code": "^1.0.100",
"@modelcontextprotocol/sdk": "^1.17.4",
"@types/express": "^5.0.3",
"@zed-industries/agent-client-protocol": "0.2.0-alpha.4",
"express": "^5.1.0",
"minimist": "^1.2.8",
"uuid": "11.1.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.71.2",
"@types/node": "25.0.2",
"@typescript-eslint/eslint-plugin": "8.50.0",
"@typescript-eslint/parser": "8.50.0",
"eslint": "9.39.2",
"eslint-config-prettier": "10.1.8",
"globals": "16.5.0",
"prettier": "3.7.4",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vitest": "4.0.15"
"@types/minimist": "^1.2.5",
"@types/node": "^24.3.0",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.42.0",
"@typescript-eslint/parser": "^8.42.0",
"@vitest/ui": "^3.2.4",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.6.2",
"ts-node": "^10.9.2",
"typescript": "^5.4.0",
"vitest": "^3.2.4"
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,36 +0,0 @@
// Export the main agent class and utilities for library usage
export {
ClaudeAcpAgent,
runAcp,
toAcpNotifications,
streamEventToAcpNotifications,
type ToolUpdateMeta,
type NewSessionMeta,
} from "./acp-agent.js";
export {
loadManagedSettings,
applyEnvironmentSettings,
nodeToWebReadable,
nodeToWebWritable,
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 types
export type { ClaudePlanEntry } from "./tools.js";

View File

@@ -1,24 +1,13 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
BashInput,
FileEditInput,
FileReadInput,
FileWriteInput,
KillShellInput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
import { z } from "zod";
import { CLAUDE_CONFIG_DIR, ClaudeAcpAgent } from "./acp-agent.js";
import {
ClientCapabilities,
ReadTextFileResponse,
TerminalOutputResponse,
} from "@agentclientprotocol/sdk";
import * as diff from "diff";
import * as path from "node:path";
import * as fs from "node:fs/promises";
import express from "express";
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
import { acpToolNames } from "./tools.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { Server } from "node:http";
import { ClaudeAcpAgent } from "./acp-agent.js";
import { ClientCapabilities, TerminalOutputResponse } from "@zed-industries/agent-client-protocol";
import { sleep, unreachable } from "./utils.js";
export const SYSTEM_REMINDER = `
@@ -26,116 +15,34 @@ export const SYSTEM_REMINDER = `
Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.
</system-reminder>`;
const defaults = { maxFileSize: 50000, linesToRead: 2000 };
const unqualifiedToolNames = {
read: "Read",
edit: "Edit",
write: "Write",
bash: "Bash",
killShell: "KillShell",
bashOutput: "BashOutput",
};
export function createMcpServer(
agent: ClaudeAcpAgent,
sessionId: string,
clientCapabilities: ClientCapabilities | undefined,
): McpServer {
/**
* This checks if a given path is related to internal agent persistence and if the agent should be allowed to read/write from here.
* We let the agent do normal fs operations on these paths so that it can persist its state.
* However, we block access to settings files for security reasons.
*/
function internalPath(file_path: string) {
return (
file_path.startsWith(CLAUDE_CONFIG_DIR) &&
!file_path.startsWith(path.join(CLAUDE_CONFIG_DIR, "settings.json")) &&
!file_path.startsWith(path.join(CLAUDE_CONFIG_DIR, "session-env"))
);
}
async function readTextFile(input: FileReadInput): Promise<ReadTextFileResponse> {
if (internalPath(input.file_path)) {
const content = await fs.readFile(input.file_path, "utf8");
// eslint-disable-next-line eqeqeq
if (input.offset != null || input.limit != null) {
const lines = content.split("\n");
// Apply offset and limit if provided
const offset = input.offset ?? 1;
const limit = input.limit ?? lines.length;
// Extract the requested lines (offset is 1-based)
const startIndex = Math.max(0, offset - 1);
const endIndex = Math.min(lines.length, startIndex + limit);
const selectedLines = lines.slice(startIndex, endIndex);
return { content: selectedLines.join("\n") };
} else {
return { content };
}
}
return agent.readTextFile({
sessionId,
path: input.file_path,
line: input.offset,
limit: input.limit,
});
}
async function writeTextFile(input: FileWriteInput): Promise<void> {
if (internalPath(input.file_path)) {
await fs.writeFile(input.file_path, input.content, "utf8");
} else {
await agent.writeTextFile({
sessionId,
path: input.file_path,
content: input.content,
});
}
}
): Promise<Server> {
// Create MCP server
const server = new McpServer({ name: "acp", version: "1.0.0" }, { capabilities: { tools: {} } });
const server = new McpServer({
name: "acp-mcp-server",
version: "1.0.0",
});
if (clientCapabilities?.fs?.readTextFile) {
server.registerTool(
unqualifiedToolNames.read,
"read",
{
title: unqualifiedToolNames.read,
title: "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.
Never attempt to read a path that hasn't been previously mentioned.
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.
Usage:
- The file_path parameter must be an absolute path, not a relative path
- By default, it reads up to ${defaults.linesToRead} lines starting from the beginning of the file
- 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.
- 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.`,
In sessions with mcp__acp__read always use it instead of Read as it contains the most up-to-date contents.`,
inputSchema: {
file_path: z.string().describe("The absolute path to the file to read"),
abs_path: z.string().describe("The absolute path to the file to read."),
offset: z
.number()
.optional()
.default(1)
.describe(
"The line number to start reading from. Only provide if the file is too large to read at once",
),
limit: z
.number()
.optional()
.default(defaults.linesToRead)
.describe(
`The number of lines to read. Only provide if the file is too large to read at once.`,
),
.describe("Which line to start reading from. Omit to start from the beginning."),
limit: z.number().optional().describe("How many lines to read. Omit for the whole file."),
},
annotations: {
title: "Read file",
@@ -145,7 +52,7 @@ Usage:
idempotentHint: false,
},
},
async (input: FileReadInput) => {
async (input) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
@@ -158,39 +65,18 @@ Usage:
],
};
}
const readResponse = await readTextFile(input);
if (typeof readResponse?.content !== "string") {
throw new Error(`No file contents for ${input.file_path}.`);
}
// Extract lines with byte limit enforcement
const result = extractLinesWithByteLimit(readResponse.content, defaults.maxFileSize);
// Construct informative message about what was read
let readInfo = "";
if ((input.offset && input.offset > 1) || result.wasLimited) {
readInfo = "\n\n<file-read-info>";
if (result.wasLimited) {
readInfo += `Read ${result.linesRead} lines (hit 50KB limit). `;
} else {
readInfo += `Read lines ${input.offset}-${result.linesRead}. `;
}
if (result.wasLimited) {
readInfo += `Continue with offset=${result.linesRead}.`;
}
readInfo += "</file-read-info>";
}
const content = await agent.readTextFile({
sessionId,
path: input.abs_path,
limit: input.limit,
line: input.offset,
});
return {
content: [
{
type: "text",
text: result.content + readInfo + SYSTEM_REMINDER,
text: content.content + SYSTEM_REMINDER,
},
],
};
@@ -210,25 +96,16 @@ Usage:
if (clientCapabilities?.fs?.writeTextFile) {
server.registerTool(
unqualifiedToolNames.write,
"write",
{
title: unqualifiedToolNames.write,
description: `Writes a file to the local filesystem..
title: "Write",
description: `Writes content to the specified file in the project.
In sessions with ${acpToolNames.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.
- 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.`,
In sessions with mcp__acp__write always use it instead of Write as it will
allow the user to conveniently review changes.`,
inputSchema: {
file_path: z
.string()
.describe("The absolute path to the file to write (must be absolute, not relative)"),
content: z.string().describe("The content to write to the file"),
abs_path: z.string().describe("The absolute path to the file to write"),
content: z.string().describe("The full content to write"),
},
annotations: {
title: "Write file",
@@ -238,7 +115,7 @@ Usage:
idempotentHint: false,
},
},
async (input: FileWriteInput) => {
async (input) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
@@ -251,7 +128,11 @@ Usage:
],
};
}
await writeTextFile(input);
await agent.writeTextFile({
sessionId,
path: input.abs_path,
content: input.content,
});
return {
content: [],
@@ -270,32 +151,27 @@ Usage:
);
server.registerTool(
unqualifiedToolNames.edit,
"edit",
{
title: unqualifiedToolNames.edit,
description: `Performs exact string replacements in files.
title: "Edit",
description: `Edit a file.
In sessions with ${acpToolNames.edit} always use it instead of Edit as it will
In sessions with mcp__acp__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.
- 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.
- The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
- Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`,
File editing instructions:
- The \`old_string\` param must match existing file content, including indentation.
- The \`old_string\` param must come from the actual file, not an outline.
- The \`old_string\` section must not be empty.
- Be minimal with replacements:
- For unique lines, include only those lines.
- For non-unique lines, include enough context to identify them.
- Do not escape quotes, newlines, or other characters.
- Only edit the specified file.`,
inputSchema: {
file_path: z.string().describe("The absolute path to the file to modify"),
old_string: z.string().describe("The text to replace"),
new_string: z
.string()
.describe("The text to replace it with (must be different from old_string)"),
replace_all: z
.boolean()
.default(false)
.optional()
.describe("Replace all occurrences of old_string (default false)"),
abs_path: z.string().describe("The absolute path to the file to read."),
old_string: z.string().describe("The old text to replace (must be unique in the file)"),
new_string: z.string().describe("The new text."),
},
annotations: {
title: "Edit file",
@@ -305,7 +181,7 @@ Usage:
idempotentHint: false,
},
},
async (input: FileEditInput) => {
async (input) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
@@ -319,31 +195,30 @@ Usage:
};
}
const readResponse = await readTextFile({
file_path: input.file_path,
const { content } = await agent.readTextFile({
sessionId,
path: input.abs_path,
});
if (typeof readResponse?.content !== "string") {
throw new Error(`No file contents for ${input.file_path}.`);
}
const { newContent } = replaceAndCalculateLocation(readResponse.content, [
const { newContent, lineNumbers } = replaceAndCalculateLocation(content, [
{
oldText: input.old_string,
newText: input.new_string,
replaceAll: input.replace_all,
replaceAll: false,
},
]);
const patch = diff.createPatch(input.file_path, readResponse.content, newContent);
await writeTextFile({ file_path: input.file_path, content: newContent });
await agent.writeTextFile({
sessionId,
path: input.abs_path,
content: newContent,
});
return {
content: [
{
type: "text",
text: patch,
text: JSON.stringify({ lineNumbers }),
},
],
};
@@ -352,48 +227,109 @@ Usage:
content: [
{
type: "text",
text: "Editing file failed: " + (error?.message ?? String(error)),
text: "Editing file failed: " + error.message,
},
],
};
}
},
);
server.registerTool(
"multi-edit",
{
title: "Multi Edit",
description: `Edit a file with multiple sequential edits.`,
inputSchema: {
file_path: z.string().describe("The absolute path to the file to modify"),
edits: z
.array(
z.object({
old_string: z.string().describe("The text to replace"),
new_string: z.string().describe("The text to replace it with"),
replace_all: z
.boolean()
.optional()
.describe("Replace all occurrences of old_string (default false)"),
}),
)
.min(1)
.describe("Array of edit operations to perform sequentially on the file"),
},
annotations: {
title: "Multi Edit file",
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
},
async (input) => {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const { content } = await agent.readTextFile({
sessionId,
path: input.file_path,
});
const { newContent, lineNumbers } = replaceAndCalculateLocation(
content,
input.edits.map((edit) => ({
oldText: edit.old_string,
newText: edit.new_string,
replaceAll: edit.replace_all ?? false,
})),
);
await agent.writeTextFile({
sessionId,
path: input.file_path,
content: newContent,
});
return {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers }),
},
],
};
},
);
}
if (agent.clientCapabilities?.terminal) {
server.registerTool(
unqualifiedToolNames.bash,
"Bash",
{
title: unqualifiedToolNames.bash,
description: `Executes a bash command
In sessions with ${acpToolNames.bash} always use it instead of Bash`,
title: "Bash",
description: "Executes a bash command",
inputSchema: {
command: z.string().describe("The command to execute"),
timeout: z.number().describe(`Optional timeout in milliseconds (max ${2 * 60 * 1000})`),
description: z.string().optional()
.describe(`Clear, concise description of what this command does in 5-10 words, in active voice. Examples:
Input: ls
Output: List files in current directory
Input: git status
Output: Show working tree status
Input: npm install
Output: Install package dependencies
Input: mkdir foo
Output: Create directory 'foo'`),
command: z.string().describe("The bash command to execute as a one-liner"),
timeout_ms: z
.number()
.default(2 * 60 * 1000)
.describe("Optional timeout in milliseconds"),
run_in_background: z
.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.`,
"When set to true, the command is started in the background. The tool returns an `id` that can be used with the `mcp__acp__BashOutput` tool to retrieve the current output, or the `mcp__acp__KillBash` tool to stop it early.",
),
},
},
async (input: BashInput, extra) => {
async (input, extra) => {
const session = agent.sessions[sessionId];
if (!session) {
return {
@@ -418,7 +354,6 @@ Output: Create directory 'foo'`),
const handle = await agent.client.createTerminal({
command: input.command,
env: [{ name: "CLAUDECODE", value: "1" }],
sessionId,
outputByteLimit: 32_000,
});
@@ -429,7 +364,6 @@ Output: Create directory 'foo'`),
sessionUpdate: "tool_call_update",
toolCallId,
status: "in_progress",
title: input.description,
content: [{ type: "terminal", terminalId: handle.id }],
},
});
@@ -447,7 +381,7 @@ Output: Create directory 'foo'`),
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 () => {
sleep(input.timeout_ms).then(async () => {
if (agent.backgroundTerminals[handle.id]?.status === "started") {
await handle.kill();
}
@@ -512,29 +446,22 @@ Output: Create directory 'foo'`),
);
server.registerTool(
unqualifiedToolNames.bashOutput,
"BashOutput",
{
title: unqualifiedToolNames.bashOutput,
description: `- Retrieves output from a running or completed background bash shell
- Takes a bash_id parameter identifying the shell
- Always returns only new output since the last check
- 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.`,
title: "BashOutput",
description:
"Returns the current output and exit status of a background bash command by its id. Includes only new output since last invocation.",
inputSchema: {
bash_id: z
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 `mcp__acp__Bash`"),
},
},
async (input) => {
const bgTerm = agent.backgroundTerminals[input.bash_id];
const bgTerm = agent.backgroundTerminals[input.id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.bash_id}`);
throw new Error(`Unknown shell ${input.id}`);
}
if (bgTerm.status === "started") {
@@ -570,28 +497,21 @@ In sessions with ${acpToolNames.bashOutput} always use it for output from Bash c
);
server.registerTool(
unqualifiedToolNames.killShell,
"KillBash",
{
title: unqualifiedToolNames.killShell,
description: `- Kills a running background bash shell by its ID
- Takes a shell_id parameter identifying the shell to kill
- 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.`,
title: "KillBash",
description: "Stops a background command by its id",
inputSchema: {
shell_id: z
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 `mcp__acp__Bash`"),
},
},
async (input: KillShellInput) => {
const bgTerm = agent.backgroundTerminals[input.shell_id];
async (input) => {
const bgTerm = agent.backgroundTerminals[input.id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.shell_id}`);
throw new Error(`Unknown shell ${input.id}`);
}
switch (bgTerm.status) {
@@ -628,15 +548,140 @@ In sessions with ${acpToolNames.killShell} always use it instead of KillShell.`,
content: [{ type: "text", text: "Command killed by timeout." }],
};
default: {
unreachable(bgTerm);
throw new Error("Unexpected background terminal status");
return unreachable(bgTerm);
}
}
},
);
}
return server;
const alwaysAllowedTools: { [key: string]: boolean } = {};
server.registerTool(
"permission",
{
title: "Permission Tool",
description: "Used to request tool permissions",
inputSchema: {
tool_name: z.string(),
input: z.any(),
tool_use_id: z.string().optional(),
},
},
async (input) => {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: JSON.stringify({
behavior: "deny",
message: "Session not found",
}),
},
],
};
}
if (alwaysAllowedTools[input.tool_name]) {
return {
content: [
{
type: "text",
text: JSON.stringify({
behavior: "allow",
updatedInput: input.input,
}),
},
],
};
}
const response = await agent.client.requestPermission({
options: [
{
kind: "allow_always",
name: "Always Allow",
optionId: "allow_always",
},
{ kind: "allow_once", name: "Allow", optionId: "allow" },
{ kind: "reject_once", name: "Reject", optionId: "reject" },
],
sessionId,
toolCall: {
toolCallId: input.tool_use_id!,
rawInput: input.input,
},
});
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
) {
if (response.outcome.optionId === "allow_always") {
alwaysAllowedTools[input.tool_name] = true;
}
return {
content: [
{
type: "text",
text: JSON.stringify({
behavior: "allow",
updatedInput: input.input,
}),
},
],
};
} else {
return {
content: [
{
type: "text",
text: JSON.stringify({
behavior: "deny",
message: "User refused permission to run tool",
}),
},
],
};
}
},
);
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
try {
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: `Internal server error: ${error}`,
},
id: null,
});
}
}
});
return new Promise((resolve, reject) => {
const listener = app.listen(0, "127.0.0.1", (error) => {
if (error) {
reject(error);
return;
}
resolve(listener);
});
});
}
function stripCommonPrefix(a: string, b: string): string {
@@ -726,7 +771,7 @@ export function replaceAndCalculateLocation(
for (const edit of edits) {
// Skip empty oldText
if (edit.oldText === "") {
throw new Error(`The provided \`old_string\` is empty.\n\nNo edits were applied.`);
continue;
}
if (edit.replaceAll) {
@@ -738,11 +783,6 @@ export function replaceAndCalculateLocation(
while (true) {
const index = currentContent.indexOf(edit.oldText, searchIndex);
if (index === -1) {
if (searchIndex === 0) {
throw new Error(
`The provided \`old_string\` does not appear in the file: "${edit.oldText}".\n\nNo edits were applied.`,
);
}
break;
}
@@ -764,11 +804,7 @@ export function replaceAndCalculateLocation(
} else {
// Replace first occurrence only
const index = currentContent.indexOf(edit.oldText);
if (index === -1) {
throw new Error(
`The provided \`old_string\` does not appear in the file: "${edit.oldText}".\n\nNo edits were applied.`,
);
} else {
if (index !== -1) {
const marker = `${markerPrefix}${markerCounter++}__`;
markers.push(marker);
currentContent =

View File

@@ -1,522 +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>;
}
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);
}
return path.normalize(filePath);
}
/**
* 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" ||
(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 };
}
}
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

@@ -2,11 +2,8 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { spawn, spawnSync } from "child_process";
import {
Agent,
AgentSideConnection,
AvailableCommand,
Client,
ClientSideConnection,
ndJsonStream,
NewSessionResponse,
ReadTextFileRequest,
ReadTextFileResponse,
@@ -15,12 +12,11 @@ import {
SessionNotification,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk";
} from "@zed-industries/agent-client-protocol";
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
import { toAcpNotifications, promptToClaude } from "../acp-agent.js";
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { randomUUID } from "crypto";
import { toAcpNotifications } from "../acp-agent.js";
import { SDKAssistantMessage } from "@anthropic-ai/claude-code";
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
let child: ReturnType<typeof spawn>;
@@ -51,15 +47,9 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
agent: Agent;
files: Map<string, string> = new Map();
receivedText: string = "";
resolveAvailableCommands: (commands: AvailableCommand[]) => void;
availableCommandsPromise: Promise<AvailableCommand[]>;
constructor(agent: Agent) {
this.agent = agent;
this.resolveAvailableCommands = () => {};
this.availableCommandsPromise = new Promise((resolve) => {
this.resolveAvailableCommands = resolve;
});
}
takeReceivedText() {
@@ -77,24 +67,17 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
async sessionUpdate(params: SessionNotification): Promise<void> {
console.error("RECEIVED", JSON.stringify(params, null, 4));
switch (params.update.sessionUpdate) {
case "agent_message_chunk": {
if (params.update.content.type === "text") {
this.receivedText += params.update.content.text;
}
break;
}
case "available_commands_update":
this.resolveAvailableCommands(params.update.availableCommands);
break;
default:
break;
if (
params.update.sessionUpdate === "agent_message_chunk" &&
params.update.content.type === "text"
) {
this.receivedText += params.update.content.text;
}
}
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
this.files.set(params.path, params.content);
return {};
return null;
}
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
@@ -111,13 +94,14 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
newSessionResponse: NewSessionResponse;
}> {
let client;
const input = nodeToWebWritable(child.stdin!);
const output = nodeToWebReadable(child.stdout!);
const stream = ndJsonStream(input, output);
const connection = new ClientSideConnection((agent) => {
client = new TestClient(agent);
return client;
}, stream);
const connection = new ClientSideConnection(
(agent) => {
client = new TestClient(agent);
return client;
},
nodeToWebWritable(child.stdin!),
nodeToWebReadable(child.stdout!),
);
await connection.initialize({
protocolVersion: 1,
@@ -156,14 +140,12 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
it("should include available commands", async () => {
const { client, connection, newSessionResponse } = await setupTestSession(__dirname);
const commands = await client.availableCommandsPromise;
expect(commands).toContainEqual({
expect(newSessionResponse.availableCommands).toContainEqual({
name: "quick-math",
description: "10 * 3 = 30 (project)",
input: null,
});
expect(commands).toContainEqual({
expect(newSessionResponse.availableCommands).toContainEqual({
name: "say-hello",
description: "Say hello (project)",
input: { hint: "[name]" },
@@ -193,50 +175,6 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
expect(client.takeReceivedText()).toContain("Hello GPT-5");
}, 30000);
it("/compact works", async () => {
const { client, connection, newSessionResponse } = await setupTestSession(__dirname);
const commands = await client.availableCommandsPromise;
expect(commands).toContainEqual({
description:
"Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]",
input: {
hint: "<optional custom summarization instructions>",
},
name: "compact",
});
// Error case (no previous message)
await connection.prompt({
prompt: [{ type: "text", text: "/compact" }],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toBe("");
// Send something
await connection.prompt({
prompt: [{ type: "text", text: "Hi" }],
sessionId: newSessionResponse.sessionId,
});
// Clear response
client.takeReceivedText();
// Test with instruction
await connection.prompt({
prompt: [
{
type: "text",
text: "/compact greeting",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toContain("");
}, 30000);
});
describe("tool conversions", () => {
@@ -373,13 +311,13 @@ describe("tool conversions", () => {
});
});
it("should handle mcp__acp__Write tool calls", () => {
it("should handle mcp__acp__write tool calls", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01GHI789JKL456",
name: "mcp__acp__Write",
name: "mcp__acp__write",
input: {
file_path: "/Users/test/project/config.json",
abs_path: "/Users/test/project/config.json",
content: '{"version": "1.0.0"}',
},
};
@@ -417,13 +355,13 @@ describe("tool conversions", () => {
});
});
it("should handle mcp__acp__Read tool calls", () => {
it("should handle mcp__acp__read tool calls", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01YZA789BCD123",
name: "mcp__acp__Read",
name: "mcp__acp__read",
input: {
file_path: "/Users/test/project/data.json",
abs_path: "/Users/test/project/data.json",
},
};
@@ -435,13 +373,13 @@ describe("tool conversions", () => {
});
});
it("should handle mcp__acp__Read with limit", () => {
it("should handle mcp__acp__read with limit", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01EFG456HIJ789",
name: "mcp__acp__Read",
name: "mcp__acp__read",
input: {
file_path: "/Users/test/project/large.txt",
abs_path: "/Users/test/project/large.txt",
limit: 100,
},
};
@@ -454,13 +392,13 @@ describe("tool conversions", () => {
});
});
it("should handle mcp__acp__Read with offset and limit", () => {
it("should handle mcp__acp__read with offset and limit", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01KLM789NOP456",
name: "mcp__acp__Read",
name: "mcp__acp__read",
input: {
file_path: "/Users/test/project/large.txt",
abs_path: "/Users/test/project/large.txt",
offset: 50,
limit: 100,
},
@@ -474,13 +412,13 @@ describe("tool conversions", () => {
});
});
it("should handle mcp__acp__Read with only offset", () => {
it("should handle mcp__acp__read with only offset", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01QRS123TUV789",
name: "mcp__acp__Read",
name: "mcp__acp__read",
input: {
file_path: "/Users/test/project/large.txt",
abs_path: "/Users/test/project/large.txt",
offset: 200,
},
};
@@ -493,11 +431,55 @@ 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",
id: "toolu_01PhLms5fuvmdjy2bb6dfUKT",
name: "KillShell",
name: "KillBash",
input: {
shell_id: "bash_1",
},
@@ -534,7 +516,6 @@ describe("tool conversions", () => {
id: "msg_017eNosJgww7F5qD4a8BcAcx",
type: "message",
role: "assistant",
container: null,
model: "claude-sonnet-4-20250514",
content: [
{
@@ -599,25 +580,13 @@ describe("tool conversions", () => {
},
output_tokens: 1,
service_tier: "standard",
server_tool_use: null,
},
context_management: null,
},
parent_tool_use_id: null,
session_id: "d056596f-e328-41e9-badd-b07122ae5227",
uuid: "b7c3330c-de8f-4bba-ac53-68c7f76ffeb5",
};
expect(
toAcpNotifications(
received.message.content,
received.message.role,
"test",
{},
{},
{} as AgentSideConnection,
console,
),
).toStrictEqual([
expect(toAcpNotifications(received, "test", {}, {})).toStrictEqual([
{
sessionId: "test",
update: {
@@ -669,13 +638,196 @@ describe("tool conversions", () => {
]);
});
it("should return empty update for successful edit result", () => {
it("should extract locations from edit tool results", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01ABC123",
name: "edit",
input: {
abs_path: "/Users/test/project/example.txt",
old_string: "old content",
new_string: "new content",
},
};
const toolResult = {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers: [5, 10, 15] }),
},
],
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([
{ path: "/Users/test/project/example.txt", line: 5 },
{ path: "/Users/test/project/example.txt", line: 10 },
{ path: "/Users/test/project/example.txt", line: 15 },
]);
});
it("should show full diff for multi edit tool", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01DEF456",
name: "mcp__acp__multi-edit",
input: {
file_path: "/Users/test/project/config.json",
edits: [
{
old_string: 'version": 1',
new_string: 'version": 2',
replace_all: false,
},
{
old_string: 'enabled": false',
new_string: 'enabled": true',
replace_all: true,
},
],
},
};
const fileCache: { [key: string]: string } = {
"/Users/test/project/config.json": JSON.stringify(
{
version: 1,
filler: "filler",
enabled: false,
},
null,
4,
),
};
expect(toolInfoFromToolUse(toolUse, fileCache)).toEqual({
content: [
{
newText: `{
"version": 2,
"filler": "filler",
"enabled": true
}`,
oldText: `{
"version": 1,
"filler": "filler",
"enabled": false
}`,
path: "/Users/test/project/config.json",
type: "diff",
},
],
kind: "edit",
locations: [
{
path: "/Users/test/project/config.json",
},
],
title: "Edit /Users/test/project/config.json",
});
});
it("should extract locations from multi-edit tool results", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01DEF456",
name: "multi-edit",
input: {
file_path: "/Users/test/project/config.json",
edits: [
{
old_string: "version: 1.0",
new_string: "version: 2.0",
replace_all: false,
},
{
old_string: "enabled: false",
new_string: "enabled: true",
replace_all: true,
},
],
},
};
const toolResult = {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers: [2, 8, 12, 20] }),
},
],
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([
{ path: "/Users/test/project/config.json", line: 2 },
{ path: "/Users/test/project/config.json", line: 8 },
{ path: "/Users/test/project/config.json", line: 12 },
{ path: "/Users/test/project/config.json", line: 20 },
]);
});
it("should handle mcp__acp__edit tool results", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01GHI789",
name: "mcp__acp__edit",
input: {
abs_path: "/Users/test/project/main.py",
old_string: "def hello():",
new_string: "def hello_world():",
},
};
const toolResult = {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers: [42] }),
},
],
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([{ path: "/Users/test/project/main.py", line: 42 }]);
});
it("should handle empty locations from edit tools", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01JKL012",
name: "edit",
input: {
abs_path: "/Users/test/project/notfound.txt",
old_string: "nonexistent",
new_string: "replacement",
},
};
const toolResult = {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers: [] }),
},
],
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([]);
});
it("should handle malformed tool results gracefully", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "mcp__acp__Edit",
name: "edit",
input: {
file_path: "/Users/test/project/test.txt",
abs_path: "/Users/test/project/test.txt",
old_string: "old",
new_string: "new",
},
@@ -684,13 +836,10 @@ describe("tool conversions", () => {
const toolResult = {
content: [
{
type: "text" as const,
type: "text",
text: "not valid json",
},
],
tool_use_id: "test",
is_error: false,
type: "tool_result" as const,
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
@@ -698,43 +847,6 @@ describe("tool conversions", () => {
// Should return empty object when parsing fails
expect(update).toEqual({});
});
it("should return content update for edit failure", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "mcp__acp__Edit",
input: {
file_path: "/Users/test/project/test.txt",
old_string: "old",
new_string: "new",
},
};
const toolResult = {
content: [
{
type: "text" as const,
text: "Failed to find `old_string`",
},
],
tool_use_id: "test",
is_error: true,
type: "tool_result" as const,
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
// Should return empty object when parsing fails
expect(update).toEqual({
content: [
{
content: { type: "text", text: "```\nFailed to find `old_string`\n```" },
type: "content",
},
],
});
});
});
describe("escape markdown", () => {
@@ -748,123 +860,3 @@ describe("escape markdown", () => {
expect(escaped).toEqual("````\nfor example:\n```markdown\nHello *world*!\n```\n````");
});
});
describe("prompt conversion", () => {
it("should not change built-in slash commands", () => {
const message = promptToClaude({
sessionId: "test",
prompt: [
{
type: "text",
text: "/compact args",
},
],
});
expect(message.message.content).toEqual([
{
text: "/compact args",
type: "text",
},
]);
});
it("should remove MCP prefix from MCP slash commands", () => {
const message = promptToClaude({
sessionId: "test",
prompt: [
{
type: "text",
text: "/mcp:server:name args",
},
],
});
expect(message.message.content).toEqual([
{
text: "/server:name (MCP) args",
type: "text",
},
]);
});
});
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
it("query has a 'default' model", async () => {
const q = query({ prompt: "hi" });
const models = await q.supportedModels();
const defaultModel = models.find((m) => m.value === "default");
expect(defaultModel).toBeDefined();
}, 10000);
it("custom session id", async () => {
const sessionId = randomUUID();
const q = query({
prompt: "hi",
options: {
systemPrompt: { type: "preset", preset: "claude_code" },
extraArgs: { "session-id": sessionId },
settingSources: ["user", "project", "local"],
includePartialMessages: true,
},
});
const { value } = await q.next();
expect(value).toMatchObject({ type: "system", subtype: "init", session_id: sessionId });
}, 10000);
});
describe("permission requests", () => {
it("should include title field in tool permission request structure", () => {
// Test various tool types to ensure title is correctly generated
const testCases = [
{
toolUse: {
type: "tool_use" as const,
id: "test-1",
name: "Write",
input: { file_path: "/test/file.txt", content: "test" },
},
expectedTitlePart: "/test/file.txt",
},
{
toolUse: {
type: "tool_use" as const,
id: "test-2",
name: "Bash",
input: { command: "ls -la", description: "List files" },
},
expectedTitlePart: "`ls -la`",
},
{
toolUse: {
type: "tool_use" as const,
id: "test-3",
name: "mcp__acp__Read",
input: { file_path: "/test/data.json" },
},
expectedTitlePart: "/test/data.json",
},
];
for (const testCase of testCases) {
// Get the tool info that would be used in requestPermission
const toolInfo = toolInfoFromToolUse(testCase.toolUse, {});
// Verify toolInfo has a title
expect(toolInfo.title).toBeDefined();
expect(toolInfo.title).toContain(testCase.expectedTitlePart);
// Verify the structure that our fix creates for requestPermission
const requestStructure = {
toolCall: {
toolCallId: testCase.toolUse.id,
rawInput: testCase.toolUse.input,
title: toolInfo.title, // This is what commit 1785d86 adds
},
};
// Ensure the title field is present and populated
expect(requestStructure.toolCall.title).toBeDefined();
expect(requestStructure.toolCall.title).toContain(testCase.expectedTitlePart);
}
});
});

View File

@@ -1,103 +0,0 @@
import { describe, it, expect } from "vitest";
import { extractLinesWithByteLimit } from "../utils.js";
describe("extractLinesWithByteLimit", () => {
const simpleContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
it("should extract all lines when under byte limit", () => {
const result = extractLinesWithByteLimit(simpleContent, 1000);
expect(result.content).toBe(simpleContent);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(5);
});
it("should limit output when exceeding byte limit", () => {
// Create content that will exceed byte limit
const longLine = "x".repeat(100);
const manyLines = Array(10).fill(longLine).join("\n");
const result = extractLinesWithByteLimit(manyLines, 250);
expect(result.wasLimited).toBe(true);
expect(result.linesRead).toBe(2);
});
it("should handle empty content", () => {
const result = extractLinesWithByteLimit("", 1000);
expect(result.content).toBe("");
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(1); // We read the one empty line
});
it("should handle single line file", () => {
const singleLine = "This is a single line without newline";
const result = extractLinesWithByteLimit(singleLine, 1000);
expect(result.content).toBe(singleLine);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(1);
});
it("should correctly count bytes with multi-byte characters", () => {
const unicodeContent = "Hello 世界\n你好 World\nEmoji: 🌍\nNormal line";
const result = extractLinesWithByteLimit(unicodeContent, 1000);
expect(result.content).toBe(unicodeContent);
expect(result.linesRead).toBe(4);
});
it("should stop at byte limit even with one more line available", () => {
// Create lines where adding one more would exceed limit
const line1 = "a".repeat(40);
const line2 = "b".repeat(40);
const line3 = "c".repeat(40);
const content = `${line1}\n${line2}\n${line3}`;
const result = extractLinesWithByteLimit(content, 85);
expect(result.content).toBe(`${line1}\n${line2}\n`);
expect(result.wasLimited).toBe(true);
expect(result.linesRead).toBe(2);
});
it("should read exactly to limit when possible", () => {
const exactContent = "12345\n67890\n12345"; // 17 bytes total
const result = extractLinesWithByteLimit(exactContent, 17);
expect(result.content).toBe(exactContent);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(3);
});
it("should handle Windows-style line endings", () => {
const windowsContent = "Line 1\r\nLine 2\r\nLine 3";
const result = extractLinesWithByteLimit(windowsContent, 1000);
// Note: split("\n") will keep the \r characters
expect(result.content).toBe("Line 1\r\nLine 2\r\nLine 3");
expect(result.linesRead).toBe(3);
});
it("should handle very large files efficiently", () => {
// Create a 100KB file
const largeLine = "x".repeat(1000);
const largeContent = Array(110).fill(largeLine).join("\n");
const result = extractLinesWithByteLimit(largeContent, 50000);
expect(result.wasLimited).toBe(true);
});
it("should allow at least one line even if it exceeds byte limit", () => {
const veryLongLine = "x".repeat(100000); // 100KB line
const result = extractLinesWithByteLimit(veryLongLine, 50000);
// Should return the line even though it exceeds the byte limit
// because we always allow at least one line if no lines have been added yet
expect(result.content).toBe(veryLongLine);
expect(result.linesRead).toBe(1);
expect(result.wasLimited).toBe(false);
});
});

View File

@@ -22,9 +22,12 @@ describe("replaceAndCalculateLocation", () => {
it("should return empty line numbers array when no match found", () => {
const content = "line 1\nline 2\nline 3";
expect(() => {
replaceAndCalculateLocation(content, [{ oldText: "notfound", newText: "replaced" }]);
}).toThrow('The provided `old_string` does not appear in the file: "notfound"');
const result = replaceAndCalculateLocation(content, [
{ oldText: "notfound", newText: "replaced" },
]);
expect(result.newContent).toBe(content);
expect(result.lineNumbers).toEqual([]);
});
it("should handle Windows line endings (CRLF)", () => {
@@ -86,31 +89,35 @@ describe("replaceAndCalculateLocation", () => {
});
it("should handle empty file content", () => {
expect(() => {
replaceAndCalculateLocation("", [{ oldText: "text", newText: "replaced" }]);
}).toThrowError(
'The provided `old_string` does not appear in the file: "text".\n\nNo edits were applied.',
);
const content = "";
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("");
expect(result.lineNumbers).toEqual([]);
});
it("should handle empty search text", () => {
// Test with replaceAll false (default)
const content1 = "line 1\nline 2\nline 3";
expect(() => {
replaceAndCalculateLocation(content1, [{ oldText: "", newText: "replaced" }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
const result1 = replaceAndCalculateLocation(content1, [{ oldText: "", newText: "replaced" }]);
expect(result1.newContent).toBe(content1);
expect(result1.lineNumbers).toEqual([]);
// Test with replaceAll true on single line
const content2 = "abc";
expect(() => {
replaceAndCalculateLocation(content2, [{ oldText: "", newText: "X", replaceAll: true }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
const result2 = replaceAndCalculateLocation(content2, [
{ oldText: "", newText: "X", replaceAll: true },
]);
expect(result2.newContent).toBe(content2);
expect(result2.lineNumbers).toEqual([]);
// Test with replaceAll true on multiline content
const content3 = "ab\ncd";
expect(() => {
replaceAndCalculateLocation(content3, [{ oldText: "", newText: "X", replaceAll: true }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
const result3 = replaceAndCalculateLocation(content3, [
{ oldText: "", newText: "X", replaceAll: true },
]);
expect(result3.newContent).toBe(content3);
expect(result3.lineNumbers).toEqual([]);
});
it("should handle single line content", () => {

View File

@@ -1,617 +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");
});
});
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,40 +1,10 @@
import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
import { replaceAndCalculateLocation, SYSTEM_REMINDER } from "./mcp-server.js";
import { ToolResultBlockParam, WebSearchToolResultBlockParam } from "@anthropic-ai/sdk/resources";
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];
import {
BetaBashCodeExecutionToolResultBlockParam,
BetaCodeExecutionToolResultBlockParam,
BetaRequestMCPToolResultBlockParam,
BetaTextEditorCodeExecutionToolResultBlockParam,
BetaToolSearchToolResultBlockParam,
BetaWebFetchToolResultBlockParam,
BetaWebSearchToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
import { Logger } from "./acp-agent.js";
import { SettingsManager } from "./settings.js";
PlanEntry,
ToolCallContent,
ToolCallLocation,
ToolKind,
} from "@zed-industries/agent-client-protocol";
import { replaceAndCalculateLocation, SYSTEM_REMINDER } from "./mcp-server.js";
interface ToolInfo {
title: string;
@@ -52,7 +22,6 @@ interface ToolUpdate {
export function toolInfoFromToolUse(
toolUse: any,
cachedFileContent: { [key: string]: string },
logger: Logger = console,
): ToolInfo {
const name = toolUse.name;
const input = toolUse.input;
@@ -98,7 +67,7 @@ export function toolInfoFromToolUse(
};
case "Bash":
case acpToolNames.bash:
case "mcp__acp__Bash":
return {
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
kind: "execute",
@@ -114,22 +83,22 @@ export function toolInfoFromToolUse(
};
case "BashOutput":
case acpToolNames.bashOutput:
case "mcp__acp__BashOutput":
return {
title: "Tail Logs",
kind: "execute",
content: [],
};
case "KillShell":
case acpToolNames.killShell:
case "KillBash":
case "mcp__acp__KillBash":
return {
title: "Kill Process",
kind: "execute",
content: [],
};
case acpToolNames.read: {
case "mcp__acp__read": {
let limit = "";
if (input.limit) {
limit =
@@ -138,16 +107,14 @@ export function toolInfoFromToolUse(
limit = " (from line " + (input.offset + 1) + ")";
}
return {
title: "Read " + (input.file_path ?? "File") + limit,
title: "Read " + (input.abs_path ?? "File") + limit,
kind: "read",
locations: input.file_path
? [
{
path: input.file_path,
line: input.offset ?? 0,
},
]
: [],
locations: [
{
path: input.abs_path,
line: input.offset ?? 0,
},
],
content: [],
};
}
@@ -157,14 +124,7 @@ export function toolInfoFromToolUse(
title: "Read File",
kind: "read",
content: [],
locations: input.file_path
? [
{
path: input.file_path,
line: input.offset ?? 0,
},
]
: [],
locations: [{ path: input.file_path, line: input.offset ?? 0 }],
};
case "LS":
@@ -175,28 +135,28 @@ export function toolInfoFromToolUse(
locations: [],
};
case acpToolNames.edit:
case "mcp__acp__edit":
case "Edit": {
const path = input?.file_path ?? input?.file_path;
const path = input?.abs_path ?? input?.file_path;
let oldText = input.old_string ?? null;
let newText = input.new_string ?? "";
let affectedLines: number[] = [];
let line = null;
if (path && oldText) {
if (path && input.old_text) {
try {
const oldContent = cachedFileContent[path] || "";
const newContent = replaceAndCalculateLocation(oldContent, [
{
oldText,
newText,
oldText: input.old_string,
newText: input.new_string,
replaceAll: false,
},
]);
oldText = oldContent;
newText = newContent.newContent;
affectedLines = newContent.lineNumbers;
line = newContent.lineNumbers[0];
} catch (e) {
logger.error(e);
console.error(e);
}
}
return {
@@ -213,21 +173,64 @@ export function toolInfoFromToolUse(
},
]
: [],
locations: path
? affectedLines.length > 0
? affectedLines.map((line) => ({ line, path }))
: [{ path }]
: [],
locations: path ? [{ path, line }] : [],
};
}
case acpToolNames.write: {
case "mcp__acp__multi-edit":
case "MultiEdit": {
const multiInput = input as {
file_path: string;
edits: {
old_string: string;
new_string: string;
replace_all?: boolean;
}[];
};
let oldTextMulti = multiInput.edits.map((edit: any) => edit.old_string).join("\n");
let newTextMulti = multiInput.edits.map((edit: any) => edit.new_string).join("\n");
try {
if (multiInput.edits && multiInput.file_path) {
const oldContent =
cachedFileContent[multiInput.file_path] ||
multiInput.edits.map((edit: any) => edit.oldText).join("\n");
const newContent = replaceAndCalculateLocation(
oldContent,
multiInput.edits.map((edit) => ({
oldText: edit.old_string,
newText: edit.new_string,
replaceAll: edit.replace_all,
})),
);
oldTextMulti = oldContent;
newTextMulti = newContent.newContent;
}
} catch (e) {
console.error(e);
}
// Display it as a normal edit, because end users don't care about
// the distinction between edits and multi-edits.
return {
title: input?.file_path ? `Edit ${input.file_path}` : "Edit",
kind: "edit",
content: [
{
type: "diff" as const,
path: input.file_path,
oldText: oldTextMulti,
newText: newTextMulti,
},
],
locations: input?.file_path ? [{ path: input.file_path }] : [],
};
}
case "mcp__acp__write": {
let content: ToolCallContent[] = [];
if (input && input.file_path) {
if (input && input.abs_path) {
content = [
{
type: "diff",
path: input.file_path,
path: input.abs_path,
oldText: null,
newText: input.content,
},
@@ -241,10 +244,10 @@ export function toolInfoFromToolUse(
];
}
return {
title: input?.file_path ? `Write ${input.file_path}` : "Write",
title: input?.abs_path ? `Write ${input.abs_path}` : "Write",
kind: "edit",
content,
locations: input?.file_path ? [{ path: input.file_path }] : [],
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
};
}
@@ -380,30 +383,24 @@ export function toolInfoFromToolUse(
case "TodoWrite":
return {
title: Array.isArray(input?.todos)
title: input?.todos
? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}`
: "Update TODOs",
kind: "think",
content: [],
};
case "ExitPlanMode":
case "exit_plan_mode":
return {
title: "Ready to code?",
kind: "switch_mode",
title: "Exit Plan Mode",
kind: "think",
content:
input && input.plan
? [{ type: "content", content: { type: "text", text: input.plan } }]
: [],
};
case "Other": {
let output;
try {
output = JSON.stringify(input, null, 2);
} catch {
output = typeof input === "string" ? input : "{}";
}
case "Other":
return {
title: name || "Unknown Tool",
kind: "other",
@@ -412,12 +409,11 @@ export function toolInfoFromToolUse(
type: "content",
content: {
type: "text",
text: `\`\`\`json\n${output}\`\`\``,
text: `\`\`\`json\n${JSON.stringify(input, null, 2) || "{}"}\`\`\``,
},
},
],
};
}
default:
return {
@@ -428,22 +424,62 @@ export function toolInfoFromToolUse(
}
}
export function toolUpdateFromToolResult(
toolResult:
| ToolResultBlockParam
| BetaWebSearchToolResultBlockParam
| BetaWebFetchToolResultBlockParam
| WebSearchToolResultBlockParam
| BetaCodeExecutionToolResultBlockParam
| BetaBashCodeExecutionToolResultBlockParam
| BetaTextEditorCodeExecutionToolResultBlockParam
| BetaRequestMCPToolResultBlockParam
| BetaToolSearchToolResultBlockParam,
toolUse: any | undefined,
): ToolUpdate {
export function toolUpdateFromToolResult(toolResult: any, toolUse: any | undefined): ToolUpdate {
switch (toolUse?.name) {
case "mcp__acp__edit":
case "edit":
case "Edit": {
// Parse the line numbers from the tool result
if (toolResult.content && toolResult.content.length > 0) {
try {
const firstContent = toolResult.content[0];
const data = JSON.parse(firstContent.text || firstContent);
if (data.lineNumbers && Array.isArray(data.lineNumbers)) {
const locations: ToolCallLocation[] = data.lineNumbers.map((line: number) => ({
path: toolUse?.input?.abs_path || toolUse?.input?.file_path,
line: line,
}));
return { locations };
}
} catch (error) {
console.error(`Error parsing edit tool result: ${error}`);
// If parsing fails, return empty object
return {};
}
}
return {};
}
case "mcp__acp__multi-edit":
case "multi-edit":
case "MultiEdit": {
// Parse the line numbers from the tool result
if (toolResult.content && toolResult.content.length > 0) {
try {
const firstContent = toolResult.content[0];
const data = JSON.parse(firstContent.text || firstContent);
if (data.lineNumbers && Array.isArray(data.lineNumbers)) {
const locations: ToolCallLocation[] = data.lineNumbers.map((line: number) => ({
path: toolUse?.input?.file_path || toolUse?.input?.abs_path,
line: line,
}));
return { locations };
}
} catch (error) {
console.error(`Error parsing edit tool result: ${error}`);
// If parsing fails, return empty object
return {};
}
}
return {};
}
case "mcp__acp__write":
case "Write":
return {};
case "Read":
case acpToolNames.read:
case "mcp__acp__read":
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
return {
content: toolResult.content.map((content: any) => ({
@@ -472,27 +508,9 @@ export function toolUpdateFromToolResult(
}
return {};
case acpToolNames.bash:
case "edit":
case "Edit":
case acpToolNames.edit:
case acpToolNames.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);
}
case "mcp__acp__Bash":
// The bash tool already embeds a terminal
return {};
}
case "ExitPlanMode": {
return { title: "Exited Plan Mode" };
}
case "Task":
case "NotebookEdit":
@@ -509,48 +527,32 @@ export function toolUpdateFromToolResult(
case "WebSearch":
case "Other":
default: {
return toAcpContentUpdate(
toolResult.content,
"is_error" in toolResult ? toolResult.is_error : false,
);
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
return {
content: toolResult.content.map((content: any) => ({
type: "content",
content,
})),
};
} else if (typeof toolResult.content === "string" && toolResult.content.length > 0) {
return {
content: [
{
type: "content",
content: {
type: "text",
text: toolResult.content,
},
},
],
};
}
return {};
}
}
}
function toAcpContentUpdate(
content: any,
isError: boolean = false,
): { content?: ToolCallContent[] } {
if (Array.isArray(content) && content.length > 0) {
return {
content: content.map((content: any) => ({
type: "content",
content:
isError && content.type === "text"
? {
...content,
text: `\`\`\`\n${content.text}\n\`\`\``,
}
: content,
})),
};
} else if (typeof content === "string" && content.length > 0) {
return {
content: [
{
type: "content",
content: {
type: "text",
text: isError ? `\`\`\`\n${content}\n\`\`\`` : content,
},
},
],
};
}
return {};
}
export type ClaudePlanEntry = {
type ClaudePlanEntry = {
content: string;
status: "pending" | "in_progress" | "completed";
activeForm: string;
@@ -573,100 +575,3 @@ export function markdownEscape(text: string): string {
}
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
}
/* A global variable to store callbacks that should be executed when receiving hooks from Claude Code */
const toolUseCallbacks: {
[toolUseId: string]: {
onPostToolUseHook?: (
toolUseID: string,
toolInput: unknown,
toolResponse: unknown,
) => Promise<void>;
};
} = {};
/* Setup callbacks that will be called when receiving hooks from Claude Code */
export const registerHookCallback = (
toolUseID: string,
{
onPostToolUseHook,
}: {
onPostToolUseHook?: (
toolUseID: string,
toolInput: unknown,
toolResponse: unknown,
) => Promise<void>;
},
) => {
toolUseCallbacks[toolUseID] = {
onPostToolUseHook,
};
};
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
export const createPostToolUseHook =
(logger: Logger = console): HookCallback =>
async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => {
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

@@ -2,9 +2,6 @@
import { Readable, Writable } from "node:stream";
import { WritableStream, ReadableStream } from "node:stream/web";
import { readFileSync } from "node:fs";
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> {
@@ -76,96 +73,10 @@ export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Arr
});
}
export function unreachable(value: never, logger: Logger = console) {
let valueAsString;
try {
valueAsString = JSON.stringify(value);
} catch {
valueAsString = value;
}
logger.error(`Unexpected case: ${valueAsString}`);
export function unreachable(value: never): never {
throw new Error(`Unexpected case: ${value}`);
}
export function sleep(time: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, time));
}
export function loadManagedSettings(): ClaudeCodeSettings | null {
try {
return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ClaudeCodeSettings;
} catch {
return null;
}
}
export function applyEnvironmentSettings(settings: ClaudeCodeSettings): void {
if (settings.env) {
for (const [key, value] of Object.entries(settings.env)) {
process.env[key] = value;
}
}
}
export interface ExtractLinesResult {
content: string;
wasLimited: boolean;
linesRead: number;
}
/**
* Extracts lines from file content with byte limit enforcement.
*
* @param fullContent - The complete file content
* @param maxContentLength - Maximum number of UTF-16 Code Units to return
* @returns Object containing extracted content and metadata
*/
export function extractLinesWithByteLimit(
fullContent: string,
maxContentLength: number,
): ExtractLinesResult {
if (fullContent === "") {
return {
content: "",
wasLimited: false,
linesRead: 1,
};
}
let linesSeen = 0;
let index = 0;
linesSeen = 0;
let contentLength = 0;
let wasLimited = false;
while (true) {
const nextIndex = fullContent.indexOf("\n", index);
if (nextIndex < 0) {
// Last line in file (no trailing newline)
if (linesSeen > 0 && fullContent.length > maxContentLength) {
wasLimited = true;
break;
}
linesSeen += 1;
contentLength = fullContent.length;
break;
} else {
// Line with newline - include up to the newline
const newContentLength = nextIndex + 1;
if (linesSeen > 0 && newContentLength > maxContentLength) {
wasLimited = true;
break;
}
linesSeen += 1;
contentLength = newContentLength;
index = newContentLength;
}
}
return {
content: fullContent.slice(0, contentLength),
wasLimited,
linesRead: linesSeen,
};
}