Compare commits

...

169 Commits

Author SHA1 Message Date
Ben Brandt
2b60bad254 Update to claude-agent-sdk 0.1.67 (#206) 2025-12-12 15:15:56 +01:00
Steffen Deusch
e81ecbd96e Implement session/fork (#183)
Work in progress! Uses a fork for now.

See
https://github.com/agentclientprotocol/agent-client-protocol/pull/277.

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-12-12 14:40:59 +01:00
Bennet Bo Fenner
e1f08905f7 Respect permissions specified in setting files (#197) 2025-12-11 17:12:35 +01:00
Ben Brandt
634f1ef6a4 Update to claude-agent-sdk 0.1.65 (#202) 2025-12-11 14:18:36 +01:00
Ben Brandt
86d333b8cc Prep release (#201) 2025-12-11 13:04:07 +00:00
Ben Brandt
9f6fef55dd Allow agent to write plans and todos to its config directory (#200)
- **Use official sdk types for tools**
- **Allow agent to write plans and todos to its config directory**
2025-12-11 14:02:16 +01:00
Guillaume Sabran
c3e1418d89 Allow to resume previous session (#199)
When the ACP client persists the session id to support resuming, it has
no way to pass it back to the agent to resume an existing session as the
current implementation always creates a new id that is now shared with
Claude Code.

My client used to pass the value in the `--resume` within Claude Code
option, but `--resume` is not compatible with the new `--session-id`:

```
--session-id cannot be used with --continue or --resume.
```

Change:

When `--resume` is used, extract its value and use it as the session id.

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-12-11 12:18:38 +01:00
Bennet Bo Fenner
3b4cd4b2d5 Fix duplicate tool use IDs error (#198)
<img width="595" height="107" alt="image"
src="https://github.com/user-attachments/assets/3b9ae5e9-cbc8-425a-8cb6-1d201df3a812"
/>

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-12-09 22:00:38 +01:00
Ben Brandt
9320e05891 Update to claude-agent-sdk 0.1.61 (#195) 2025-12-08 12:17:49 +01:00
Ben Brandt
3f44543019 Unify ACP and Claude Code session ids (#193)
There have been some issues for folks because the session id we returned
for ACP wasn't the same as the session id for Claude Code.

Now, we pass it through to the `--session-id` flag of the Claude Code
CLI, which means both ACP and Claude Code are always referring to the
same UUID. Yay!
2025-12-05 15:14:16 +00:00
Ben Brandt
4db3d855bf Update to claude-agent-sdk v0.1.59 (#192) 2025-12-05 16:06:31 +01:00
Ben Brandt
e57893f6d0 Update to claude-agent-sdk 0.1.57 (#191) 2025-12-03 11:27:01 +00:00
Ben Brandt
6a93d7e786 Update to ACP SDK v0.7.0 (#188) 2025-12-01 17:45:24 +01:00
RAIT-09
c3c0f4da36 Prevent invalid locations when Read tool call has no file_path (#186)
## Summary

Follow-up to #121. The same issue (invalid locations when `file_path` is
undefined) existed in the `Read` tool case as well.

## Problem

The ACP spec requires `path` as a required field in `ToolCallLocation`.
PR #121 fixed this for `mcp__acp__Read`, but the same issue remained for
the `Read` tool case.

When Claude generates a Read tool call without `file_path`, the current
code produces an invalid notification:

```json
{
  "locations": [
    {
      "line": 0
    }
  ]
}
```

This causes the following error in the client:

```
Error handling notification
{code: -32602, message: 'Invalid params', data: {…}}
```

## Solution

Return an empty `locations` array when `input.file_path` is undefined,
consistent with the fix in #121.
2025-12-01 13:22:06 +01:00
Ben Brandt
73d308e4ad Update to claude-agent-sdk 0.1.55 (#182) 2025-11-27 14:35:53 +01:00
Ben Brandt
49b28a25eb Add CLAUDECODE environment to terminal invocations (#181) 2025-11-27 13:10:11 +00:00
Ben Brandt
8516df7188 fix: Access to undefined meta field (#180) 2025-11-27 14:06:47 +01:00
Guillaume Sabran
e31a16228a Allow user to pass Options to Claude Code in new session (#176)
When using Claude Code ACP as a direct dependency, this will allow the
user to configure Claude Code using the `Options` provided by the SDK.
2025-11-26 11:36:01 +01:00
Guillaume Sabran
c00782c103 Logging configuration (#177)
Allow to provide a logger to replace `console` (`console` remains the
default value)
2025-11-25 11:29:16 +01:00
Ben Brandt
4211f8cf05 Update to claude-agent-sdk 0.1.51 (#178)
Adds support for Opus 4.5
2025-11-24 23:24:34 +01:00
Ben Brandt
ac2119a23a More forgiving unreachable (#173)
Currently, if we get an unexpected message, we crash. While this is fine
in the world where we vendor CC (which we do by default) we also allow
users to override the executable for cases where their work needs a
wrapper.

This makes it so that we just log the event that we get, but don't crash
right away in case we can continue on.
2025-11-24 12:17:11 +00:00
Ben Brandt
427117dce6 Update to claude-agent-sdk 0.1.50 (#171) 2025-11-24 12:05:38 +00:00
Paul Nelson
b5a7e0f28d Fix read/edit tool error handling so upstream errors surface (#163)
Fixes #162:
- guard both tool callbacks so they return the host-provided error
instead of crashing on missing content
- skip writeTextFile in the edit tool when the initial read fails,
mirroring the read tool behavior
- add regression tests

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-11-24 11:37:18 +01:00
Ben Brandt
8ef0b4e4c6 Update to claude-agent-sdk 0.1.46 (#168) 2025-11-19 08:42:32 +00:00
Ben Brandt
959cd6c8e2 Disable experimental terminal auth support for now (#167)
This was causing issues on Windows in the current form, and also has
some other issues that need looking into. Will revisit later.
2025-11-19 08:37:59 +00:00
Ben Brandt
ef209d9d12 Don't special-case "default" model id (#166)
A model of id "default" only shows up sometimes in certain plans. So
rather than special-case this one, we just take the first one in the
list.
2025-11-18 10:51:20 +00:00
Ben Brandt
91b00b71e3 Report errors from "status" messages (#165)
The "status" message may say "success" but can actually have an error
which we weren't surfacing.

We should now be returning a more helpful message to the user.

Closes #161 and possibly #146
2025-11-18 10:30:30 +00:00
Ben Brandt
101a527732 Update to claude-agent-sdk 0.1.44 (#164) 2025-11-18 09:59:41 +00:00
Amos Wenger
59c5a0a8b8 Add experimental support for disabling built-in tools via _meta flag (#158)
Add ability to disable built-in tools (Read, Write, Edit, Bash, etc.)
when `_meta.disableBuiltInTools` is set to true. This allows clients to
rely solely on MCP tools without the built-in tool implementations.

---

This is just my take on #157 — not necessarily the one that makes sense
/cc @benbrandt

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-11-17 10:31:59 +00:00
Ben Brandt
5133e2dec7 Update to claude-agent-sdk 0.1.42 (#160) 2025-11-17 10:24:02 +00:00
Tim Visher
ed035da9d8 Add title field to tool permission requests (#159)
🤖 Co-authored-by: Claude (Anthropic AI Assistant)

## Problem

Tool approval dialogs in ACP clients (like Emacs
[agent-shell](https://github.com/xenodium/agent-shell)) were showing up
empty because the `toolCall` object in `session/request_permission`
requests was missing the `title` field.

This caused dialogs to appear with just buttons and no description of
what tool is being approved, making it impossible for users to make
informed decisions.

## Solution

Add the `title` field to both `requestPermission` calls (lines 553 and
612) by using the existing `toolInfoFromToolUse()` function which
already generates appropriate titles for all tool types.

## Before
```
  ╭─
      ⚠ Tool Permission ⚠
      
      
       Allow (y)   Reject (n)   Always Allow (!)
  ╰─
```

## After
```
  ╭─
      ⚠ Tool Permission ⚠
      
      Write /path/to/file.txt
      
       Allow (y)   Reject (n)   Always Allow (!)
  ╰─
```

## References

- This agent-shell.el issue shows (as has been shown above) what the
expected interaction would look like and what this changeset supports:
https://github.com/xenodium/agent-shell/issues/123
- The ACP protocol already supports the `title` field and agent-shell
displays it correctly when present (confirmed with OpenCode Agent which
does send titles)

## Testing

- **Manual testing**: Confirmed with agent-shell that permission dialogs
now show descriptive titles like "Write /path/to/file.txt"
- **Unit tests**: Added test coverage that verifies the `title` field is
present in permission request structures for multiple tool types (Write,
Bash, Read)
- **Regression testing**: Verified the new test fails when the `title`
field is omitted, confirming it catches the bug this PR fixes
- **Existing tests**: All 61 existing unit tests pass

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-11-17 11:19:22 +01:00
Ben Brandt
8df7f10058 Use better file path resolution for experimental auth support (#156) 2025-11-14 12:21:49 +01:00
Ben Brandt
6a0d0b5522 Fix incorrect stop reason after tool call rejection (#153)
We were mapping tool call rejections to actual "refusals" which isn't
quite right in ACP.

Also filters out more of these status messages that Claude is returning.
2025-11-13 11:19:48 +01:00
Ben Brandt
7e0d2c9506 Update to claude-agent-sdk 0.1.37 (#152) 2025-11-13 09:56:19 +00:00
Guillaume Sabran
2835f030cf Add metadata to tool calls, including structured tool output (#128)
Use the `_meta` field for tool calls to forward:
- the original tool name (ie `Bash` etc)
- the structured output of the tool call

The output is received by using the `PostToolUse` hook from the SDK. The
`tool_call_update` for the tool result is now only sent when both the
hook callback and the original tool result (plain text) have been
received.

Claude Code describes [in its documentation
](https://docs.claude.com/en/api/agent-sdk/typescript#tool-output-types)the
expected structure of the output for each tools. Unfortunately those are
not part of the SDK, and [empirically I've found that there was some
differences](58c3100a36/local-server/src/server/endpoints/sendMessage/acp/clients/claudeCode/types.ts (L265)).
Still I've found that having this structured output was useful for my
client to provide a richer UX. Maybe Zed will too.

I was not sure how to add tests for this change, given the current setup
2025-11-13 10:42:21 +01:00
Ben Brandt
2710e146f4 Use vendored claude cli for experimental login method (#143) 2025-11-05 11:36:02 +01:00
Richard Feldman
bf396fea67 Use terminal-auth from _meta if supported (#141) 2025-11-04 21:34:16 -05:00
Ben Brandt
84b5744a2f prep v0.10.0 (#140) 2025-11-04 11:30:01 +00:00
Ben Brandt
b213152a94 Migrate from permission MCP server to canUseTool callback (#139)
- **Migrate from permission MCP server to canUseTool callback**
- **Use SDK suggestions for permission updates**
2025-11-04 12:26:25 +01:00
Ben Brandt
cd5aabdd74 Update to claude-agent-sdk 0.1.30 (#138) 2025-11-04 10:38:20 +00:00
Bennet Bo Fenner
9e18a4e6fd Add docs for release process (#135) 2025-10-31 18:12:11 +01:00
Bennet Bo Fenner
48df285bd0 Prep v0.9.0 (#134) 2025-10-31 16:50:55 +01:00
Bennet Bo Fenner
b138c84cdb Support slash commands coming from MCP prompts (#133)
Closes #115. Thanks to @joeljohansson99 for reporting/debugging.
2025-10-31 16:32:30 +01:00
Bennet Fenner
0735aca015 Update UNSUPPORTED_COMMANDS (#132)
The SDK seems to now filter out slash commands that result in
errors/crashes.

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-10-30 17:17:47 +01:00
Ben Brandt
847d62dacb Revert entrypoint changes (#127) 2025-10-27 12:45:58 +00:00
Ben Brandt
becf1d4f0a Update to claude agent sdk 0.1.27 (#125)
Also simplifies the permission mode workaround so we don't have to
change the profile immediately, which could have raced with client
changes anwyay.
2025-10-27 12:34:43 +01:00
Guillaume Sabran
5491bb8dcc Allow for the package to be used as a dependency, while preserving existing behavior when run (#124)
This change will allow for this package to be used directly, without
launching a subprocess and communicating over stdio.

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-10-27 11:10:41 +00:00
Ben Brandt
17aad5b7a1 Provide agentInfo on initialization response (#123)
- **Provide agentInfo on initialization**
- **Prep 0.6.10**
2025-10-24 17:02:07 +00:00
Steffen Deusch
dadc7f9f40 prevent invalid locations when read call has no path (#121)
The ACP spec has the path as a required field for tool call locations,
but I've seen Claude generate read calls without an input path, which
would generate:

```json
{
  "content": [],
  "kind": "read",
  "locations": [
    {
      "line": 0
    }
  ],
  "rawInput": {},
  "sessionUpdate": "tool_call",
  "status": "pending",
  "title": "Read File",
  "toolCallId": "toolu_012PzeR5whUjWN4ij2HBubJw"
}
```

And then fail to parse as SessionNotification in the SDK.
2025-10-24 12:49:43 +00:00
Ben Brandt
552f0a286d Fix job name 2025-10-24 13:05:27 +02:00
Ben Brandt
589e1b5fc9 Update claude-agent-sdk to 0.1.26 (#120) 2025-10-24 11:03:18 +00:00
Ben Brandt
0d97bb4753 Fix type holes in switch statements (#119)
We were relying on unreachable to catch stuff for us, but it wasn't
erroring because we were manually casting a few of the types to never,
leading to a bug when a hook response was encountered.

We'll revisit showing these hook responses to the user once ACP supports
multiple types of status messages.
2025-10-24 10:19:41 +00:00
Ben Brandt
e3119b81f9 Prep v0.6.8 (#114) 2025-10-21 09:19:37 +00:00
Ben Brandt
cd3c9f7b13 Update to claude-agent-sdk v0.1.23 (#113)
- **Don't emit content from message start**
- **Update to claude-agent-sdk v0.1.23**
2025-10-21 11:16:35 +02:00
Ben Brandt
8aea8fd0e1 Remove extra console.log 2025-10-17 13:35:36 +02:00
Ben Brandt
81a541c644 Fix for invalid plan input in the model (#110)
Seems to have been introduced in the latest claude agent sdk.

Closes #109
2025-10-17 11:33:46 +00:00
Ben Brandt
b49154bef8 Don't allow bypassPermissions to be enabled if in root/sudo mode, as Claude Code won't start (#108)
Closes #104

Turns out this was the root cause, not the order of messages after all.
2025-10-17 10:58:38 +00:00
Ben Brandt
5fa453a33a Reorganize order of loading config from the query. (#107) 2025-10-17 12:36:58 +02:00
Ben Brandt
c2ab2da5ca Remove duplicated text content after streaming (#106) 2025-10-17 10:12:58 +00:00
Ben Brandt
373d940eb8 Update to claude-agent-sdk 0.1.21 and support streaming partial messages (#105) 2025-10-17 11:58:36 +02:00
Ben Brandt
2c16a41fa4 prep 0.6.3 (#102) 2025-10-16 12:35:47 +00:00
Ben Brandt
656cbe5921 Fix issue where slash commands were loaded before initialization was complete (#101) 2025-10-16 14:34:17 +02:00
Ben Brandt
2f811f8a85 Update to claude-agent-sdk 0.1.19 (#100) 2025-10-16 12:22:31 +00:00
Ben Brandt
6aa6537f67 Fix initialization error (#99)
Closes #98

It turns out we can't always call this right away, as on some machines
the process hasn't initialized yet.
Since available models waits for the process to start, as a workaround
we wait for that first, and then set the permission mode again once we
know that it is ready.
2025-10-16 12:17:32 +00:00
Ben Brandt
1b72ac7c8a Fix for allowing bypassPermissions to work (#97)
It seems that unless you start the session with bypassPermissions, you
can't change to it later.

So we start with it on, and immediately change it back to default. This
allows people to change it later however.

Closes https://github.com/zed-industries/zed/issues/39392
2025-10-15 13:23:26 +00:00
Ben Brandt
6ae2c05ddb Prep v0.6.0 (#96) 2025-10-15 10:25:43 +00:00
Bennet Fenner
8f0fc59a48 Add model selection support (#93)
This part of ACP is not yet stabilized, so marking as Draft for now

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2025-10-15 12:21:30 +02:00
Ben Brandt
c25150523a Update claude-agent-sdk to 0.1.15 (#95)
- **Update claude-agent-sdk to 0.1.15**
- **v0.5.6**
2025-10-15 09:05:16 +00:00
WhiskeyJack96
ea796f3cbd Add support for appending or overriding the system prompt (#91)
resolves #90 

Tested both the `append` and `override` flows by sending:
```javascript
const resp = await this.connection.newSession({
			cwd: basePath,
			_meta: {
				systemPrompt: "you are an obsidian assistant agent, you should be helpful to the user when working with their notes"
			},
			mcpServers: []
		});
```

```javascript
const resp = await this.connection.newSession({
			cwd: basePath,
			_meta: {
				systemPrompt: {
					append: "always speak in spanish"
				}
			},
			mcpServers: []
		});
```

and confirmed that in both cases those prompts are what gets sent to the
agent!

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-10-15 10:48:06 +02:00
WhiskeyJack96
7f5ebca3fb Make write and edit permissions consistent (#92)
Currently edit requires a permissions call and write does not. This
means that if clients want to allow users to approve `Write` calls they
need to add handling to `HandleFileWrite` but if they do that then edits
get _two_ permissions checks.

Alternatively we could remove the write tool from approved tools (which
would make more sense to me) but I'm assuming its there for a good
reason, if not happy to change to remove it instead!

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-10-15 10:34:49 +02:00
Ben Brandt
75b44e945e Update to claude-agent-sdk 0.1.13 (#89) 2025-10-10 14:43:44 +00:00
Ben Brandt
ad8611c399 Migrate to @agentclientprotocol/sdk (#88) 2025-10-10 16:17:37 +02:00
ozer
c5abff0e43 Improve setSessionMode error handling for SDK messages (#85)
When `default`, `acceptEdits`, or `plan` modes fail, users see the real
error from the SDK instead of a generic message.

`Bypass permissions` gets special treatment - it shows both the SDK
error AND explains it needs proper configuration. I couldn't test SDK
thats why I added special treatment for bypass permissions. If you can
test it we can talk further.

Related to: https://github.com/zed-industries/zed/pull/39524 @benbrandt
2025-10-09 12:49:44 +02:00
Ben Brandt
5c98bd0404 Update to claude-agent-sdk@1.0.11 (#87)
Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-10-09 08:30:56 +00:00
Alfredo Luque
71b660ae3b Enable setting CLAUDE_CODE_EXECUTABLE to override the claude code CLI used (#86)
## Description
Closes #84 by allowing users to set `CLAUDE_CODE_EXECUTABLE` to override
the binary that's used by the claude code SDK. This is key in enterprise
environments where claude CLI has been shimmed in some way.
2025-10-07 22:54:50 +02:00
Ben Brandt
4ee25af3f9 Update to claude-agent-sdk 0.1.8 (#83) 2025-10-06 09:01:40 +00:00
Ben Brandt
1a5a9c16c6 Add back @anthropic-ai/claude-code as runtime dependency (#79)
Some clients (i.e. Zed) still relied on this package being there at
runtime.
Adding it as a dependency for now.
2025-09-30 16:46:04 +02:00
Ben Brandt
5ca27369ef Prep v0.5.1 (#78) 2025-09-30 09:19:37 +00:00
Ben Brandt
8068d894c1 Align ACP tools with latest Claude Code tools (#77)
Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-30 11:08:24 +02:00
Ben Brandt
71e745d5fb Use default behavior for bypassPermissions mode (#75)
They updated the types to allow us to just pass this through now. So we
shouldn't have to have the special casing here.
2025-09-29 23:13:12 +02:00
Ben Brandt
2aa181be27 Migrate to Claude Agent SDK (#74)
- **Migrate to Claude Agent SDK**
- **Prep v0.5**
2025-09-29 21:58:38 +02:00
Ben Brandt
600a2db82f More efficient file reads (#73)
Allows the client to handle line offsets, rather than loading the entire
file and then slicing it on the agent side.

Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>

Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-29 13:54:30 +02:00
Ben Brandt
f2a4b3d19b claude 1.0.128 (#72)
- **Update to 1.0.128 + use anthropic sdk types**
- **Fix compacting output**
- **Handle JSON errors**

---------

Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-29 12:59:59 +02:00
Ben Brandt
9135a0f1be Update to claude-code-sdk v1.0.124 (#68) 2025-09-25 15:05:47 +02:00
Ben Brandt
92c5cd84e8 Update clients list (#67) 2025-09-25 12:26:40 +00:00
Christian Tietze
8311a7a222 add Emacs client to README (#66) 2025-09-25 12:21:43 +00:00
Ben Brandt
ba513b190b Claude Code SDK 1.0.123 (#65)
- **Bump to claude code sdk v1.0.123**
- **Prep release**

---------

Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-24 10:36:14 +02:00
Ben Brandt
a011363320 v0.4.3 (#63) 2025-09-19 12:16:16 +02:00
Ben Brandt
7737878dd2 Pull permissions into own server (#50)
This allows us to use the SDK version of an MCP server for all other
tool calls. Ideally the permission server goes away eventually.

Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>

---------

Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-19 12:05:46 +02:00
Ben Brandt
2e53f1d3e4 Update to ACP TS library v0.4 (#59)
Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-18 10:04:30 +00:00
Ben Brandt
aa7364d49b v0.4.2 (#55) 2025-09-15 18:48:44 +02:00
Ben Brandt
e22d1ed52c v0.4.1 (#54)
Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-15 18:42:07 +02:00
Ben Brandt
ecfd36afa6 Support /compact (#51)
It would be nice to also support sending status via hooks when the
compact action is happening (and maybe show something in the UI to show
where compacting happened in the thread) but for now this at least
allows it.

---------

Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-15 18:27:43 +02:00
Richard Feldman
7bd163818b Add default limits to read tool (#52)
Now it won't read files over 50K code units by default, and instead
insist on breaking them up by line number.
2025-09-15 11:03:01 -04:00
Richard Feldman
491efe32e8 Tool error backticks (#53)
This looks more reasonable now, because the code is now rendered in a
monospace font:

<img width="475" height="450" alt="Screenshot 2025-09-15 at 10 58 20 AM"
src="https://github.com/user-attachments/assets/9870436b-ee8f-44cd-b814-b22ad9261ebe"
/>

That said, this definitely would benefit from a horizontal scroll bar on
the code blocks. You can't really read what it's saying without copying
the text out of the block.

Of course, in this particular case the relevant info is all in the
beginning of the error message (namely, that it failed to find the
`old_string`), and this does look overall better than before (where it
was just this big jumble of non-monospaced code), so I think it's worth
merging as-is and then revisiting horizontal scrolling later.
2025-09-15 11:02:47 -04:00
Ben Brandt
ee17e45030 Use Trusted Publishing for npm release (#49) 2025-09-15 10:59:16 +02:00
Ben Brandt
468c3522a0 Update deps to latest versions (#48)
Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-15 10:46:05 +02:00
Ben Brandt
84d64b2762 Add build step to release 2025-09-15 10:35:21 +02:00
Myles Scolnick
9ff7919ea4 readme: Add marimo notebook (#47) 2025-09-15 10:11:14 +02:00
Agus Zubiaga
d58c2dd45d Update to acp 0.2.1 (#45) 2025-09-12 14:07:00 -03:00
Richard Feldman
55532ecc57 Re-upgrade to SDK version 1.0.111 (#44)
There's apparently a RCE on 1.0.100, so we shouldn't stay on that
version.
2025-09-11 12:50:19 -04:00
Richard Feldman
f4711e96b0 Pin npm dependencies (#43)
We discussed this and decided we'll manually upgrade on our own cadence.
Don't want surprises like
https://github.com/zed-industries/zed/issues/37976
2025-09-11 12:27:52 -04:00
Richard Feldman
f691024350 Load managed-settings.json (#42)
The SDK doesn't currently load [Claude Code's
`managed-settings.json`](https://docs.anthropic.com/en/docs/claude-code/settings#settings-files)
- now the adapter does (following [the rules in the
docs](https://docs.anthropic.com/en/docs/claude-code/settings#settings-files)),
but we can remove this once the SDK supports it.
2025-09-11 08:47:33 -04:00
Agus Zubiaga
d6c91b1ec1 0.4.0 2025-09-09 11:49:37 -03:00
Agus Zubiaga
0289dd9de7 Support plan mode and all other permission modes (#40)
Exposes all Claude Code permission modes as [ACP Session
modes](https://github.com/zed-industries/agent-client-protocol/pull/67).
This allows ACP clients to build mode selectors and to be notified when
the mode changes as a result of `ExitPlanMode`.

See a demo in the [Zed
PR](https://github.com/zed-industries/zed/pull/37632).

Closes #31

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
Co-authored-by: Bennet <bennet@zed.dev>
2025-09-09 11:48:26 -03:00
Agus Zubiaga
5cd3ac91e8 Removing "Coming soon" 2025-09-08 13:18:28 -03:00
Agus Zubiaga
6b5873f005 0.3.0 2025-09-08 13:13:25 -03:00
Agus Zubiaga
539f94de45 Support HTTP/SSE MCP servers (#38)
HTTP/SSE MCP servers can be now included in `session/new`
2025-09-08 13:12:19 -03:00
Jie Wang
70441b0aa3 Move @types/express to dev dependencies (#34) 2025-09-05 06:19:09 +02:00
Agus Zubiaga
de514862f8 0.2.6 2025-09-04 18:01:30 -03:00
Ben Brandt
9ebdfffd48 Remove option for using an API Key, just support /login
Co-authored-by: Agus Zubiaga <agus@zed.dev>
2025-09-04 08:41:45 -07:00
Agus Zubiaga
e6bf33cd30 0.2.5 2025-09-03 22:02:40 -03:00
Xiangyi Li
96261a5b50 Remove dollar signs from CLI commands in installation section (#22)
Makes it easier to copy and paste the installation commands without
having to manually remove the dollar signs. This is a common convention
in documentation to improve user experience when following installation
instructions.
2025-09-03 22:01:24 -03:00
Agus Zubiaga
003ae41d18 Send available commands over notifications (#20)
See https://github.com/zed-industries/agent-client-protocol/pull/62

---------

Co-authored-by: Cole Miller <cole@zed.dev>
2025-09-03 22:00:54 -03:00
Danilo Leal
0ba0ec1017 Update Claude Code auth button label (#16)
We were using "Claude CLI" which is not the correct product name.
2025-09-03 16:37:50 -04:00
Oli
f70730f1ab Update README for CodeCompanion.nvim (#17)
Added CodeCompanion.nvim to the README, linking to my own setup
instructions.
2025-09-03 12:53:40 -07:00
Agus Zubiaga
13cd9214d7 0.2.4 2025-09-03 15:15:13 -03:00
Agus Zubiaga
78975d75f5 Provide more helpful message on init error 2025-09-03 15:15:03 -03:00
Agus Zubiaga
bbd0774ae5 0.2.3 2025-09-03 15:07:22 -03:00
Agus Zubiaga
76331017b2 Pass node executable path 2025-09-03 15:07:13 -03:00
Agus Zubiaga
90f1447e99 0.2.2 2025-09-03 14:04:10 -03:00
Agus Zubiaga
dae4c9956e Raise timeout 2025-09-03 14:03:47 -03:00
Agus Zubiaga
eb282abb50 Fix readme format 2025-09-03 11:39:33 -03:00
Agus Zubiaga
74c0be057b Add npm badge 2025-09-03 11:36:39 -03:00
Agus Zubiaga
e66091f723 Add permission request note 2025-09-03 11:02:33 -03:00
Agus Zubiaga
1b99ab96a0 Fix test 2025-09-03 09:28:11 -03:00
Agus Zubiaga
8d14d9dd1d 0.2.1 2025-09-03 09:25:44 -03:00
Agus Zubiaga
98b6cd5c07 Fix edit location reporting (#15)
When reporting the edit tool uses, we were accessing a field with a typo
(we should start using Zod, btw), and that caused locations not to be
reported correctly. We also now report all line numbers.

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

---------

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

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

---------

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

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

@@ -0,0 +1,45 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: "ci-${{ github.ref }}"
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build:
name: Build
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Use Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: latest
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Check formatting
run: npm run format:check
- name: Run linter
run: npm run lint
- name: Build
run: npm run build
- name: Run tests
run: npm run test:run

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

@@ -0,0 +1,34 @@
name: Publish Package to npmjs
on:
push:
tags:
- "v*.*.*"
jobs:
publish-npm:
runs-on: ubuntu-latest
environment: release # Optional: for enhanced security
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "lts/*"
registry-url: "https://registry.npmjs.org"
- name: Update npm
run: npm install -g npm@latest
- run: npm ci
- run: npm run build
- run: npm publish
release:
runs-on: ubuntu-latest
needs: [publish-npm]
permissions:
contents: write
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Create Release
id: create_release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090

1
.gitignore vendored
View File

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

4
.prettierrc.json Normal file
View File

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

224
CHANGELOG.md Normal file
View File

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

View File

@@ -1,3 +1,59 @@
An ACP-compatible coding agent powered by the Claude Code SDK
# ACP adapter for Claude Code
This wrapper lets you use the real version of Claude Code from the IDE of your choice.
[![npm](https://img.shields.io/npm/v/%40zed-industries%2Fclaude-code-acp)](https://www.npmjs.com/package/@zed-industries/claude-code-acp)
Use [Claude Code](https://www.anthropic.com/claude-code) from [ACP-compatible](https://agentclientprotocol.com) clients such as [Zed](https://zed.dev)!
This tool implements an ACP agent by using the official [Claude Code SDK](https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-overview), supporting:
- Context @-mentions
- Images
- Tool calls (with permission requests)
- Following
- Edit review
- TODO lists
- Interactive (and background) terminals
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
- Client MCP servers
Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
## How to use
### Zed
The latest version of Zed can already use this adapter out of the box.
To use Claude Code, open the Agent Panel and click "New Claude Code Thread" from the `+` button menu in the top-right:
https://github.com/user-attachments/assets/ddce66c7-79ac-47a3-ad59-4a6a3ca74903
Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) support.
### Other clients
- Emacs via [agent-shell.el](https://github.com/xenodium/agent-shell)
- [marimo notebook](https://github.com/marimo-team/marimo)
- Neovim
- via [CodeCompanion.nvim](https://codecompanion.olimorris.dev/configuration/adapters#setup-claude-code-via-acp)
- via [yetone/avante.nvim](https://github.com/yetone/avante.nvim)
[Submit a PR](https://github.com/zed-industries/claude-code-acp/pulls) to add yours!
#### Installation
Install the adapter from `npm`:
```bash
npm install @zed-industries/claude-code-acp
```
You can then use `claude-code-acp` as a regular ACP agent:
```
ANTHROPIC_API_KEY=sk-... claude-code-acp
```
## License
Apache-2.0

24
docs/RELEASES.md Normal file
View 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
View File

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

2484
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,19 +3,33 @@
"publishConfig": {
"access": "public"
},
"version": "0.1.0",
"version": "0.12.4",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/acp-agent.js",
"main": "dist/lib.js",
"bin": {
"claude-code-acp": "./dist/index.js"
},
"type": "module",
"files": [
"dist/",
"README.md",
"LICENSE",
"package.json"
],
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "npm run build && npm run start",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"check": "npm run lint && npm run format:check",
"test": "vitest",
"test:integration": "RUN_INTEGRATION_TESTS=true vitest run",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
"test:coverage": "vitest run --coverage",
"prepublishOnly": "npm run build"
},
"keywords": [
"claude",
@@ -26,25 +40,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.96",
"@modelcontextprotocol/sdk": "^1.17.4",
"@types/express": "^5.0.3",
"@zed-industries/agent-client-protocol": "0.1.2",
"express": "^5.1.0",
"minimist": "^1.2.8",
"uuid": "11.1.0"
"@agentclientprotocol/sdk": "0.11.0",
"@anthropic-ai/claude-agent-sdk": "0.1.67",
"@modelcontextprotocol/sdk": "1.24.3",
"diff": "8.0.2",
"minimatch": "^10.1.1"
},
"devDependencies": {
"@types/minimist": "^1.2.5",
"@types/node": "^24.3.0",
"@types/uuid": "^10.0.0",
"@vitest/ui": "^3.2.4",
"eslint": "^8.56.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.0",
"vitest": "^3.2.4"
"@anthropic-ai/sdk": "0.71.2",
"@types/node": "25.0.1",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
"globals": "16.5.0",
"prettier": "3.7.4",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vitest": "4.0.15"
}
}

File diff suppressed because it is too large Load Diff

View File

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

36
src/lib.ts Normal file
View 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";

File diff suppressed because it is too large Load Diff

522
src/settings.ts Normal file
View 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;
}
}

View File

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

View File

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

View File

@@ -2,8 +2,12 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { spawn, spawnSync } from "child_process";
import {
Agent,
AgentSideConnection,
AvailableCommand,
Client,
ClientSideConnection,
ndJsonStream,
NewSessionResponse,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionRequest,
@@ -11,91 +15,229 @@ import {
SessionNotification,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@zed-industries/agent-client-protocol";
} from "@agentclientprotocol/sdk";
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import { toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
import { toAcpNotifications } from "../acp-agent.js";
import { SDKAssistantMessage } from "@anthropic-ai/claude-code";
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
import { toAcpNotifications, promptToClaude } from "../acp-agent.js";
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { randomUUID } from "crypto";
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
"ACP subprocess integration",
() => {
let child: ReturnType<typeof spawn>;
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
let child: ReturnType<typeof spawn>;
beforeAll(async () => {
let valid = spawnSync("tsc", { stdio: "inherit" });
if (valid.status) {
throw new Error("failed to compile");
}
// Start the subprocess
child = spawn("npm", ["run", "--silent", "dev"], {
stdio: ["pipe", "pipe", "inherit"],
env: process.env,
});
child.on("error", (error) => {
console.error("Error starting subprocess:", error);
});
child.on("exit", (exit) => {
console.error("Exited with", exit);
});
beforeAll(async () => {
const valid = spawnSync("tsc", { stdio: "inherit" });
if (valid.status) {
throw new Error("failed to compile");
}
// Start the subprocess
child = spawn("npm", ["run", "--silent", "dev"], {
stdio: ["pipe", "pipe", "inherit"],
env: process.env,
});
afterAll(() => {
child.kill();
child.on("error", (error) => {
console.error("Error starting subprocess:", error);
});
child.on("exit", (exit) => {
console.error("Exited with", exit);
});
});
class TestClient implements Client {
agent: Agent;
afterAll(() => {
child.kill();
});
constructor(agent: Agent) {
this.agent = agent;
}
requestPermission(
params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
throw new Error("Method not implemented.");
}
async sessionUpdate(params: SessionNotification): Promise<void> {
console.error("RECEVIED", params);
}
writeTextFile(
params: WriteTextFileRequest,
): Promise<WriteTextFileResponse> {
throw new Error("Method not implemented.");
}
async readTextFile(
params: ReadTextFileRequest,
): Promise<ReadTextFileResponse> {
return {
content:
"This project attempts to take over the world by solving the Goldbach conjecture.",
};
class TestClient implements Client {
agent: Agent;
files: Map<string, string> = new Map();
receivedText: string = "";
resolveAvailableCommands: (commands: AvailableCommand[]) => void;
availableCommandsPromise: Promise<AvailableCommand[]>;
constructor(agent: Agent) {
this.agent = agent;
this.resolveAvailableCommands = () => {};
this.availableCommandsPromise = new Promise((resolve) => {
this.resolveAvailableCommands = resolve;
});
}
takeReceivedText() {
const text = this.receivedText;
this.receivedText = "";
return text;
}
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
const optionId = params.options.find((p) => p.kind === "allow_once")!.optionId;
return { outcome: { outcome: "selected", optionId } };
}
async sessionUpdate(params: SessionNotification): Promise<void> {
console.error("RECEIVED", JSON.stringify(params, null, 4));
switch (params.update.sessionUpdate) {
case "agent_message_chunk": {
if (params.update.content.type === "text") {
this.receivedText += params.update.content.text;
}
break;
}
case "available_commands_update":
this.resolveAvailableCommands(params.update.availableCommands);
break;
default:
break;
}
}
it("should connect to the ACP subprocess", async () => {
const connection = new ClientSideConnection(
(agent) => {
return new TestClient(agent);
},
nodeToWebWritable(child.stdin!),
nodeToWebReadable(child.stdout!),
);
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
this.files.set(params.path, params.content);
return {};
}
let session = await connection.newSession({ cwd: "./", mcpServers: [] });
await connection.prompt({
prompt: [
{
type: "text",
text: "What does this project do? Read the README.md file.",
},
],
sessionId: session.sessionId,
});
console.log("DONE AWAITING");
}, 20000);
},
);
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
const content = this.files.get(params.path) ?? "";
return {
content,
};
}
}
async function setupTestSession(cwd: string): Promise<{
client: TestClient;
connection: ClientSideConnection;
newSessionResponse: NewSessionResponse;
}> {
let client;
const 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,
clientCapabilities: {
fs: {
readTextFile: true,
writeTextFile: true,
},
},
});
const newSessionResponse = await connection.newSession({
cwd,
mcpServers: [],
});
return { client: client!, connection, newSessionResponse };
}
it("should connect to the ACP subprocess", async () => {
const { client, connection, newSessionResponse } = await setupTestSession("./");
await connection.prompt({
prompt: [
{
type: "text",
text: "Hello",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).not.toEqual("");
}, 30000);
it("should include available commands", async () => {
const { client, connection, newSessionResponse } = await setupTestSession(__dirname);
const commands = await client.availableCommandsPromise;
expect(commands).toContainEqual({
name: "quick-math",
description: "10 * 3 = 30 (project)",
input: null,
});
expect(commands).toContainEqual({
name: "say-hello",
description: "Say hello (project)",
input: { hint: "[name]" },
});
await connection.prompt({
prompt: [
{
type: "text",
text: "/quick-math",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toContain("30");
await connection.prompt({
prompt: [
{
type: "text",
text: "/say-hello GPT-5",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toContain("Hello GPT-5");
}, 30000);
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", () => {
it("should handle Bash nicely", () => {
@@ -109,7 +251,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "execute",
title: "`rm README.md.rm`",
content: [
@@ -134,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: [],
});
@@ -155,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: [
@@ -176,15 +318,15 @@ describe("tool conversions", () => {
id: "toolu_01EEqsX7Eb9hpx87KAHVPTey",
name: "LS",
input: {
path: "/Users/benbrandt/github/claude-code-acp",
path: "/Users/test/github/claude-code-acp",
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "search",
title: "/Users/benbrandt/github/claude-code-acp",
title: "List the `/Users/test/github/claude-code-acp` directory's contents",
content: [],
locations: [{ path: "/Users/benbrandt/github/claude-code-acp" }],
locations: [],
});
});
@@ -198,7 +340,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "search",
title: 'grep ".*"',
content: [],
@@ -211,12 +353,12 @@ describe("tool conversions", () => {
id: "toolu_01ABC123XYZ789",
name: "Write",
input: {
abs_path: "/Users/test/project/example.txt",
file_path: "/Users/test/project/example.txt",
content: "Hello, World!\nThis is test content.",
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "edit",
title: "Write /Users/test/project/example.txt",
content: [
@@ -231,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: [
@@ -263,34 +405,29 @@ describe("tool conversions", () => {
id: "toolu_01MNO456PQR789",
name: "Read",
input: {
abs_path: "/Users/test/project/readme.md",
file_path: "/Users/test/project/readme.md",
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "read",
title: "Read File",
content: [
{
type: "content",
content: { type: "text", text: "/Users/test/project/readme.md" },
},
],
content: [],
locations: [{ path: "/Users/test/project/readme.md", line: 0 }],
});
});
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: [],
@@ -298,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: [],
@@ -317,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: [],
@@ -337,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: [],
@@ -356,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", () => {
let tool_use = {
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: [],
@@ -418,7 +511,7 @@ describe("tool conversions", () => {
});
it("should handle BashOutput entries", () => {
let tool_use = {
const tool_use = {
type: "tool_use",
id: "toolu_01SJUWPtj1QspgANgtpqGPuN",
name: "BashOutput",
@@ -427,7 +520,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "execute",
title: `Tail Logs`,
content: [],
@@ -441,6 +534,7 @@ describe("tool conversions", () => {
id: "msg_017eNosJgww7F5qD4a8BcAcx",
type: "message",
role: "assistant",
container: null,
model: "claude-sonnet-4-20250514",
content: [
{
@@ -475,8 +569,7 @@ describe("tool conversions", () => {
activeForm: "Adding concurrent access tests",
},
{
content:
"Add tests for Each function with various data types",
content: "Add tests for Each function with various data types",
status: "pending",
activeForm: "Adding Each function tests",
},
@@ -506,13 +599,25 @@ 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: {
@@ -564,150 +669,28 @@ describe("tool conversions", () => {
]);
});
it("should extract locations from edit tool results", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01ABC123",
name: "edit",
input: {
abs_path: "/Users/test/project/example.txt",
old_text: "old content",
new_text: "new content",
},
};
const toolResult = {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers: [5, 10, 15] }),
},
],
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([
{ path: "/Users/test/project/example.txt", line: 5 },
{ path: "/Users/test/project/example.txt", line: 10 },
{ path: "/Users/test/project/example.txt", line: 15 },
]);
});
it("should extract locations from multi-edit tool results", () => {
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_text: "def hello():",
new_text: "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_text: "nonexistent",
new_text: "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",
old_text: "old",
new_text: "new",
file_path: "/Users/test/project/test.txt",
old_string: "old",
new_string: "new",
},
};
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);
@@ -715,4 +698,173 @@ 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", () => {
it("should escape markdown characters", () => {
let text = "Hello *world*!";
let escaped = markdownEscape(text);
expect(escaped).toEqual("```\nHello *world*!\n```");
text = "for example:\n```markdown\nHello *world*!\n```\n";
escaped = markdownEscape(text);
expect(escaped).toEqual("````\nfor example:\n```markdown\nHello *world*!\n```\n````");
});
});
describe("prompt conversion", () => {
it("should not change built-in slash commands", () => {
const message = promptToClaude({
sessionId: "test",
prompt: [
{
type: "text",
text: "/compact args",
},
],
});
expect(message.message.content).toEqual([
{
text: "/compact args",
type: "text",
},
]);
});
it("should remove MCP prefix from MCP slash commands", () => {
const message = promptToClaude({
sessionId: "test",
prompt: [
{
type: "text",
text: "/mcp:server:name args",
},
],
});
expect(message.message.content).toEqual([
{
text: "/server:name (MCP) args",
type: "text",
},
]);
});
});
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
it("query has a 'default' model", async () => {
const q = query({ prompt: "hi" });
const models = await q.supportedModels();
const defaultModel = models.find((m) => m.value === "default");
expect(defaultModel).toBeDefined();
}, 10000);
it("custom session id", async () => {
const sessionId = randomUUID();
const q = query({
prompt: "hi",
options: {
systemPrompt: { type: "preset", preset: "claude_code" },
extraArgs: { "session-id": sessionId },
settingSources: ["user", "project", "local"],
includePartialMessages: true,
},
});
const { value } = await q.next();
expect(value).toMatchObject({ type: "system", subtype: "init", session_id: sessionId });
}, 10000);
});
describe("permission requests", () => {
it("should include title field in tool permission request structure", () => {
// Test various tool types to ensure title is correctly generated
const testCases = [
{
toolUse: {
type: "tool_use" as const,
id: "test-1",
name: "Write",
input: { file_path: "/test/file.txt", content: "test" },
},
expectedTitlePart: "/test/file.txt",
},
{
toolUse: {
type: "tool_use" as const,
id: "test-2",
name: "Bash",
input: { command: "ls -la", description: "List files" },
},
expectedTitlePart: "`ls -la`",
},
{
toolUse: {
type: "tool_use" as const,
id: "test-3",
name: "mcp__acp__Read",
input: { file_path: "/test/data.json" },
},
expectedTitlePart: "/test/data.json",
},
];
for (const testCase of testCases) {
// Get the tool info that would be used in requestPermission
const toolInfo = toolInfoFromToolUse(testCase.toolUse, {});
// Verify toolInfo has a title
expect(toolInfo.title).toBeDefined();
expect(toolInfo.title).toContain(testCase.expectedTitlePart);
// Verify the structure that our fix creates for requestPermission
const requestStructure = {
toolCall: {
toolCallId: testCase.toolUse.id,
rawInput: testCase.toolUse.input,
title: toolInfo.title, // This is what commit 1785d86 adds
},
};
// Ensure the title field is present and populated
expect(requestStructure.toolCall.title).toBeDefined();
expect(requestStructure.toolCall.title).toContain(testCase.expectedTitlePart);
}
});
});

View File

@@ -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);
});
});

View File

@@ -4,13 +4,9 @@ import { replaceAndCalculateLocation } from "../mcp-server.js";
describe("replaceAndCalculateLocation", () => {
it("should replace first occurrence and return correct line number", () => {
const content = "line 1\nline 2 with text\nline 3 with text\nline 4";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe(
"line 1\nline 2 with replaced\nline 3 with text\nline 4",
);
expect(result.newContent).toBe("line 1\nline 2 with replaced\nline 3 with text\nline 4");
expect(result.lineNumbers).toEqual([1]); // Line 2 (0-based indexing)
});
@@ -20,20 +16,15 @@ describe("replaceAndCalculateLocation", () => {
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe(
"line 1\nline 2 with replaced\nline 3 with replaced\nline 4",
);
expect(result.newContent).toBe("line 1\nline 2 with replaced\nline 3 with replaced\nline 4");
expect(result.lineNumbers).toEqual([1, 2]); // Lines 2 and 3 (0-based)
});
it("should return empty line numbers array when no match found", () => {
const content = "line 1\nline 2\nline 3";
const result = replaceAndCalculateLocation(content, [
{ oldText: "notfound", newText: "replaced" },
]);
expect(result.newContent).toBe(content);
expect(result.lineNumbers).toEqual([]);
expect(() => {
replaceAndCalculateLocation(content, [{ oldText: "notfound", newText: "replaced" }]);
}).toThrow('The provided `old_string` does not appear in the file: "notfound"');
});
it("should handle Windows line endings (CRLF)", () => {
@@ -54,9 +45,7 @@ describe("replaceAndCalculateLocation", () => {
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe(
"line 1\rline 2 with replaced\rline 3 with replaced\rline 4",
);
expect(result.newContent).toBe("line 1\rline 2 with replaced\rline 3 with replaced\rline 4");
expect(result.lineNumbers).toEqual([1, 2]);
});
@@ -66,17 +55,13 @@ describe("replaceAndCalculateLocation", () => {
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe(
"line 1\nline 2 with replaced\r\nline 3 with replaced\rline 4",
);
expect(result.newContent).toBe("line 1\nline 2 with replaced\r\nline 3 with replaced\rline 4");
expect(result.lineNumbers).toEqual([1, 2]);
});
it("should handle text at the beginning of file", () => {
const content = "text at start\nline 2\nline 3";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("replaced at start\nline 2\nline 3");
expect(result.lineNumbers).toEqual([0]); // First line (0-based)
@@ -84,9 +69,7 @@ describe("replaceAndCalculateLocation", () => {
it("should handle text at the end of file", () => {
const content = "line 1\nline 2\nlast line with text";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("line 1\nline 2\nlast line with replaced");
expect(result.lineNumbers).toEqual([2]); // Last line (0-based)
@@ -98,53 +81,41 @@ describe("replaceAndCalculateLocation", () => {
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe(
"line 1\nreplaced replaced replaced\nline 3",
);
expect(result.newContent).toBe("line 1\nreplaced replaced replaced\nline 3");
expect(result.lineNumbers).toEqual([1]); // All on line 2 (0-based), deduplicated
});
it("should handle empty file content", () => {
const content = "";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
expect(result.newContent).toBe("");
expect(result.lineNumbers).toEqual([]);
expect(() => {
replaceAndCalculateLocation("", [{ oldText: "text", newText: "replaced" }]);
}).toThrowError(
'The provided `old_string` does not appear in the file: "text".\n\nNo edits were applied.',
);
});
it("should handle empty search text", () => {
// Test with replaceAll false (default)
const content1 = "line 1\nline 2\nline 3";
const result1 = replaceAndCalculateLocation(content1, [
{ oldText: "", newText: "replaced" },
]);
expect(result1.newContent).toBe(content1);
expect(result1.lineNumbers).toEqual([]);
expect(() => {
replaceAndCalculateLocation(content1, [{ oldText: "", newText: "replaced" }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
// Test with replaceAll true on single line
const content2 = "abc";
const result2 = replaceAndCalculateLocation(content2, [
{ oldText: "", newText: "X", replaceAll: true },
]);
expect(result2.newContent).toBe(content2);
expect(result2.lineNumbers).toEqual([]);
expect(() => {
replaceAndCalculateLocation(content2, [{ oldText: "", newText: "X", replaceAll: true }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
// Test with replaceAll true on multiline content
const content3 = "ab\ncd";
const result3 = replaceAndCalculateLocation(content3, [
{ oldText: "", newText: "X", replaceAll: true },
]);
expect(result3.newContent).toBe(content3);
expect(result3.lineNumbers).toEqual([]);
expect(() => {
replaceAndCalculateLocation(content3, [{ oldText: "", newText: "X", replaceAll: true }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
});
it("should handle single line content", () => {
const content = "single line with text here";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("single line with replaced here");
expect(result.lineNumbers).toEqual([0]); // Line 1 (0-based)
@@ -183,9 +154,7 @@ describe("replaceAndCalculateLocation", () => {
it("should handle very long lines", () => {
const longLine = "a".repeat(10000) + "text" + "b".repeat(10000);
const content = `line 1\n${longLine}\nline 3`;
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe(
`line 1\n${"a".repeat(10000)}replaced${"b".repeat(10000)}\nline 3`,
@@ -198,9 +167,7 @@ describe("replaceAndCalculateLocation", () => {
lines[500] = "line with text";
const content = lines.join("\n");
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.lineNumbers).toEqual([500]);
expect(result.newContent).includes("line with replaced");
@@ -224,9 +191,7 @@ describe("replaceAndCalculateLocation", () => {
const result1 = replaceAndCalculateLocation(content, [
{ oldText: "short", newText: "very very long text" },
]);
expect(result1.newContent).toBe(
"very very long text\nmedium line\nlong line here",
);
expect(result1.newContent).toBe("very very long text\nmedium line\nlong line here");
expect(result1.lineNumbers).toEqual([0]);
// Replacing with shorter text
@@ -260,9 +225,7 @@ describe("replaceAndCalculateLocation", () => {
it("should handle replacement at exact end of line", () => {
const content = "line ends with text\nnext line";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("line ends with replaced\nnext line");
expect(result.lineNumbers).toEqual([0]);
@@ -302,9 +265,7 @@ describe("replaceAndCalculateLocation", () => {
// - All "foo" occurrences (lines 1, 3, 5 in original)
// - First "bar" occurrence (line 1 after first edit)
// - "line 3" (line 2 after previous edits)
expect(result.newContent).toBe(
"line 1\nFOO BAR\nLINE THREE\nFOO baz\nline 5\nbar FOO\nline 7",
);
expect(result.newContent).toBe("line 1\nFOO BAR\nLINE THREE\nFOO baz\nline 5\nbar FOO\nline 7");
// Line numbers reflect position in final content, deduplicated and sorted
expect(result.lineNumbers).toEqual([
@@ -326,9 +287,7 @@ describe("replaceAndCalculateLocation", () => {
// First edit replaces first "hello" only
// Second edit replaces all "world" in the modified content
// Third edit replaces remaining "hello"s in the modified content
expect(result.newContent).toBe(
"hi earth\ngreetings greetings\nearth greetings earth",
);
expect(result.newContent).toBe("hi earth\ngreetings greetings\nearth greetings earth");
// Line numbers reflect position in final content, deduplicated and sorted
expect(result.lineNumbers).toEqual([
@@ -340,17 +299,14 @@ describe("replaceAndCalculateLocation", () => {
it("should handle complex scenarios with marker-like text", () => {
// Test that our marker approach doesn't get confused by similar text
const content =
"line 1\n__MARKER__text\nline 3\n__REPLACE_MARKER_abc__\nline 5";
const content = "line 1\n__MARKER__text\nline 3\n__REPLACE_MARKER_abc__\nline 5";
const result = replaceAndCalculateLocation(content, [
{ oldText: "__MARKER__", newText: "REPLACED", replaceAll: true },
{ oldText: "text", newText: "content" },
{ oldText: "__REPLACE_MARKER_abc__", newText: "DONE" },
]);
expect(result.newContent).toBe(
"line 1\nREPLACEDcontent\nline 3\nDONE\nline 5",
);
expect(result.newContent).toBe("line 1\nREPLACEDcontent\nline 3\nDONE\nline 5");
// Line numbers should be correct even with marker-like text
expect(result.lineNumbers).toEqual([

617
src/tests/settings.test.ts Normal file
View 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 });
});
});
});

View File

@@ -1,10 +1,40 @@
import { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
import { replaceAndCalculateLocation, SYSTEM_REMINDER } from "./mcp-server.js";
import { ToolResultBlockParam, WebSearchToolResultBlockParam } from "@anthropic-ai/sdk/resources";
const acpUnqualifiedToolNames = {
read: "Read",
edit: "Edit",
write: "Write",
bash: "Bash",
killShell: "KillShell",
bashOutput: "BashOutput",
};
export const ACP_TOOL_NAME_PREFIX = "mcp__acp__";
export const acpToolNames = {
read: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.read,
edit: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.edit,
write: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.write,
bash: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.bash,
killShell: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.killShell,
bashOutput: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.bashOutput,
};
export const EDIT_TOOL_NAMES = [acpToolNames.edit, acpToolNames.write];
import {
PlanEntry,
ToolCallContent,
ToolCallLocation,
ToolKind,
} from "@zed-industries/agent-client-protocol";
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,7 +49,11 @@ interface ToolUpdate {
locations?: ToolCallLocation[];
}
export function toolInfoFromToolUse(toolUse: any): ToolInfo {
export function toolInfoFromToolUse(
toolUse: any,
cachedFileContent: { [key: string]: string },
logger: Logger = console,
): ToolInfo {
const name = toolUse.name;
const input = toolUse.input;
@@ -41,30 +75,15 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
case "NotebookRead":
return {
title: input?.notebook_path
? `Read Notebook ${input.notebook_path}`
: "Read Notebook",
title: input?.notebook_path ? `Read Notebook ${input.notebook_path}` : "Read Notebook",
kind: "read",
content:
input && input.notebook_path
? [
{
type: "content",
content: {
type: "text",
text: input.notebook_path.toString(),
},
},
]
: [],
content: [],
locations: input?.notebook_path ? [{ path: input.notebook_path }] : [],
};
case "NotebookEdit":
return {
title: input?.notebook_path
? `Edit Notebook ${input.notebook_path}`
: "Edit Notebook",
title: input?.notebook_path ? `Edit Notebook ${input.notebook_path}` : "Edit Notebook",
kind: "edit",
content:
input && input.new_source
@@ -79,13 +98,12 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
};
case "Bash":
case acpToolNames.bash:
return {
title: input?.command
? "`" + input.command.replaceAll("`", "\\`") + "`"
: "Terminal",
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
kind: "execute",
content:
input && input.command
input && input.description
? [
{
type: "content",
@@ -96,40 +114,40 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
};
case "BashOutput":
case acpToolNames.bashOutput:
return {
title: "Tail Logs",
kind: "execute",
content: [],
};
case "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 =
" (" +
((input.offset ?? 0) + 1) +
" - " +
((input.offset ?? 0) + input.limit) +
")";
" (" + ((input.offset ?? 0) + 1) + " - " + ((input.offset ?? 0) + input.limit) + ")";
} else if (input.offset) {
limit = " (from line " + (input.offset + 1) + ")";
}
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: [],
};
}
@@ -138,71 +156,78 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
return {
title: "Read File",
kind: "read",
content:
input && input.abs_path
? [
{
type: "content",
content: { type: "text", text: input.abs_path.toString() },
},
]
: [],
locations: [{ path: input.abs_path, line: input.offset ?? 0 }],
content: [],
locations: input.file_path
? [
{
path: input.file_path,
line: input.offset ?? 0,
},
]
: [],
};
case "LS":
return {
title: input?.path ? input.path : "Current Directory",
title: `List the ${input?.path ? "`" + input.path + "`" : "current"} directory's contents`,
kind: "search",
content: [],
locations: input?.path ? [{ path: input.path }] : [],
locations: [],
};
case "mcp__acp__edit":
case "Edit":
case acpToolNames.edit:
case "Edit": {
const path = input?.file_path ?? input?.file_path;
let oldText = input.old_string ?? null;
let newText = input.new_string ?? "";
let affectedLines: number[] = [];
if (path && oldText) {
try {
const oldContent = cachedFileContent[path] || "";
const newContent = replaceAndCalculateLocation(oldContent, [
{
oldText,
newText,
replaceAll: false,
},
]);
oldText = oldContent;
newText = newContent.newContent;
affectedLines = newContent.lineNumbers;
} catch (e) {
logger.error(e);
}
}
return {
title: input?.abs_path ? `Edit ${input.abs_path}` : "Edit",
title: path ? `Edit \`${path}\`` : "Edit",
kind: "edit",
content:
input && input.abs_path
input && path
? [
{
type: "diff",
path: input.abs_path,
oldText: input.old_text || null,
newText: input.new_text,
path,
oldText,
newText,
},
]
: [],
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
locations: path
? affectedLines.length > 0
? affectedLines.map((line) => ({ line, path }))
: [{ path }]
: [],
};
}
case "mcp__acp__multi-edit":
case "MultiEdit":
// Display it as a normal edit, because end users don't care about
// the distinction between edits and multi-edits.
return {
title: input?.file_path ? `Edit ${input.file_path}` : "Edit",
kind: "edit",
content:
input && input.edits && input.edits.length > 0
? input.edits.map((edit: any) => ({
type: "diff" as const,
path: input.file_path,
oldText: edit.old_string || null,
newText: edit.new_string,
}))
: [],
locations: input?.file_path ? [{ path: input.file_path }] : [],
};
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,
},
@@ -216,37 +241,38 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
];
}
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 }] : [],
};
}
case "Write":
return {
title: input?.abs_path ? `Write ${input.abs_path}` : "Write",
title: input?.file_path ? `Write ${input.file_path}` : "Write",
kind: "edit",
content:
input && input.abs_path
input && input.file_path
? [
{
type: "diff",
path: input.abs_path,
path: input.file_path,
oldText: null,
newText: input.content,
},
]
: [],
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
locations: input?.file_path ? [{ path: input.file_path }] : [],
};
case "Glob": {
let label = "Find";
if (input.path) {
label += ` ${input.path}`;
label += ` \`${input.path}\``;
}
if (input.pattern) {
label += ` ${input.pattern}`;
label += ` \`${input.pattern}\``;
}
return {
title: label,
@@ -285,6 +311,7 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
label += " -c";
break;
case "Content":
default:
break;
}
}
@@ -353,24 +380,30 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
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",
@@ -379,11 +412,12 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
type: "content",
content: {
type: "text",
text: `\`\`\`json\n${JSON.stringify(input, null, 2) || "{}"}\`\`\``,
text: `\`\`\`json\n${output}\`\`\``,
},
},
],
};
}
default:
return {
@@ -395,63 +429,74 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
}
export function toolUpdateFromToolResult(
toolResult: any,
toolResult:
| ToolResultBlockParam
| BetaWebSearchToolResultBlockParam
| BetaWebFetchToolResultBlockParam
| WebSearchToolResultBlockParam
| BetaCodeExecutionToolResultBlockParam
| BetaBashCodeExecutionToolResultBlockParam
| BetaTextEditorCodeExecutionToolResultBlockParam
| BetaRequestMCPToolResultBlockParam
| BetaToolSearchToolResultBlockParam,
toolUse: any | undefined,
): ToolUpdate {
switch (toolUse?.name) {
case "mcp__acp__edit":
case "Read":
case acpToolNames.read:
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
return {
content: toolResult.content.map((content: any) => ({
type: "content",
content:
content.type === "text"
? {
type: "text",
text: markdownEscape(content.text.replace(SYSTEM_REMINDER, "")),
}
: content,
})),
};
} else if (typeof toolResult.content === "string" && toolResult.content.length > 0) {
return {
content: [
{
type: "content",
content: {
type: "text",
text: markdownEscape(toolResult.content.replace(SYSTEM_REMINDER, "")),
},
},
],
};
}
return {};
case acpToolNames.bash:
case "edit":
case "Edit": {
// Parse the line numbers from the tool result
if (toolResult.content && toolResult.content.length > 0) {
try {
const firstContent = toolResult.content[0];
const data = JSON.parse(firstContent.text || firstContent);
if (data.lineNumbers && Array.isArray(data.lineNumbers)) {
const locations: ToolCallLocation[] = data.lineNumbers.map(
(line: number) => ({
path: toolUse?.input?.abs_path || toolUse?.input?.file_path,
line: line,
}),
);
return { locations };
}
} catch (e) {
// If parsing fails, return empty object
return {};
}
case "Edit":
case acpToolNames.edit:
case acpToolNames.write:
case "Write": {
if (
"is_error" in toolResult &&
toolResult.is_error &&
toolResult.content &&
toolResult.content.length > 0
) {
// Only return errors
return toAcpContentUpdate(toolResult.content, true);
}
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 (e) {
// If parsing fails, return empty object
return {};
}
}
return {};
case "ExitPlanMode": {
return { title: "Exited Plan Mode" };
}
case "Task":
case "NotebookEdit":
case "NotebookRead":
case "mcp__acp__write":
case "Write":
case "TodoWrite":
case "exit_plan_mode":
case "Bash":
@@ -464,31 +509,48 @@ export function toolUpdateFromToolResult(
case "WebSearch":
case "Other":
default: {
// This happens for the mcp__acp__read tool,
// but may also for others...
if (Array.isArray(toolResult.content)) {
return {
content: toolResult.content.map((content: any) => ({
type: "content",
content,
})),
};
} else if (typeof toolResult.content === "string") {
return {
content: [
{
type: "content",
content: { type: "text", text: toolResult.content },
},
],
};
}
return {};
return toAcpContentUpdate(
toolResult.content,
"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;
@@ -501,3 +563,110 @@ export function planEntries(input: { todos: ClaudePlanEntry[] }): PlanEntry[] {
priority: "medium",
}));
}
export function markdownEscape(text: string): string {
let escape = "```";
for (const [m] of text.matchAll(/^```+/gm)) {
while (m.length >= escape.length) {
escape += "`";
}
}
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
}
/* A global variable to store callbacks that should be executed when receiving hooks from Claude Code */
const toolUseCallbacks: {
[toolUseId: string]: {
onPostToolUseHook?: (
toolUseID: string,
toolInput: unknown,
toolResponse: unknown,
) => Promise<void>;
};
} = {};
/* Setup callbacks that will be called when receiving hooks from Claude Code */
export const registerHookCallback = (
toolUseID: string,
{
onPostToolUseHook,
}: {
onPostToolUseHook?: (
toolUseID: string,
toolInput: unknown,
toolResponse: unknown,
) => Promise<void>;
},
) => {
toolUseCallbacks[toolUseID] = {
onPostToolUseHook,
};
};
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
export const createPostToolUseHook =
(logger: Logger = console): HookCallback =>
async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => {
if (input.hook_event_name === "PostToolUse" && toolUseID) {
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
if (onPostToolUseHook) {
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
} else {
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
delete toolUseCallbacks[toolUseID];
}
}
return { continue: true };
};
/**
* Creates a PreToolUse hook that checks permissions using the SettingsManager.
* This runs before the SDK's built-in permission rules, allowing us to enforce
* our own permission settings for ACP-prefixed tools.
*/
export const createPreToolUseHook =
(settingsManager: SettingsManager, logger: Logger = console): HookCallback =>
async (input: any, _toolUseID: string | undefined) => {
if (input.hook_event_name !== "PreToolUse") {
return { continue: true };
}
const toolName = input.tool_name;
const toolInput = input.tool_input;
const permissionCheck = settingsManager.checkPermission(toolName, toolInput);
if (permissionCheck.decision !== "ask") {
logger.log(
`[PreToolUseHook] Tool: ${toolName}, Decision: ${permissionCheck.decision}, Rule: ${permissionCheck.rule}`,
);
}
switch (permissionCheck.decision) {
case "allow":
return {
continue: true,
hookSpecificOutput: {
hookEventName: "PreToolUse" as const,
permissionDecision: "allow" as const,
permissionDecisionReason: `Allowed by settings rule: ${permissionCheck.rule}`,
},
};
case "deny":
return {
continue: true,
hookSpecificOutput: {
hookEventName: "PreToolUse" as const,
permissionDecision: "deny" as const,
permissionDecisionReason: `Denied by settings rule: ${permissionCheck.rule}`,
},
};
case "ask":
default:
// Let the normal permission flow continue
return { continue: true };
}
};

View File

@@ -2,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> {
@@ -45,24 +48,23 @@ export class Pushable<T> implements AsyncIterable<T> {
}
// Helper to convert Node.js streams to Web Streams
export function nodeToWebWritable(
nodeStream: Writable,
): WritableStream<Uint8Array> {
export function nodeToWebWritable(nodeStream: Writable): WritableStream<Uint8Array> {
return new WritableStream<Uint8Array>({
write(chunk) {
return new Promise<void>((resolve, reject) => {
nodeStream.write(Buffer.from(chunk), (err) => {
if (err) reject(err);
else resolve();
if (err) {
reject(err);
} else {
resolve();
}
});
});
},
});
}
export function nodeToWebReadable(
nodeStream: Readable,
): ReadableStream<Uint8Array> {
export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
nodeStream.on("data", (chunk: Buffer) => {
@@ -74,6 +76,96 @@ export function nodeToWebReadable(
});
}
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,
};
}