mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5268661b7f | ||
|
|
9583f97536 | ||
|
|
95721e4e39 | ||
|
|
7f1c6c2078 | ||
|
|
3500b6c16e | ||
|
|
07db59ef0d | ||
|
|
34c847a9e3 | ||
|
|
d2428a49ef | ||
|
|
91d629d6c6 | ||
|
|
5fd7873c7c | ||
|
|
83496544b2 | ||
|
|
c5d5c11f40 | ||
|
|
521d1f766d | ||
|
|
f11435df13 | ||
|
|
e8545266fe | ||
|
|
ff3b97aece | ||
|
|
c40d7bc30f | ||
|
|
152fe91152 | ||
|
|
45de3d2847 | ||
|
|
c7ae07da6b | ||
|
|
0af467c400 | ||
|
|
f4ce899223 | ||
|
|
31539b4ee9 | ||
|
|
61a39ea801 | ||
|
|
c0b7752b45 | ||
|
|
3002539e4a | ||
|
|
87a0886a34 | ||
|
|
2193a13a3b | ||
|
|
c13edf3b02 | ||
|
|
a217891e6c | ||
|
|
867de85f0b | ||
|
|
af11cdbba5 | ||
|
|
f7af07758c | ||
|
|
3524606555 | ||
|
|
9ecb222807 | ||
|
|
dd9fe3a98e | ||
|
|
1c641cd6a7 | ||
|
|
a02ef184d3 | ||
|
|
3beddee766 | ||
|
|
52d5b74aa7 | ||
|
|
edf6486c44 | ||
|
|
232a8cd8e2 | ||
|
|
37e3f389f6 | ||
|
|
f513a14a46 | ||
|
|
c0e2cee265 | ||
|
|
b4f56b17fc | ||
|
|
c2e45e638d | ||
|
|
5c1863d56e | ||
|
|
6253b1611e | ||
|
|
6ff856c94f | ||
|
|
1b057923e8 | ||
|
|
3b3ce6f287 | ||
|
|
b1d09f2366 | ||
|
|
d24066bd9d | ||
|
|
5ca63abc47 | ||
|
|
001614b459 | ||
|
|
218661f97b | ||
|
|
5ede2b7c4c | ||
|
|
988a741d59 | ||
|
|
b57a429867 | ||
|
|
101f09dbf8 | ||
|
|
d36204f6da | ||
|
|
4de2f98a8e | ||
|
|
f5a5c73607 | ||
|
|
5bd0af27ec | ||
|
|
be618f5dcb | ||
|
|
28b85aed41 | ||
|
|
44bb150b5d | ||
|
|
18bd9f2490 | ||
|
|
b8e655d7ac | ||
|
|
0e77751692 | ||
|
|
e5b836820b | ||
|
|
c3ffcfa98f | ||
|
|
4d232dafad | ||
|
|
4f0ffa2735 | ||
|
|
f069df5857 | ||
|
|
e2a1ac9b9d | ||
|
|
673b0507d7 | ||
|
|
ea00a4014a | ||
|
|
ebdd115b76 | ||
|
|
7c87fe7a8c | ||
|
|
8420b50523 | ||
|
|
24339faf2b | ||
|
|
8360a21358 | ||
|
|
a9985d49e9 | ||
|
|
9dfd0a0c4f | ||
|
|
fd4fd247bf | ||
|
|
67926a9ac2 | ||
|
|
4d0cb9cbdd | ||
|
|
e0cac12239 | ||
|
|
6b68ddf00e | ||
|
|
34a617e20f | ||
|
|
9016cba978 | ||
|
|
325c513f1b | ||
|
|
87fa0a23c0 | ||
|
|
c5a49e7481 | ||
|
|
c597cfae85 | ||
|
|
a31f4534dd | ||
|
|
2e9bb8e82b | ||
|
|
cfdb6463d6 | ||
|
|
adb495d5d3 | ||
|
|
11ca660dec | ||
|
|
32898580a5 | ||
|
|
a7794b5ffb | ||
|
|
8802888e39 | ||
|
|
e4cebeca96 | ||
|
|
45a539257b | ||
|
|
670fb18728 | ||
|
|
36ea238d76 | ||
|
|
8366864daf | ||
|
|
5917529f0d | ||
|
|
2597775ef0 | ||
|
|
6572fcf978 | ||
|
|
5586ae0d2c | ||
|
|
39ab0c248f | ||
|
|
72a9cde888 | ||
|
|
c9d5d3bb9c | ||
|
|
cee383c67c | ||
|
|
041aa44d00 | ||
|
|
3db76b2d20 | ||
|
|
db965a31aa | ||
|
|
5280246773 | ||
|
|
9f3dd402ea | ||
|
|
d19e399018 | ||
|
|
24d1050f7a |
94
.github/workflows/publish.yml
vendored
94
.github/workflows/publish.yml
vendored
@@ -1,11 +1,84 @@
|
||||
name: Publish Package to npmjs
|
||||
name: Publish and Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
build-binaries:
|
||||
name: Build (${{ matrix.target }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 15
|
||||
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- target: bun-linux-x64
|
||||
artifact: claude-agent-acp
|
||||
archive: claude-agent-acp-linux-x64.tar.gz
|
||||
- target: bun-linux-arm64
|
||||
artifact: claude-agent-acp
|
||||
archive: claude-agent-acp-linux-arm64.tar.gz
|
||||
- target: bun-linux-x64-musl
|
||||
artifact: claude-agent-acp
|
||||
archive: claude-agent-acp-linux-x64-musl.tar.gz
|
||||
- target: bun-linux-arm64-musl
|
||||
artifact: claude-agent-acp
|
||||
archive: claude-agent-acp-linux-arm64-musl.tar.gz
|
||||
- target: bun-darwin-x64
|
||||
artifact: claude-agent-acp
|
||||
archive: claude-agent-acp-darwin-x64.zip
|
||||
- target: bun-darwin-arm64
|
||||
artifact: claude-agent-acp
|
||||
archive: claude-agent-acp-darwin-arm64.zip
|
||||
- target: bun-windows-x64
|
||||
artifact: claude-agent-acp.exe
|
||||
archive: claude-agent-acp-windows-x64.zip
|
||||
- target: bun-windows-arm64
|
||||
artifact: claude-agent-acp.exe
|
||||
archive: claude-agent-acp-windows-arm64.zip
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
|
||||
- uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Build binary
|
||||
run: |
|
||||
bun build src/index.ts \
|
||||
--compile \
|
||||
--target ${{ matrix.target }} \
|
||||
--define 'process.env.CLAUDE_AGENT_ACP_IS_SINGLE_FILE_BUN="true"' \
|
||||
--outfile ${{ matrix.artifact }}
|
||||
|
||||
- name: Compress binary
|
||||
run: |
|
||||
if [[ "${{ matrix.archive }}" == *.zip ]]; then
|
||||
zip ${{ matrix.archive }} ${{ matrix.artifact }}
|
||||
else
|
||||
tar czf ${{ matrix.archive }} ${{ matrix.artifact }}
|
||||
fi
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
with:
|
||||
name: ${{ matrix.archive }}
|
||||
path: ${{ matrix.archive }}
|
||||
|
||||
publish-npm:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-binaries]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -22,13 +95,26 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm publish
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [publish-npm]
|
||||
needs: [build-binaries, publish-npm]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090
|
||||
with:
|
||||
generate_release_notes: true
|
||||
files: artifacts/*
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
.claude/*.local.*
|
||||
CLAUDE.local.md
|
||||
|
||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.agent-shell
|
||||
141
CHANGELOG.md
141
CHANGELOG.md
@@ -1,5 +1,146 @@
|
||||
# Changelog
|
||||
|
||||
## 0.23.1
|
||||
|
||||
- Add back error_during_execution break point (#469)
|
||||
|
||||
## 0.23.0
|
||||
|
||||
- Use idle session state as end of turn (#463)
|
||||
- Update claude-agent-sdk to 0.2.83 (#462)
|
||||
- Fix handling of local-only slash commands (#432)
|
||||
- fix: correct null check for gatewayAuthMeta in subscription validation (#455)
|
||||
- fix: include both stdout and stderr in Bash tool output (#456)
|
||||
- fix: prevent prompt loop hang when cancel races with first result (#458)
|
||||
- fix: dispose SettingsManager on session close to prevent resource leaks (#454)
|
||||
- fix: restore plan content in ExitPlanMode tool call (#451)
|
||||
|
||||
## 0.22.2
|
||||
|
||||
- Add experimental meta param for testing additional directories
|
||||
|
||||
## 0.22.1
|
||||
|
||||
- Fix: invalid auth required state in gateway mode
|
||||
|
||||
## 0.22.0
|
||||
|
||||
- Use stable list sessions method (#429)
|
||||
- Use correct Claude CLI path for static binaries (#428)
|
||||
- Update claude-agent-sdk to 0.2.76 (#427)
|
||||
- fix: resolve model aliases in setSessionConfigOption (#401) (#403)
|
||||
- Reuse existing sessions for load/resume if possible (#426)
|
||||
- Remove interrupt flag from deny responses (#425)
|
||||
- Allow Bypass permissions mode after Exiting plan (#410)
|
||||
- Don't get out of sync when background task creates new init/result (try 2) (#400)
|
||||
- Add session/close support (#409)
|
||||
|
||||
## 0.21.0
|
||||
|
||||
- Update to claude-agent-sdk 0.2.71
|
||||
- show project-relative paths in tool call titles
|
||||
- Lib: pass through tools array to control built-in tool availability
|
||||
- fix: skip user replay
|
||||
- fix: handle renamed Agent tool in toolInfoFromToolUse
|
||||
|
||||
## 0.20.2
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.68
|
||||
|
||||
## 0.20.1
|
||||
|
||||
- fix: inherit process.env when spawning agent subprocess
|
||||
|
||||
## 0.20.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.63
|
||||
- Respect user settings for permission mode and model selection
|
||||
- Better handling of concurrent prompts
|
||||
- Support --cli for node as well
|
||||
- Propagate max_tokens stop reason instead of throwing internal error
|
||||
- fix: throw resourceNotFound when loadSession fails to resume
|
||||
- fix: add missing zod dependency
|
||||
- Surface better error message when Claude Code process exits unexpectedly
|
||||
|
||||
## 0.19.2
|
||||
|
||||
- Fix for broken notifications when reloading session messages
|
||||
|
||||
## 0.19.1
|
||||
|
||||
- Support windows arm builds and clean up artifact files
|
||||
|
||||
## 0.19.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.62
|
||||
- Use SDK functions for listing and loading session history
|
||||
- Build single-file executables using bun.
|
||||
- Fix for overwritten disallowed tools.
|
||||
|
||||
## 0.18.0
|
||||
|
||||
- Switch over to built-in Claude tools. We no longer replicate specific ACP tools and just rely on sending updates based on Claude's internal tools. This means it won't use client capabilities for files or terminals, but also means there will be less difference and hopefully issues arising from the differences in behavior.
|
||||
- Support ACP session config options: https://agentclientprotocol.com/protocol/session-config-options
|
||||
- Fix for image output from tool calls.
|
||||
|
||||
## 0.17.1
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.45 to add access to Sonnet 4.6
|
||||
|
||||
## 0.17.0
|
||||
|
||||
Rename from `@zed-industries/claude-code-acp` to `@zed-industries/claude-agent-acp` to align with the current [branding guidelines](https://platform.claude.com/docs/en/agent-sdk/overview#branding-guidelines)
|
||||
|
||||
## 0.16.2
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.44
|
||||
- fix: Replace all non-alphanumeric characters for session loading in encodeProjectPath (#307)
|
||||
- don't include /login slash command in login command (#315)
|
||||
|
||||
## 0.16.1
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.38
|
||||
- Fix incorrect paths for session/list
|
||||
- Fix available commands after loading a session
|
||||
- Make loading session more permissive for finding events
|
||||
- Fix overriding user-provided disallowedTools
|
||||
|
||||
## 0.16.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.34
|
||||
- Experimental support for session loading
|
||||
|
||||
## 0.15.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.32 (adds support for Opus 4.6 and 1M context Opus)
|
||||
|
||||
## 0.14.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.29
|
||||
- Update to using the recommended `CLAUDE_CONFIG_DIR` env variable for setting where config files are kept
|
||||
- Support /context command
|
||||
- Fix incorrect context type mapping for tool calls
|
||||
- Fix glob metching for file permissions on Windows
|
||||
- Support the `IS_SANDBOX` env var for supporting bypass permissions in root mode
|
||||
- Fix missing notification for entering plan mode
|
||||
- Experimental unstable support for listing sessions
|
||||
|
||||
## 0.13.2
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.22
|
||||
- Fix: return content from ACP write tool to help with issues with alternate providers.
|
||||
|
||||
## 0.13.1
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.7
|
||||
- Add TypeScript declaration files for library users
|
||||
- Fixed error handling in custom ACP focused MCP tools
|
||||
|
||||
## 0.13.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.6
|
||||
- Update to @agentclientprotocol/sdk@0.13.0
|
||||
|
||||
## 0.12.6
|
||||
|
||||
- Fix model selection
|
||||
|
||||
29
README.md
29
README.md
@@ -1,10 +1,10 @@
|
||||
# ACP adapter for Claude Code
|
||||
# ACP adapter for the Claude Agent SDK
|
||||
|
||||
[](https://www.npmjs.com/package/@zed-industries/claude-code-acp)
|
||||
[](https://www.npmjs.com/package/@zed-industries/claude-agent-acp)
|
||||
|
||||
Use [Claude Code](https://www.anthropic.com/claude-code) from [ACP-compatible](https://agentclientprotocol.com) clients such as [Zed](https://zed.dev)!
|
||||
Use [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview#branding-guidelines) 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:
|
||||
This tool implements an ACP agent by using the official [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview), supporting:
|
||||
|
||||
- Context @-mentions
|
||||
- Images
|
||||
@@ -24,7 +24,7 @@ Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
|
||||
|
||||
The latest version of Zed can already use this adapter out of the box.
|
||||
|
||||
To use Claude Code, open the Agent Panel and click "New Claude Code Thread" from the `+` button menu in the top-right:
|
||||
To use Claude Agent, open the Agent Panel and click "New Claude Agent Thread" from the `+` button menu in the top-right:
|
||||
|
||||
https://github.com/user-attachments/assets/ddce66c7-79ac-47a3-ad59-4a6a3ca74903
|
||||
|
||||
@@ -32,28 +32,27 @@ Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) suppo
|
||||
|
||||
### 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!
|
||||
Or try it with any of the other [ACP compatible clients](https://agentclientprotocol.com/overview/clients)!
|
||||
|
||||
#### Installation
|
||||
|
||||
Install the adapter from `npm`:
|
||||
|
||||
```bash
|
||||
npm install @zed-industries/claude-code-acp
|
||||
npm install -g @zed-industries/claude-agent-acp
|
||||
```
|
||||
|
||||
You can then use `claude-code-acp` as a regular ACP agent:
|
||||
You can then use `claude-agent-acp` as a regular ACP agent:
|
||||
|
||||
```
|
||||
ANTHROPIC_API_KEY=sk-... claude-code-acp
|
||||
ANTHROPIC_API_KEY=sk-... claude-agent-acp
|
||||
```
|
||||
|
||||
#### Single-file executable
|
||||
|
||||
Pre-built single-file binaries are available on the [Releases](https://github.com/zed-industries/claude-agent-acp/releases) page for Linux, macOS, and Windows.
|
||||
These binaries bundle everything needed and don't require Node.js.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
|
||||
3288
package-lock.json
generated
3288
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
48
package.json
48
package.json
@@ -1,17 +1,26 @@
|
||||
{
|
||||
"name": "@zed-industries/claude-code-acp",
|
||||
"name": "@zed-industries/claude-agent-acp",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.12.6",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"version": "0.23.1",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
"bin": {
|
||||
"claude-code-acp": "./dist/index.js"
|
||||
"claude-agent-acp": "dist/index.js"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/lib.d.ts",
|
||||
"import": "./dist/lib.js"
|
||||
},
|
||||
"./*": "./*"
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
"!dist/tests/",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"package.json"
|
||||
@@ -40,34 +49,33 @@
|
||||
"sdk",
|
||||
"code"
|
||||
],
|
||||
"homepage": "https://github.com/zed-industries/claude-code-acp#readme",
|
||||
"homepage": "https://github.com/zed-industries/claude-agent-acp#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/zed-industries/claude-code-acp/issues"
|
||||
"url": "https://github.com/zed-industries/claude-agent-acp/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/zed-industries/claude-code-acp.git"
|
||||
"url": "git+https://github.com/zed-industries/claude-agent-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"
|
||||
"@agentclientprotocol/sdk": "0.17.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.83",
|
||||
"zod": "^3.25.0 || ^4.0.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",
|
||||
"@anthropic-ai/sdk": "0.80.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"eslint": "10.1.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "16.5.0",
|
||||
"prettier": "3.7.4",
|
||||
"globals": "17.4.0",
|
||||
"prettier": "3.8.1",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.15"
|
||||
"vitest": "4.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
1758
src/acp-agent.ts
1758
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
45
src/index.ts
45
src/index.ts
@@ -2,25 +2,30 @@
|
||||
|
||||
// Load managed settings and apply environment variables
|
||||
import { loadManagedSettings, applyEnvironmentSettings } from "./utils.js";
|
||||
import { claudeCliPath, runAcp } from "./acp-agent.js";
|
||||
|
||||
const managedSettings = loadManagedSettings();
|
||||
if (managedSettings) {
|
||||
applyEnvironmentSettings(managedSettings);
|
||||
if (process.argv.includes("--cli")) {
|
||||
process.argv = process.argv.filter((arg) => arg !== "--cli");
|
||||
await import(await claudeCliPath());
|
||||
} else {
|
||||
const managedSettings = loadManagedSettings();
|
||||
if (managedSettings) {
|
||||
applyEnvironmentSettings(managedSettings);
|
||||
}
|
||||
|
||||
// stdout is used to send messages to the client
|
||||
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
|
||||
console.log = console.error;
|
||||
console.info = console.error;
|
||||
console.warn = console.error;
|
||||
console.debug = console.error;
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
|
||||
runAcp();
|
||||
|
||||
// Keep process alive
|
||||
process.stdin.resume();
|
||||
}
|
||||
|
||||
// stdout is used to send messages to the client
|
||||
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
|
||||
console.log = console.error;
|
||||
console.info = console.error;
|
||||
console.warn = console.error;
|
||||
console.debug = console.error;
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
|
||||
import { runAcp } from "./acp-agent.js";
|
||||
runAcp();
|
||||
|
||||
// Keep process alive
|
||||
process.stdin.resume();
|
||||
|
||||
@@ -15,20 +15,15 @@ export {
|
||||
Pushable,
|
||||
unreachable,
|
||||
} from "./utils.js";
|
||||
export { createMcpServer } from "./mcp-server.js";
|
||||
export {
|
||||
toolInfoFromToolUse,
|
||||
toDisplayPath,
|
||||
planEntries,
|
||||
toolUpdateFromToolResult,
|
||||
createPreToolUseHook,
|
||||
acpToolNames as toolNames,
|
||||
} from "./tools.js";
|
||||
export {
|
||||
SettingsManager,
|
||||
type ClaudeCodeSettings,
|
||||
type PermissionSettings,
|
||||
type PermissionDecision,
|
||||
type PermissionCheckResult,
|
||||
type SettingsManagerOptions,
|
||||
} from "./settings.js";
|
||||
|
||||
|
||||
@@ -1,806 +0,0 @@
|
||||
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 { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
|
||||
import { acpToolNames } from "./tools.js";
|
||||
|
||||
export const SYSTEM_REMINDER = `
|
||||
|
||||
<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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create MCP server
|
||||
const server = new McpServer({ name: "acp", version: "1.0.0" }, { capabilities: { tools: {} } });
|
||||
|
||||
if (clientCapabilities?.fs?.readTextFile) {
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.read,
|
||||
{
|
||||
title: unqualifiedToolNames.read,
|
||||
description: `Reads the content of the given file in the project.
|
||||
|
||||
In sessions with ${acpToolNames.read} always use it instead of Read as it contains the most up-to-date contents.
|
||||
|
||||
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.`,
|
||||
inputSchema: {
|
||||
file_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.`,
|
||||
),
|
||||
},
|
||||
annotations: {
|
||||
title: "Read file",
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input: FileReadInput) => {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
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>";
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: result.content + readInfo + SYSTEM_REMINDER,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Reading file failed: " + error.message,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (clientCapabilities?.fs?.writeTextFile) {
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.write,
|
||||
{
|
||||
title: unqualifiedToolNames.write,
|
||||
description: `Writes a file to the local filesystem..
|
||||
|
||||
In sessions with ${acpToolNames.write} always use it instead of Write as it will
|
||||
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.`,
|
||||
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"),
|
||||
},
|
||||
annotations: {
|
||||
title: "Write file",
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input: FileWriteInput) => {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
await writeTextFile(input);
|
||||
|
||||
return {
|
||||
content: [],
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Writing file failed: " + error.message,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.edit,
|
||||
{
|
||||
title: unqualifiedToolNames.edit,
|
||||
description: `Performs exact string replacements in files.
|
||||
|
||||
In sessions with ${acpToolNames.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.`,
|
||||
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)"),
|
||||
},
|
||||
annotations: {
|
||||
title: "Edit file",
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input: FileEditInput) => {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const readResponse = await readTextFile({
|
||||
file_path: input.file_path,
|
||||
});
|
||||
|
||||
if (typeof readResponse?.content !== "string") {
|
||||
throw new Error(`No file contents for ${input.file_path}.`);
|
||||
}
|
||||
|
||||
const { newContent } = replaceAndCalculateLocation(readResponse.content, [
|
||||
{
|
||||
oldText: input.old_string,
|
||||
newText: input.new_string,
|
||||
replaceAll: input.replace_all,
|
||||
},
|
||||
]);
|
||||
|
||||
const patch = diff.createPatch(input.file_path, readResponse.content, newContent);
|
||||
|
||||
await writeTextFile({ file_path: input.file_path, content: newContent });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: patch,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Editing file failed: " + (error?.message ?? String(error)),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (agent.clientCapabilities?.terminal) {
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.bash,
|
||||
{
|
||||
title: unqualifiedToolNames.bash,
|
||||
description: `Executes a bash command
|
||||
|
||||
In sessions with ${acpToolNames.bash} always use it instead of Bash`,
|
||||
inputSchema: {
|
||||
command: z.string().describe("The command to execute"),
|
||||
timeout: z.number().describe(`Optional timeout in milliseconds (max ${2 * 60 * 1000})`),
|
||||
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'`),
|
||||
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.`,
|
||||
),
|
||||
},
|
||||
},
|
||||
async (input: BashInput, extra) => {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const toolCallId = extra._meta?.["claudecode/toolUseId"];
|
||||
|
||||
if (typeof toolCallId !== "string") {
|
||||
throw new Error("No tool call ID found");
|
||||
}
|
||||
|
||||
if (!agent.clientCapabilities?.terminal || !agent.client.createTerminal) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
const handle = await agent.client.createTerminal({
|
||||
command: input.command,
|
||||
env: [{ name: "CLAUDECODE", value: "1" }],
|
||||
sessionId,
|
||||
outputByteLimit: 32_000,
|
||||
});
|
||||
|
||||
await agent.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId,
|
||||
status: "in_progress",
|
||||
title: input.description,
|
||||
content: [{ type: "terminal", terminalId: handle.id }],
|
||||
},
|
||||
});
|
||||
|
||||
const abortPromise = new Promise((resolve) => {
|
||||
if (extra.signal.aborted) {
|
||||
resolve(null);
|
||||
} else {
|
||||
extra.signal.addEventListener("abort", () => {
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const statusPromise = Promise.race([
|
||||
handle.waitForExit().then((exitStatus) => ({ status: "exited" as const, exitStatus })),
|
||||
abortPromise.then(() => ({ status: "aborted" as const, exitStatus: null })),
|
||||
sleep(input.timeout ?? 2 * 60 * 1000).then(async () => {
|
||||
if (agent.backgroundTerminals[handle.id]?.status === "started") {
|
||||
await handle.kill();
|
||||
}
|
||||
return { status: "timedOut" as const, exitStatus: null };
|
||||
}),
|
||||
]);
|
||||
|
||||
if (input.run_in_background) {
|
||||
agent.backgroundTerminals[handle.id] = {
|
||||
handle,
|
||||
lastOutput: null,
|
||||
status: "started",
|
||||
};
|
||||
|
||||
statusPromise.then(async ({ status, exitStatus }) => {
|
||||
const bgTerm = agent.backgroundTerminals[handle.id];
|
||||
|
||||
if (bgTerm.status !== "started") {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentOutput = await handle.currentOutput();
|
||||
|
||||
agent.backgroundTerminals[handle.id] = {
|
||||
status,
|
||||
pendingOutput: {
|
||||
...currentOutput,
|
||||
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
|
||||
exitStatus: exitStatus ?? currentOutput.exitStatus,
|
||||
},
|
||||
};
|
||||
|
||||
return handle.release();
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Command started in background with id: ${handle.id}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
await using terminal = handle;
|
||||
|
||||
const { status } = await statusPromise;
|
||||
|
||||
if (status === "aborted") {
|
||||
return {
|
||||
content: [{ type: "text", text: "Tool cancelled by user" }],
|
||||
};
|
||||
}
|
||||
|
||||
const output = await terminal.currentOutput();
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: toolCommandOutput(status, output) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.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.`,
|
||||
inputSchema: {
|
||||
bash_id: z
|
||||
.string()
|
||||
.describe(
|
||||
`The id of the background bash command as returned by \`${acpToolNames.bash}\``,
|
||||
),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const bgTerm = agent.backgroundTerminals[input.bash_id];
|
||||
|
||||
if (!bgTerm) {
|
||||
throw new Error(`Unknown shell ${input.bash_id}`);
|
||||
}
|
||||
|
||||
if (bgTerm.status === "started") {
|
||||
const newOutput = await bgTerm.handle.currentOutput();
|
||||
const strippedOutput = stripCommonPrefix(
|
||||
bgTerm.lastOutput?.output ?? "",
|
||||
newOutput.output,
|
||||
);
|
||||
bgTerm.lastOutput = newOutput;
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: toolCommandOutput(bgTerm.status, {
|
||||
...newOutput,
|
||||
output: strippedOutput,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: toolCommandOutput(bgTerm.status, bgTerm.pendingOutput),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.killShell,
|
||||
{
|
||||
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.`,
|
||||
inputSchema: {
|
||||
shell_id: z
|
||||
.string()
|
||||
.describe(
|
||||
`The id of the background bash command as returned by \`${acpToolNames.bash}\``,
|
||||
),
|
||||
},
|
||||
},
|
||||
async (input: KillShellInput) => {
|
||||
const bgTerm = agent.backgroundTerminals[input.shell_id];
|
||||
|
||||
if (!bgTerm) {
|
||||
throw new Error(`Unknown shell ${input.shell_id}`);
|
||||
}
|
||||
|
||||
switch (bgTerm.status) {
|
||||
case "started": {
|
||||
await bgTerm.handle.kill();
|
||||
const currentOutput = await bgTerm.handle.currentOutput();
|
||||
agent.backgroundTerminals[bgTerm.handle.id] = {
|
||||
status: "killed",
|
||||
pendingOutput: {
|
||||
...currentOutput,
|
||||
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
|
||||
},
|
||||
};
|
||||
await bgTerm.handle.release();
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: "Command killed successfully." }],
|
||||
};
|
||||
}
|
||||
case "aborted":
|
||||
return {
|
||||
content: [{ type: "text", text: "Command aborted by user." }],
|
||||
};
|
||||
case "exited":
|
||||
return {
|
||||
content: [{ type: "text", text: "Command had already exited." }],
|
||||
};
|
||||
case "killed":
|
||||
return {
|
||||
content: [{ type: "text", text: "Command was already killed." }],
|
||||
};
|
||||
case "timedOut":
|
||||
return {
|
||||
content: [{ type: "text", text: "Command killed by timeout." }],
|
||||
};
|
||||
default: {
|
||||
unreachable(bgTerm);
|
||||
throw new Error("Unexpected background terminal status");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function stripCommonPrefix(a: string, b: string): string {
|
||||
let i = 0;
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) {
|
||||
i++;
|
||||
}
|
||||
return b.slice(i);
|
||||
}
|
||||
|
||||
function toolCommandOutput(
|
||||
status: "started" | "aborted" | "exited" | "killed" | "timedOut",
|
||||
output: TerminalOutputResponse,
|
||||
): string {
|
||||
const { exitStatus, output: commandOutput, truncated } = output;
|
||||
|
||||
let toolOutput = "";
|
||||
|
||||
switch (status) {
|
||||
case "started":
|
||||
case "exited": {
|
||||
if (exitStatus && (exitStatus.exitCode ?? null) === null) {
|
||||
toolOutput += `Interrupted by the user. `;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "killed":
|
||||
toolOutput += `Killed. `;
|
||||
break;
|
||||
case "timedOut":
|
||||
toolOutput += `Timed out. `;
|
||||
break;
|
||||
case "aborted":
|
||||
break;
|
||||
default: {
|
||||
const unreachable: never = status;
|
||||
return unreachable;
|
||||
}
|
||||
}
|
||||
|
||||
if (exitStatus) {
|
||||
if (typeof exitStatus.exitCode === "number") {
|
||||
toolOutput += `Exited with code ${exitStatus.exitCode}.`;
|
||||
}
|
||||
|
||||
if (typeof exitStatus.signal === "string") {
|
||||
toolOutput += `Signal \`${exitStatus.signal}\`. `;
|
||||
}
|
||||
|
||||
toolOutput += "Final output:\n\n";
|
||||
} else {
|
||||
toolOutput += "New output:\n\n";
|
||||
}
|
||||
|
||||
toolOutput += commandOutput;
|
||||
|
||||
if (truncated) {
|
||||
toolOutput += `\n\nCommand output was too long, so it was truncated to ${commandOutput.length} bytes.`;
|
||||
}
|
||||
|
||||
return toolOutput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace text in a file and calculate the line numbers where the edits occurred.
|
||||
*
|
||||
* @param fileContent - The full file content
|
||||
* @param edits - Array of edit operations to apply sequentially
|
||||
* @returns the new content and the line numbers where replacements occurred in the final content
|
||||
*/
|
||||
export function replaceAndCalculateLocation(
|
||||
fileContent: string,
|
||||
edits: Array<{
|
||||
oldText: string;
|
||||
newText: string;
|
||||
replaceAll?: boolean;
|
||||
}>,
|
||||
): { newContent: string; lineNumbers: number[] } {
|
||||
let currentContent = fileContent;
|
||||
|
||||
// Use unique markers to track where replacements happen
|
||||
const markerPrefix = `__REPLACE_MARKER_${Math.random().toString(36).substr(2, 9)}_`;
|
||||
let markerCounter = 0;
|
||||
const markers: string[] = [];
|
||||
|
||||
// Apply edits sequentially, inserting markers at replacement positions
|
||||
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.`);
|
||||
}
|
||||
|
||||
if (edit.replaceAll) {
|
||||
// Replace all occurrences with marker + newText
|
||||
const parts: string[] = [];
|
||||
let lastIndex = 0;
|
||||
let searchIndex = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Add content before the match
|
||||
parts.push(currentContent.substring(lastIndex, index));
|
||||
|
||||
// Add marker and replacement
|
||||
const marker = `${markerPrefix}${markerCounter++}__`;
|
||||
markers.push(marker);
|
||||
parts.push(marker + edit.newText);
|
||||
|
||||
lastIndex = index + edit.oldText.length;
|
||||
searchIndex = lastIndex;
|
||||
}
|
||||
|
||||
// Add remaining content
|
||||
parts.push(currentContent.substring(lastIndex));
|
||||
currentContent = parts.join("");
|
||||
} 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 {
|
||||
const marker = `${markerPrefix}${markerCounter++}__`;
|
||||
markers.push(marker);
|
||||
currentContent =
|
||||
currentContent.substring(0, index) +
|
||||
marker +
|
||||
edit.newText +
|
||||
currentContent.substring(index + edit.oldText.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find line numbers where markers appear in the content
|
||||
const lineNumbers: number[] = [];
|
||||
for (const marker of markers) {
|
||||
const index = currentContent.indexOf(marker);
|
||||
if (index !== -1) {
|
||||
const lineNumber = Math.max(
|
||||
0,
|
||||
currentContent.substring(0, index).split(/\r\n|\r|\n/).length - 1,
|
||||
);
|
||||
lineNumbers.push(lineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all markers from the final content
|
||||
let finalContent = currentContent;
|
||||
for (const marker of markers) {
|
||||
finalContent = finalContent.replace(marker, "");
|
||||
}
|
||||
|
||||
// Dedupe and sort line numbers
|
||||
const uniqueLineNumbers = [...new Set(lineNumbers)].sort();
|
||||
|
||||
return { newContent: finalContent, lineNumbers: uniqueLineNumbers };
|
||||
}
|
||||
267
src/settings.ts
267
src/settings.ts
@@ -1,8 +1,5 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -18,189 +15,13 @@ import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
|
||||
*/
|
||||
|
||||
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);
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -336,39 +157,23 @@ export class SettingsManager {
|
||||
this.enterpriseSettings,
|
||||
];
|
||||
|
||||
const merged: ClaudeCodeSettings = {
|
||||
permissions: {
|
||||
allow: [],
|
||||
deny: [],
|
||||
ask: [],
|
||||
},
|
||||
};
|
||||
const merged: ClaudeCodeSettings = {};
|
||||
|
||||
for (const settings of allSettings) {
|
||||
if (settings.permissions) {
|
||||
if (settings.permissions.allow) {
|
||||
merged.permissions!.allow!.push(...settings.permissions.allow);
|
||||
}
|
||||
if (settings.permissions.deny) {
|
||||
merged.permissions!.deny!.push(...settings.permissions.deny);
|
||||
}
|
||||
if (settings.permissions.ask) {
|
||||
merged.permissions!.ask!.push(...settings.permissions.ask);
|
||||
}
|
||||
if (settings.permissions.additionalDirectories) {
|
||||
merged.permissions!.additionalDirectories = [
|
||||
...(merged.permissions!.additionalDirectories || []),
|
||||
...settings.permissions.additionalDirectories,
|
||||
];
|
||||
}
|
||||
if (settings.permissions.defaultMode) {
|
||||
merged.permissions!.defaultMode = settings.permissions.defaultMode;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.env) {
|
||||
merged.env = { ...merged.env, ...settings.env };
|
||||
}
|
||||
|
||||
if (settings.model) {
|
||||
merged.model = settings.model;
|
||||
}
|
||||
|
||||
if (settings.permissions?.defaultMode !== undefined) {
|
||||
merged.permissions = {
|
||||
...merged.permissions,
|
||||
defaultMode: settings.permissions.defaultMode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.mergedSettings = merged;
|
||||
@@ -430,52 +235,6 @@ export class SettingsManager {
|
||||
}, 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
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Say hello
|
||||
argument-hint: [name]
|
||||
argument-hint: name
|
||||
---
|
||||
|
||||
Respond with "Hello $1" and nothing else.
|
||||
|
||||
220
src/tests/acp-agent-settings.test.ts
Normal file
220
src/tests/acp-agent-settings.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
|
||||
const { querySpy } = vi.hoisted(() => ({
|
||||
querySpy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<any>("@anthropic-ai/claude-agent-sdk");
|
||||
return {
|
||||
...actual,
|
||||
query: querySpy,
|
||||
};
|
||||
});
|
||||
|
||||
describe("ClaudeAcpAgent settings", () => {
|
||||
let tempDir: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
return {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
}
|
||||
|
||||
function mockQuery() {
|
||||
let capturedOptions: any;
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(({ options }: any) => {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
description: "Default",
|
||||
},
|
||||
],
|
||||
}),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
});
|
||||
return { getCapturedOptions: () => capturedOptions, setModelSpy };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "acp-agent-settings-"));
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = tempDir;
|
||||
querySpy.mockReset();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalClaudeConfigDir) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("uses permissions.defaultMode for new sessions", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
defaultMode: "dontAsk",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("dontAsk");
|
||||
expect(getCapturedOptions().settingSources).toEqual(["user", "project", "local"]);
|
||||
expect(response.modes.currentModeId).toBe("dontAsk");
|
||||
});
|
||||
|
||||
it("supports acceptEdits mode defaults", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
defaultMode: "acceptEdits",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("acceptEdits");
|
||||
expect(response.modes.currentModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
it("defaults to 'default' when no permissions.defaultMode is set", async () => {
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
});
|
||||
|
||||
it("throws when permissions.defaultMode is not a string", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
defaultMode: 123,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
await expect(
|
||||
(agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
}),
|
||||
).rejects.toThrow("Invalid permissions.defaultMode");
|
||||
});
|
||||
|
||||
it("resolves model aliases like opus[1m] to the correct model", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
model: "opus[1m]",
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(({ options: _options }: any) => {
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-opus-4-6",
|
||||
displayName: "Claude Opus 4.6",
|
||||
description: "Base",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-6-1m",
|
||||
displayName: "Claude Opus 4.6 (1M)",
|
||||
description: "Long context",
|
||||
},
|
||||
],
|
||||
}),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
});
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-opus-4-6-1m");
|
||||
expect(response.models.currentModelId).toBe("claude-opus-4-6-1m");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
71
src/tests/additional-roots.test.ts
Normal file
71
src/tests/additional-roots.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let capturedOptions: Options | undefined;
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
|
||||
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
)),
|
||||
query: ({ options }: { options: Options }) => {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [{ value: "claude-sonnet-4-5", displayName: "Claude Sonnet", description: "Fast" }],
|
||||
}),
|
||||
setModel: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
};
|
||||
},
|
||||
}));
|
||||
vi.mock("../tools.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
|
||||
registerHookCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("additionalRoots", () => {
|
||||
let agent: ClaudeAcpAgentType;
|
||||
const tempDirs: string[] = [];
|
||||
const newSession = (meta: Record<string, unknown>, cwd = "/test") =>
|
||||
agent.newSession({ cwd, mcpServers: [], _meta: meta });
|
||||
|
||||
beforeEach(async () => {
|
||||
capturedOptions = undefined;
|
||||
tempDirs.length = 0;
|
||||
vi.resetModules();
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
agent = new ClaudeAcpAgent({
|
||||
sessionUpdate: async (_notification: SessionNotification) => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection);
|
||||
});
|
||||
|
||||
afterEach(
|
||||
async () =>
|
||||
void (await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))),
|
||||
);
|
||||
|
||||
it("passes through relative roots as provided", async () => {
|
||||
const projectRoot = await mkdtemp(path.join(os.tmpdir(), "claude-project-"));
|
||||
tempDirs.push(projectRoot);
|
||||
await newSession({ additionalRoots: ["."] }, projectRoot);
|
||||
expect(capturedOptions!.additionalDirectories).toEqual(["."]);
|
||||
});
|
||||
|
||||
it("merges additionalRoots with user additionalDirectories without normalization", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "claude-root-"));
|
||||
tempDirs.push(root);
|
||||
await newSession({
|
||||
additionalRoots: ["", root],
|
||||
claudeCode: { options: { additionalDirectories: ["/workspace/shared"] } },
|
||||
});
|
||||
expect(capturedOptions!.additionalDirectories).toEqual(["/workspace/shared", "", root]);
|
||||
});
|
||||
});
|
||||
158
src/tests/authorization.test.ts
Normal file
158
src/tests/authorization.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
|
||||
import { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
|
||||
describe("authorization", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
//await all pending events like
|
||||
vi.runAllTimers();
|
||||
vi.useRealTimers();
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
async function createAgentMock(): Promise<[ClaudeAcpAgent, Mock]> {
|
||||
const mockQuery = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
initializationResult: vi.fn().mockResolvedValue({
|
||||
models: [{ value: "id", displayName: "name", description: "description" }],
|
||||
}),
|
||||
setModel: vi.fn(),
|
||||
supportedCommands: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: mockQuery,
|
||||
}));
|
||||
|
||||
const connectionMock = {
|
||||
sessionUpdate: async (_: any) => {},
|
||||
} as AgentSideConnection;
|
||||
|
||||
const agent = new ClaudeAcpAgent(connectionMock);
|
||||
|
||||
return [agent, mockQuery];
|
||||
}
|
||||
|
||||
it("gateway auth not offered without capability", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {},
|
||||
});
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("gateway auth offered when client advertises auth._meta.gateway capability", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
auth: { _meta: { gateway: true } },
|
||||
} as any,
|
||||
});
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses gateway env after gateway auth", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
auth: { terminal: true, _meta: { gateway: true } },
|
||||
} as any,
|
||||
});
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
);
|
||||
|
||||
await agent.authenticate({
|
||||
methodId: "gateway",
|
||||
_meta: { gateway: { baseUrl: "https://gateway.example", headers: { "x-api-key": "test" } } },
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "testRoot",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
env: {
|
||||
userEnv: "userEnv",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_BASE_URL: "https://gateway.example",
|
||||
ANTHROPIC_CUSTOM_HEADERS: "x-api-key: test",
|
||||
userEnv: "userEnv",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("hide claude authentication without terminal-auth", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
auth: { _meta: { gateway: true } },
|
||||
} as any,
|
||||
});
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("hide terminal auth even when terminal-auth is set", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
_meta: { "terminal-auth": true },
|
||||
},
|
||||
});
|
||||
expect(initializeResponse.authMethods).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("show claude authentication", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
287
src/tests/create-session-options.test.ts
Normal file
287
src/tests/create-session-options.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
|
||||
let capturedOptions: Options | undefined;
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
query: (args: { prompt: unknown; options: Options }) => {
|
||||
capturedOptions = args.options;
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{ value: "claude-sonnet-4-5", displayName: "Claude Sonnet", description: "Fast" },
|
||||
],
|
||||
}),
|
||||
setModel: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../tools.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../tools.js")>("../tools.js");
|
||||
return {
|
||||
...actual,
|
||||
registerHookCallback: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("createSession options merging", () => {
|
||||
let agent: ClaudeAcpAgentType;
|
||||
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
return {
|
||||
sessionUpdate: async (_notification: SessionNotification) => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
capturedOptions = undefined;
|
||||
|
||||
vi.resetModules();
|
||||
const acpAgent = await import("../acp-agent.js");
|
||||
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
|
||||
|
||||
agent = new ClaudeAcpAgent(createMockClient());
|
||||
});
|
||||
|
||||
it("merges user-provided disallowedTools with ACP internal list", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
disallowedTools: ["WebSearch", "WebFetch"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// User-provided tools should be present
|
||||
expect(capturedOptions!.disallowedTools).toContain("WebSearch");
|
||||
expect(capturedOptions!.disallowedTools).toContain("WebFetch");
|
||||
// ACP's internal disallowed tool should also be present
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
});
|
||||
|
||||
it("works when user provides no disallowedTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
});
|
||||
|
||||
it("works when user provides empty disallowedTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
disallowedTools: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
});
|
||||
|
||||
it("sets tools to empty array when disableBuiltInTools is true", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
disableBuiltInTools: true,
|
||||
claudeCode: {
|
||||
options: {
|
||||
disallowedTools: ["CustomTool"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// disableBuiltInTools removes all built-in tools from context
|
||||
expect(capturedOptions!.tools).toEqual([]);
|
||||
// User-provided and ACP disallowedTools still apply
|
||||
expect(capturedOptions!.disallowedTools).toContain("CustomTool");
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
});
|
||||
|
||||
it("merges user-provided hooks with ACP hooks", async () => {
|
||||
const userPreToolUseHook = { hooks: [{ command: "echo pre" }] };
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
hooks: {
|
||||
PreToolUse: [userPreToolUseHook],
|
||||
PostToolUse: [{ hooks: [{ command: "echo user-post" }] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// User's PreToolUse hooks should be preserved
|
||||
expect(capturedOptions!.hooks?.PreToolUse).toEqual([userPreToolUseHook]);
|
||||
// PostToolUse should contain both user and ACP hooks
|
||||
expect(capturedOptions!.hooks?.PostToolUse).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("inherits HOME and PATH from process.env when no env is provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(capturedOptions?.env?.HOME).toBe(process.env.HOME);
|
||||
expect(capturedOptions?.env?.PATH).toBe(process.env.PATH);
|
||||
});
|
||||
|
||||
it("merges user-provided env vars on top of process.env", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
env: {
|
||||
CUSTOM_VAR: "custom-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions?.env?.HOME).toBe(process.env.HOME);
|
||||
expect(capturedOptions?.env?.PATH).toBe(process.env.PATH);
|
||||
expect(capturedOptions?.env?.CUSTOM_VAR).toBe("custom-value");
|
||||
});
|
||||
|
||||
it("allows user-provided env vars to override process.env entries", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
env: {
|
||||
HOME: "/custom/home",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions?.env?.HOME).toBe("/custom/home");
|
||||
});
|
||||
|
||||
it("defaults tools to claude_code preset when not provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(capturedOptions!.tools).toEqual({ type: "preset", preset: "claude_code" });
|
||||
});
|
||||
|
||||
it("passes through user-provided tools string array", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
tools: ["Read", "Glob"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions!.tools).toEqual(["Read", "Glob"]);
|
||||
});
|
||||
|
||||
it("explicit tools array takes precedence over disableBuiltInTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
disableBuiltInTools: true,
|
||||
claudeCode: {
|
||||
options: {
|
||||
tools: ["Read", "Glob"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions!.tools).toEqual(["Read", "Glob"]);
|
||||
});
|
||||
|
||||
it("passes through empty tools array to disable all built-in tools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
tools: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions!.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it("merges user-provided mcpServers with ACP mcpServers", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [
|
||||
{
|
||||
name: "acp-server",
|
||||
command: "node",
|
||||
args: ["acp-server.js"],
|
||||
env: [],
|
||||
},
|
||||
],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
mcpServers: {
|
||||
"user-server": {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// User-provided MCP server should be present
|
||||
expect(capturedOptions!.mcpServers).toHaveProperty("user-server");
|
||||
// ACP-provided MCP server should also be present
|
||||
expect(capturedOptions!.mcpServers).toHaveProperty("acp-server");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,334 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { replaceAndCalculateLocation } from "../mcp-server.js";
|
||||
|
||||
describe("replaceAndCalculateLocation", () => {
|
||||
it("should replace first occurrence and return correct line number", () => {
|
||||
const content = "line 1\nline 2 with text\nline 3 with text\nline 4";
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nline 2 with replaced\nline 3 with text\nline 4");
|
||||
expect(result.lineNumbers).toEqual([1]); // Line 2 (0-based indexing)
|
||||
});
|
||||
|
||||
it("should replace all occurrences when replaceAll is true", () => {
|
||||
const content = "line 1\nline 2 with text\nline 3 with text\nline 4";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nline 2 with replaced\nline 3 with replaced\nline 4");
|
||||
expect(result.lineNumbers).toEqual([1, 2]); // Lines 2 and 3 (0-based)
|
||||
});
|
||||
|
||||
it("should return empty line numbers array when no match found", () => {
|
||||
const content = "line 1\nline 2\nline 3";
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content, [{ oldText: "notfound", newText: "replaced" }]);
|
||||
}).toThrow('The provided `old_string` does not appear in the file: "notfound"');
|
||||
});
|
||||
|
||||
it("should handle Windows line endings (CRLF)", () => {
|
||||
const content = "line 1\r\nline 2 with text\r\nline 3 with text\r\nline 4";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe(
|
||||
"line 1\r\nline 2 with replaced\r\nline 3 with replaced\r\nline 4",
|
||||
);
|
||||
expect(result.lineNumbers).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("should handle old Mac line endings (CR)", () => {
|
||||
const content = "line 1\rline 2 with text\rline 3 with text\rline 4";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\rline 2 with replaced\rline 3 with replaced\rline 4");
|
||||
expect(result.lineNumbers).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("should handle mixed line endings", () => {
|
||||
const content = "line 1\nline 2 with text\r\nline 3 with text\rline 4";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nline 2 with replaced\r\nline 3 with replaced\rline 4");
|
||||
expect(result.lineNumbers).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("should handle text at the beginning of file", () => {
|
||||
const content = "text at start\nline 2\nline 3";
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe("replaced at start\nline 2\nline 3");
|
||||
expect(result.lineNumbers).toEqual([0]); // First line (0-based)
|
||||
});
|
||||
|
||||
it("should handle text at the end of file", () => {
|
||||
const content = "line 1\nline 2\nlast line with text";
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nline 2\nlast line with replaced");
|
||||
expect(result.lineNumbers).toEqual([2]); // Last line (0-based)
|
||||
});
|
||||
|
||||
it("should handle multiple occurrences on the same line", () => {
|
||||
const content = "line 1\ntext text text\nline 3";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nreplaced replaced replaced\nline 3");
|
||||
expect(result.lineNumbers).toEqual([1]); // All on line 2 (0-based), deduplicated
|
||||
});
|
||||
|
||||
it("should handle empty file content", () => {
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation("", [{ oldText: "text", newText: "replaced" }]);
|
||||
}).toThrowError(
|
||||
'The provided `old_string` does not appear in the file: "text".\n\nNo edits were applied.',
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty search text", () => {
|
||||
// Test with replaceAll false (default)
|
||||
const content1 = "line 1\nline 2\nline 3";
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content1, [{ oldText: "", newText: "replaced" }]);
|
||||
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
|
||||
|
||||
// Test with replaceAll true on single line
|
||||
const content2 = "abc";
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content2, [{ oldText: "", newText: "X", replaceAll: true }]);
|
||||
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
|
||||
|
||||
// Test with replaceAll true on multiline content
|
||||
const content3 = "ab\ncd";
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content3, [{ oldText: "", newText: "X", replaceAll: true }]);
|
||||
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
|
||||
});
|
||||
|
||||
it("should handle single line content", () => {
|
||||
const content = "single line with text here";
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe("single line with replaced here");
|
||||
expect(result.lineNumbers).toEqual([0]); // Line 1 (0-based)
|
||||
});
|
||||
|
||||
it("should handle special characters in search text", () => {
|
||||
const content = "line 1\nline with $special.chars*\nline 3";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "$special.chars*", newText: "replaced" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nline with replaced\nline 3");
|
||||
expect(result.lineNumbers).toEqual([1]);
|
||||
});
|
||||
|
||||
it("should handle newlines in search text", () => {
|
||||
const content = "line 1\nline 2\nline 3";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "1\nline", newText: "1\nmodified line" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nmodified line 2\nline 3");
|
||||
expect(result.lineNumbers).toEqual([0]); // Match starts at line 1 (0-based)
|
||||
});
|
||||
|
||||
it("should handle newlines in replacement text", () => {
|
||||
const content = "line 1\nline 2\nline 3";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "line 2", newText: "line 2a\nline 2b" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nline 2a\nline 2b\nline 3");
|
||||
expect(result.lineNumbers).toEqual([1]);
|
||||
});
|
||||
|
||||
it("should handle very long lines", () => {
|
||||
const longLine = "a".repeat(10000) + "text" + "b".repeat(10000);
|
||||
const content = `line 1\n${longLine}\nline 3`;
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe(
|
||||
`line 1\n${"a".repeat(10000)}replaced${"b".repeat(10000)}\nline 3`,
|
||||
);
|
||||
expect(result.lineNumbers).toEqual([1]);
|
||||
});
|
||||
|
||||
it("should handle many lines", () => {
|
||||
const lines = Array.from({ length: 1000 }, (_, i) => `line ${i}`);
|
||||
lines[500] = "line with text";
|
||||
const content = lines.join("\n");
|
||||
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.lineNumbers).toEqual([500]);
|
||||
expect(result.newContent).includes("line with replaced");
|
||||
});
|
||||
|
||||
it("should handle overlapping matches correctly", () => {
|
||||
const content = "aaaa\nbbbb\ncccc";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "aa", newText: "xx", replaceAll: true },
|
||||
]);
|
||||
|
||||
// Should replace non-overlapping occurrences
|
||||
expect(result.newContent).toBe("xxxx\nbbbb\ncccc");
|
||||
expect(result.lineNumbers).toEqual([0]); // Two matches on first line, deduplicated
|
||||
});
|
||||
|
||||
it("should preserve content after replacement with different lengths", () => {
|
||||
const content = "short\nmedium line\nlong line here";
|
||||
|
||||
// Replacing with longer text
|
||||
const result1 = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "short", newText: "very very long text" },
|
||||
]);
|
||||
expect(result1.newContent).toBe("very very long text\nmedium line\nlong line here");
|
||||
expect(result1.lineNumbers).toEqual([0]);
|
||||
|
||||
// Replacing with shorter text
|
||||
const result2 = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "medium line", newText: "m" },
|
||||
]);
|
||||
expect(result2.newContent).toBe("short\nm\nlong line here");
|
||||
expect(result2.lineNumbers).toEqual([1]);
|
||||
});
|
||||
|
||||
it("should handle consecutive replacements", () => {
|
||||
const content = "text1 text2 text3";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("replaced1 replaced2 replaced3");
|
||||
expect(result.lineNumbers).toEqual([0]); // All on first line, deduplicated
|
||||
});
|
||||
|
||||
it("should handle case-sensitive replacements", () => {
|
||||
const content = "Text TEXT text";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
// Should only replace exact matches
|
||||
expect(result.newContent).toBe("Text TEXT replaced");
|
||||
expect(result.lineNumbers).toEqual([0]); // Only lowercase "text" matched
|
||||
});
|
||||
|
||||
it("should handle replacement at exact end of line", () => {
|
||||
const content = "line ends with text\nnext line";
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe("line ends with replaced\nnext line");
|
||||
expect(result.lineNumbers).toEqual([0]);
|
||||
});
|
||||
|
||||
it("should handle replacement spanning multiple lines", () => {
|
||||
const content = "line 1\nspan\nacross\nlines\nline 5";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "span\nacross\nlines", newText: "single" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nsingle\nline 5");
|
||||
expect(result.lineNumbers).toEqual([1]); // Match starts at line 2 (0-based)
|
||||
});
|
||||
|
||||
it("should default replaceAll to false", () => {
|
||||
const content = "match match match";
|
||||
|
||||
// Without passing replaceAll parameter
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "match", newText: "replaced" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("replaced match match");
|
||||
expect(result.lineNumbers).toEqual([0]); // Only first match
|
||||
});
|
||||
|
||||
it("should handle multiple edits with correct line numbers", () => {
|
||||
const content = "line 1\nfoo bar\nline 3\nfoo baz\nline 5\nbar foo\nline 7";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "foo", newText: "FOO", replaceAll: true },
|
||||
{ oldText: "bar", newText: "BAR", replaceAll: false },
|
||||
{ oldText: "line 3", newText: "LINE THREE" },
|
||||
]);
|
||||
|
||||
// Should replace:
|
||||
// - All "foo" occurrences (lines 1, 3, 5 in original)
|
||||
// - First "bar" occurrence (line 1 after first edit)
|
||||
// - "line 3" (line 2 after previous edits)
|
||||
expect(result.newContent).toBe("line 1\nFOO BAR\nLINE THREE\nFOO baz\nline 5\nbar FOO\nline 7");
|
||||
|
||||
// Line numbers reflect position in final content, deduplicated and sorted
|
||||
expect(result.lineNumbers).toEqual([
|
||||
1, // FOO and BAR on line 1
|
||||
2, // LINE THREE on line 2
|
||||
3, // FOO on line 3
|
||||
5, // FOO on line 5
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle multiple edits with overlapping text", () => {
|
||||
const content = "hello world\nhello hello\nworld hello world";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "hello", newText: "hi", replaceAll: false },
|
||||
{ oldText: "world", newText: "earth", replaceAll: true },
|
||||
{ oldText: "hello", newText: "greetings", replaceAll: true },
|
||||
]);
|
||||
|
||||
// First edit replaces first "hello" only
|
||||
// Second edit replaces all "world" in the modified content
|
||||
// Third edit replaces remaining "hello"s in the modified content
|
||||
expect(result.newContent).toBe("hi earth\ngreetings greetings\nearth greetings earth");
|
||||
|
||||
// Line numbers reflect position in final content, deduplicated and sorted
|
||||
expect(result.lineNumbers).toEqual([
|
||||
0, // hi and earth on line 0
|
||||
1, // greetings (multiple) on line 1
|
||||
2, // earth and greetings on line 2
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle complex scenarios with marker-like text", () => {
|
||||
// Test that our marker approach doesn't get confused by similar text
|
||||
const content = "line 1\n__MARKER__text\nline 3\n__REPLACE_MARKER_abc__\nline 5";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "__MARKER__", newText: "REPLACED", replaceAll: true },
|
||||
{ oldText: "text", newText: "content" },
|
||||
{ oldText: "__REPLACE_MARKER_abc__", newText: "DONE" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nREPLACEDcontent\nline 3\nDONE\nline 5");
|
||||
|
||||
// Line numbers should be correct even with marker-like text
|
||||
expect(result.lineNumbers).toEqual([
|
||||
1, // REPLACED on line 1
|
||||
3, // DONE on line 3
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle edits where newText matches subsequent oldText", () => {
|
||||
const content = "foo\nbar\nbaz";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "foo", newText: "bar" },
|
||||
{ oldText: "bar", newText: "baz", replaceAll: true },
|
||||
{ oldText: "baz", newText: "qux", replaceAll: true },
|
||||
]);
|
||||
|
||||
// First edit: foo -> bar
|
||||
// Second edit: both original bar and new bar -> baz
|
||||
// Third edit: all baz instances -> qux
|
||||
expect(result.newContent).toBe("qux\nqux\nqux");
|
||||
|
||||
// All replacements end up on their respective lines
|
||||
expect(result.lineNumbers).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
43
src/tests/resolve-permission-mode.test.ts
Normal file
43
src/tests/resolve-permission-mode.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolvePermissionMode } from "../acp-agent.js";
|
||||
|
||||
describe("resolvePermissionMode", () => {
|
||||
it("returns 'default' when no mode is provided", () => {
|
||||
expect(resolvePermissionMode()).toBe("default");
|
||||
expect(resolvePermissionMode(undefined)).toBe("default");
|
||||
});
|
||||
|
||||
it("resolves exact canonical modes", () => {
|
||||
expect(resolvePermissionMode("default")).toBe("default");
|
||||
expect(resolvePermissionMode("acceptEdits")).toBe("acceptEdits");
|
||||
expect(resolvePermissionMode("dontAsk")).toBe("dontAsk");
|
||||
expect(resolvePermissionMode("plan")).toBe("plan");
|
||||
expect(resolvePermissionMode("bypassPermissions")).toBe("bypassPermissions");
|
||||
});
|
||||
|
||||
it("resolves case-insensitive aliases", () => {
|
||||
expect(resolvePermissionMode("DontAsk")).toBe("dontAsk");
|
||||
expect(resolvePermissionMode("DONTASK")).toBe("dontAsk");
|
||||
expect(resolvePermissionMode("AcceptEdits")).toBe("acceptEdits");
|
||||
expect(resolvePermissionMode("bypass")).toBe("bypassPermissions");
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(resolvePermissionMode(" dontAsk ")).toBe("dontAsk");
|
||||
});
|
||||
|
||||
it("throws on non-string values", () => {
|
||||
expect(() => resolvePermissionMode(123)).toThrow("expected a string");
|
||||
expect(() => resolvePermissionMode(true)).toThrow("expected a string");
|
||||
expect(() => resolvePermissionMode({})).toThrow("expected a string");
|
||||
});
|
||||
|
||||
it("throws on empty string", () => {
|
||||
expect(() => resolvePermissionMode("")).toThrow("expected a non-empty string");
|
||||
expect(() => resolvePermissionMode(" ")).toThrow("expected a non-empty string");
|
||||
});
|
||||
|
||||
it("throws on unknown mode", () => {
|
||||
expect(() => resolvePermissionMode("yolo")).toThrow("Invalid permissions.defaultMode: yolo");
|
||||
});
|
||||
});
|
||||
473
src/tests/session-config-options.test.ts
Normal file
473
src/tests/session-config-options.test.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
|
||||
const { registerHookCallbackSpy } = vi.hoisted(() => ({
|
||||
registerHookCallbackSpy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../tools.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../tools.js")>("../tools.js");
|
||||
return {
|
||||
...actual,
|
||||
registerHookCallback: registerHookCallbackSpy,
|
||||
};
|
||||
});
|
||||
|
||||
const SESSION_ID = "test-session-id";
|
||||
|
||||
const MOCK_MODES = {
|
||||
currentModeId: "default",
|
||||
availableModes: [
|
||||
{ id: "default", name: "Default", description: "Standard behavior" },
|
||||
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
|
||||
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_MODELS = {
|
||||
currentModelId: "claude-opus-4-5",
|
||||
availableModels: [
|
||||
{ modelId: "claude-opus-4-5", name: "Claude Opus", description: "Most capable" },
|
||||
{ modelId: "claude-sonnet-4-5", name: "Claude Sonnet", description: "Balanced" },
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_CONFIG_OPTIONS = [
|
||||
{
|
||||
id: "mode",
|
||||
name: "Mode",
|
||||
type: "select",
|
||||
category: "mode",
|
||||
currentValue: "default",
|
||||
options: MOCK_MODES.availableModes.map((m) => ({
|
||||
value: m.id,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
type: "select",
|
||||
category: "model",
|
||||
currentValue: "claude-opus-4-5",
|
||||
options: MOCK_MODELS.availableModels.map((m) => ({
|
||||
value: m.modelId,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
describe("session config options", () => {
|
||||
let agent: ClaudeAcpAgentType;
|
||||
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
|
||||
let sessionUpdates: SessionNotification[];
|
||||
let createSessionSpy: ReturnType<typeof vi.fn>;
|
||||
let setPermissionModeSpy: ReturnType<typeof vi.fn>;
|
||||
let setModelSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
return {
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
sessionUpdates.push(notification);
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
}
|
||||
|
||||
function populateSession() {
|
||||
setPermissionModeSpy = vi.fn();
|
||||
setModelSpy = vi.fn();
|
||||
|
||||
(agent as unknown as { sessions: Record<string, unknown> }).sessions[SESSION_ID] = {
|
||||
query: {
|
||||
setPermissionMode: setPermissionModeSpy,
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
},
|
||||
input: null,
|
||||
cancelled: false,
|
||||
permissionMode: "default",
|
||||
settingsManager: {},
|
||||
configOptions: structuredClone(MOCK_CONFIG_OPTIONS),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
sessionUpdates = [];
|
||||
registerHookCallbackSpy.mockClear();
|
||||
|
||||
vi.resetModules();
|
||||
const acpAgent = await import("../acp-agent.js");
|
||||
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
|
||||
|
||||
agent = new ClaudeAcpAgent(createMockClient());
|
||||
createSessionSpy = vi.fn(async () => ({
|
||||
sessionId: SESSION_ID,
|
||||
modes: MOCK_MODES,
|
||||
models: MOCK_MODELS,
|
||||
configOptions: MOCK_CONFIG_OPTIONS,
|
||||
}));
|
||||
(agent as unknown as { createSession: typeof createSessionSpy }).createSession =
|
||||
createSessionSpy;
|
||||
});
|
||||
|
||||
describe("newSession returns configOptions", () => {
|
||||
it("includes configOptions in the response", async () => {
|
||||
const response = await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
expect(response.configOptions).toBeDefined();
|
||||
expect(response.configOptions).toEqual(MOCK_CONFIG_OPTIONS);
|
||||
});
|
||||
|
||||
it("includes mode and model config options", async () => {
|
||||
const response = await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
const modeOption = response.configOptions?.find((o) => o.id === "mode");
|
||||
const modelOption = response.configOptions?.find((o) => o.id === "model");
|
||||
expect(modeOption).toBeDefined();
|
||||
expect(modelOption).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadSession returns configOptions", () => {
|
||||
it("includes configOptions from createSession", async () => {
|
||||
// loadSession calls findSessionFile first - override the whole method
|
||||
const loadSessionSpy = vi.fn(async () => ({
|
||||
modes: MOCK_MODES,
|
||||
models: MOCK_MODELS,
|
||||
configOptions: MOCK_CONFIG_OPTIONS,
|
||||
}));
|
||||
(agent as unknown as { loadSession: typeof loadSessionSpy }).loadSession = loadSessionSpy;
|
||||
|
||||
const response = await agent.loadSession({
|
||||
cwd: "/test",
|
||||
sessionId: SESSION_ID,
|
||||
mcpServers: [],
|
||||
});
|
||||
expect(response.configOptions).toEqual(MOCK_CONFIG_OPTIONS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSessionConfigOption", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("throws when session not found", async () => {
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: "nonexistent",
|
||||
configId: "mode",
|
||||
value: "plan",
|
||||
}),
|
||||
).rejects.toThrow("Session not found");
|
||||
});
|
||||
|
||||
it("throws when config option not found", async () => {
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "unknown-option",
|
||||
value: "some-value",
|
||||
}),
|
||||
).rejects.toThrow("Unknown config option: unknown-option");
|
||||
});
|
||||
|
||||
it("throws when value is not valid for the option", async () => {
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "mode",
|
||||
value: "invalid-mode",
|
||||
}),
|
||||
).rejects.toThrow("Invalid value for config option mode: invalid-mode");
|
||||
});
|
||||
|
||||
it("changes mode, sends current_mode_update but not config_option_update", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "mode",
|
||||
value: "plan",
|
||||
});
|
||||
|
||||
expect(setPermissionModeSpy).toHaveBeenCalledWith("plan");
|
||||
|
||||
const modeUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "current_mode_update",
|
||||
);
|
||||
expect(modeUpdate?.update).toMatchObject({
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: "plan",
|
||||
});
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("changes model and does not send a config_option_update notification", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves model alias 'opus' to full model ID", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "opus",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-opus-4-5");
|
||||
|
||||
const modelOption = response.configOptions.find((o) => o.id === "model");
|
||||
expect(modelOption?.currentValue).toBe("claude-opus-4-5");
|
||||
});
|
||||
|
||||
it("resolves model alias 'sonnet' to full model ID", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "sonnet",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("resolves display name to model ID", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "Claude Sonnet",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("still works with exact model ID", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
const modelOption = response.configOptions.find((o) => o.id === "model");
|
||||
expect(modelOption?.currentValue).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("throws for completely invalid model value", async () => {
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "gpt-4",
|
||||
}),
|
||||
).rejects.toThrow("Invalid value for config option model: gpt-4");
|
||||
});
|
||||
|
||||
it("returns full configOptions in the response", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "mode",
|
||||
value: "plan",
|
||||
});
|
||||
|
||||
expect(response.configOptions).toHaveLength(MOCK_CONFIG_OPTIONS.length);
|
||||
const modeOption = response.configOptions.find((o) => o.id === "mode");
|
||||
expect(modeOption?.currentValue).toBe("plan");
|
||||
});
|
||||
|
||||
it("other options are unchanged when one is updated", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "mode",
|
||||
value: "plan",
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o) => o.id === "model");
|
||||
expect(modelOption?.currentValue).toBe("claude-opus-4-5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSessionMode sends config_option_update", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("sends config_option_update when mode is changed via setSessionMode", async () => {
|
||||
await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "acceptEdits" });
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdate).toBeDefined();
|
||||
expect(configUpdate?.update).toMatchObject({
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "mode", currentValue: "acceptEdits" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("updates stored configOptions currentValue when mode changes", async () => {
|
||||
await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "plan" });
|
||||
|
||||
const session = (
|
||||
agent as unknown as {
|
||||
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
|
||||
}
|
||||
).sessions[SESSION_ID];
|
||||
const modeOption = session.configOptions.find((o) => o.id === "mode");
|
||||
expect(modeOption?.currentValue).toBe("plan");
|
||||
});
|
||||
|
||||
it("does not send config_option_update for an invalid mode", async () => {
|
||||
await expect(
|
||||
agent.setSessionMode({ sessionId: SESSION_ID, modeId: "not-a-mode" as any }),
|
||||
).rejects.toThrow("Invalid Mode");
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdate).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unstable_setSessionModel sends config_option_update", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("sends config_option_update when model is changed via setSessionModel", async () => {
|
||||
await agent.unstable_setSessionModel({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdate).toBeDefined();
|
||||
expect(configUpdate?.update).toMatchObject({
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "model", currentValue: "claude-sonnet-4-5" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("updates stored configOptions currentValue when model changes", async () => {
|
||||
await agent.unstable_setSessionModel({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const session = (
|
||||
agent as unknown as {
|
||||
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
|
||||
}
|
||||
).sessions[SESSION_ID];
|
||||
const modelOption = session.configOptions.find((o) => o.id === "model");
|
||||
expect(modelOption?.currentValue).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("no config_option_update notification when using setSessionConfigOption", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("sends no config_option_update when setting mode via config option", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "mode",
|
||||
value: "plan",
|
||||
});
|
||||
|
||||
const configUpdates = sessionUpdates.filter(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sends no config_option_update when setting model via config option", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const configUpdates = sessionUpdates.filter(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bidirectional consistency", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("setSessionConfigOption for mode also calls underlying setPermissionMode", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "mode",
|
||||
value: "acceptEdits",
|
||||
});
|
||||
|
||||
expect(setPermissionModeSpy).toHaveBeenCalledWith("acceptEdits");
|
||||
});
|
||||
|
||||
it("setSessionConfigOption for model also calls underlying setModel", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("setSessionMode also syncs configOptions", async () => {
|
||||
await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "plan" });
|
||||
|
||||
const session = (
|
||||
agent as unknown as {
|
||||
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
|
||||
}
|
||||
).sessions[SESSION_ID];
|
||||
expect(session.configOptions.find((o) => o.id === "mode")?.currentValue).toBe("plan");
|
||||
});
|
||||
|
||||
it("setSessionModel also syncs configOptions", async () => {
|
||||
await agent.unstable_setSessionModel({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const session = (
|
||||
agent as unknown as {
|
||||
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
|
||||
}
|
||||
).sessions[SESSION_ID];
|
||||
expect(session.configOptions.find((o) => o.id === "model")?.currentValue).toBe(
|
||||
"claude-sonnet-4-5",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,601 +17,77 @@ describe("SettingsManager", () => {
|
||||
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 () => {
|
||||
it("should merge model setting with later sources taking precedence", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
|
||||
// Project settings with one model
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
model: "claude-3-5-sonnet",
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
let settings = settingsManager.getSettings();
|
||||
expect(settings.model).toBe("claude-3-5-sonnet");
|
||||
|
||||
// Add local settings that override the model
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.local.json"),
|
||||
JSON.stringify({
|
||||
model: "claude-3-5-haiku",
|
||||
}),
|
||||
);
|
||||
|
||||
// Re-initialize to pick up local settings
|
||||
settingsManager.dispose();
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
settings = settingsManager.getSettings();
|
||||
expect(settings.model).toBe("claude-3-5-haiku");
|
||||
});
|
||||
|
||||
it("should merge permissions.defaultMode with later sources taking precedence", 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"],
|
||||
defaultMode: "dontAsk",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Local settings
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
let settings = settingsManager.getSettings();
|
||||
expect(settings.permissions?.defaultMode).toBe("dontAsk");
|
||||
|
||||
// Local settings override the mode
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.local.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read(./.env)"],
|
||||
defaultMode: "plan",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager.dispose();
|
||||
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 });
|
||||
settings = settingsManager.getSettings();
|
||||
expect(settings.permissions?.defaultMode).toBe("plan");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
1273
src/tests/tools.test.ts
Normal file
1273
src/tests/tools.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
682
src/tools.ts
682
src/tools.ts
@@ -1,40 +1,81 @@
|
||||
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 {
|
||||
ContentBlock,
|
||||
PlanEntry,
|
||||
ToolCallContent,
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
AgentInput,
|
||||
BashInput,
|
||||
FileEditInput,
|
||||
FileReadInput,
|
||||
FileWriteInput,
|
||||
GlobInput,
|
||||
GrepInput,
|
||||
TodoWriteInput,
|
||||
WebFetchInput,
|
||||
WebSearchInput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
import {
|
||||
ImageBlockParam,
|
||||
TextBlockParam,
|
||||
ToolResultBlockParam,
|
||||
WebSearchResultBlock,
|
||||
WebSearchToolResultBlockParam,
|
||||
WebSearchToolResultError,
|
||||
} from "@anthropic-ai/sdk/resources";
|
||||
import {
|
||||
BetaBashCodeExecutionResultBlock,
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
BetaBashCodeExecutionToolResultError,
|
||||
BetaCodeExecutionResultBlock,
|
||||
BetaCodeExecutionToolResultBlockParam,
|
||||
BetaCodeExecutionToolResultError,
|
||||
BetaImageBlockParam,
|
||||
BetaRequestMCPToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionCreateResultBlock,
|
||||
BetaTextEditorCodeExecutionStrReplaceResultBlock,
|
||||
BetaTextEditorCodeExecutionToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionToolResultError,
|
||||
BetaTextEditorCodeExecutionViewResultBlock,
|
||||
BetaToolReferenceBlock,
|
||||
BetaToolResultBlockParam,
|
||||
BetaToolSearchToolResultBlockParam,
|
||||
BetaToolSearchToolResultError,
|
||||
BetaToolSearchToolSearchResultBlock,
|
||||
BetaWebFetchBlock,
|
||||
BetaWebFetchToolResultBlockParam,
|
||||
BetaWebFetchToolResultErrorBlock,
|
||||
BetaWebSearchToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import path from "node:path";
|
||||
import { Logger } from "./acp-agent.js";
|
||||
import { SettingsManager } from "./settings.js";
|
||||
|
||||
/**
|
||||
* Union of all possible content types that can appear in tool results from the Anthropic SDK.
|
||||
* These are transformed to valid ACP ContentBlock types by toValidAcpContent().
|
||||
*/
|
||||
type ToolResultContent =
|
||||
| TextBlockParam
|
||||
| ImageBlockParam
|
||||
| BetaImageBlockParam
|
||||
| BetaToolReferenceBlock
|
||||
| BetaToolSearchToolSearchResultBlock
|
||||
| BetaToolSearchToolResultError
|
||||
| WebSearchResultBlock
|
||||
| WebSearchToolResultError
|
||||
| BetaWebFetchBlock
|
||||
| BetaWebFetchToolResultErrorBlock
|
||||
| BetaCodeExecutionResultBlock
|
||||
| BetaCodeExecutionToolResultError
|
||||
| BetaBashCodeExecutionResultBlock
|
||||
| BetaBashCodeExecutionToolResultError
|
||||
| BetaTextEditorCodeExecutionViewResultBlock
|
||||
| BetaTextEditorCodeExecutionCreateResultBlock
|
||||
| BetaTextEditorCodeExecutionStrReplaceResultBlock
|
||||
| BetaTextEditorCodeExecutionToolResultError;
|
||||
|
||||
interface ToolInfo {
|
||||
title: string;
|
||||
@@ -47,23 +88,52 @@ interface ToolUpdate {
|
||||
title?: string;
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
_meta?: {
|
||||
terminal_info?: {
|
||||
terminal_id: string;
|
||||
};
|
||||
terminal_output?: {
|
||||
terminal_id: string;
|
||||
data: string;
|
||||
};
|
||||
terminal_exit?: {
|
||||
terminal_id: string;
|
||||
exit_code: number;
|
||||
signal: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an absolute file path to a project-relative path for display.
|
||||
* Returns the original path if it's outside the project directory or if no cwd is provided.
|
||||
*/
|
||||
export function toDisplayPath(filePath: string, cwd?: string): string {
|
||||
if (!cwd) return filePath;
|
||||
const resolvedCwd = path.resolve(cwd);
|
||||
const resolvedFile = path.resolve(filePath);
|
||||
if (resolvedFile.startsWith(resolvedCwd + path.sep) || resolvedFile === resolvedCwd) {
|
||||
return path.relative(resolvedCwd, resolvedFile);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function toolInfoFromToolUse(
|
||||
toolUse: any,
|
||||
cachedFileContent: { [key: string]: string },
|
||||
logger: Logger = console,
|
||||
supportsTerminalOutput: boolean = false,
|
||||
cwd?: string,
|
||||
): ToolInfo {
|
||||
const name = toolUse.name;
|
||||
const input = toolUse.input;
|
||||
|
||||
switch (name) {
|
||||
case "Task":
|
||||
case "Agent":
|
||||
case "Task": {
|
||||
const input = toolUse.input as AgentInput | BashInput;
|
||||
return {
|
||||
title: input?.description ? input.description : "Task",
|
||||
kind: "think",
|
||||
content:
|
||||
input && input.prompt
|
||||
input && "prompt" in input
|
||||
? [
|
||||
{
|
||||
type: "content",
|
||||
@@ -72,38 +142,16 @@ export function toolInfoFromToolUse(
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "NotebookRead":
|
||||
case "Bash": {
|
||||
const input = toolUse.input as BashInput;
|
||||
return {
|
||||
title: input?.notebook_path ? `Read Notebook ${input.notebook_path}` : "Read Notebook",
|
||||
kind: "read",
|
||||
content: [],
|
||||
locations: input?.notebook_path ? [{ path: input.notebook_path }] : [],
|
||||
};
|
||||
|
||||
case "NotebookEdit":
|
||||
return {
|
||||
title: input?.notebook_path ? `Edit Notebook ${input.notebook_path}` : "Edit Notebook",
|
||||
kind: "edit",
|
||||
content:
|
||||
input && input.new_source
|
||||
? [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: input.new_source },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
locations: input?.notebook_path ? [{ path: input.notebook_path }] : [],
|
||||
};
|
||||
|
||||
case "Bash":
|
||||
case acpToolNames.bash:
|
||||
return {
|
||||
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
|
||||
title: input?.command ? input.command : "Terminal",
|
||||
kind: "execute",
|
||||
content:
|
||||
input && input.description
|
||||
content: supportsTerminalOutput
|
||||
? [{ type: "terminal" as const, terminalId: toolUse.id }]
|
||||
: input && input.description
|
||||
? [
|
||||
{
|
||||
type: "content",
|
||||
@@ -112,39 +160,25 @@ export function toolInfoFromToolUse(
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "BashOutput":
|
||||
case acpToolNames.bashOutput:
|
||||
return {
|
||||
title: "Tail Logs",
|
||||
kind: "execute",
|
||||
content: [],
|
||||
};
|
||||
|
||||
case "KillShell":
|
||||
case acpToolNames.killShell:
|
||||
return {
|
||||
title: "Kill Process",
|
||||
kind: "execute",
|
||||
content: [],
|
||||
};
|
||||
|
||||
case acpToolNames.read: {
|
||||
case "Read": {
|
||||
const input = toolUse.input as FileReadInput;
|
||||
let limit = "";
|
||||
if (input.limit) {
|
||||
limit =
|
||||
" (" + ((input.offset ?? 0) + 1) + " - " + ((input.offset ?? 0) + input.limit) + ")";
|
||||
if (input.limit && input.limit > 0) {
|
||||
limit = " (" + (input.offset ?? 1) + " - " + ((input.offset ?? 1) + input.limit - 1) + ")";
|
||||
} else if (input.offset) {
|
||||
limit = " (from line " + (input.offset + 1) + ")";
|
||||
limit = " (from line " + input.offset + ")";
|
||||
}
|
||||
const displayPath = input.file_path ? toDisplayPath(input.file_path, cwd) : "File";
|
||||
return {
|
||||
title: "Read " + (input.file_path ?? "File") + limit,
|
||||
title: "Read " + displayPath + limit,
|
||||
kind: "read",
|
||||
locations: input.file_path
|
||||
? [
|
||||
{
|
||||
path: input.file_path,
|
||||
line: input.offset ?? 0,
|
||||
line: input.offset ?? 1,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -152,76 +186,8 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "Read":
|
||||
return {
|
||||
title: "Read File",
|
||||
kind: "read",
|
||||
content: [],
|
||||
locations: input.file_path
|
||||
? [
|
||||
{
|
||||
path: input.file_path,
|
||||
line: input.offset ?? 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
|
||||
case "LS":
|
||||
return {
|
||||
title: `List the ${input?.path ? "`" + input.path + "`" : "current"} directory's contents`,
|
||||
kind: "search",
|
||||
content: [],
|
||||
locations: [],
|
||||
};
|
||||
|
||||
case acpToolNames.edit:
|
||||
case "Edit": {
|
||||
const path = input?.file_path ?? input?.file_path;
|
||||
let oldText = input.old_string ?? null;
|
||||
let newText = input.new_string ?? "";
|
||||
let affectedLines: number[] = [];
|
||||
|
||||
if (path && oldText) {
|
||||
try {
|
||||
const oldContent = cachedFileContent[path] || "";
|
||||
const newContent = replaceAndCalculateLocation(oldContent, [
|
||||
{
|
||||
oldText,
|
||||
newText,
|
||||
replaceAll: false,
|
||||
},
|
||||
]);
|
||||
oldText = oldContent;
|
||||
newText = newContent.newContent;
|
||||
affectedLines = newContent.lineNumbers;
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: path ? `Edit \`${path}\`` : "Edit",
|
||||
kind: "edit",
|
||||
content:
|
||||
input && path
|
||||
? [
|
||||
{
|
||||
type: "diff",
|
||||
path,
|
||||
oldText,
|
||||
newText,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
locations: path
|
||||
? affectedLines.length > 0
|
||||
? affectedLines.map((line) => ({ line, path }))
|
||||
: [{ path }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
case acpToolNames.write: {
|
||||
case "Write": {
|
||||
const input = toolUse.input as FileWriteInput;
|
||||
let content: ToolCallContent[] = [];
|
||||
if (input && input.file_path) {
|
||||
content = [
|
||||
@@ -240,33 +206,39 @@ export function toolInfoFromToolUse(
|
||||
},
|
||||
];
|
||||
}
|
||||
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
|
||||
return {
|
||||
title: input?.file_path ? `Write ${input.file_path}` : "Write",
|
||||
title: displayPath ? `Write ${displayPath}` : "Write",
|
||||
kind: "edit",
|
||||
content,
|
||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
case "Write":
|
||||
case "Edit": {
|
||||
const input = toolUse.input as FileEditInput;
|
||||
let content: ToolCallContent[] = [];
|
||||
if (input && input.file_path && (input.old_string || input.new_string)) {
|
||||
content = [
|
||||
{
|
||||
type: "diff",
|
||||
path: input.file_path,
|
||||
oldText: input.old_string || null,
|
||||
newText: input.new_string ?? "",
|
||||
},
|
||||
];
|
||||
}
|
||||
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
|
||||
return {
|
||||
title: input?.file_path ? `Write ${input.file_path}` : "Write",
|
||||
title: displayPath ? `Edit ${displayPath}` : "Edit",
|
||||
kind: "edit",
|
||||
content:
|
||||
input && input.file_path
|
||||
? [
|
||||
{
|
||||
type: "diff",
|
||||
path: input.file_path,
|
||||
oldText: null,
|
||||
newText: input.content,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
content,
|
||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
case "Glob": {
|
||||
const input = toolUse.input as GlobInput;
|
||||
let label = "Find";
|
||||
if (input.path) {
|
||||
label += ` \`${input.path}\``;
|
||||
@@ -283,6 +255,7 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "Grep": {
|
||||
const input = toolUse.input as GrepInput;
|
||||
let label = "grep";
|
||||
|
||||
if (input["-i"]) {
|
||||
@@ -304,13 +277,13 @@ export function toolInfoFromToolUse(
|
||||
|
||||
if (input.output_mode) {
|
||||
switch (input.output_mode) {
|
||||
case "FilesWithMatches":
|
||||
case "files_with_matches":
|
||||
label += " -l";
|
||||
break;
|
||||
case "Count":
|
||||
case "count":
|
||||
label += " -c";
|
||||
break;
|
||||
case "Content":
|
||||
case "content":
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -332,7 +305,9 @@ export function toolInfoFromToolUse(
|
||||
label += " -P";
|
||||
}
|
||||
|
||||
label += ` "${input.pattern}"`;
|
||||
if (input.pattern) {
|
||||
label += ` "${input.pattern}"`;
|
||||
}
|
||||
|
||||
if (input.path) {
|
||||
label += ` ${input.path}`;
|
||||
@@ -345,7 +320,8 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "WebFetch":
|
||||
case "WebFetch": {
|
||||
const input = toolUse.input as WebFetchInput;
|
||||
return {
|
||||
title: input?.url ? `Fetch ${input.url}` : "Fetch",
|
||||
kind: "fetch",
|
||||
@@ -359,8 +335,10 @@ export function toolInfoFromToolUse(
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "WebSearch": {
|
||||
const input = toolUse.input as WebSearchInput;
|
||||
let label = `"${input.query}"`;
|
||||
|
||||
if (input.allowed_domains && input.allowed_domains.length > 0) {
|
||||
@@ -378,7 +356,8 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "TodoWrite":
|
||||
case "TodoWrite": {
|
||||
const input = toolUse.input as TodoWriteInput;
|
||||
return {
|
||||
title: Array.isArray(input?.todos)
|
||||
? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}`
|
||||
@@ -386,18 +365,21 @@ export function toolInfoFromToolUse(
|
||||
kind: "think",
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "ExitPlanMode":
|
||||
case "ExitPlanMode": {
|
||||
const planInput = toolUse.input as { plan?: string };
|
||||
return {
|
||||
title: "Ready to code?",
|
||||
kind: "switch_mode",
|
||||
content:
|
||||
input && input.plan
|
||||
? [{ type: "content", content: { type: "text", text: input.plan } }]
|
||||
: [],
|
||||
content: planInput?.plan
|
||||
? [{ type: "content" as const, content: { type: "text" as const, text: planInput.plan } }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "Other": {
|
||||
const input = toolUse.input;
|
||||
let output;
|
||||
try {
|
||||
output = JSON.stringify(input, null, 2);
|
||||
@@ -431,6 +413,7 @@ export function toolInfoFromToolUse(
|
||||
export function toolUpdateFromToolResult(
|
||||
toolResult:
|
||||
| ToolResultBlockParam
|
||||
| BetaToolResultBlockParam
|
||||
| BetaWebSearchToolResultBlockParam
|
||||
| BetaWebFetchToolResultBlockParam
|
||||
| WebSearchToolResultBlockParam
|
||||
@@ -440,10 +423,20 @@ export function toolUpdateFromToolResult(
|
||||
| BetaRequestMCPToolResultBlockParam
|
||||
| BetaToolSearchToolResultBlockParam,
|
||||
toolUse: any | undefined,
|
||||
supportsTerminalOutput: boolean = false,
|
||||
): ToolUpdate {
|
||||
if (
|
||||
"is_error" in toolResult &&
|
||||
toolResult.is_error &&
|
||||
toolResult.content &&
|
||||
toolResult.content.length > 0
|
||||
) {
|
||||
// Only return errors
|
||||
return toAcpContentUpdate(toolResult.content, true);
|
||||
}
|
||||
|
||||
switch (toolUse?.name) {
|
||||
case "Read":
|
||||
case acpToolNames.read:
|
||||
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
|
||||
return {
|
||||
content: toolResult.content.map((content: any) => ({
|
||||
@@ -452,9 +445,9 @@ export function toolUpdateFromToolResult(
|
||||
content.type === "text"
|
||||
? {
|
||||
type: "text",
|
||||
text: markdownEscape(content.text.replace(SYSTEM_REMINDER, "")),
|
||||
text: markdownEscape(content.text),
|
||||
}
|
||||
: content,
|
||||
: toAcpContentBlock(content, false),
|
||||
})),
|
||||
};
|
||||
} else if (typeof toolResult.content === "string" && toolResult.content.length > 0) {
|
||||
@@ -464,7 +457,7 @@ export function toolUpdateFromToolResult(
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: markdownEscape(toolResult.content.replace(SYSTEM_REMINDER, "")),
|
||||
text: markdownEscape(toolResult.content),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -472,21 +465,76 @@ export function toolUpdateFromToolResult(
|
||||
}
|
||||
return {};
|
||||
|
||||
case acpToolNames.bash:
|
||||
case "edit":
|
||||
case "Edit":
|
||||
case acpToolNames.edit:
|
||||
case acpToolNames.write:
|
||||
case "Write": {
|
||||
case "Bash": {
|
||||
const result = toolResult.content;
|
||||
const terminalId = "tool_use_id" in toolResult ? String(toolResult.tool_use_id) : "";
|
||||
const isError = "is_error" in toolResult && toolResult.is_error;
|
||||
|
||||
// Extract output and exit code from either format:
|
||||
// 1. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code }
|
||||
// 2. Plain string content from a regular tool_result
|
||||
// 3. Array content (e.g. [{ type: "text", text: "..." }])
|
||||
let output = "";
|
||||
let exitCode = isError ? 1 : 0;
|
||||
|
||||
if (
|
||||
"is_error" in toolResult &&
|
||||
toolResult.is_error &&
|
||||
toolResult.content &&
|
||||
toolResult.content.length > 0
|
||||
result &&
|
||||
typeof result === "object" &&
|
||||
"type" in result &&
|
||||
result.type === "bash_code_execution_result"
|
||||
) {
|
||||
// Only return errors
|
||||
return toAcpContentUpdate(toolResult.content, true);
|
||||
const bashResult = result as BetaBashCodeExecutionResultBlock;
|
||||
output = [bashResult.stdout, bashResult.stderr].filter(Boolean).join("\n");
|
||||
exitCode = bashResult.return_code;
|
||||
} else if (typeof result === "string") {
|
||||
output = result;
|
||||
} else if (
|
||||
Array.isArray(result) &&
|
||||
result.length > 0 &&
|
||||
"text" in result[0] &&
|
||||
typeof result[0].text === "string"
|
||||
) {
|
||||
output = result.map((c: any) => c.text).join("\n");
|
||||
}
|
||||
|
||||
if (supportsTerminalOutput) {
|
||||
return {
|
||||
content: [{ type: "terminal" as const, terminalId }],
|
||||
_meta: {
|
||||
terminal_info: {
|
||||
terminal_id: terminalId,
|
||||
},
|
||||
terminal_output: {
|
||||
terminal_id: terminalId,
|
||||
data: output,
|
||||
},
|
||||
terminal_exit: {
|
||||
terminal_id: terminalId,
|
||||
exit_code: exitCode,
|
||||
signal: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
// Fallback: format output as a code block without terminal _meta
|
||||
if (output.trim()) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `\`\`\`console\n${output.trimEnd()}\n\`\`\``,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
case "Edit": // Edit is handled in hooks
|
||||
case "Write": {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -494,20 +542,6 @@ export function toolUpdateFromToolResult(
|
||||
return { title: "Exited Plan Mode" };
|
||||
}
|
||||
|
||||
case "Task":
|
||||
case "NotebookEdit":
|
||||
case "NotebookRead":
|
||||
case "TodoWrite":
|
||||
case "exit_plan_mode":
|
||||
case "Bash":
|
||||
case "BashOutput":
|
||||
case "KillBash":
|
||||
case "LS":
|
||||
case "Glob":
|
||||
case "Grep":
|
||||
case "WebFetch":
|
||||
case "WebSearch":
|
||||
case "Other":
|
||||
default: {
|
||||
return toAcpContentUpdate(
|
||||
toolResult.content,
|
||||
@@ -523,17 +557,20 @@ function toAcpContentUpdate(
|
||||
): { 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,
|
||||
content: content.map((c: any) => ({
|
||||
type: "content" as const,
|
||||
content: toAcpContentBlock(c, isError),
|
||||
})),
|
||||
};
|
||||
} else if (typeof content === "object" && content !== null && "type" in content) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "content" as const,
|
||||
content: toAcpContentBlock(content, isError),
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (typeof content === "string" && content.length > 0) {
|
||||
return {
|
||||
content: [
|
||||
@@ -550,6 +587,74 @@ function toAcpContentUpdate(
|
||||
return {};
|
||||
}
|
||||
|
||||
function toAcpContentBlock(content: ToolResultContent, isError: boolean): ContentBlock {
|
||||
const wrapText = (text: string): ContentBlock => ({
|
||||
type: "text" as const,
|
||||
text: isError ? `\`\`\`\n${text}\n\`\`\`` : text,
|
||||
});
|
||||
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: isError ? `\`\`\`\n${content.text}\n\`\`\`` : content.text,
|
||||
};
|
||||
case "image":
|
||||
if (content.source.type === "base64") {
|
||||
return {
|
||||
type: "image" as const,
|
||||
data: content.source.data,
|
||||
mimeType: content.source.media_type,
|
||||
};
|
||||
}
|
||||
// URL and file-based images can't be converted to ACP format (requires data)
|
||||
return wrapText(
|
||||
content.source.type === "url"
|
||||
? `[image: ${content.source.url}]`
|
||||
: "[image: file reference]",
|
||||
);
|
||||
|
||||
case "tool_reference":
|
||||
return wrapText(`Tool: ${content.tool_name}`);
|
||||
case "tool_search_tool_search_result":
|
||||
return wrapText(
|
||||
`Tools found: ${content.tool_references.map((r) => r.tool_name).join(", ") || "none"}`,
|
||||
);
|
||||
case "tool_search_tool_result_error":
|
||||
return wrapText(
|
||||
`Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`,
|
||||
);
|
||||
case "web_search_result":
|
||||
return wrapText(`${content.title} (${content.url})`);
|
||||
case "web_search_tool_result_error":
|
||||
return wrapText(`Error: ${content.error_code}`);
|
||||
case "web_fetch_result":
|
||||
return wrapText(`Fetched: ${content.url}`);
|
||||
case "web_fetch_tool_result_error":
|
||||
return wrapText(`Error: ${content.error_code}`);
|
||||
case "code_execution_result":
|
||||
return wrapText(`Output: ${content.stdout || content.stderr || ""}`);
|
||||
case "bash_code_execution_result":
|
||||
return wrapText(`Output: ${content.stdout || content.stderr || ""}`);
|
||||
case "code_execution_tool_result_error":
|
||||
case "bash_code_execution_tool_result_error":
|
||||
return wrapText(`Error: ${content.error_code}`);
|
||||
case "text_editor_code_execution_view_result":
|
||||
return wrapText(content.content);
|
||||
case "text_editor_code_execution_create_result":
|
||||
return wrapText(content.is_file_update ? "File updated" : "File created");
|
||||
case "text_editor_code_execution_str_replace_result":
|
||||
return wrapText(content.lines?.join("\n") || "");
|
||||
case "text_editor_code_execution_tool_result_error":
|
||||
return wrapText(
|
||||
`Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`,
|
||||
);
|
||||
|
||||
default:
|
||||
return wrapText(JSON.stringify(content));
|
||||
}
|
||||
}
|
||||
|
||||
export type ClaudePlanEntry = {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
@@ -574,6 +679,66 @@ export function markdownEscape(text: string): string {
|
||||
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
|
||||
}
|
||||
|
||||
interface EditToolResponseHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
newLines: number;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
interface EditToolResponse {
|
||||
filePath?: string;
|
||||
structuredPatch?: EditToolResponseHunk[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds diff ToolUpdate content from the structured Edit toolResponse provided
|
||||
* by the PostToolUse hook. Unlike parsing the plain unified diff string, this uses
|
||||
* the pre-parsed structuredPatch which supports multiple replacement sites (replaceAll)
|
||||
* and always includes context lines for better readability.
|
||||
*/
|
||||
export function toolUpdateFromEditToolResponse(toolResponse: unknown): {
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
} {
|
||||
if (!toolResponse || typeof toolResponse !== "object") return {};
|
||||
const response = toolResponse as EditToolResponse;
|
||||
if (!response.filePath || !Array.isArray(response.structuredPatch)) return {};
|
||||
|
||||
const content: ToolCallContent[] = [];
|
||||
const locations: ToolCallLocation[] = [];
|
||||
|
||||
for (const { lines, newStart } of response.structuredPatch) {
|
||||
const oldText: string[] = [];
|
||||
const newText: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("-")) {
|
||||
oldText.push(line.slice(1));
|
||||
} else if (line.startsWith("+")) {
|
||||
newText.push(line.slice(1));
|
||||
} else {
|
||||
oldText.push(line.slice(1));
|
||||
newText.push(line.slice(1));
|
||||
}
|
||||
}
|
||||
if (oldText.length > 0 || newText.length > 0) {
|
||||
locations.push({ path: response.filePath, line: newStart });
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: response.filePath,
|
||||
oldText: oldText.join("\n") || null,
|
||||
newText: newText.join("\n"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result: { content?: ToolCallContent[]; locations?: ToolCallLocation[] } = {};
|
||||
if (content.length > 0) result.content = content;
|
||||
if (locations.length > 0) result.locations = locations;
|
||||
return result;
|
||||
}
|
||||
|
||||
/* A global variable to store callbacks that should be executed when receiving hooks from Claude Code */
|
||||
const toolUseCallbacks: {
|
||||
[toolUseId: string]: {
|
||||
@@ -605,68 +770,29 @@ export const registerHookCallback = (
|
||||
|
||||
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
|
||||
export const createPostToolUseHook =
|
||||
(logger: Logger = console): HookCallback =>
|
||||
(
|
||||
logger: Logger = console,
|
||||
options?: {
|
||||
onEnterPlanMode?: () => Promise<void>;
|
||||
},
|
||||
): 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];
|
||||
if (input.hook_event_name === "PostToolUse") {
|
||||
// Handle EnterPlanMode tool - notify client of mode change after successful execution
|
||||
if (input.tool_name === "EnterPlanMode" && options?.onEnterPlanMode) {
|
||||
await options.onEnterPlanMode();
|
||||
}
|
||||
|
||||
if (toolUseID) {
|
||||
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
|
||||
if (onPostToolUseHook) {
|
||||
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
|
||||
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
|
||||
} else {
|
||||
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
|
||||
delete toolUseCallbacks[toolUseID];
|
||||
}
|
||||
}
|
||||
}
|
||||
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 };
|
||||
}
|
||||
};
|
||||
|
||||
64
src/utils.ts
64
src/utils.ts
@@ -105,67 +105,3 @@ export function applyEnvironmentSettings(settings: ClaudeCodeSettings): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"target": "ES2021",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2020"],
|
||||
"lib": ["ES2021"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
Reference in New Issue
Block a user