mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
162 Commits
glob-backt
...
v0.13.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32898580a5 | ||
|
|
a7794b5ffb | ||
|
|
8802888e39 | ||
|
|
e4cebeca96 | ||
|
|
45a539257b | ||
|
|
670fb18728 | ||
|
|
36ea238d76 | ||
|
|
8366864daf | ||
|
|
5917529f0d | ||
|
|
2597775ef0 | ||
|
|
6572fcf978 | ||
|
|
5586ae0d2c | ||
|
|
39ab0c248f | ||
|
|
72a9cde888 | ||
|
|
c9d5d3bb9c | ||
|
|
cee383c67c | ||
|
|
041aa44d00 | ||
|
|
3db76b2d20 | ||
|
|
db965a31aa | ||
|
|
5280246773 | ||
|
|
9f3dd402ea | ||
|
|
d19e399018 | ||
|
|
24d1050f7a | ||
|
|
a94598dd89 | ||
|
|
8228c471c8 | ||
|
|
c2200eec80 | ||
|
|
3e5f0b4830 | ||
|
|
172565f19d | ||
|
|
09106474ea | ||
|
|
2b60bad254 | ||
|
|
e81ecbd96e | ||
|
|
e1f08905f7 | ||
|
|
634f1ef6a4 | ||
|
|
86d333b8cc | ||
|
|
9f6fef55dd | ||
|
|
c3e1418d89 | ||
|
|
3b4cd4b2d5 | ||
|
|
9320e05891 | ||
|
|
3f44543019 | ||
|
|
4db3d855bf | ||
|
|
e57893f6d0 | ||
|
|
6a93d7e786 | ||
|
|
c3c0f4da36 | ||
|
|
73d308e4ad | ||
|
|
49b28a25eb | ||
|
|
8516df7188 | ||
|
|
e31a16228a | ||
|
|
c00782c103 | ||
|
|
4211f8cf05 | ||
|
|
ac2119a23a | ||
|
|
427117dce6 | ||
|
|
b5a7e0f28d | ||
|
|
8ef0b4e4c6 | ||
|
|
959cd6c8e2 | ||
|
|
ef209d9d12 | ||
|
|
91b00b71e3 | ||
|
|
101a527732 | ||
|
|
59c5a0a8b8 | ||
|
|
5133e2dec7 | ||
|
|
ed035da9d8 | ||
|
|
8df7f10058 | ||
|
|
6a0d0b5522 | ||
|
|
7e0d2c9506 | ||
|
|
2835f030cf | ||
|
|
2710e146f4 | ||
|
|
bf396fea67 | ||
|
|
84b5744a2f | ||
|
|
b213152a94 | ||
|
|
cd5aabdd74 | ||
|
|
9e18a4e6fd | ||
|
|
48df285bd0 | ||
|
|
b138c84cdb | ||
|
|
0735aca015 | ||
|
|
847d62dacb | ||
|
|
becf1d4f0a | ||
|
|
5491bb8dcc | ||
|
|
17aad5b7a1 | ||
|
|
dadc7f9f40 | ||
|
|
552f0a286d | ||
|
|
589e1b5fc9 | ||
|
|
0d97bb4753 | ||
|
|
e3119b81f9 | ||
|
|
cd3c9f7b13 | ||
|
|
8aea8fd0e1 | ||
|
|
81a541c644 | ||
|
|
b49154bef8 | ||
|
|
5fa453a33a | ||
|
|
c2ab2da5ca | ||
|
|
373d940eb8 | ||
|
|
2c16a41fa4 | ||
|
|
656cbe5921 | ||
|
|
2f811f8a85 | ||
|
|
6aa6537f67 | ||
|
|
1b72ac7c8a | ||
|
|
6ae2c05ddb | ||
|
|
8f0fc59a48 | ||
|
|
c25150523a | ||
|
|
ea796f3cbd | ||
|
|
7f5ebca3fb | ||
|
|
75b44e945e | ||
|
|
ad8611c399 | ||
|
|
c5abff0e43 | ||
|
|
5c98bd0404 | ||
|
|
71b660ae3b | ||
|
|
4ee25af3f9 | ||
|
|
1a5a9c16c6 | ||
|
|
5ca27369ef | ||
|
|
8068d894c1 | ||
|
|
71e745d5fb | ||
|
|
2aa181be27 | ||
|
|
600a2db82f | ||
|
|
f2a4b3d19b | ||
|
|
9135a0f1be | ||
|
|
92c5cd84e8 | ||
|
|
8311a7a222 | ||
|
|
ba513b190b | ||
|
|
a011363320 | ||
|
|
7737878dd2 | ||
|
|
2e53f1d3e4 | ||
|
|
aa7364d49b | ||
|
|
e22d1ed52c | ||
|
|
ecfd36afa6 | ||
|
|
7bd163818b | ||
|
|
491efe32e8 | ||
|
|
ee17e45030 | ||
|
|
468c3522a0 | ||
|
|
84d64b2762 | ||
|
|
9ff7919ea4 | ||
|
|
d58c2dd45d | ||
|
|
55532ecc57 | ||
|
|
f4711e96b0 | ||
|
|
f691024350 | ||
|
|
d6c91b1ec1 | ||
|
|
0289dd9de7 | ||
|
|
5cd3ac91e8 | ||
|
|
6b5873f005 | ||
|
|
539f94de45 | ||
|
|
70441b0aa3 | ||
|
|
de514862f8 | ||
|
|
9ebdfffd48 | ||
|
|
e6bf33cd30 | ||
|
|
96261a5b50 | ||
|
|
003ae41d18 | ||
|
|
0ba0ec1017 | ||
|
|
f70730f1ab | ||
|
|
13cd9214d7 | ||
|
|
78975d75f5 | ||
|
|
bbd0774ae5 | ||
|
|
76331017b2 | ||
|
|
90f1447e99 | ||
|
|
dae4c9956e | ||
|
|
eb282abb50 | ||
|
|
74c0be057b | ||
|
|
e66091f723 | ||
|
|
1b99ab96a0 | ||
|
|
8d14d9dd1d | ||
|
|
98b6cd5c07 | ||
|
|
a4c9ba3c7a | ||
|
|
a08ce88f30 | ||
|
|
f717175cdc | ||
|
|
d1fd794ecb | ||
|
|
f54da50bf5 |
@@ -1,6 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.min.js
|
||||
*.config.js
|
||||
.github/
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2020,
|
||||
"sourceType": "module",
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
|
||||
"env": {
|
||||
"node": true,
|
||||
"es2020": true
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"no-console": "off",
|
||||
"no-constant-condition": "off",
|
||||
"default-case": "error",
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
"eqeqeq": ["error", "always"],
|
||||
"curly": ["error", "all"]
|
||||
},
|
||||
"ignorePatterns": ["dist", "node_modules", "*.js"]
|
||||
}
|
||||
21
.github/workflows/publish.yml
vendored
21
.github/workflows/publish.yml
vendored
@@ -4,8 +4,9 @@ on:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
jobs:
|
||||
build:
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
@@ -16,8 +17,18 @@ jobs:
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
- run: npm ci
|
||||
# Add back provenance once repo is public
|
||||
- run: npm publish # --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- run: npm run build
|
||||
- run: npm publish
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [publish-npm]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
.claude/*.local.*
|
||||
CLAUDE.local.md
|
||||
|
||||
249
CHANGELOG.md
Normal file
249
CHANGELOG.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# Changelog
|
||||
|
||||
## 0.13.2
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.22
|
||||
- Fix: return content from ACP write tool to help with issues with alternate providers.
|
||||
|
||||
## 0.13.1
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.7
|
||||
- Add TypeScript declaration files for library users
|
||||
- Fixed error handling in custom ACP focused MCP tools
|
||||
|
||||
## 0.13.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.6
|
||||
- Update to @agentclientprotocol/sdk@0.13.0
|
||||
|
||||
## 0.12.6
|
||||
|
||||
- Fix model selection
|
||||
|
||||
## 0.12.5
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.70
|
||||
- Unstable implementation of resuming sessions
|
||||
|
||||
## 0.12.4
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.67
|
||||
- Better respect permissions specified in settings files
|
||||
- Unstable implementation of forking
|
||||
|
||||
## 0.12.3
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.65
|
||||
- Update to @agentclientprotocol/sdk@0.9.0
|
||||
- Allow agent to write plans and todos to its config directory
|
||||
- Fix experimental resume ids
|
||||
|
||||
## 0.12.2
|
||||
|
||||
- Fix duplicate tool use IDs error
|
||||
|
||||
## 0.12.1
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.61
|
||||
- Update to @agentclientprotocol/sdk@0.8.0
|
||||
|
||||
## 0.12.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.59
|
||||
- Brings Opus to Claude Pro plans
|
||||
- Support "Don't Ask" profile
|
||||
- Unify ACP + Claude Code session ids
|
||||
|
||||
## 0.11.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.57
|
||||
- Removed dependency on @anthropic-ai/claude-code since this is no longer needed
|
||||
|
||||
## 0.10.10
|
||||
|
||||
- Update to @agentclientprotocol/sdk@0.7.0
|
||||
|
||||
## 0.10.9
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.55
|
||||
- Allow defining a custom logger when used as a library
|
||||
- Allow specifying custom options when used as a library
|
||||
- Add `CLAUDECODE=1` to terminal invocations to match default Claude Code behavior
|
||||
|
||||
## 0.10.8
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.51 (adds support for Opus 4.5)
|
||||
|
||||
## 0.10.7
|
||||
|
||||
- Fix read/edit tool error handling so upstream errors surface
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.50
|
||||
|
||||
## 0.10.6
|
||||
|
||||
- Disable experimental terminal auth support for now, as it was causing issues on Windows. Will revisit with a fix later.
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.46
|
||||
|
||||
## 0.10.5
|
||||
|
||||
- Better error messages at end of turn if there were any
|
||||
- Add experimental support for disabling built-in tools via \_meta flag
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.44
|
||||
|
||||
## 0.10.4
|
||||
|
||||
- Fix tool call titles not appearing during approval in some cases
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.42
|
||||
|
||||
## 0.10.3
|
||||
|
||||
- Fix for experimental terminal auth support
|
||||
|
||||
## 0.10.2
|
||||
|
||||
- Fix incorrect stop reason for tool call refusals
|
||||
|
||||
## 0.10.1
|
||||
|
||||
- Add additional structured metadata to tool calls
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.37
|
||||
|
||||
## 0.10.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.30
|
||||
- Use `canUseTool` callback instead of launching an HTTP MCP server for permission checks.
|
||||
|
||||
## 0.9.0
|
||||
|
||||
- Support slash commands coming from MCP servers (Prompts)
|
||||
|
||||
## 0.8.0
|
||||
|
||||
- Revert changes to filename for cli entrypoint
|
||||
- Provide library entrypoint via lib.ts
|
||||
|
||||
## 0.7.0
|
||||
|
||||
- Allow importing from this package as a library in addition to running it as a CLI. Allows for easier integration into existing node applications.
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.27
|
||||
|
||||
## 0.6.10
|
||||
|
||||
- Provide `agentInfo` on initialization response.
|
||||
- Update to @agentclientprotocol/sdk@0.5.1
|
||||
- Fix crash when receiving a hook_response event
|
||||
- Fix for invalid locations when read call has no path
|
||||
|
||||
## 0.6.9
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.26
|
||||
- Update to @agentclientprotocol/sdk@0.5.0
|
||||
|
||||
## 0.6.8
|
||||
|
||||
- Fix for duplicate tokens appearing in thread with streaming enabled
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.23
|
||||
- Update to @agentclientprotocol/sdk@0.4.9
|
||||
|
||||
## 0.6.7
|
||||
|
||||
- Fix for invalid plan input from the model introduced in latest agent-sdk
|
||||
|
||||
## 0.6.6
|
||||
|
||||
- Do not enable bypassPermissions mode if in root/sudo mode, because Claude Code will not start
|
||||
|
||||
## 0.6.5
|
||||
|
||||
- Fix for duplicated text content after streaming
|
||||
|
||||
## 0.6.4
|
||||
|
||||
- Support streaming partial messages!
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.21
|
||||
|
||||
## 0.6.3
|
||||
|
||||
- Fix issue where slash commands were loaded before initialization was complete.
|
||||
|
||||
## 0.6.2
|
||||
|
||||
- Fix bug where mode selection would sometimes fire before initialization was complete.
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.19
|
||||
|
||||
## 0.6.1
|
||||
|
||||
- Fix to allow bypassPermissions mode to be selected (it wasn't permitted previously)
|
||||
|
||||
## 0.6.0
|
||||
|
||||
- Provide a model selector. We use the "default" model by default, and the user can change it via the client.
|
||||
- Make sure writes require permissions when necessary: https://github.com/zed-industries/claude-code-acp/pull/92
|
||||
- Add support for appending or overriding the system prompt: https://github.com/zed-industries/claude-code-acp/pull/91
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.15
|
||||
- Update to @agentclientprotocol/sdk@0.4.8
|
||||
|
||||
## 0.5.5
|
||||
|
||||
- Migrate to @agentclientprotocol/sdk@0.4.5
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.13
|
||||
|
||||
## 0.5.4
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.11
|
||||
- Enable setting CLAUDE_CODE_EXECUTABLE to override the executable used by the SDK https://github.com/zed-industries/claude-code-acp/pull/86
|
||||
|
||||
## 0.5.3
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.8
|
||||
- Update to @zed-industries/agent-client-protocol@v0.4.5
|
||||
|
||||
## 0.5.2
|
||||
|
||||
- Add back @anthropic-ai/claude-code@2.0.1 as runtime dependency
|
||||
|
||||
## 0.5.1
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.1
|
||||
- Make improvements to ACP tools provided to the model
|
||||
|
||||
## 0.5.0
|
||||
|
||||
- Migrate to @anthropic-ai/claude-agent-sdk@v0.1.0
|
||||
|
||||
## v0.4.7
|
||||
|
||||
- More efficient file reads from the client.
|
||||
|
||||
## v0.4.6
|
||||
|
||||
- Update to @anthropic-ai/claude-code@v1.0.128
|
||||
|
||||
## v0.4.5
|
||||
|
||||
- Update to @anthropic-ai/claude-code@v1.0.124
|
||||
- Update to @zed-industries/agent-client-protocol@v0.4.3
|
||||
|
||||
## v0.4.4
|
||||
|
||||
- Update to @anthropic-ai/claude-code@v1.0.123
|
||||
- Update to @zed-industries/agent-client-protocol@v0.4.2
|
||||
|
||||
## v0.4.3
|
||||
|
||||
- Move ACP tools over MCP from an "http" MCP server to an "sdk" one so more tool calls can stay in-memory.
|
||||
- Update to @anthropic-ai/claude-code@v1.0.119
|
||||
- Update to @zed-industries/agent-client-protocol@v0.4.0
|
||||
|
||||
## v0.4.2
|
||||
|
||||
- Fix missing package.json metadata
|
||||
|
||||
## v0.4.1
|
||||
|
||||
- Add support for /compact command [ecfd36a](https://github.com/zed-industries/claude-code-acp/commit/ecfd36afa6c4e31f12e1daf9b8a2bdc12dda1794)
|
||||
- Add default limits to read tool [7bd1638](https://github.com/zed-industries/claude-code-acp/commit/7bd163818bb959b11fd2c933eff73ad83c57abb8)
|
||||
- Better rendering of Tool errors [491efe3](https://github.com/zed-industries/claude-code-acp/commit/491efe32e8547075842e448d873fc01b2ffabf3a)
|
||||
- Load managed-settings.json [f691024](https://github.com/zed-industries/claude-code-acp/commit/f691024350362858e00b97248ac68e356d2331c2)
|
||||
- Update to @anthropic-ai/claude-code@v1.0.113
|
||||
- Update to @zed-industries/agent-client-protocol@v0.3.1
|
||||
14
README.md
14
README.md
@@ -1,12 +1,14 @@
|
||||
# ACP adapter for Claude Code
|
||||
|
||||
[](https://www.npmjs.com/package/@zed-industries/claude-code-acp)
|
||||
|
||||
Use [Claude Code](https://www.anthropic.com/claude-code) from [ACP-compatible](https://agentclientprotocol.com) clients such as [Zed](https://zed.dev)!
|
||||
|
||||
This tool implements an ACP agent by using the official [Claude Code SDK](https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-overview), supporting:
|
||||
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
|
||||
- Tool calls
|
||||
- Tool calls (with permission requests)
|
||||
- Following
|
||||
- Edit review
|
||||
- TODO lists
|
||||
@@ -26,24 +28,24 @@ To use Claude Code, open the Agent Panel and click "New Claude Code Thread" from
|
||||
|
||||
https://github.com/user-attachments/assets/ddce66c7-79ac-47a3-ad59-4a6a3ca74903
|
||||
|
||||
[Read the docs](https://zed.dev/docs/ai/external-agents) on External Agent support.
|
||||
Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) support.
|
||||
|
||||
### Other clients
|
||||
|
||||
Setup instructions for other clients are coming soon. Feel free to [submit a PR](https://github.com/zed-industries/claude-code-acp/pulls) to add yours!
|
||||
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-code-acp
|
||||
```
|
||||
|
||||
You can then use `claude-code-acp` as a regular ACP agent:
|
||||
|
||||
```
|
||||
$ ANTHROPIC_API_KEY=sk-... claude-code-acp
|
||||
ANTHROPIC_API_KEY=sk-... claude-code-acp
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
24
docs/RELEASES.md
Normal file
24
docs/RELEASES.md
Normal file
@@ -0,0 +1,24 @@
|
||||
## How to publish a new release
|
||||
|
||||
1. Create a new branch prep-vX.X.X
|
||||
|
||||
```sh
|
||||
git checkout -b prep-vX.X.X
|
||||
```
|
||||
|
||||
2. Bump the version in package.json
|
||||
|
||||
```sh
|
||||
npm version vX.X.X
|
||||
```
|
||||
|
||||
3. Add a new entry in CHANGELOG.md
|
||||
4. Create a PR for `prev-vX.X.X` & merge it
|
||||
5. Create tag and push it
|
||||
|
||||
```sh
|
||||
git tag vX.X.X
|
||||
git push origin vX.X.X
|
||||
```
|
||||
|
||||
6. Paste CHANGELOG into release notes and publish the version on GitHub
|
||||
48
eslint.config.js
Normal file
48
eslint.config.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "@typescript-eslint/eslint-plugin";
|
||||
import tsparser from "@typescript-eslint/parser";
|
||||
import prettierConfig from "eslint-config-prettier";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["node_modules/", "dist/", "coverage/", "*.min.js", "*.config.js", ".github/"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ["src/**/*.ts"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: "module",
|
||||
parser: tsparser,
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslint,
|
||||
},
|
||||
rules: {
|
||||
...tseslint.configs.recommended.rules,
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"no-console": "off",
|
||||
"no-constant-condition": "off",
|
||||
"default-case": "error",
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
eqeqeq: ["error", "always"],
|
||||
curly: ["error", "all"],
|
||||
},
|
||||
},
|
||||
prettierConfig,
|
||||
];
|
||||
2625
package-lock.json
generated
2625
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
58
package.json
58
package.json
@@ -3,15 +3,24 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.13.2",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
"bin": {
|
||||
"claude-code-acp": "./dist/index.js"
|
||||
},
|
||||
"version": "0.1.10",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"main": "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,29 +49,34 @@
|
||||
"sdk",
|
||||
"code"
|
||||
],
|
||||
"homepage": "https://github.com/zed-industries/claude-code-acp#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/zed-industries/claude-code-acp/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/zed-industries/claude-code-acp.git"
|
||||
},
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "^1.0.100",
|
||||
"@modelcontextprotocol/sdk": "^1.17.4",
|
||||
"@types/express": "^5.0.3",
|
||||
"@zed-industries/agent-client-protocol": "0.2.0-alpha.4",
|
||||
"express": "^5.1.0",
|
||||
"minimist": "^1.2.8",
|
||||
"uuid": "11.1.0"
|
||||
"@agentclientprotocol/sdk": "0.13.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.22",
|
||||
"@modelcontextprotocol/sdk": "1.25.3",
|
||||
"diff": "8.0.3",
|
||||
"minimatch": "10.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/minimist": "^1.2.5",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.42.0",
|
||||
"@typescript-eslint/parser": "^8.42.0",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"prettier": "^3.6.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^3.2.4"
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@types/node": "25.0.10",
|
||||
"@typescript-eslint/eslint-plugin": "8.54.0",
|
||||
"@typescript-eslint/parser": "8.54.0",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.2.0",
|
||||
"prettier": "3.8.1",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
1100
src/acp-agent.ts
1100
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
14
src/index.ts
14
src/index.ts
@@ -1,5 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Load managed settings and apply environment variables
|
||||
import { loadManagedSettings, applyEnvironmentSettings } from "./utils.js";
|
||||
|
||||
const managedSettings = loadManagedSettings();
|
||||
if (managedSettings) {
|
||||
applyEnvironmentSettings(managedSettings);
|
||||
}
|
||||
|
||||
// stdout is used to send messages to the client
|
||||
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
|
||||
console.log = console.error;
|
||||
@@ -7,7 +15,11 @@ console.info = console.error;
|
||||
console.warn = console.error;
|
||||
console.debug = console.error;
|
||||
|
||||
import { runAcp as runAcp } from "./acp-agent.js";
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
|
||||
import { runAcp } from "./acp-agent.js";
|
||||
runAcp();
|
||||
|
||||
// Keep process alive
|
||||
|
||||
36
src/lib.ts
Normal file
36
src/lib.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Export the main agent class and utilities for library usage
|
||||
export {
|
||||
ClaudeAcpAgent,
|
||||
runAcp,
|
||||
toAcpNotifications,
|
||||
streamEventToAcpNotifications,
|
||||
type ToolUpdateMeta,
|
||||
type NewSessionMeta,
|
||||
} from "./acp-agent.js";
|
||||
export {
|
||||
loadManagedSettings,
|
||||
applyEnvironmentSettings,
|
||||
nodeToWebReadable,
|
||||
nodeToWebWritable,
|
||||
Pushable,
|
||||
unreachable,
|
||||
} from "./utils.js";
|
||||
export { createMcpServer } from "./mcp-server.js";
|
||||
export {
|
||||
toolInfoFromToolUse,
|
||||
planEntries,
|
||||
toolUpdateFromToolResult,
|
||||
createPreToolUseHook,
|
||||
acpToolNames as toolNames,
|
||||
} from "./tools.js";
|
||||
export {
|
||||
SettingsManager,
|
||||
type ClaudeCodeSettings,
|
||||
type PermissionSettings,
|
||||
type PermissionDecision,
|
||||
type PermissionCheckResult,
|
||||
type SettingsManagerOptions,
|
||||
} from "./settings.js";
|
||||
|
||||
// Export types
|
||||
export type { ClaudePlanEntry } from "./tools.js";
|
||||
1023
src/mcp-server.ts
1023
src/mcp-server.ts
File diff suppressed because it is too large
Load Diff
522
src/settings.ts
Normal file
522
src/settings.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { minimatch } from "minimatch";
|
||||
import { ACP_TOOL_NAME_PREFIX, acpToolNames } from "./tools.js";
|
||||
import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
|
||||
|
||||
/**
|
||||
* Permission rule format examples:
|
||||
* - "Read" - matches all Read tool calls
|
||||
* - "Read(./.env)" - matches specific path
|
||||
* - "Read(./.env.*)" - glob pattern
|
||||
* - "Read(./secrets/**)" - recursive glob
|
||||
* - "Bash(npm run lint)" - exact command prefix
|
||||
* - "Bash(npm run:*)" - command prefix with wildcard
|
||||
*
|
||||
* Docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules
|
||||
*/
|
||||
|
||||
export interface PermissionSettings {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
ask?: string[];
|
||||
additionalDirectories?: string[];
|
||||
defaultMode?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeCodeSettings {
|
||||
permissions?: PermissionSettings;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type PermissionDecision = "allow" | "deny" | "ask";
|
||||
|
||||
export interface PermissionCheckResult {
|
||||
decision: PermissionDecision;
|
||||
rule?: string;
|
||||
source?: "allow" | "deny" | "ask";
|
||||
}
|
||||
|
||||
interface ParsedRule {
|
||||
toolName: string;
|
||||
argument?: string;
|
||||
isWildcard?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell operators that can be used for command chaining/injection
|
||||
* These should cause a prefix match to fail to prevent bypasses like:
|
||||
* - "safe-cmd && malicious-cmd"
|
||||
* - "safe-cmd; malicious-cmd"
|
||||
* - "safe-cmd | malicious-cmd"
|
||||
* - "safe-cmd || malicious-cmd"
|
||||
* - "$(malicious-cmd)"
|
||||
* - "`malicious-cmd`"
|
||||
*/
|
||||
const SHELL_OPERATORS = ["&&", "||", ";", "|", "$(", "`", "\n"];
|
||||
|
||||
/**
|
||||
* Checks if a string contains shell operators that could allow command chaining
|
||||
*/
|
||||
function containsShellOperator(str: string): boolean {
|
||||
return SHELL_OPERATORS.some((op) => str.includes(op));
|
||||
}
|
||||
|
||||
/*
|
||||
* Tools that modify files. Per Claude Code docs:
|
||||
* "Edit rules apply to all built-in tools that edit files."
|
||||
* This means an Edit(...) rule should match Write, MultiEdit, etc.
|
||||
*/
|
||||
const FILE_EDITING_TOOLS = [acpToolNames.edit, acpToolNames.write];
|
||||
|
||||
/**
|
||||
* Tools that read files. Per Claude Code docs:
|
||||
* "Claude will make a best-effort attempt to apply Read rules to all built-in tools
|
||||
* that read files like Grep and Glob."
|
||||
* This means a Read(...) rule should match Grep, Glob, etc.
|
||||
*/
|
||||
const FILE_READING_TOOLS = [acpToolNames.read];
|
||||
|
||||
/**
|
||||
* Functions to extract the relevant argument from tool input for permission matching
|
||||
*/
|
||||
const TOOL_ARG_ACCESSORS: Record<string, (input: unknown) => string | undefined> = {
|
||||
mcp__acp__Read: (input) => (input as { file_path?: string })?.file_path,
|
||||
mcp__acp__Edit: (input) => (input as { file_path?: string })?.file_path,
|
||||
mcp__acp__Write: (input) => (input as { file_path?: string })?.file_path,
|
||||
mcp__acp__Bash: (input) => (input as { command?: string })?.command,
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a permission rule string into its components
|
||||
* Examples:
|
||||
* "Read" -> { toolName: "Read" }
|
||||
* "Read(./.env)" -> { toolName: "Read", argument: "./.env" }
|
||||
* "Bash(npm run:*)" -> { toolName: "Bash", argument: "npm run", isWildcard: true }
|
||||
*/
|
||||
function parseRule(rule: string): ParsedRule {
|
||||
const match = rule.match(/^(\w+)(?:\((.+)\))?$/);
|
||||
if (!match) {
|
||||
return { toolName: rule };
|
||||
}
|
||||
|
||||
const [, toolName, argument] = match;
|
||||
|
||||
if (argument && argument.endsWith(":*")) {
|
||||
return {
|
||||
toolName,
|
||||
argument: argument.slice(0, -2),
|
||||
isWildcard: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { toolName, argument };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a path for comparison:
|
||||
* - Expands ~ to home directory
|
||||
* - Resolves relative paths against cwd
|
||||
* - Normalizes path separators
|
||||
*/
|
||||
function normalizePath(filePath: string, cwd: string): string {
|
||||
if (filePath.startsWith("~/")) {
|
||||
filePath = path.join(os.homedir(), filePath.slice(2));
|
||||
} else if (filePath.startsWith("./")) {
|
||||
filePath = path.join(cwd, filePath.slice(2));
|
||||
} else if (!path.isAbsolute(filePath)) {
|
||||
filePath = path.join(cwd, filePath);
|
||||
}
|
||||
return path.normalize(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file path matches a glob pattern
|
||||
*/
|
||||
function matchesGlob(pattern: string, filePath: string, cwd: string): boolean {
|
||||
const normalizedPattern = normalizePath(pattern, cwd);
|
||||
const normalizedPath = normalizePath(filePath, cwd);
|
||||
|
||||
return minimatch(normalizedPath, normalizedPattern, {
|
||||
dot: true,
|
||||
matchBase: false,
|
||||
nocase: process.platform === "win32",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tool invocation matches a parsed permission rule
|
||||
*/
|
||||
function matchesRule(rule: ParsedRule, toolName: string, toolInput: unknown, cwd: string): boolean {
|
||||
// Per Claude Code docs:
|
||||
// - "Edit rules apply to all built-in tools that edit files."
|
||||
// - "Claude will make a best-effort attempt to apply Read rules to all built-in tools
|
||||
// that read files like Grep, Glob, and LS."
|
||||
const ruleAppliesToTool =
|
||||
rule.toolName === "Bash" ||
|
||||
(rule.toolName === "Edit" && FILE_EDITING_TOOLS.includes(toolName)) ||
|
||||
(rule.toolName === "Read" && FILE_READING_TOOLS.includes(toolName));
|
||||
|
||||
if (!ruleAppliesToTool) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!rule.argument) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const argAccessor = TOOL_ARG_ACCESSORS[toolName];
|
||||
if (!argAccessor) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const actualArg = argAccessor(toolInput);
|
||||
if (!actualArg) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (toolName === acpToolNames.bash) {
|
||||
// Per Claude Code docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules
|
||||
// - Bash(npm run build) matches the EXACT command "npm run build"
|
||||
// - Bash(npm run test:*) matches commands STARTING WITH "npm run test"
|
||||
// The :* suffix enables prefix matching, without it the match is exact
|
||||
//
|
||||
// Also from docs: "Claude Code is aware of shell operators (like &&) so a prefix match
|
||||
// rule like Bash(safe-cmd:*) won't give it permission to run the command safe-cmd && other-cmd"
|
||||
if (rule.isWildcard) {
|
||||
if (!actualArg.startsWith(rule.argument)) {
|
||||
return false;
|
||||
}
|
||||
// Check that the matched prefix isn't followed by shell operators that could
|
||||
// allow command chaining/injection
|
||||
const remainder = actualArg.slice(rule.argument.length);
|
||||
if (containsShellOperator(remainder)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return actualArg === rule.argument;
|
||||
}
|
||||
|
||||
// For file-based tools (Read, Edit, Write), use glob matching
|
||||
return matchesGlob(rule.argument, actualArg, cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and parses a JSON settings file, returning an empty object if not found or invalid
|
||||
*/
|
||||
async function loadSettingsFile(filePath: string | null): Promise<ClaudeCodeSettings> {
|
||||
if (!filePath) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.promises.readFile(filePath, "utf-8");
|
||||
return JSON.parse(content) as ClaudeCodeSettings;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the enterprise settings path based on the current platform
|
||||
*/
|
||||
export function getManagedSettingsPath(): string {
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
||||
case "linux":
|
||||
return "/etc/claude-code/managed-settings.json";
|
||||
case "win32":
|
||||
return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
|
||||
default:
|
||||
return "/etc/claude-code/managed-settings.json";
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingsManagerOptions {
|
||||
onChange?: () => void;
|
||||
logger?: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages Claude Code settings from multiple sources with proper precedence.
|
||||
*
|
||||
* Settings are loaded from (in order of increasing precedence):
|
||||
* 1. User settings (~/.claude/settings.json)
|
||||
* 2. Project settings (<cwd>/.claude/settings.json)
|
||||
* 3. Local project settings (<cwd>/.claude/settings.local.json)
|
||||
* 4. Enterprise managed settings (platform-specific path)
|
||||
*
|
||||
* The manager watches all settings files for changes and automatically reloads.
|
||||
*/
|
||||
export class SettingsManager {
|
||||
private cwd: string;
|
||||
private userSettings: ClaudeCodeSettings = {};
|
||||
private projectSettings: ClaudeCodeSettings = {};
|
||||
private localSettings: ClaudeCodeSettings = {};
|
||||
private enterpriseSettings: ClaudeCodeSettings = {};
|
||||
private mergedSettings: ClaudeCodeSettings = {};
|
||||
private watchers: fs.FSWatcher[] = [];
|
||||
private onChange?: () => void;
|
||||
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
private initialized = false;
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(cwd: string, options?: SettingsManagerOptions) {
|
||||
this.cwd = cwd;
|
||||
this.onChange = options?.onChange;
|
||||
this.logger = options?.logger ?? console;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the settings manager by loading all settings and setting up file watchers
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadAllSettings();
|
||||
this.setupWatchers();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the user settings file
|
||||
*/
|
||||
private getUserSettingsPath(): string {
|
||||
return path.join(CLAUDE_CONFIG_DIR, "settings.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the project settings file
|
||||
*/
|
||||
private getProjectSettingsPath(): string {
|
||||
return path.join(this.cwd, ".claude", "settings.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the local project settings file
|
||||
*/
|
||||
private getLocalSettingsPath(): string {
|
||||
return path.join(this.cwd, ".claude", "settings.local.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads settings from all sources
|
||||
*/
|
||||
private async loadAllSettings(): Promise<void> {
|
||||
const [userSettings, projectSettings, localSettings, enterpriseSettings] = await Promise.all([
|
||||
loadSettingsFile(this.getUserSettingsPath()),
|
||||
loadSettingsFile(this.getProjectSettingsPath()),
|
||||
loadSettingsFile(this.getLocalSettingsPath()),
|
||||
loadSettingsFile(getManagedSettingsPath()),
|
||||
]);
|
||||
|
||||
this.userSettings = userSettings;
|
||||
this.projectSettings = projectSettings;
|
||||
this.localSettings = localSettings;
|
||||
this.enterpriseSettings = enterpriseSettings;
|
||||
|
||||
this.mergeSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges all settings sources with proper precedence.
|
||||
* For permissions, rules from all sources are combined.
|
||||
* Deny rules always take precedence during permission checks.
|
||||
*/
|
||||
private mergeSettings(): void {
|
||||
const allSettings = [
|
||||
this.userSettings,
|
||||
this.projectSettings,
|
||||
this.localSettings,
|
||||
this.enterpriseSettings,
|
||||
];
|
||||
|
||||
const merged: ClaudeCodeSettings = {
|
||||
permissions: {
|
||||
allow: [],
|
||||
deny: [],
|
||||
ask: [],
|
||||
},
|
||||
};
|
||||
|
||||
for (const settings of allSettings) {
|
||||
if (settings.permissions) {
|
||||
if (settings.permissions.allow) {
|
||||
merged.permissions!.allow!.push(...settings.permissions.allow);
|
||||
}
|
||||
if (settings.permissions.deny) {
|
||||
merged.permissions!.deny!.push(...settings.permissions.deny);
|
||||
}
|
||||
if (settings.permissions.ask) {
|
||||
merged.permissions!.ask!.push(...settings.permissions.ask);
|
||||
}
|
||||
if (settings.permissions.additionalDirectories) {
|
||||
merged.permissions!.additionalDirectories = [
|
||||
...(merged.permissions!.additionalDirectories || []),
|
||||
...settings.permissions.additionalDirectories,
|
||||
];
|
||||
}
|
||||
if (settings.permissions.defaultMode) {
|
||||
merged.permissions!.defaultMode = settings.permissions.defaultMode;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.env) {
|
||||
merged.env = { ...merged.env, ...settings.env };
|
||||
}
|
||||
}
|
||||
|
||||
this.mergedSettings = merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up file watchers for all settings files
|
||||
*/
|
||||
private setupWatchers(): void {
|
||||
const paths = [
|
||||
this.getUserSettingsPath(),
|
||||
this.getProjectSettingsPath(),
|
||||
this.getLocalSettingsPath(),
|
||||
getManagedSettingsPath(),
|
||||
];
|
||||
|
||||
for (const filePath of paths) {
|
||||
if (!filePath) continue;
|
||||
|
||||
try {
|
||||
const dir = path.dirname(filePath);
|
||||
const filename = path.basename(filePath);
|
||||
|
||||
if (fs.existsSync(dir)) {
|
||||
const watcher = fs.watch(dir, (eventType, changedFilename) => {
|
||||
if (changedFilename === filename) {
|
||||
this.handleSettingsChange();
|
||||
}
|
||||
});
|
||||
|
||||
watcher.on("error", (error) => {
|
||||
this.logger.error(`Settings watcher error for ${filePath}:`, error);
|
||||
});
|
||||
|
||||
this.watchers.push(watcher);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to set up watcher for ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles settings file changes with debouncing to avoid rapid reloads
|
||||
*/
|
||||
private handleSettingsChange(): void {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
|
||||
this.debounceTimer = setTimeout(async () => {
|
||||
this.debounceTimer = null;
|
||||
try {
|
||||
await this.loadAllSettings();
|
||||
this.onChange?.();
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to reload settings:", error);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tool invocation is allowed based on the loaded settings.
|
||||
*
|
||||
* @param toolName - The tool name (can be ACP-prefixed like mcp__acp__Read or plain like Read)
|
||||
* @param toolInput - The tool input object
|
||||
* @returns The permission decision and matching rule info
|
||||
*/
|
||||
checkPermission(toolName: string, toolInput: unknown): PermissionCheckResult {
|
||||
if (!toolName.startsWith(ACP_TOOL_NAME_PREFIX)) {
|
||||
return { decision: "ask" };
|
||||
}
|
||||
|
||||
const permissions = this.mergedSettings.permissions;
|
||||
|
||||
if (!permissions) {
|
||||
return { decision: "ask" };
|
||||
}
|
||||
|
||||
// Check deny rules first (highest priority)
|
||||
for (const rule of permissions.deny || []) {
|
||||
const parsed = parseRule(rule);
|
||||
if (matchesRule(parsed, toolName, toolInput, this.cwd)) {
|
||||
return { decision: "deny", rule, source: "deny" };
|
||||
}
|
||||
}
|
||||
|
||||
// Check allow rules
|
||||
for (const rule of permissions.allow || []) {
|
||||
const parsed = parseRule(rule);
|
||||
if (matchesRule(parsed, toolName, toolInput, this.cwd)) {
|
||||
return { decision: "allow", rule, source: "allow" };
|
||||
}
|
||||
}
|
||||
|
||||
// Check ask rules
|
||||
for (const rule of permissions.ask || []) {
|
||||
const parsed = parseRule(rule);
|
||||
if (matchesRule(parsed, toolName, toolInput, this.cwd)) {
|
||||
return { decision: "ask", rule, source: "ask" };
|
||||
}
|
||||
}
|
||||
|
||||
// No matching rule - default to ask
|
||||
return { decision: "ask" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current merged settings
|
||||
*/
|
||||
getSettings(): ClaudeCodeSettings {
|
||||
return this.mergedSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current working directory
|
||||
*/
|
||||
getCwd(): string {
|
||||
return this.cwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the working directory and reloads project-specific settings
|
||||
*/
|
||||
async setCwd(cwd: string): Promise<void> {
|
||||
if (this.cwd === cwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispose();
|
||||
this.cwd = cwd;
|
||||
this.initialized = false;
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of file watchers and cleans up resources
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
|
||||
for (const watcher of this.watchers) {
|
||||
watcher.close();
|
||||
}
|
||||
this.watchers = [];
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Say hello
|
||||
argument-hint: [name]
|
||||
argument-hint: name
|
||||
---
|
||||
|
||||
Respond with "Hello $1" and nothing else.
|
||||
|
||||
@@ -2,8 +2,11 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
AvailableCommand,
|
||||
Client,
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
NewSessionResponse,
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
@@ -12,11 +15,12 @@ import {
|
||||
SessionNotification,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
} from "@zed-industries/agent-client-protocol";
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
|
||||
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
|
||||
import { toAcpNotifications } from "../acp-agent.js";
|
||||
import { SDKAssistantMessage } from "@anthropic-ai/claude-code";
|
||||
import { toAcpNotifications, promptToClaude } from "../acp-agent.js";
|
||||
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
@@ -47,9 +51,15 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
agent: Agent;
|
||||
files: Map<string, string> = new Map();
|
||||
receivedText: string = "";
|
||||
resolveAvailableCommands: (commands: AvailableCommand[]) => void;
|
||||
availableCommandsPromise: Promise<AvailableCommand[]>;
|
||||
|
||||
constructor(agent: Agent) {
|
||||
this.agent = agent;
|
||||
this.resolveAvailableCommands = () => {};
|
||||
this.availableCommandsPromise = new Promise((resolve) => {
|
||||
this.resolveAvailableCommands = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
takeReceivedText() {
|
||||
@@ -67,17 +77,24 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
async sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
console.error("RECEIVED", JSON.stringify(params, null, 4));
|
||||
|
||||
if (
|
||||
params.update.sessionUpdate === "agent_message_chunk" &&
|
||||
params.update.content.type === "text"
|
||||
) {
|
||||
this.receivedText += params.update.content.text;
|
||||
switch (params.update.sessionUpdate) {
|
||||
case "agent_message_chunk": {
|
||||
if (params.update.content.type === "text") {
|
||||
this.receivedText += params.update.content.text;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "available_commands_update":
|
||||
this.resolveAvailableCommands(params.update.availableCommands);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
this.files.set(params.path, params.content);
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
|
||||
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
@@ -94,14 +111,13 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
newSessionResponse: NewSessionResponse;
|
||||
}> {
|
||||
let client;
|
||||
const connection = new ClientSideConnection(
|
||||
(agent) => {
|
||||
client = new TestClient(agent);
|
||||
return client;
|
||||
},
|
||||
nodeToWebWritable(child.stdin!),
|
||||
nodeToWebReadable(child.stdout!),
|
||||
);
|
||||
const input = nodeToWebWritable(child.stdin!);
|
||||
const output = nodeToWebReadable(child.stdout!);
|
||||
const stream = ndJsonStream(input, output);
|
||||
const connection = new ClientSideConnection((agent) => {
|
||||
client = new TestClient(agent);
|
||||
return client;
|
||||
}, stream);
|
||||
|
||||
await connection.initialize({
|
||||
protocolVersion: 1,
|
||||
@@ -140,15 +156,17 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
it("should include available commands", async () => {
|
||||
const { client, connection, newSessionResponse } = await setupTestSession(__dirname);
|
||||
|
||||
expect(newSessionResponse.availableCommands).toContainEqual({
|
||||
const commands = await client.availableCommandsPromise;
|
||||
|
||||
expect(commands).toContainEqual({
|
||||
name: "quick-math",
|
||||
description: "10 * 3 = 30 (project)",
|
||||
input: null,
|
||||
});
|
||||
expect(newSessionResponse.availableCommands).toContainEqual({
|
||||
expect(commands).toContainEqual({
|
||||
name: "say-hello",
|
||||
description: "Say hello (project)",
|
||||
input: { hint: "[name]" },
|
||||
input: { hint: "name" },
|
||||
});
|
||||
|
||||
await connection.prompt({
|
||||
@@ -175,6 +193,50 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
|
||||
expect(client.takeReceivedText()).toContain("Hello GPT-5");
|
||||
}, 30000);
|
||||
|
||||
it("/compact works", async () => {
|
||||
const { client, connection, newSessionResponse } = await setupTestSession(__dirname);
|
||||
|
||||
const commands = await client.availableCommandsPromise;
|
||||
|
||||
expect(commands).toContainEqual({
|
||||
description:
|
||||
"Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]",
|
||||
input: {
|
||||
hint: "<optional custom summarization instructions>",
|
||||
},
|
||||
name: "compact",
|
||||
});
|
||||
|
||||
// Error case (no previous message)
|
||||
await connection.prompt({
|
||||
prompt: [{ type: "text", text: "/compact" }],
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
|
||||
expect(client.takeReceivedText()).toBe("");
|
||||
|
||||
// Send something
|
||||
await connection.prompt({
|
||||
prompt: [{ type: "text", text: "Hi" }],
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
// Clear response
|
||||
client.takeReceivedText();
|
||||
|
||||
// Test with instruction
|
||||
await connection.prompt({
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: "/compact greeting",
|
||||
},
|
||||
],
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
|
||||
expect(client.takeReceivedText()).toContain("");
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("tool conversions", () => {
|
||||
@@ -189,7 +251,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "execute",
|
||||
title: "`rm README.md.rm`",
|
||||
content: [
|
||||
@@ -214,9 +276,9 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "search",
|
||||
title: "Find */**.ts",
|
||||
title: "Find `*/**.ts`",
|
||||
content: [],
|
||||
locations: [],
|
||||
});
|
||||
@@ -235,7 +297,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "think",
|
||||
title: "Handle user's work request",
|
||||
content: [
|
||||
@@ -260,7 +322,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "search",
|
||||
title: "List the `/Users/test/github/claude-code-acp` directory's contents",
|
||||
content: [],
|
||||
@@ -278,7 +340,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "search",
|
||||
title: 'grep ".*"',
|
||||
content: [],
|
||||
@@ -296,7 +358,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "edit",
|
||||
title: "Write /Users/test/project/example.txt",
|
||||
content: [
|
||||
@@ -311,18 +373,18 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__write tool calls", () => {
|
||||
it("should handle mcp__acp__Write tool calls", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01GHI789JKL456",
|
||||
name: "mcp__acp__write",
|
||||
name: "mcp__acp__Write",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/config.json",
|
||||
file_path: "/Users/test/project/config.json",
|
||||
content: '{"version": "1.0.0"}',
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "edit",
|
||||
title: "Write /Users/test/project/config.json",
|
||||
content: [
|
||||
@@ -347,7 +409,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "read",
|
||||
title: "Read File",
|
||||
content: [],
|
||||
@@ -355,17 +417,17 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__read tool calls", () => {
|
||||
it("should handle mcp__acp__Read tool calls", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01YZA789BCD123",
|
||||
name: "mcp__acp__read",
|
||||
name: "mcp__acp__Read",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/data.json",
|
||||
file_path: "/Users/test/project/data.json",
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "read",
|
||||
title: "Read /Users/test/project/data.json",
|
||||
content: [],
|
||||
@@ -373,18 +435,18 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__read with limit", () => {
|
||||
it("should handle mcp__acp__Read with limit", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01EFG456HIJ789",
|
||||
name: "mcp__acp__read",
|
||||
name: "mcp__acp__Read",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/large.txt",
|
||||
file_path: "/Users/test/project/large.txt",
|
||||
limit: 100,
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "read",
|
||||
title: "Read /Users/test/project/large.txt (1 - 100)",
|
||||
content: [],
|
||||
@@ -392,19 +454,19 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__read with offset and limit", () => {
|
||||
it("should handle mcp__acp__Read with offset and limit", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01KLM789NOP456",
|
||||
name: "mcp__acp__read",
|
||||
name: "mcp__acp__Read",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/large.txt",
|
||||
file_path: "/Users/test/project/large.txt",
|
||||
offset: 50,
|
||||
limit: 100,
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "read",
|
||||
title: "Read /Users/test/project/large.txt (51 - 150)",
|
||||
content: [],
|
||||
@@ -412,18 +474,18 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__read with only offset", () => {
|
||||
it("should handle mcp__acp__Read with only offset", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01QRS123TUV789",
|
||||
name: "mcp__acp__read",
|
||||
name: "mcp__acp__Read",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/large.txt",
|
||||
file_path: "/Users/test/project/large.txt",
|
||||
offset: 200,
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "read",
|
||||
title: "Read /Users/test/project/large.txt (from line 201)",
|
||||
content: [],
|
||||
@@ -431,61 +493,17 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle WebFetch tool calls", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01LxEjDn8ci9SAc3qG7LbbXV",
|
||||
name: "WebFetch",
|
||||
input: {
|
||||
url: "https://agentclientprotocol.com",
|
||||
prompt:
|
||||
"Please provide a comprehensive summary of the content on this page, including what the Agent Client Protocol is, its main features, documentation links, and any other relevant information.",
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
kind: "fetch",
|
||||
title: "Fetch https://agentclientprotocol.com",
|
||||
content: [
|
||||
{
|
||||
content: {
|
||||
text: "Please provide a comprehensive summary of the content on this page, including what the Agent Client Protocol is, its main features, documentation links, and any other relevant information.",
|
||||
type: "text",
|
||||
},
|
||||
type: "content",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle WebSearch tool calls", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01NYMwiZFbdoQFxYxuQDFZXQ",
|
||||
name: "WebSearch",
|
||||
input: {
|
||||
query: "agentclientprotocol.com",
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
kind: "fetch",
|
||||
title: '"agentclientprotocol.com"',
|
||||
content: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle KillBash entries", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01PhLms5fuvmdjy2bb6dfUKT",
|
||||
name: "KillBash",
|
||||
name: "KillShell",
|
||||
input: {
|
||||
shell_id: "bash_1",
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "execute",
|
||||
title: `Kill Process`,
|
||||
content: [],
|
||||
@@ -502,7 +520,7 @@ describe("tool conversions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "execute",
|
||||
title: `Tail Logs`,
|
||||
content: [],
|
||||
@@ -516,6 +534,7 @@ describe("tool conversions", () => {
|
||||
id: "msg_017eNosJgww7F5qD4a8BcAcx",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
container: null,
|
||||
model: "claude-sonnet-4-20250514",
|
||||
content: [
|
||||
{
|
||||
@@ -580,13 +599,24 @@ describe("tool conversions", () => {
|
||||
},
|
||||
output_tokens: 1,
|
||||
service_tier: "standard",
|
||||
server_tool_use: null,
|
||||
},
|
||||
context_management: null,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
session_id: "d056596f-e328-41e9-badd-b07122ae5227",
|
||||
uuid: "b7c3330c-de8f-4bba-ac53-68c7f76ffeb5",
|
||||
};
|
||||
expect(toAcpNotifications(received, "test", {}, {})).toStrictEqual([
|
||||
expect(
|
||||
toAcpNotifications(
|
||||
received.message.content,
|
||||
received.message.role,
|
||||
"test",
|
||||
{},
|
||||
{} as AgentSideConnection,
|
||||
console,
|
||||
),
|
||||
).toStrictEqual([
|
||||
{
|
||||
sessionId: "test",
|
||||
update: {
|
||||
@@ -638,196 +668,13 @@ describe("tool conversions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract locations from edit tool results", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01ABC123",
|
||||
name: "edit",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/example.txt",
|
||||
old_string: "old content",
|
||||
new_string: "new content",
|
||||
},
|
||||
};
|
||||
|
||||
const toolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ lineNumbers: [5, 10, 15] }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||
|
||||
expect(update.locations).toEqual([
|
||||
{ path: "/Users/test/project/example.txt", line: 5 },
|
||||
{ path: "/Users/test/project/example.txt", line: 10 },
|
||||
{ path: "/Users/test/project/example.txt", line: 15 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should show full diff for multi edit tool", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01DEF456",
|
||||
name: "mcp__acp__multi-edit",
|
||||
input: {
|
||||
file_path: "/Users/test/project/config.json",
|
||||
edits: [
|
||||
{
|
||||
old_string: 'version": 1',
|
||||
new_string: 'version": 2',
|
||||
replace_all: false,
|
||||
},
|
||||
{
|
||||
old_string: 'enabled": false',
|
||||
new_string: 'enabled": true',
|
||||
replace_all: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const fileCache: { [key: string]: string } = {
|
||||
"/Users/test/project/config.json": JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
filler: "filler",
|
||||
enabled: false,
|
||||
},
|
||||
null,
|
||||
4,
|
||||
),
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(toolUse, fileCache)).toEqual({
|
||||
content: [
|
||||
{
|
||||
newText: `{
|
||||
"version": 2,
|
||||
"filler": "filler",
|
||||
"enabled": true
|
||||
}`,
|
||||
oldText: `{
|
||||
"version": 1,
|
||||
"filler": "filler",
|
||||
"enabled": false
|
||||
}`,
|
||||
path: "/Users/test/project/config.json",
|
||||
type: "diff",
|
||||
},
|
||||
],
|
||||
kind: "edit",
|
||||
locations: [
|
||||
{
|
||||
path: "/Users/test/project/config.json",
|
||||
},
|
||||
],
|
||||
title: "Edit /Users/test/project/config.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("should extract locations from multi-edit tool results", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01DEF456",
|
||||
name: "multi-edit",
|
||||
input: {
|
||||
file_path: "/Users/test/project/config.json",
|
||||
edits: [
|
||||
{
|
||||
old_string: "version: 1.0",
|
||||
new_string: "version: 2.0",
|
||||
replace_all: false,
|
||||
},
|
||||
{
|
||||
old_string: "enabled: false",
|
||||
new_string: "enabled: true",
|
||||
replace_all: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const toolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ lineNumbers: [2, 8, 12, 20] }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||
|
||||
expect(update.locations).toEqual([
|
||||
{ path: "/Users/test/project/config.json", line: 2 },
|
||||
{ path: "/Users/test/project/config.json", line: 8 },
|
||||
{ path: "/Users/test/project/config.json", line: 12 },
|
||||
{ path: "/Users/test/project/config.json", line: 20 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__edit tool results", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01GHI789",
|
||||
name: "mcp__acp__edit",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/main.py",
|
||||
old_string: "def hello():",
|
||||
new_string: "def hello_world():",
|
||||
},
|
||||
};
|
||||
|
||||
const toolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ lineNumbers: [42] }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||
|
||||
expect(update.locations).toEqual([{ path: "/Users/test/project/main.py", line: 42 }]);
|
||||
});
|
||||
|
||||
it("should handle empty locations from edit tools", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01JKL012",
|
||||
name: "edit",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/notfound.txt",
|
||||
old_string: "nonexistent",
|
||||
new_string: "replacement",
|
||||
},
|
||||
};
|
||||
|
||||
const toolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ lineNumbers: [] }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||
|
||||
expect(update.locations).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle malformed tool results gracefully", () => {
|
||||
it("should return empty update for successful edit result", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01MNO345",
|
||||
name: "edit",
|
||||
name: "mcp__acp__Edit",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/test.txt",
|
||||
file_path: "/Users/test/project/test.txt",
|
||||
old_string: "old",
|
||||
new_string: "new",
|
||||
},
|
||||
@@ -836,10 +683,13 @@ describe("tool conversions", () => {
|
||||
const toolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
type: "text" as const,
|
||||
text: "not valid json",
|
||||
},
|
||||
],
|
||||
tool_use_id: "test",
|
||||
is_error: false,
|
||||
type: "tool_result" as const,
|
||||
};
|
||||
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||
@@ -847,6 +697,43 @@ describe("tool conversions", () => {
|
||||
// Should return empty object when parsing fails
|
||||
expect(update).toEqual({});
|
||||
});
|
||||
|
||||
it("should return content update for edit failure", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01MNO345",
|
||||
name: "mcp__acp__Edit",
|
||||
input: {
|
||||
file_path: "/Users/test/project/test.txt",
|
||||
old_string: "old",
|
||||
new_string: "new",
|
||||
},
|
||||
};
|
||||
|
||||
const toolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Failed to find `old_string`",
|
||||
},
|
||||
],
|
||||
tool_use_id: "test",
|
||||
is_error: true,
|
||||
type: "tool_result" as const,
|
||||
};
|
||||
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||
|
||||
// Should return empty object when parsing fails
|
||||
expect(update).toEqual({
|
||||
content: [
|
||||
{
|
||||
content: { type: "text", text: "```\nFailed to find `old_string`\n```" },
|
||||
type: "content",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("escape markdown", () => {
|
||||
@@ -860,3 +747,123 @@ describe("escape markdown", () => {
|
||||
expect(escaped).toEqual("````\nfor example:\n```markdown\nHello *world*!\n```\n````");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prompt conversion", () => {
|
||||
it("should not change built-in slash commands", () => {
|
||||
const message = promptToClaude({
|
||||
sessionId: "test",
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: "/compact args",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(message.message.content).toEqual([
|
||||
{
|
||||
text: "/compact args",
|
||||
type: "text",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should remove MCP prefix from MCP slash commands", () => {
|
||||
const message = promptToClaude({
|
||||
sessionId: "test",
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: "/mcp:server:name args",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(message.message.content).toEqual([
|
||||
{
|
||||
text: "/server:name (MCP) args",
|
||||
type: "text",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
|
||||
it("query has a 'default' model", async () => {
|
||||
const q = query({ prompt: "hi" });
|
||||
const models = await q.supportedModels();
|
||||
const defaultModel = models.find((m) => m.value === "default");
|
||||
expect(defaultModel).toBeDefined();
|
||||
}, 10000);
|
||||
|
||||
it("custom session id", async () => {
|
||||
const sessionId = randomUUID();
|
||||
const q = query({
|
||||
prompt: "hi",
|
||||
options: {
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
extraArgs: { "session-id": sessionId },
|
||||
settingSources: ["user", "project", "local"],
|
||||
includePartialMessages: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { value } = await q.next();
|
||||
expect(value).toMatchObject({ type: "system", subtype: "init", session_id: sessionId });
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe("permission requests", () => {
|
||||
it("should include title field in tool permission request structure", () => {
|
||||
// Test various tool types to ensure title is correctly generated
|
||||
const testCases = [
|
||||
{
|
||||
toolUse: {
|
||||
type: "tool_use" as const,
|
||||
id: "test-1",
|
||||
name: "Write",
|
||||
input: { file_path: "/test/file.txt", content: "test" },
|
||||
},
|
||||
expectedTitlePart: "/test/file.txt",
|
||||
},
|
||||
{
|
||||
toolUse: {
|
||||
type: "tool_use" as const,
|
||||
id: "test-2",
|
||||
name: "Bash",
|
||||
input: { command: "ls -la", description: "List files" },
|
||||
},
|
||||
expectedTitlePart: "`ls -la`",
|
||||
},
|
||||
{
|
||||
toolUse: {
|
||||
type: "tool_use" as const,
|
||||
id: "test-3",
|
||||
name: "mcp__acp__Read",
|
||||
input: { file_path: "/test/data.json" },
|
||||
},
|
||||
expectedTitlePart: "/test/data.json",
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
// Get the tool info that would be used in requestPermission
|
||||
const toolInfo = toolInfoFromToolUse(testCase.toolUse);
|
||||
|
||||
// Verify toolInfo has a title
|
||||
expect(toolInfo.title).toBeDefined();
|
||||
expect(toolInfo.title).toContain(testCase.expectedTitlePart);
|
||||
|
||||
// Verify the structure that our fix creates for requestPermission
|
||||
const requestStructure = {
|
||||
toolCall: {
|
||||
toolCallId: testCase.toolUse.id,
|
||||
rawInput: testCase.toolUse.input,
|
||||
title: toolInfo.title, // This is what commit 1785d86 adds
|
||||
},
|
||||
};
|
||||
|
||||
// Ensure the title field is present and populated
|
||||
expect(requestStructure.toolCall.title).toBeDefined();
|
||||
expect(requestStructure.toolCall.title).toContain(testCase.expectedTitlePart);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
103
src/tests/extract-lines.test.ts
Normal file
103
src/tests/extract-lines.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -22,12 +22,9 @@ describe("replaceAndCalculateLocation", () => {
|
||||
|
||||
it("should return empty line numbers array when no match found", () => {
|
||||
const content = "line 1\nline 2\nline 3";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "notfound", newText: "replaced" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe(content);
|
||||
expect(result.lineNumbers).toEqual([]);
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content, [{ oldText: "notfound", newText: "replaced" }]);
|
||||
}).toThrow('The provided `old_string` does not appear in the file: "notfound"');
|
||||
});
|
||||
|
||||
it("should handle Windows line endings (CRLF)", () => {
|
||||
@@ -89,35 +86,31 @@ describe("replaceAndCalculateLocation", () => {
|
||||
});
|
||||
|
||||
it("should handle empty file content", () => {
|
||||
const content = "";
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe("");
|
||||
expect(result.lineNumbers).toEqual([]);
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation("", [{ oldText: "text", newText: "replaced" }]);
|
||||
}).toThrowError(
|
||||
'The provided `old_string` does not appear in the file: "text".\n\nNo edits were applied.',
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty search text", () => {
|
||||
// Test with replaceAll false (default)
|
||||
const content1 = "line 1\nline 2\nline 3";
|
||||
const result1 = replaceAndCalculateLocation(content1, [{ oldText: "", newText: "replaced" }]);
|
||||
expect(result1.newContent).toBe(content1);
|
||||
expect(result1.lineNumbers).toEqual([]);
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content1, [{ oldText: "", newText: "replaced" }]);
|
||||
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
|
||||
|
||||
// Test with replaceAll true on single line
|
||||
const content2 = "abc";
|
||||
const result2 = replaceAndCalculateLocation(content2, [
|
||||
{ oldText: "", newText: "X", replaceAll: true },
|
||||
]);
|
||||
expect(result2.newContent).toBe(content2);
|
||||
expect(result2.lineNumbers).toEqual([]);
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content2, [{ oldText: "", newText: "X", replaceAll: true }]);
|
||||
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
|
||||
|
||||
// Test with replaceAll true on multiline content
|
||||
const content3 = "ab\ncd";
|
||||
const result3 = replaceAndCalculateLocation(content3, [
|
||||
{ oldText: "", newText: "X", replaceAll: true },
|
||||
]);
|
||||
expect(result3.newContent).toBe(content3);
|
||||
expect(result3.lineNumbers).toEqual([]);
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content3, [{ oldText: "", newText: "X", replaceAll: true }]);
|
||||
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
|
||||
});
|
||||
|
||||
it("should handle single line content", () => {
|
||||
|
||||
617
src/tests/settings.test.ts
Normal file
617
src/tests/settings.test.ts
Normal file
@@ -0,0 +1,617 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { SettingsManager } from "../settings.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
|
||||
describe("SettingsManager", () => {
|
||||
let tempDir: string;
|
||||
let settingsManager: SettingsManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
settingsManager?.dispose();
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("permission checking", () => {
|
||||
it("should return 'ask' when no settings exist", async () => {
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: "/some/file.txt",
|
||||
});
|
||||
expect(result.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should return 'ask' for non-ACP tools (permission checks only apply to mcp__acp__* tools)", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Non-ACP tools should always return 'ask' regardless of rules
|
||||
const result = settingsManager.checkPermission("Read", { file_path: "/some/file.txt" });
|
||||
expect(result.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should allow tool use when matching allow rule exists", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: "/some/file.txt",
|
||||
});
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.rule).toBe("Read");
|
||||
});
|
||||
|
||||
it("should deny tool use when matching deny rule exists", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read(./.env)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env"),
|
||||
});
|
||||
expect(result.decision).toBe("deny");
|
||||
expect(result.rule).toBe("Read(./.env)");
|
||||
});
|
||||
|
||||
it("should prioritize deny rules over allow rules", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read"],
|
||||
deny: ["Read(./.env)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// .env should be denied
|
||||
const envResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env"),
|
||||
});
|
||||
expect(envResult.decision).toBe("deny");
|
||||
|
||||
// other files should be allowed
|
||||
const otherResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, "other.txt"),
|
||||
});
|
||||
expect(otherResult.decision).toBe("allow");
|
||||
});
|
||||
|
||||
it("should handle ACP-prefixed tool names", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: "/some/file.txt",
|
||||
});
|
||||
expect(result.decision).toBe("allow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bash permission rules", () => {
|
||||
it("should match exact Bash commands without :* wildcard", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
// Per docs: Bash(npm run build) matches the EXACT command "npm run build"
|
||||
allow: ["Bash(npm run lint)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Exact match should be allowed
|
||||
const exactResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run lint",
|
||||
});
|
||||
expect(exactResult.decision).toBe("allow");
|
||||
|
||||
// Command with extra arguments should NOT match (exact match only)
|
||||
const withArgsResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run lint --fix",
|
||||
});
|
||||
expect(withArgsResult.decision).toBe("ask");
|
||||
|
||||
// Similar command should NOT match (exact match only)
|
||||
const similarResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run linting",
|
||||
});
|
||||
expect(similarResult.decision).toBe("ask");
|
||||
|
||||
// Different command should not match
|
||||
const differentResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run test",
|
||||
});
|
||||
expect(differentResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should match Bash commands with :* wildcard suffix (prefix matching)", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
// The :* suffix is a convention to make prefix matching explicit
|
||||
allow: ["Bash(npm run:*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Any command starting with "npm run" should match (prefix matching with :*)
|
||||
const lintResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run lint",
|
||||
});
|
||||
expect(lintResult.decision).toBe("allow");
|
||||
|
||||
const testResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run test",
|
||||
});
|
||||
expect(testResult.decision).toBe("allow");
|
||||
|
||||
// Commands with additional args also match
|
||||
const withArgsResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run test --watch",
|
||||
});
|
||||
expect(withArgsResult.decision).toBe("allow");
|
||||
|
||||
// Non-matching command
|
||||
const installResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm install",
|
||||
});
|
||||
expect(installResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should not allow shell operators to bypass prefix matching", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Bash(safe-cmd:*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Normal prefix match should work
|
||||
const normalResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd --flag",
|
||||
});
|
||||
expect(normalResult.decision).toBe("allow");
|
||||
|
||||
// Shell operators should NOT be allowed (per docs: Claude Code is aware of shell operators)
|
||||
const andResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd && malicious-cmd",
|
||||
});
|
||||
expect(andResult.decision).toBe("ask");
|
||||
|
||||
const orResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd || malicious-cmd",
|
||||
});
|
||||
expect(orResult.decision).toBe("ask");
|
||||
|
||||
const semicolonResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd; malicious-cmd",
|
||||
});
|
||||
expect(semicolonResult.decision).toBe("ask");
|
||||
|
||||
const pipeResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd | malicious-cmd",
|
||||
});
|
||||
expect(pipeResult.decision).toBe("ask");
|
||||
|
||||
const subshellResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd $(malicious-cmd)",
|
||||
});
|
||||
expect(subshellResult.decision).toBe("ask");
|
||||
|
||||
const backtickResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd `malicious-cmd`",
|
||||
});
|
||||
expect(backtickResult.decision).toBe("ask");
|
||||
|
||||
const newlineResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd\nmalicious-cmd",
|
||||
});
|
||||
expect(newlineResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should deny dangerous Bash commands", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Bash(curl:*)", "Bash(wget:*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const curlResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "curl https://example.com",
|
||||
});
|
||||
expect(curlResult.decision).toBe("deny");
|
||||
|
||||
const wgetResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "wget https://example.com",
|
||||
});
|
||||
expect(wgetResult.decision).toBe("deny");
|
||||
|
||||
const lsResult = settingsManager.checkPermission("mcp__acp__Bash", { command: "ls -la" });
|
||||
expect(lsResult.decision).toBe("ask");
|
||||
});
|
||||
});
|
||||
|
||||
describe("file path glob matching", () => {
|
||||
it("should match exact file paths", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read(./.env)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const envResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env"),
|
||||
});
|
||||
expect(envResult.decision).toBe("deny");
|
||||
|
||||
const otherResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".envrc"),
|
||||
});
|
||||
expect(otherResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should match glob patterns with single wildcard", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read(./.env.*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const envLocalResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env.local"),
|
||||
});
|
||||
expect(envLocalResult.decision).toBe("deny");
|
||||
|
||||
const envProdResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env.production"),
|
||||
});
|
||||
expect(envProdResult.decision).toBe("deny");
|
||||
|
||||
// Plain .env should not match .env.*
|
||||
const plainEnvResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env"),
|
||||
});
|
||||
expect(plainEnvResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should match glob patterns with double wildcard (recursive)", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read(./secrets/**)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const secretResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, "secrets", "api-key.txt"),
|
||||
});
|
||||
expect(secretResult.decision).toBe("deny");
|
||||
|
||||
const nestedSecretResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, "secrets", "deep", "nested", "key.txt"),
|
||||
});
|
||||
expect(nestedSecretResult.decision).toBe("deny");
|
||||
|
||||
const otherResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, "public", "file.txt"),
|
||||
});
|
||||
expect(otherResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should handle home directory expansion", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read(~/.zshrc)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const zshrcResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(os.homedir(), ".zshrc"),
|
||||
});
|
||||
expect(zshrcResult.decision).toBe("allow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings merging", () => {
|
||||
it("should merge project and local settings", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
|
||||
// Project settings
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Local settings
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.local.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read(./.env)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Read should be allowed in general
|
||||
const readResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, "file.txt"),
|
||||
});
|
||||
expect(readResult.decision).toBe("allow");
|
||||
|
||||
// But .env should be denied (local settings take precedence)
|
||||
const envResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env"),
|
||||
});
|
||||
expect(envResult.decision).toBe("deny");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ask rules", () => {
|
||||
it("should return 'ask' for matching ask rules", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
ask: ["Bash(git push:*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "git push origin main",
|
||||
});
|
||||
expect(result.decision).toBe("ask");
|
||||
expect(result.rule).toBe("Bash(git push:*)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edit and Write tools", () => {
|
||||
it("should handle Edit tool permissions", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Edit(./package-lock.json)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const lockFileResult = settingsManager.checkPermission("mcp__acp__Edit", {
|
||||
file_path: path.join(tempDir, "package-lock.json"),
|
||||
});
|
||||
expect(lockFileResult.decision).toBe("deny");
|
||||
|
||||
const otherResult = settingsManager.checkPermission("mcp__acp__Edit", {
|
||||
file_path: path.join(tempDir, "package.json"),
|
||||
});
|
||||
expect(otherResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__Edit and mcp__acp__Write tool names", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Edit", "Write"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const editResult = settingsManager.checkPermission("mcp__acp__Edit", {
|
||||
file_path: "/some/file.ts",
|
||||
});
|
||||
expect(editResult.decision).toBe("allow");
|
||||
|
||||
const writeResult = settingsManager.checkPermission("mcp__acp__Write", {
|
||||
file_path: "/some/file.ts",
|
||||
});
|
||||
expect(writeResult.decision).toBe("allow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSettings", () => {
|
||||
it("should return merged settings", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read", "Bash(npm:*)"],
|
||||
deny: ["Read(./.env)"],
|
||||
},
|
||||
env: {
|
||||
FOO: "bar",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const settings = settingsManager.getSettings();
|
||||
expect(settings.permissions?.allow).toContain("Read");
|
||||
expect(settings.permissions?.allow).toContain("Bash(npm:*)");
|
||||
expect(settings.permissions?.deny).toContain("Read(./.env)");
|
||||
expect(settings.env?.FOO).toBe("bar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCwd", () => {
|
||||
it("should reload settings when cwd changes", async () => {
|
||||
const claudeDir1 = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir1, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir1, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
let result = settingsManager.checkPermission("mcp__acp__Read", { file_path: "/file.txt" });
|
||||
expect(result.decision).toBe("allow");
|
||||
|
||||
// Create a new temp directory with different settings
|
||||
const tempDir2 = await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-test-2-"));
|
||||
const claudeDir2 = path.join(tempDir2, ".claude");
|
||||
await fs.promises.mkdir(claudeDir2, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir2, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await settingsManager.setCwd(tempDir2);
|
||||
|
||||
result = settingsManager.checkPermission("mcp__acp__Read", { file_path: "/file.txt" });
|
||||
expect(result.decision).toBe("deny");
|
||||
|
||||
// Cleanup second temp dir
|
||||
await fs.promises.rm(tempDir2, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
318
src/tests/tools.test.ts
Normal file
318
src/tests/tools.test.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
import { ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import {
|
||||
BetaMCPToolResultBlock,
|
||||
BetaTextBlock,
|
||||
BetaWebSearchResultBlock,
|
||||
BetaWebSearchToolResultBlock,
|
||||
BetaBashCodeExecutionToolResultBlock,
|
||||
BetaBashCodeExecutionResultBlock,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
|
||||
|
||||
describe("rawOutput in tool call updates", () => {
|
||||
const mockClient = {} as AgentSideConnection;
|
||||
const mockLogger: Logger = { log: () => {}, error: () => {} };
|
||||
|
||||
it("should include rawOutput with string content for tool_result", () => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
toolu_123: {
|
||||
type: "tool_use",
|
||||
id: "toolu_123",
|
||||
name: "Bash",
|
||||
input: { command: "echo hello" },
|
||||
},
|
||||
};
|
||||
|
||||
const toolResult: ToolResultBlockParam = {
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_123",
|
||||
content: "hello\n",
|
||||
is_error: false,
|
||||
};
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[toolResult],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "toolu_123",
|
||||
status: "completed",
|
||||
rawOutput: "hello\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("should include rawOutput with array content for tool_result", () => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
toolu_456: {
|
||||
type: "tool_use",
|
||||
id: "toolu_456",
|
||||
name: "Read",
|
||||
input: { file_path: "/test/file.txt" },
|
||||
},
|
||||
};
|
||||
|
||||
// ToolResultBlockParam content can be string or array of TextBlockParam
|
||||
const toolResult: ToolResultBlockParam = {
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_456",
|
||||
content: [{ type: "text", text: "Line 1\nLine 2\nLine 3" }],
|
||||
is_error: false,
|
||||
};
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[toolResult],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "toolu_456",
|
||||
status: "completed",
|
||||
rawOutput: [{ type: "text", text: "Line 1\nLine 2\nLine 3" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should include rawOutput for mcp_tool_result with string content", () => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
toolu_789: {
|
||||
type: "tool_use",
|
||||
id: "toolu_789",
|
||||
name: "mcp__server__tool",
|
||||
input: { query: "test" },
|
||||
},
|
||||
};
|
||||
|
||||
// BetaMCPToolResultBlock content can be string or Array<BetaTextBlock>
|
||||
const toolResult: BetaMCPToolResultBlock = {
|
||||
type: "mcp_tool_result",
|
||||
tool_use_id: "toolu_789",
|
||||
content: '{"result": "success", "data": [1, 2, 3]}',
|
||||
is_error: false,
|
||||
};
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[toolResult],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "toolu_789",
|
||||
status: "completed",
|
||||
rawOutput: '{"result": "success", "data": [1, 2, 3]}',
|
||||
});
|
||||
});
|
||||
|
||||
it("should include rawOutput for mcp_tool_result with array content", () => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
toolu_abc: {
|
||||
type: "tool_use",
|
||||
id: "toolu_abc",
|
||||
name: "mcp__server__search",
|
||||
input: { term: "test" },
|
||||
},
|
||||
};
|
||||
|
||||
// BetaTextBlock requires citations field
|
||||
const arrayContent: BetaTextBlock[] = [
|
||||
{ type: "text", text: "Result 1", citations: null },
|
||||
{ type: "text", text: "Result 2", citations: null },
|
||||
];
|
||||
|
||||
const toolResult: BetaMCPToolResultBlock = {
|
||||
type: "mcp_tool_result",
|
||||
tool_use_id: "toolu_abc",
|
||||
content: arrayContent,
|
||||
is_error: false,
|
||||
};
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[toolResult],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "toolu_abc",
|
||||
status: "completed",
|
||||
rawOutput: arrayContent,
|
||||
});
|
||||
});
|
||||
|
||||
it("should include rawOutput for web_search_tool_result", () => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
toolu_web: {
|
||||
type: "tool_use",
|
||||
id: "toolu_web",
|
||||
name: "WebSearch",
|
||||
input: { query: "test search" },
|
||||
},
|
||||
};
|
||||
|
||||
// BetaWebSearchResultBlock from SDK
|
||||
const searchResults: BetaWebSearchResultBlock[] = [
|
||||
{
|
||||
type: "web_search_result",
|
||||
url: "https://example.com",
|
||||
title: "Example",
|
||||
encrypted_content: "encrypted content here",
|
||||
page_age: "2 days ago",
|
||||
},
|
||||
];
|
||||
|
||||
const toolResult: BetaWebSearchToolResultBlock = {
|
||||
type: "web_search_tool_result",
|
||||
tool_use_id: "toolu_web",
|
||||
content: searchResults,
|
||||
};
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[toolResult],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "toolu_web",
|
||||
status: "completed",
|
||||
rawOutput: searchResults,
|
||||
});
|
||||
});
|
||||
|
||||
it("should include rawOutput for bash_code_execution_tool_result", () => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
toolu_bash: {
|
||||
type: "tool_use",
|
||||
id: "toolu_bash",
|
||||
name: "Bash",
|
||||
input: { command: "ls -la" },
|
||||
},
|
||||
};
|
||||
|
||||
// BetaBashCodeExecutionResultBlock from SDK
|
||||
const bashResult: BetaBashCodeExecutionResultBlock = {
|
||||
type: "bash_code_execution_result",
|
||||
stdout: "file1.txt\nfile2.txt",
|
||||
stderr: "",
|
||||
return_code: 0,
|
||||
content: [],
|
||||
};
|
||||
|
||||
const toolResult: BetaBashCodeExecutionToolResultBlock = {
|
||||
type: "bash_code_execution_tool_result",
|
||||
tool_use_id: "toolu_bash",
|
||||
content: bashResult,
|
||||
};
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[toolResult],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "toolu_bash",
|
||||
status: "completed",
|
||||
rawOutput: bashResult,
|
||||
});
|
||||
});
|
||||
|
||||
it("should set status to failed when is_error is true", () => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
toolu_err: {
|
||||
type: "tool_use",
|
||||
id: "toolu_err",
|
||||
name: "Bash",
|
||||
input: { command: "invalid_command" },
|
||||
},
|
||||
};
|
||||
|
||||
const toolResult: ToolResultBlockParam = {
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_err",
|
||||
content: "command not found: invalid_command",
|
||||
is_error: true,
|
||||
};
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[toolResult],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "toolu_err",
|
||||
status: "failed",
|
||||
rawOutput: "command not found: invalid_command",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not emit tool_call_update for TodoWrite (emits plan instead)", () => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
toolu_todo: {
|
||||
type: "tool_use",
|
||||
id: "toolu_todo",
|
||||
name: "TodoWrite",
|
||||
input: { todos: [{ content: "Test task", status: "pending" }] },
|
||||
},
|
||||
};
|
||||
|
||||
const toolResult: ToolResultBlockParam = {
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_todo",
|
||||
content: "Todos updated successfully",
|
||||
is_error: false,
|
||||
};
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[toolResult],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
// TodoWrite should not emit tool_call_update - it emits plan updates instead
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
558
src/tests/typescript-declarations.test.ts
Normal file
558
src/tests/typescript-declarations.test.ts
Normal file
@@ -0,0 +1,558 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { spawnSync } from "child_process";
|
||||
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
|
||||
"TypeScript declaration files integration",
|
||||
() => {
|
||||
let tempDir: string;
|
||||
let tarballPath: string;
|
||||
const projectRoot = path.resolve(__dirname, "../..");
|
||||
|
||||
// Base configuration templates
|
||||
const basePackageJson = {
|
||||
name: "ts-declaration-test",
|
||||
version: "1.0.0",
|
||||
type: "module",
|
||||
dependencies: {},
|
||||
devDependencies: {
|
||||
typescript: "5.9.3",
|
||||
"@types/node": "25.0.3",
|
||||
},
|
||||
};
|
||||
|
||||
const baseTsConfig = {
|
||||
compilerOptions: {
|
||||
target: "ES2020",
|
||||
module: "NodeNext",
|
||||
moduleResolution: "NodeNext",
|
||||
lib: ["ES2020"],
|
||||
strict: true,
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true, // Skip checking dependency internals, focus on our types
|
||||
noEmit: true,
|
||||
declaration: false,
|
||||
},
|
||||
include: ["*.ts"],
|
||||
};
|
||||
|
||||
// Build and pack the package once for all tests
|
||||
beforeAll(async () => {
|
||||
// Step 1: Clean dist folder to ensure fresh build
|
||||
const distPath = path.join(projectRoot, "dist");
|
||||
await fs.promises.rm(distPath, { recursive: true, force: true });
|
||||
console.log("Cleaned dist folder");
|
||||
|
||||
console.log("Building package...");
|
||||
|
||||
// Step 2: Build the package
|
||||
const buildResult = spawnSync("npm", ["run", "build"], {
|
||||
cwd: projectRoot,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
if (buildResult.status !== 0) {
|
||||
throw new Error(`Build failed: ${buildResult.stderr || buildResult.stdout}`);
|
||||
}
|
||||
|
||||
console.log("Packing package...");
|
||||
|
||||
// Step 3: Pack to create tarball
|
||||
const packResult = spawnSync("npm", ["pack", "--pack-destination", os.tmpdir()], {
|
||||
cwd: projectRoot,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
if (packResult.status !== 0) {
|
||||
throw new Error(`Pack failed: ${packResult.stderr || packResult.stdout}`);
|
||||
}
|
||||
|
||||
// Get the tarball filename from stdout (npm pack outputs the filename)
|
||||
const tarballName = packResult.stdout.trim().split("\n").pop();
|
||||
if (!tarballName) {
|
||||
throw new Error("Failed to get tarball name from npm pack output");
|
||||
}
|
||||
tarballPath = path.join(os.tmpdir(), tarballName);
|
||||
|
||||
console.log(`Tarball created at: ${tarballPath}`);
|
||||
}, 60000); // 60 second timeout for build
|
||||
|
||||
// Clean up the tarball after all tests
|
||||
afterAll(async () => {
|
||||
if (tarballPath && fs.existsSync(tarballPath)) {
|
||||
await fs.promises.unlink(tarballPath);
|
||||
console.log("Cleaned up tarball");
|
||||
}
|
||||
});
|
||||
|
||||
// Create fresh temp directory for each test
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "ts-declaration-test-"));
|
||||
});
|
||||
|
||||
// Clean up temp directory after each test
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to set up a test TypeScript project
|
||||
async function setupTestProject(
|
||||
packageJson: object = basePackageJson,
|
||||
tsconfig: object = baseTsConfig,
|
||||
): Promise<void> {
|
||||
// Write package.json
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "package.json"),
|
||||
JSON.stringify(packageJson, null, 2),
|
||||
);
|
||||
|
||||
// Write tsconfig.json
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "tsconfig.json"),
|
||||
JSON.stringify(tsconfig, null, 2),
|
||||
);
|
||||
|
||||
// Install all dependencies (TypeScript, @types/node, and the tarball)
|
||||
console.log(`Installing dependencies in ${tempDir}...`);
|
||||
const installResult = spawnSync("npm", ["install", tarballPath], {
|
||||
cwd: tempDir,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
timeout: 60000, // 60 second timeout
|
||||
});
|
||||
|
||||
if (installResult.status !== 0) {
|
||||
throw new Error(`npm install failed: ${installResult.stderr || installResult.stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to run TypeScript type checking
|
||||
function runTypeCheck(srcDir: string = tempDir): {
|
||||
success: boolean;
|
||||
output: string;
|
||||
} {
|
||||
const result = spawnSync("npx", ["tsc", "--noEmit"], {
|
||||
cwd: srcDir,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
timeout: 30000, // 30 second timeout
|
||||
});
|
||||
|
||||
return {
|
||||
success: result.status === 0,
|
||||
output: result.stdout + result.stderr,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to write a test TypeScript file
|
||||
async function writeTestFile(filename: string, content: string): Promise<void> {
|
||||
await fs.promises.writeFile(path.join(tempDir, filename), content);
|
||||
}
|
||||
|
||||
// Test 1: Main exports import verification
|
||||
it("should successfully type-check main exports", async () => {
|
||||
await setupTestProject();
|
||||
|
||||
await writeTestFile(
|
||||
"test-main-exports.ts",
|
||||
`
|
||||
import {
|
||||
ClaudeAcpAgent,
|
||||
runAcp,
|
||||
toAcpNotifications,
|
||||
streamEventToAcpNotifications,
|
||||
SettingsManager,
|
||||
createMcpServer,
|
||||
loadManagedSettings,
|
||||
applyEnvironmentSettings,
|
||||
nodeToWebReadable,
|
||||
nodeToWebWritable,
|
||||
Pushable,
|
||||
unreachable,
|
||||
toolInfoFromToolUse,
|
||||
planEntries,
|
||||
toolUpdateFromToolResult,
|
||||
createPreToolUseHook,
|
||||
toolNames,
|
||||
} from "@zed-industries/claude-code-acp";
|
||||
|
||||
// Type-only imports
|
||||
import type {
|
||||
ToolUpdateMeta,
|
||||
NewSessionMeta,
|
||||
ClaudeCodeSettings,
|
||||
PermissionSettings,
|
||||
PermissionDecision,
|
||||
PermissionCheckResult,
|
||||
SettingsManagerOptions,
|
||||
ClaudePlanEntry,
|
||||
} from "@zed-industries/claude-code-acp";
|
||||
|
||||
// Verify exports exist and have expected types
|
||||
const _runAcp: typeof runAcp = runAcp;
|
||||
const _createMcpServer: typeof createMcpServer = createMcpServer;
|
||||
const _toolNames: typeof toolNames = toolNames;
|
||||
`,
|
||||
);
|
||||
|
||||
const result = runTypeCheck();
|
||||
if (!result.success) {
|
||||
console.error("TypeScript errors:", result.output);
|
||||
}
|
||||
expect(result.success).toBe(true);
|
||||
}, 120000);
|
||||
|
||||
// Test 2: Deep imports verification (backwards compatibility)
|
||||
it("should successfully type-check deep imports", async () => {
|
||||
await setupTestProject();
|
||||
|
||||
await writeTestFile(
|
||||
"test-deep-imports.ts",
|
||||
`
|
||||
// Deep import from dist/tools.js
|
||||
import {
|
||||
acpToolNames,
|
||||
EDIT_TOOL_NAMES,
|
||||
ACP_TOOL_NAME_PREFIX,
|
||||
toolInfoFromToolUse,
|
||||
planEntries,
|
||||
} from "@zed-industries/claude-code-acp/dist/tools.js";
|
||||
|
||||
// Deep import from dist/settings.js
|
||||
import {
|
||||
SettingsManager,
|
||||
getManagedSettingsPath,
|
||||
} from "@zed-industries/claude-code-acp/dist/settings.js";
|
||||
|
||||
// Deep import from dist/utils.js
|
||||
import {
|
||||
Pushable,
|
||||
nodeToWebReadable,
|
||||
nodeToWebWritable,
|
||||
loadManagedSettings,
|
||||
} from "@zed-industries/claude-code-acp/dist/utils.js";
|
||||
|
||||
// Verify types work
|
||||
const prefix: string = ACP_TOOL_NAME_PREFIX;
|
||||
const editTools: readonly string[] = EDIT_TOOL_NAMES;
|
||||
`,
|
||||
);
|
||||
|
||||
const result = runTypeCheck();
|
||||
if (!result.success) {
|
||||
console.error("TypeScript errors:", result.output);
|
||||
}
|
||||
expect(result.success).toBe(true);
|
||||
}, 120000);
|
||||
|
||||
// Test 3: SettingsManager type shape verification
|
||||
it("should verify SettingsManager has correct type shape", async () => {
|
||||
await setupTestProject();
|
||||
|
||||
await writeTestFile(
|
||||
"test-settings-manager.ts",
|
||||
`
|
||||
import {
|
||||
SettingsManager,
|
||||
ClaudeCodeSettings,
|
||||
PermissionCheckResult,
|
||||
SettingsManagerOptions
|
||||
} from "@zed-industries/claude-code-acp";
|
||||
|
||||
// Test constructor signature
|
||||
const options: SettingsManagerOptions = {
|
||||
onChange: () => {},
|
||||
logger: { log: console.log, error: console.error },
|
||||
};
|
||||
declare const cwd: string;
|
||||
const manager = new SettingsManager(cwd, options);
|
||||
|
||||
// Test method signatures
|
||||
async function testMethods() {
|
||||
// initialize returns Promise<void>
|
||||
await manager.initialize();
|
||||
|
||||
// checkPermission returns PermissionCheckResult
|
||||
const result: PermissionCheckResult = manager.checkPermission(
|
||||
"mcp__acp__Read",
|
||||
{ file_path: "/some/path" }
|
||||
);
|
||||
|
||||
// Verify decision type
|
||||
const decision: "allow" | "deny" | "ask" = result.decision;
|
||||
const rule: string | undefined = result.rule;
|
||||
const source: "allow" | "deny" | "ask" | undefined = result.source;
|
||||
|
||||
// getSettings returns ClaudeCodeSettings
|
||||
const settings: ClaudeCodeSettings = manager.getSettings();
|
||||
|
||||
// getCwd returns string
|
||||
const currentCwd: string = manager.getCwd();
|
||||
|
||||
// setCwd returns Promise<void>
|
||||
await manager.setCwd("/new/path");
|
||||
|
||||
// dispose returns void
|
||||
manager.dispose();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const result = runTypeCheck();
|
||||
if (!result.success) {
|
||||
console.error("TypeScript errors:", result.output);
|
||||
}
|
||||
expect(result.success).toBe(true);
|
||||
}, 120000);
|
||||
|
||||
// Test 4: ClaudeAcpAgent instantiation and type verification
|
||||
it("should verify ClaudeAcpAgent has correct type shape", async () => {
|
||||
await setupTestProject();
|
||||
|
||||
await writeTestFile(
|
||||
"test-claude-acp-agent.ts",
|
||||
`
|
||||
import { ClaudeAcpAgent } from "@zed-industries/claude-code-acp";
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
|
||||
// ClaudeAcpAgent should be a class that can be instantiated
|
||||
declare const mockConnection: AgentSideConnection;
|
||||
declare const mockLogger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
|
||||
// Test constructor - accepts AgentSideConnection and optional logger
|
||||
const agent = new ClaudeAcpAgent(mockConnection, mockLogger);
|
||||
|
||||
// Verify it has sessions property
|
||||
const sessions: Record<string, any> = agent.sessions;
|
||||
|
||||
// Verify it has client property
|
||||
const client: AgentSideConnection = agent.client;
|
||||
|
||||
// Verify it has logger property
|
||||
const logger = agent.logger;
|
||||
`,
|
||||
);
|
||||
|
||||
const result = runTypeCheck();
|
||||
if (!result.success) {
|
||||
console.error("TypeScript errors:", result.output);
|
||||
}
|
||||
expect(result.success).toBe(true);
|
||||
}, 120000);
|
||||
|
||||
// Test 5: Type-only exports work correctly
|
||||
it("should verify type-only exports are usable", async () => {
|
||||
await setupTestProject();
|
||||
|
||||
await writeTestFile(
|
||||
"test-type-exports.ts",
|
||||
`
|
||||
import type {
|
||||
ClaudeCodeSettings,
|
||||
PermissionSettings,
|
||||
PermissionDecision,
|
||||
PermissionCheckResult,
|
||||
SettingsManagerOptions,
|
||||
ClaudePlanEntry,
|
||||
ToolUpdateMeta,
|
||||
NewSessionMeta,
|
||||
} from "@zed-industries/claude-code-acp";
|
||||
|
||||
// Test ClaudeCodeSettings shape
|
||||
const settings: ClaudeCodeSettings = {
|
||||
permissions: {
|
||||
allow: ["Read"],
|
||||
deny: ["Read(./.env)"],
|
||||
ask: ["Bash"],
|
||||
additionalDirectories: ["/extra"],
|
||||
defaultMode: "default",
|
||||
},
|
||||
env: {
|
||||
API_KEY: "secret",
|
||||
},
|
||||
};
|
||||
|
||||
// Test PermissionSettings shape
|
||||
const perms: PermissionSettings = {
|
||||
allow: ["Read"],
|
||||
deny: ["Write"],
|
||||
};
|
||||
|
||||
// Test PermissionDecision
|
||||
const decisions: PermissionDecision[] = ["allow", "deny", "ask"];
|
||||
|
||||
// Test ClaudePlanEntry shape
|
||||
const planEntry: ClaudePlanEntry = {
|
||||
content: "Do something",
|
||||
status: "pending",
|
||||
activeForm: "Doing something",
|
||||
};
|
||||
|
||||
// Test valid status values
|
||||
const validStatuses: ClaudePlanEntry["status"][] = [
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed",
|
||||
];
|
||||
|
||||
// Test ToolUpdateMeta shape
|
||||
const toolMeta: ToolUpdateMeta = {
|
||||
claudeCode: {
|
||||
toolName: "Read",
|
||||
toolResponse: { success: true },
|
||||
},
|
||||
};
|
||||
|
||||
// Test NewSessionMeta shape
|
||||
const sessionMeta: NewSessionMeta = {
|
||||
claudeCode: {
|
||||
options: {},
|
||||
},
|
||||
};
|
||||
`,
|
||||
);
|
||||
|
||||
const result = runTypeCheck();
|
||||
if (!result.success) {
|
||||
console.error("TypeScript errors:", result.output);
|
||||
}
|
||||
expect(result.success).toBe(true);
|
||||
}, 120000);
|
||||
|
||||
// Test 6: Function signatures verification
|
||||
it("should verify function signatures are correct", async () => {
|
||||
await setupTestProject();
|
||||
|
||||
await writeTestFile(
|
||||
"test-function-signatures.ts",
|
||||
`
|
||||
import {
|
||||
runAcp,
|
||||
createMcpServer,
|
||||
toolInfoFromToolUse,
|
||||
planEntries,
|
||||
createPreToolUseHook,
|
||||
loadManagedSettings,
|
||||
applyEnvironmentSettings,
|
||||
SettingsManager,
|
||||
} from "@zed-industries/claude-code-acp";
|
||||
|
||||
import type { ClaudeCodeSettings } from "@zed-industries/claude-code-acp";
|
||||
|
||||
// runAcp should be a function with no parameters that returns void
|
||||
const runAcpType: () => void = runAcp;
|
||||
|
||||
// toolInfoFromToolUse should accept any and return object with title and kind
|
||||
const info = toolInfoFromToolUse({ name: "Read", input: {} });
|
||||
const title: string = info.title;
|
||||
const kind: string = info.kind;
|
||||
|
||||
// planEntries should accept todos array and return array
|
||||
const entries = planEntries({
|
||||
todos: [
|
||||
{ content: "test", status: "pending", activeForm: "testing" }
|
||||
]
|
||||
});
|
||||
// entries should be an array
|
||||
const entriesArray: any[] = entries;
|
||||
|
||||
// createPreToolUseHook should accept SettingsManager and optional logger
|
||||
declare const settingsManager: SettingsManager;
|
||||
const hook = createPreToolUseHook(settingsManager, console);
|
||||
|
||||
// loadManagedSettings should return ClaudeCodeSettings | null
|
||||
const managedSettings: ClaudeCodeSettings | null = loadManagedSettings();
|
||||
|
||||
// applyEnvironmentSettings should accept ClaudeCodeSettings and return void
|
||||
const applyResult: void = applyEnvironmentSettings({ permissions: {} });
|
||||
`,
|
||||
);
|
||||
|
||||
const result = runTypeCheck();
|
||||
if (!result.success) {
|
||||
console.error("TypeScript errors:", result.output);
|
||||
}
|
||||
expect(result.success).toBe(true);
|
||||
}, 120000);
|
||||
|
||||
// Test 7: Pushable generic class verification
|
||||
it("should verify Pushable class works correctly", async () => {
|
||||
await setupTestProject();
|
||||
|
||||
await writeTestFile(
|
||||
"test-pushable.ts",
|
||||
`
|
||||
import { Pushable } from "@zed-industries/claude-code-acp";
|
||||
|
||||
// Pushable should be a generic class
|
||||
const pushable = new Pushable<string>();
|
||||
|
||||
// Should have push method
|
||||
pushable.push("test");
|
||||
|
||||
// Should have end method
|
||||
pushable.end();
|
||||
|
||||
// Should implement AsyncIterable
|
||||
async function consume() {
|
||||
for await (const item of pushable) {
|
||||
const str: string = item;
|
||||
}
|
||||
}
|
||||
|
||||
// Generic type parameter should work
|
||||
const numPushable = new Pushable<number>();
|
||||
numPushable.push(42);
|
||||
|
||||
interface MyType { id: number; name: string; }
|
||||
const customPushable = new Pushable<MyType>();
|
||||
customPushable.push({ id: 1, name: "test" });
|
||||
`,
|
||||
);
|
||||
|
||||
const result = runTypeCheck();
|
||||
if (!result.success) {
|
||||
console.error("TypeScript errors:", result.output);
|
||||
}
|
||||
expect(result.success).toBe(true);
|
||||
}, 120000);
|
||||
|
||||
// Test 8: Verify incorrect types fail
|
||||
it("should fail type-check with incorrect types", async () => {
|
||||
await setupTestProject();
|
||||
|
||||
await writeTestFile(
|
||||
"test-invalid-types.ts",
|
||||
`
|
||||
import { SettingsManager, ClaudeCodeSettings } from "@zed-industries/claude-code-acp";
|
||||
|
||||
// This should fail - SettingsManager constructor requires string cwd
|
||||
// @ts-expect-error - Testing that wrong argument type fails
|
||||
const badManager = new SettingsManager(123);
|
||||
|
||||
// This should fail - ClaudeCodeSettings permissions must be an object
|
||||
const badSettings: ClaudeCodeSettings = {
|
||||
// @ts-expect-error - Testing that wrong type fails
|
||||
permissions: "not-an-object",
|
||||
};
|
||||
`,
|
||||
);
|
||||
|
||||
// This test should PASS because we expect tsc to catch these errors
|
||||
// with @ts-expect-error directives
|
||||
const result = runTypeCheck();
|
||||
if (!result.success) {
|
||||
// If it fails, it means @ts-expect-error didn't catch the error
|
||||
// which could mean the types are too permissive
|
||||
console.error("TypeScript errors (expected @ts-expect-error to catch):", result.output);
|
||||
}
|
||||
expect(result.success).toBe(true);
|
||||
}, 120000);
|
||||
},
|
||||
);
|
||||
495
src/tools.ts
495
src/tools.ts
@@ -1,10 +1,41 @@
|
||||
import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
|
||||
import { SYSTEM_REMINDER } from "./mcp-server.js";
|
||||
import * as diff from "diff";
|
||||
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 {
|
||||
PlanEntry,
|
||||
ToolCallContent,
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from "@zed-industries/agent-client-protocol";
|
||||
import { replaceAndCalculateLocation, SYSTEM_REMINDER } from "./mcp-server.js";
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
BetaCodeExecutionToolResultBlockParam,
|
||||
BetaRequestMCPToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionToolResultBlockParam,
|
||||
BetaToolSearchToolResultBlockParam,
|
||||
BetaWebFetchToolResultBlockParam,
|
||||
BetaWebSearchToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { Logger } from "./acp-agent.js";
|
||||
import { SettingsManager } from "./settings.js";
|
||||
|
||||
interface ToolInfo {
|
||||
title: string;
|
||||
@@ -19,10 +50,7 @@ interface ToolUpdate {
|
||||
locations?: ToolCallLocation[];
|
||||
}
|
||||
|
||||
export function toolInfoFromToolUse(
|
||||
toolUse: any,
|
||||
cachedFileContent: { [key: string]: string },
|
||||
): ToolInfo {
|
||||
export function toolInfoFromToolUse(toolUse: any): ToolInfo {
|
||||
const name = toolUse.name;
|
||||
const input = toolUse.input;
|
||||
|
||||
@@ -67,7 +95,7 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
|
||||
case "Bash":
|
||||
case "mcp__acp__Bash":
|
||||
case acpToolNames.bash:
|
||||
return {
|
||||
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
|
||||
kind: "execute",
|
||||
@@ -83,22 +111,22 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
|
||||
case "BashOutput":
|
||||
case "mcp__acp__BashOutput":
|
||||
case acpToolNames.bashOutput:
|
||||
return {
|
||||
title: "Tail Logs",
|
||||
kind: "execute",
|
||||
content: [],
|
||||
};
|
||||
|
||||
case "KillBash":
|
||||
case "mcp__acp__KillBash":
|
||||
case "KillShell":
|
||||
case acpToolNames.killShell:
|
||||
return {
|
||||
title: "Kill Process",
|
||||
kind: "execute",
|
||||
content: [],
|
||||
};
|
||||
|
||||
case "mcp__acp__read": {
|
||||
case acpToolNames.read: {
|
||||
let limit = "";
|
||||
if (input.limit) {
|
||||
limit =
|
||||
@@ -107,14 +135,16 @@ export function toolInfoFromToolUse(
|
||||
limit = " (from line " + (input.offset + 1) + ")";
|
||||
}
|
||||
return {
|
||||
title: "Read " + (input.abs_path ?? "File") + limit,
|
||||
title: "Read " + (input.file_path ?? "File") + limit,
|
||||
kind: "read",
|
||||
locations: [
|
||||
{
|
||||
path: input.abs_path,
|
||||
line: input.offset ?? 0,
|
||||
},
|
||||
],
|
||||
locations: input.file_path
|
||||
? [
|
||||
{
|
||||
path: input.file_path,
|
||||
line: input.offset ?? 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
@@ -124,7 +154,14 @@ export function toolInfoFromToolUse(
|
||||
title: "Read File",
|
||||
kind: "read",
|
||||
content: [],
|
||||
locations: [{ path: input.file_path, line: input.offset ?? 0 }],
|
||||
locations: input.file_path
|
||||
? [
|
||||
{
|
||||
path: input.file_path,
|
||||
line: input.offset ?? 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
|
||||
case "LS":
|
||||
@@ -135,32 +172,12 @@ export function toolInfoFromToolUse(
|
||||
locations: [],
|
||||
};
|
||||
|
||||
case "mcp__acp__edit":
|
||||
case acpToolNames.edit:
|
||||
case "Edit": {
|
||||
const path = input?.abs_path ?? input?.file_path;
|
||||
let oldText = input.old_string ?? null;
|
||||
let newText = input.new_string ?? "";
|
||||
let line = null;
|
||||
const path = input?.file_path ?? input?.file_path;
|
||||
|
||||
if (path && input.old_text) {
|
||||
try {
|
||||
const oldContent = cachedFileContent[path] || "";
|
||||
const newContent = replaceAndCalculateLocation(oldContent, [
|
||||
{
|
||||
oldText: input.old_string,
|
||||
newText: input.new_string,
|
||||
replaceAll: false,
|
||||
},
|
||||
]);
|
||||
oldText = oldContent;
|
||||
newText = newContent.newContent;
|
||||
line = newContent.lineNumbers[0];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: path ? `Edit ${path}` : "Edit",
|
||||
title: path ? `Edit \`${path}\`` : "Edit",
|
||||
kind: "edit",
|
||||
content:
|
||||
input && path
|
||||
@@ -168,69 +185,22 @@ export function toolInfoFromToolUse(
|
||||
{
|
||||
type: "diff",
|
||||
path,
|
||||
oldText,
|
||||
newText,
|
||||
oldText: input.old_string ?? null,
|
||||
newText: input.new_string ?? "",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
locations: path ? [{ path, line }] : [],
|
||||
locations: path ? [{ path }] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
case "mcp__acp__multi-edit":
|
||||
case "MultiEdit": {
|
||||
const multiInput = input as {
|
||||
file_path: string;
|
||||
edits: {
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
replace_all?: boolean;
|
||||
}[];
|
||||
};
|
||||
let oldTextMulti = multiInput.edits.map((edit: any) => edit.old_string).join("\n");
|
||||
let newTextMulti = multiInput.edits.map((edit: any) => edit.new_string).join("\n");
|
||||
try {
|
||||
if (multiInput.edits && multiInput.file_path) {
|
||||
const oldContent =
|
||||
cachedFileContent[multiInput.file_path] ||
|
||||
multiInput.edits.map((edit: any) => edit.oldText).join("\n");
|
||||
const newContent = replaceAndCalculateLocation(
|
||||
oldContent,
|
||||
multiInput.edits.map((edit) => ({
|
||||
oldText: edit.old_string,
|
||||
newText: edit.new_string,
|
||||
replaceAll: edit.replace_all,
|
||||
})),
|
||||
);
|
||||
oldTextMulti = oldContent;
|
||||
newTextMulti = newContent.newContent;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
// Display it as a normal edit, because end users don't care about
|
||||
// the distinction between edits and multi-edits.
|
||||
return {
|
||||
title: input?.file_path ? `Edit ${input.file_path}` : "Edit",
|
||||
kind: "edit",
|
||||
content: [
|
||||
{
|
||||
type: "diff" as const,
|
||||
path: input.file_path,
|
||||
oldText: oldTextMulti,
|
||||
newText: newTextMulti,
|
||||
},
|
||||
],
|
||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||
};
|
||||
}
|
||||
case "mcp__acp__write": {
|
||||
case acpToolNames.write: {
|
||||
let content: ToolCallContent[] = [];
|
||||
if (input && input.abs_path) {
|
||||
if (input && input.file_path) {
|
||||
content = [
|
||||
{
|
||||
type: "diff",
|
||||
path: input.abs_path,
|
||||
path: input.file_path,
|
||||
oldText: null,
|
||||
newText: input.content,
|
||||
},
|
||||
@@ -244,10 +214,10 @@ export function toolInfoFromToolUse(
|
||||
];
|
||||
}
|
||||
return {
|
||||
title: input?.abs_path ? `Write ${input.abs_path}` : "Write",
|
||||
title: input?.file_path ? `Write ${input.file_path}` : "Write",
|
||||
kind: "edit",
|
||||
content,
|
||||
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
|
||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -272,10 +242,10 @@ export function toolInfoFromToolUse(
|
||||
case "Glob": {
|
||||
let label = "Find";
|
||||
if (input.path) {
|
||||
label += ` ${input.path}`;
|
||||
label += ` \`${input.path}\``;
|
||||
}
|
||||
if (input.pattern) {
|
||||
label += ` ${input.pattern}`;
|
||||
label += ` \`${input.pattern}\``;
|
||||
}
|
||||
return {
|
||||
title: label,
|
||||
@@ -335,7 +305,9 @@ export function toolInfoFromToolUse(
|
||||
label += " -P";
|
||||
}
|
||||
|
||||
label += ` "${input.pattern}"`;
|
||||
if (input.pattern) {
|
||||
label += ` "${input.pattern}"`;
|
||||
}
|
||||
|
||||
if (input.path) {
|
||||
label += ` ${input.path}`;
|
||||
@@ -383,24 +355,30 @@ export function toolInfoFromToolUse(
|
||||
|
||||
case "TodoWrite":
|
||||
return {
|
||||
title: input?.todos
|
||||
title: Array.isArray(input?.todos)
|
||||
? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}`
|
||||
: "Update TODOs",
|
||||
kind: "think",
|
||||
content: [],
|
||||
};
|
||||
|
||||
case "exit_plan_mode":
|
||||
case "ExitPlanMode":
|
||||
return {
|
||||
title: "Exit Plan Mode",
|
||||
kind: "think",
|
||||
title: "Ready to code?",
|
||||
kind: "switch_mode",
|
||||
content:
|
||||
input && input.plan
|
||||
? [{ type: "content", content: { type: "text", text: input.plan } }]
|
||||
: [],
|
||||
};
|
||||
|
||||
case "Other":
|
||||
case "Other": {
|
||||
let output;
|
||||
try {
|
||||
output = JSON.stringify(input, null, 2);
|
||||
} catch {
|
||||
output = typeof input === "string" ? input : "{}";
|
||||
}
|
||||
return {
|
||||
title: name || "Unknown Tool",
|
||||
kind: "other",
|
||||
@@ -409,11 +387,12 @@ export function toolInfoFromToolUse(
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `\`\`\`json\n${JSON.stringify(input, null, 2) || "{}"}\`\`\``,
|
||||
text: `\`\`\`json\n${output}\`\`\``,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
@@ -424,62 +403,32 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
}
|
||||
|
||||
export function toolUpdateFromToolResult(toolResult: any, toolUse: any | undefined): ToolUpdate {
|
||||
export function toolUpdateFromToolResult(
|
||||
toolResult:
|
||||
| ToolResultBlockParam
|
||||
| BetaWebSearchToolResultBlockParam
|
||||
| BetaWebFetchToolResultBlockParam
|
||||
| WebSearchToolResultBlockParam
|
||||
| BetaCodeExecutionToolResultBlockParam
|
||||
| BetaBashCodeExecutionToolResultBlockParam
|
||||
| BetaTextEditorCodeExecutionToolResultBlockParam
|
||||
| BetaRequestMCPToolResultBlockParam
|
||||
| BetaToolSearchToolResultBlockParam,
|
||||
toolUse: any | undefined,
|
||||
): ToolUpdate {
|
||||
if (
|
||||
"is_error" in toolResult &&
|
||||
toolResult.is_error &&
|
||||
toolResult.content &&
|
||||
toolResult.content.length > 0
|
||||
) {
|
||||
// Only return errors
|
||||
return toAcpContentUpdate(toolResult.content, true);
|
||||
}
|
||||
|
||||
switch (toolUse?.name) {
|
||||
case "mcp__acp__edit":
|
||||
case "edit":
|
||||
case "Edit": {
|
||||
// Parse the line numbers from the tool result
|
||||
if (toolResult.content && toolResult.content.length > 0) {
|
||||
try {
|
||||
const firstContent = toolResult.content[0];
|
||||
const data = JSON.parse(firstContent.text || firstContent);
|
||||
if (data.lineNumbers && Array.isArray(data.lineNumbers)) {
|
||||
const locations: ToolCallLocation[] = data.lineNumbers.map((line: number) => ({
|
||||
path: toolUse?.input?.abs_path || toolUse?.input?.file_path,
|
||||
line: line,
|
||||
}));
|
||||
return { locations };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error parsing edit tool result: ${error}`);
|
||||
// If parsing fails, return empty object
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
case "mcp__acp__multi-edit":
|
||||
case "multi-edit":
|
||||
case "MultiEdit": {
|
||||
// Parse the line numbers from the tool result
|
||||
if (toolResult.content && toolResult.content.length > 0) {
|
||||
try {
|
||||
const firstContent = toolResult.content[0];
|
||||
const data = JSON.parse(firstContent.text || firstContent);
|
||||
if (data.lineNumbers && Array.isArray(data.lineNumbers)) {
|
||||
const locations: ToolCallLocation[] = data.lineNumbers.map((line: number) => ({
|
||||
path: toolUse?.input?.file_path || toolUse?.input?.abs_path,
|
||||
line: line,
|
||||
}));
|
||||
return { locations };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error parsing edit tool result: ${error}`);
|
||||
|
||||
// If parsing fails, return empty object
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
case "mcp__acp__write":
|
||||
case "Write":
|
||||
return {};
|
||||
|
||||
case "Read":
|
||||
case "mcp__acp__read":
|
||||
case acpToolNames.read:
|
||||
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
|
||||
return {
|
||||
content: toolResult.content.map((content: any) => ({
|
||||
@@ -508,9 +457,66 @@ export function toolUpdateFromToolResult(toolResult: any, toolUse: any | undefin
|
||||
}
|
||||
return {};
|
||||
|
||||
case "mcp__acp__Bash":
|
||||
// The bash tool already embeds a terminal
|
||||
case acpToolNames.edit: {
|
||||
const content: ToolCallContent[] = [];
|
||||
const locations: ToolCallLocation[] = [];
|
||||
|
||||
if (
|
||||
Array.isArray(toolResult.content) &&
|
||||
toolResult.content.length > 0 &&
|
||||
"text" in toolResult.content[0] &&
|
||||
typeof toolResult.content[0].text === "string"
|
||||
) {
|
||||
const patches = diff.parsePatch(toolResult.content[0].text);
|
||||
console.error(JSON.stringify(patches));
|
||||
for (const { oldFileName, newFileName, hunks } of patches) {
|
||||
for (const { lines, newStart } of hunks) {
|
||||
const oldText = [];
|
||||
const newText = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("-")) {
|
||||
oldText.push(line.slice(1));
|
||||
} else if (line.startsWith("+")) {
|
||||
newText.push(line.slice(1));
|
||||
} else {
|
||||
oldText.push(line.slice(1));
|
||||
newText.push(line.slice(1));
|
||||
}
|
||||
}
|
||||
if (oldText.length > 0 || newText.length > 0) {
|
||||
locations.push({ path: newFileName || oldFileName, line: newStart });
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: newFileName || oldFileName,
|
||||
oldText: oldText.join("\n") || null,
|
||||
newText: newText.join("\n"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: ToolUpdate = {};
|
||||
if (content.length > 0) {
|
||||
result.content = content;
|
||||
}
|
||||
if (locations.length > 0) {
|
||||
result.locations = locations;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
case acpToolNames.bash:
|
||||
case "edit":
|
||||
case "Edit":
|
||||
case acpToolNames.write:
|
||||
case "Write": {
|
||||
return {};
|
||||
}
|
||||
|
||||
case "ExitPlanMode": {
|
||||
return { title: "Exited Plan Mode" };
|
||||
}
|
||||
|
||||
case "Task":
|
||||
case "NotebookEdit":
|
||||
@@ -527,32 +533,48 @@ export function toolUpdateFromToolResult(toolResult: any, toolUse: any | undefin
|
||||
case "WebSearch":
|
||||
case "Other":
|
||||
default: {
|
||||
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
|
||||
return {
|
||||
content: toolResult.content.map((content: any) => ({
|
||||
type: "content",
|
||||
content,
|
||||
})),
|
||||
};
|
||||
} else if (typeof toolResult.content === "string" && toolResult.content.length > 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: toolResult.content,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
return toAcpContentUpdate(
|
||||
toolResult.content,
|
||||
"is_error" in toolResult ? toolResult.is_error : false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ClaudePlanEntry = {
|
||||
function toAcpContentUpdate(
|
||||
content: any,
|
||||
isError: boolean = false,
|
||||
): { content?: ToolCallContent[] } {
|
||||
if (Array.isArray(content) && content.length > 0) {
|
||||
return {
|
||||
content: content.map((content: any) => ({
|
||||
type: "content",
|
||||
content:
|
||||
isError && content.type === "text"
|
||||
? {
|
||||
...content,
|
||||
text: `\`\`\`\n${content.text}\n\`\`\``,
|
||||
}
|
||||
: content,
|
||||
})),
|
||||
};
|
||||
} else if (typeof content === "string" && content.length > 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: isError ? `\`\`\`\n${content}\n\`\`\`` : content,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export type ClaudePlanEntry = {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
activeForm: string;
|
||||
@@ -575,3 +597,100 @@ export function markdownEscape(text: string): string {
|
||||
}
|
||||
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
|
||||
}
|
||||
|
||||
/* A global variable to store callbacks that should be executed when receiving hooks from Claude Code */
|
||||
const toolUseCallbacks: {
|
||||
[toolUseId: string]: {
|
||||
onPostToolUseHook?: (
|
||||
toolUseID: string,
|
||||
toolInput: unknown,
|
||||
toolResponse: unknown,
|
||||
) => Promise<void>;
|
||||
};
|
||||
} = {};
|
||||
|
||||
/* Setup callbacks that will be called when receiving hooks from Claude Code */
|
||||
export const registerHookCallback = (
|
||||
toolUseID: string,
|
||||
{
|
||||
onPostToolUseHook,
|
||||
}: {
|
||||
onPostToolUseHook?: (
|
||||
toolUseID: string,
|
||||
toolInput: unknown,
|
||||
toolResponse: unknown,
|
||||
) => Promise<void>;
|
||||
},
|
||||
) => {
|
||||
toolUseCallbacks[toolUseID] = {
|
||||
onPostToolUseHook,
|
||||
};
|
||||
};
|
||||
|
||||
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
|
||||
export const createPostToolUseHook =
|
||||
(logger: Logger = console): HookCallback =>
|
||||
async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => {
|
||||
if (input.hook_event_name === "PostToolUse" && toolUseID) {
|
||||
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
|
||||
if (onPostToolUseHook) {
|
||||
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
|
||||
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
|
||||
} else {
|
||||
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
|
||||
delete toolUseCallbacks[toolUseID];
|
||||
}
|
||||
}
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a PreToolUse hook that checks permissions using the SettingsManager.
|
||||
* This runs before the SDK's built-in permission rules, allowing us to enforce
|
||||
* our own permission settings for ACP-prefixed tools.
|
||||
*/
|
||||
export const createPreToolUseHook =
|
||||
(settingsManager: SettingsManager, logger: Logger = console): HookCallback =>
|
||||
async (input: any, _toolUseID: string | undefined) => {
|
||||
if (input.hook_event_name !== "PreToolUse") {
|
||||
return { continue: true };
|
||||
}
|
||||
|
||||
const toolName = input.tool_name;
|
||||
const toolInput = input.tool_input;
|
||||
|
||||
const permissionCheck = settingsManager.checkPermission(toolName, toolInput);
|
||||
|
||||
if (permissionCheck.decision !== "ask") {
|
||||
logger.log(
|
||||
`[PreToolUseHook] Tool: ${toolName}, Decision: ${permissionCheck.decision}, Rule: ${permissionCheck.rule}`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (permissionCheck.decision) {
|
||||
case "allow":
|
||||
return {
|
||||
continue: true,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse" as const,
|
||||
permissionDecision: "allow" as const,
|
||||
permissionDecisionReason: `Allowed by settings rule: ${permissionCheck.rule}`,
|
||||
},
|
||||
};
|
||||
|
||||
case "deny":
|
||||
return {
|
||||
continue: true,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse" as const,
|
||||
permissionDecision: "deny" as const,
|
||||
permissionDecisionReason: `Denied by settings rule: ${permissionCheck.rule}`,
|
||||
},
|
||||
};
|
||||
|
||||
case "ask":
|
||||
default:
|
||||
// Let the normal permission flow continue
|
||||
return { continue: true };
|
||||
}
|
||||
};
|
||||
|
||||
93
src/utils.ts
93
src/utils.ts
@@ -2,6 +2,9 @@
|
||||
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { WritableStream, ReadableStream } from "node:stream/web";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Logger } from "./acp-agent.js";
|
||||
import { ClaudeCodeSettings, getManagedSettingsPath } from "./settings.js";
|
||||
|
||||
// Useful for bridging push-based and async-iterator-based code.
|
||||
export class Pushable<T> implements AsyncIterable<T> {
|
||||
@@ -73,10 +76,96 @@ export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Arr
|
||||
});
|
||||
}
|
||||
|
||||
export function unreachable(value: never): never {
|
||||
throw new Error(`Unexpected case: ${value}`);
|
||||
export function unreachable(value: never, logger: Logger = console) {
|
||||
let valueAsString;
|
||||
try {
|
||||
valueAsString = JSON.stringify(value);
|
||||
} catch {
|
||||
valueAsString = value;
|
||||
}
|
||||
logger.error(`Unexpected case: ${valueAsString}`);
|
||||
}
|
||||
|
||||
export function sleep(time: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, time));
|
||||
}
|
||||
|
||||
export function loadManagedSettings(): ClaudeCodeSettings | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ClaudeCodeSettings;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyEnvironmentSettings(settings: ClaudeCodeSettings): void {
|
||||
if (settings.env) {
|
||||
for (const [key, value] of Object.entries(settings.env)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExtractLinesResult {
|
||||
content: string;
|
||||
wasLimited: boolean;
|
||||
linesRead: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts lines from file content with byte limit enforcement.
|
||||
*
|
||||
* @param fullContent - The complete file content
|
||||
* @param maxContentLength - Maximum number of UTF-16 Code Units to return
|
||||
* @returns Object containing extracted content and metadata
|
||||
*/
|
||||
export function extractLinesWithByteLimit(
|
||||
fullContent: string,
|
||||
maxContentLength: number,
|
||||
): ExtractLinesResult {
|
||||
if (fullContent === "") {
|
||||
return {
|
||||
content: "",
|
||||
wasLimited: false,
|
||||
linesRead: 1,
|
||||
};
|
||||
}
|
||||
|
||||
let linesSeen = 0;
|
||||
let index = 0;
|
||||
linesSeen = 0;
|
||||
|
||||
let contentLength = 0;
|
||||
let wasLimited = false;
|
||||
|
||||
while (true) {
|
||||
const nextIndex = fullContent.indexOf("\n", index);
|
||||
|
||||
if (nextIndex < 0) {
|
||||
// Last line in file (no trailing newline)
|
||||
if (linesSeen > 0 && fullContent.length > maxContentLength) {
|
||||
wasLimited = true;
|
||||
break;
|
||||
}
|
||||
linesSeen += 1;
|
||||
contentLength = fullContent.length;
|
||||
break;
|
||||
} else {
|
||||
// Line with newline - include up to the newline
|
||||
const newContentLength = nextIndex + 1;
|
||||
if (linesSeen > 0 && newContentLength > maxContentLength) {
|
||||
wasLimited = true;
|
||||
break;
|
||||
}
|
||||
linesSeen += 1;
|
||||
contentLength = newContentLength;
|
||||
index = newContentLength;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: fullContent.slice(0, contentLength),
|
||||
wasLimited,
|
||||
linesRead: linesSeen,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"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