Compare commits

...

18 Commits

Author SHA1 Message Date
Ben Brandt
5917529f0d Prep 0.13.1 release (#246) 2026-01-15 21:10:52 +00:00
Ben Brandt
2597775ef0 Disable AskUserQuestion for now (#245)
This is a cool tool. But we don't have a great way to express it over
ACP at the moment.

Using RequestPermission _kind of_ works. But it conflates question
asking with a very security focused flow, which has problems as well.

Rather than have strange tool output, we'll just turn it off for now.
2026-01-15 22:03:09 +01:00
Charles Covey-Brandt
6572fcf978 Add TypeScript declaration files and integration tests (#237)
## Summary

- Enable TypeScript declaration file generation (`.d.ts` and
`.d.ts.map`)
- Add `types` field and proper `exports` configuration to package.json
- Add comprehensive integration test that verifies the full packaging
flow

## Changes

### Build Configuration
- **tsconfig.json**: Added `declaration: true` and `declarationMap:
true`
- **package.json**: Added `types` field and `exports` field with:
  - Main entry point with proper `types` and `import` conditions
- Wildcard `"./*": "./*"` to preserve backwards compatibility for deep
imports

### Integration Test
The new test at `src/tests/typescript-declarations.test.ts` validates:
- **Full packaging flow**: build → npm pack → install tarball →
type-check
- **Main exports**: All primary exports type-check correctly
- **Deep imports**: Backwards compatibility for `dist/*.js` imports
- **Type shapes**: Classes, interfaces, and function signatures match
expected types
- **Generic types**: Pushable<T> works correctly with type parameters
- **Invalid types**: TypeScript correctly rejects incorrect usage

The test creates temporary TypeScript projects, installs the actual
`.tgz` package, and runs `tsc --noEmit` to verify type-checking passes.

## Test plan
- [x] `npm run build` generates `.d.ts` and `.d.ts.map` files in `dist/`
- [x] All 8 integration tests pass: `npm run test:integration --
typescript-declarations`
- [x] Main exports type-check correctly
- [x] Deep imports continue to work (no breaking change)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-15 21:16:07 +01:00
Ben Brandt
5586ae0d2c Better error handling in custom MCP tools (#243)
Closes #229
2026-01-15 13:33:16 +01:00
Pointless
39ab0c248f Add MAX_THINKING_TOKENS env support and fix file-read-info format (#226)
PR for awareness and discussion.

1) claude-code-acp doesn't currently support configuring
"maxThinkingTokens"
(https://platform.claude.com/docs/en/agent-sdk/typescript) nor is it
configured in the query. Result, at least for me, is that agent thinking
chunks are never flowing through the chain. I added environment support
to my ACP host, added env variable to query init for maxThinkingTokens
following the pyton sdk env "MAX_THINKING_TOKENS". Result is now agent
thinking chunks DO flow. And I can see Claude reason. Before, no chunks
ever. This is perhaps based on configuration that Claude Agent SDK reads
but could be made more flexibel. Such as this suggestions.

2) Fix file-read-info message: was showing nonsensical range format
'Read lines 160-40', now shows 'Read 40 lines starting at line 160'. The
way this was written was confusing for myself and for Claude. If the
file-read-info is supposed to give information, then lines like "Read
lines 160-40" says absolutely nothing.

Reproduce:

Configure your ACP host to parse thinking tokens with no other external
configuration. Claude will not send any.
Enable maxThinkingTokens in ACP and set it to something like 8096. Try
your ACP implementation again, Claude now sends thinking chunks.

Bugs:

Anthropic has a meta issue. Don't talk about the "thinking" tag with
Claude. It will break output regardless of what client you use. (even on
Claude Code CLI)

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-01-15 12:21:35 +00:00
Ben Brandt
72a9cde888 Update to claude-agent-sdk 0.2.7 (#242) 2026-01-15 13:05:23 +01:00
Ben Brandt
c9d5d3bb9c Update to claude-agent-sdk 0.2.6 (#239) 2026-01-13 15:23:53 +01:00
dependabot[bot]
cee383c67c Bump @modelcontextprotocol/sdk from 1.25.1 to 1.25.2 in the npm_and_yarn group across 1 directory (#234)
Bumps the npm_and_yarn group with 1 update in the / directory:
[@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk).

Updates `@modelcontextprotocol/sdk` from 1.25.1 to 1.25.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/modelcontextprotocol/typescript-sdk/releases"><code>@​modelcontextprotocol/sdk</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v1.25.2</h2>
<h2>What's Changed</h2>
<ul>
<li>ci: trigger workflow on v1.x branch by <a
href="https://github.com/felixweinberger"><code>@​felixweinberger</code></a>
in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/1319">modelcontextprotocol/typescript-sdk#1319</a></li>
<li>fix: README badges links destinations by <a
href="https://github.com/antonpk1"><code>@​antonpk1</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/907">modelcontextprotocol/typescript-sdk#907</a></li>
<li>fix: prevent ReDoS in UriTemplate regex patterns (v1.x backport) by
<a href="https://github.com/pcarleton"><code>@​pcarleton</code></a> in
<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/1365">modelcontextprotocol/typescript-sdk#1365</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/antonpk1"><code>@​antonpk1</code></a>
made their first contribution in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/907">modelcontextprotocol/typescript-sdk#907</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/modelcontextprotocol/typescript-sdk/compare/1.25.1...v1.25.2">https://github.com/modelcontextprotocol/typescript-sdk/compare/1.25.1...v1.25.2</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="b392f02ffc"><code>b392f02</code></a>
fix: prevent ReDoS in UriTemplate regex patterns (v1.x backport) (<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/1365">#1365</a>)</li>
<li><a
href="a0c9b13484"><code>a0c9b13</code></a>
fix: README badges links destinations (<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/907">#907</a>)</li>
<li><a
href="6dd08ac608"><code>6dd08ac</code></a>
ci: trigger workflow on v1.x branch (<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/1319">#1319</a>)</li>
<li>See full diff in <a
href="https://github.com/modelcontextprotocol/typescript-sdk/compare/1.25.1...v1.25.2">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@modelcontextprotocol/sdk&package-manager=npm_and_yarn&previous-version=1.25.1&new-version=1.25.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/zed-industries/claude-code-acp/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-07 18:03:25 +01:00
dependabot[bot]
041aa44d00 Bump qs from 6.14.0 to 6.14.1 in the npm_and_yarn group across 1 directory (#230)
Bumps the npm_and_yarn group with 1 update in the / directory:
[qs](https://github.com/ljharb/qs).

Updates `qs` from 6.14.0 to 6.14.1
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/ljharb/qs/blob/main/CHANGELOG.md">qs's
changelog</a>.</em></p>
<blockquote>
<h2><strong>6.14.1</strong></h2>
<ul>
<li>[Fix] ensure arrayLength applies to <code>[]</code> notation as
well</li>
<li>[Fix] <code>parse</code>: when a custom decoder returns
<code>null</code> for a key, ignore that key</li>
<li>[Refactor] <code>parse</code>: extract key segment splitting
helper</li>
<li>[meta] add threat model</li>
<li>[actions] add workflow permissions</li>
<li>[Tests] <code>stringify</code>: increase coverage</li>
<li>[Dev Deps] update <code>eslint</code>,
<code>@ljharb/eslint-config</code>, <code>npmignore</code>,
<code>es-value-fixtures</code>, <code>for-each</code>,
<code>object-inspect</code></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="3fa11a5f64"><code>3fa11a5</code></a>
v6.14.1</li>
<li><a
href="a62670423c"><code>a626704</code></a>
[Dev Deps] update <code>npmignore</code></li>
<li><a
href="3086902ecf"><code>3086902</code></a>
[Fix] ensure arrayLength applies to <code>[]</code> notation as
well</li>
<li><a
href="fc7930e86c"><code>fc7930e</code></a>
[Dev Deps] update <code>eslint</code>,
<code>@ljharb/eslint-config</code></li>
<li><a
href="0b06aac566"><code>0b06aac</code></a>
[Dev Deps] update <code>@ljharb/eslint-config</code></li>
<li><a
href="64951f6200"><code>64951f6</code></a>
[Refactor] <code>parse</code>: extract key segment splitting helper</li>
<li><a
href="e1bd2599cd"><code>e1bd259</code></a>
[Dev Deps] update <code>@ljharb/eslint-config</code></li>
<li><a
href="f4b3d39709"><code>f4b3d39</code></a>
[eslint] add eslint 9 optional peer dep</li>
<li><a
href="6e94d9596c"><code>6e94d95</code></a>
[Dev Deps] update <code>eslint</code>,
<code>@ljharb/eslint-config</code>, <code>npmignore</code></li>
<li><a
href="973dc3c51c"><code>973dc3c</code></a>
[actions] add workflow permissions</li>
<li>Additional commits viewable in <a
href="https://github.com/ljharb/qs/compare/v6.14.0...v6.14.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=qs&package-manager=npm_and_yarn&previous-version=6.14.0&new-version=6.14.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/zed-industries/claude-code-acp/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 22:57:28 +01:00
Ben Brandt
3db76b2d20 Update clients list in readme (#228) 2025-12-30 21:51:22 +00:00
Forte Scarlet
db965a31aa Change npm install command to global installation in README (#224)
As mentioned in #216, npm installations should be global, but the
documentation does not reflect this
2025-12-30 21:39:07 +00:00
Steffen Deusch
5280246773 announce fork and ensure fork uses a new session id (#222)
Followup for https://github.com/zed-industries/claude-code-acp/pull/221
and https://github.com/zed-industries/claude-code-acp/pull/183.
2025-12-19 18:29:43 +01:00
Ben Brandt
9f3dd402ea Update to claude-agent-sdk 0.1.73 (#221)
Adds the ability for us to also define a session id when forking
2025-12-19 11:10:28 +01:00
Piet Brömmel
d19e399018 rename Claude Code SDK to Claude Agent SDK in the README und update the link (#220) 2025-12-18 22:27:09 +00:00
Ben Brandt
24d1050f7a Better Edit diffs (#217)
Removes the problematic file cache and instead builds the diff out of
the unified diff that gets returned from the edit tool already.

This should solve several error cases and provide a more reliable diff
2025-12-17 12:50:26 +01:00
Bennet Bo Fenner
a94598dd89 Prep v0.12.6 (#214) 2025-12-16 17:28:07 +01:00
Bennet Bo Fenner
8228c471c8 Prep release (#213) 2025-12-16 17:22:49 +01:00
Steffen Deusch
c2200eec80 fix unstable methods (#212) 2025-12-16 17:05:33 +01:00
10 changed files with 1394 additions and 693 deletions

View File

@@ -1,5 +1,20 @@
# Changelog
## 0.13.1
- Update to @anthropic-ai/claude-agent-sdk@0.2.7
- Add TypeScript declaration files for library users
- Fixed error handling in custom ACP focused MCP tools
## 0.13.0
- Update to @anthropic-ai/claude-agent-sdk@0.2.6
- Update to @agentclientprotocol/sdk@0.13.0
## 0.12.6
- Fix model selection
## 0.12.5
- Update to @anthropic-ai/claude-agent-sdk@v0.1.70

View File

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

850
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,13 +3,21 @@
"publishConfig": {
"access": "public"
},
"version": "0.12.5",
"version": "0.13.1",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
"bin": {
"claude-code-acp": "./dist/index.js"
},
"type": "module",
"exports": {
".": {
"types": "./dist/lib.d.ts",
"import": "./dist/lib.js"
},
"./*": "./*"
},
"files": [
"dist/",
"README.md",
@@ -51,23 +59,23 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.12.0",
"@anthropic-ai/claude-agent-sdk": "0.1.70",
"@modelcontextprotocol/sdk": "1.25.0",
"diff": "8.0.2",
"minimatch": "^10.1.1"
"@agentclientprotocol/sdk": "0.13.0",
"@anthropic-ai/claude-agent-sdk": "0.2.7",
"@modelcontextprotocol/sdk": "1.25.2",
"diff": "8.0.3",
"minimatch": "10.1.1"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.71.2",
"@types/node": "25.0.2",
"@typescript-eslint/eslint-plugin": "8.50.0",
"@typescript-eslint/parser": "8.50.0",
"@types/node": "25.0.8",
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"eslint": "9.39.2",
"eslint-config-prettier": "10.1.8",
"globals": "16.5.0",
"prettier": "3.7.4",
"globals": "17.0.0",
"prettier": "3.8.0",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vitest": "4.0.15"
"vitest": "4.0.17"
}
}

View File

@@ -143,7 +143,6 @@ export class ClaudeAcpAgent implements Agent {
};
client: AgentSideConnection;
toolUseCache: ToolUseCache;
fileContentCache: { [key: string]: string };
backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
clientCapabilities?: ClientCapabilities;
logger: Logger;
@@ -152,7 +151,6 @@ export class ClaudeAcpAgent implements Agent {
this.sessions = {};
this.client = client;
this.toolUseCache = {};
this.fileContentCache = {};
this.logger = logger ?? console;
}
@@ -191,8 +189,7 @@ export class ClaudeAcpAgent implements Agent {
sse: true,
},
sessionCapabilities: {
// TODO: announce fork capability when sessionId handling is fixed
// fork: {},
fork: {},
resume: {},
},
},
@@ -219,7 +216,7 @@ export class ClaudeAcpAgent implements Agent {
});
}
async forkSession(params: ForkSessionRequest): Promise<ForkSessionResponse> {
async unstable_forkSession(params: ForkSessionRequest): Promise<ForkSessionResponse> {
return await this.createSession(
{
cwd: params.cwd,
@@ -233,7 +230,7 @@ export class ClaudeAcpAgent implements Agent {
);
}
async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
const response = await this.createSession(
{
cwd: params.cwd,
@@ -330,7 +327,6 @@ export class ClaudeAcpAgent implements Agent {
message,
params.sessionId,
this.toolUseCache,
this.fileContentCache,
this.client,
this.logger,
)) {
@@ -394,7 +390,6 @@ export class ClaudeAcpAgent implements Agent {
message.message.role,
params.sessionId,
this.toolUseCache,
this.fileContentCache,
this.client,
this.logger,
)) {
@@ -422,7 +417,9 @@ export class ClaudeAcpAgent implements Agent {
await this.sessions[params.sessionId].query.interrupt();
}
async setSessionModel(params: SetSessionModelRequest): Promise<SetSessionModelResponse | void> {
async unstable_setSessionModel(
params: SetSessionModelRequest,
): Promise<SetSessionModelResponse | void> {
if (!this.sessions[params.sessionId]) {
throw new Error("Session not found");
}
@@ -457,15 +454,11 @@ export class ClaudeAcpAgent implements Agent {
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
const response = await this.client.readTextFile(params);
if (!params.limit && !params.line) {
this.fileContentCache[params.path] = response.content;
}
return response;
}
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
const response = await this.client.writeTextFile(params);
this.fileContentCache[params.path] = params.content;
return response;
}
@@ -495,11 +488,7 @@ export class ClaudeAcpAgent implements Agent {
toolCall: {
toolCallId: toolUseID,
rawInput: toolInput,
title: toolInfoFromToolUse(
{ name: toolName, input: toolInput },
this.fileContentCache,
this.logger,
).title,
title: toolInfoFromToolUse({ name: toolName, input: toolInput }).title,
},
});
@@ -562,11 +551,7 @@ export class ClaudeAcpAgent implements Agent {
toolCall: {
toolCallId: toolUseID,
rawInput: toolInput,
title: toolInfoFromToolUse(
{ name: toolName, input: toolInput },
this.fileContentCache,
this.logger,
).title,
title: toolInfoFromToolUse({ name: toolName, input: toolInput }).title,
},
});
if (signal.aborted || response.outcome?.outcome === "cancelled") {
@@ -609,7 +594,17 @@ export class ClaudeAcpAgent implements Agent {
params: NewSessionRequest,
creationOpts: { resume?: string; forkSession?: boolean } = {},
): Promise<NewSessionResponse> {
const sessionId = creationOpts.resume ?? randomUUID();
// We want to create a new session id unless it is resume,
// but not resume + forkSession.
let sessionId;
if (creationOpts.forkSession) {
sessionId = randomUUID();
} else if (creationOpts.resume) {
sessionId = creationOpts.resume;
} else {
sessionId = randomUUID();
}
const input = new Pushable<SDKUserMessage>();
const settingsManager = new SettingsManager(params.cwd, {
@@ -670,16 +665,21 @@ export class ClaudeAcpAgent implements Agent {
// Extract options from _meta if provided
const userProvidedOptions = (params._meta as NewSessionMeta | undefined)?.claudeCode?.options;
const extraArgs = { ...userProvidedOptions?.extraArgs };
if (creationOpts?.resume === undefined) {
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
// Set our own session id if not resuming an existing session.
// TODO: find a way to make this work for fork
extraArgs["session-id"] = sessionId;
}
// Configure thinking tokens from environment variable
const maxThinkingTokens = process.env.MAX_THINKING_TOKENS
? parseInt(process.env.MAX_THINKING_TOKENS, 10)
: undefined;
const options: Options = {
systemPrompt,
settingSources: ["user", "project", "local"],
stderr: (err) => this.logger.error(err),
...(maxThinkingTokens !== undefined && { maxThinkingTokens }),
...userProvidedOptions,
// Override certain fields that must be controlled by ACP
cwd: params.cwd,
@@ -697,6 +697,7 @@ export class ClaudeAcpAgent implements Agent {
...(process.env.CLAUDE_CODE_EXECUTABLE && {
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
}),
tools: { type: "preset", preset: "claude_code" },
hooks: {
...userProvidedOptions?.hooks,
PreToolUse: [
@@ -716,7 +717,8 @@ export class ClaudeAcpAgent implements Agent {
};
const allowedTools = [];
const disallowedTools = [];
// Disable this for now, not a great way to expose this over ACP at the moment (in progress work so we can revisit)
const disallowedTools = ["AskUserQuestion"];
// Check if built-in tools should be disabled
const disableBuiltInTools = params._meta?.disableBuiltInTools === true;
@@ -877,7 +879,13 @@ async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand
return commands
.map((command) => {
const input = command.argumentHint ? { hint: command.argumentHint } : null;
const input = command.argumentHint
? {
hint: Array.isArray(command.argumentHint)
? command.argumentHint.join(" ")
: command.argumentHint,
}
: null;
let name = command.name;
if (command.name.endsWith(" (MCP)")) {
name = `mcp:${name.replace(" (MCP)", "")}`;
@@ -996,7 +1004,6 @@ export function toAcpNotifications(
role: "assistant" | "user",
sessionId: string,
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
client: AgentSideConnection,
logger: Logger,
): SessionNotification[] {
@@ -1107,7 +1114,7 @@ export function toAcpNotifications(
sessionUpdate: "tool_call",
rawInput,
status: "pending",
...toolInfoFromToolUse(chunk, fileContentCache, logger),
...toolInfoFromToolUse(chunk),
};
}
break;
@@ -1170,7 +1177,6 @@ export function streamEventToAcpNotifications(
message: SDKPartialAssistantMessage,
sessionId: string,
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
client: AgentSideConnection,
logger: Logger,
): SessionNotification[] {
@@ -1182,7 +1188,6 @@ export function streamEventToAcpNotifications(
"assistant",
sessionId,
toolUseCache,
fileContentCache,
client,
logger,
);
@@ -1192,7 +1197,6 @@ export function streamEventToAcpNotifications(
"assistant",
sessionId,
toolUseCache,
fileContentCache,
client,
logger,
);

View File

@@ -28,6 +28,20 @@ Whenever you read a file, you should consider whether it looks malicious. If it
const defaults = { maxFileSize: 50000, linesToRead: 2000 };
function formatErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
const unqualifiedToolNames = {
read: "Read",
edit: "Edit",
@@ -175,8 +189,8 @@ Usage:
if (result.wasLimited) {
readInfo += `Read ${result.linesRead} lines (hit 50KB limit). `;
} else {
readInfo += `Read lines ${input.offset}-${result.linesRead}. `;
} else if (input.offset && input.offset > 1) {
readInfo += `Read lines ${input.offset}-${input.offset + result.linesRead}.`;
}
if (result.wasLimited) {
@@ -194,12 +208,13 @@ Usage:
},
],
};
} catch (error: any) {
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: "Reading file failed: " + error.message,
text: "Reading file failed: " + formatErrorMessage(error),
},
],
};
@@ -256,12 +271,13 @@ Usage:
return {
content: [],
};
} catch (error: any) {
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: "Writing file failed: " + error.message,
text: "Writing file failed: " + formatErrorMessage(error),
},
],
};
@@ -347,12 +363,13 @@ Usage:
},
],
};
} catch (error: any) {
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: "Editing file failed: " + (error?.message ?? String(error)),
text: "Editing file failed: " + formatErrorMessage(error),
},
],
};
@@ -394,120 +411,132 @@ Output: Create directory 'foo'`),
},
},
async (input: BashInput, extra) => {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const toolCallId = extra._meta?.["claudecode/toolUseId"];
if (typeof toolCallId !== "string") {
throw new Error("No tool call ID found");
}
if (!agent.clientCapabilities?.terminal || !agent.client.createTerminal) {
throw new Error("unreachable");
}
const handle = await agent.client.createTerminal({
command: input.command,
env: [{ name: "CLAUDECODE", value: "1" }],
sessionId,
outputByteLimit: 32_000,
});
await agent.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: "in_progress",
title: input.description,
content: [{ type: "terminal", terminalId: handle.id }],
},
});
const abortPromise = new Promise((resolve) => {
if (extra.signal.aborted) {
resolve(null);
} else {
extra.signal.addEventListener("abort", () => {
resolve(null);
});
}
});
const statusPromise = Promise.race([
handle.waitForExit().then((exitStatus) => ({ status: "exited" as const, exitStatus })),
abortPromise.then(() => ({ status: "aborted" as const, exitStatus: null })),
sleep(input.timeout ?? 2 * 60 * 1000).then(async () => {
if (agent.backgroundTerminals[handle.id]?.status === "started") {
await handle.kill();
}
return { status: "timedOut" as const, exitStatus: null };
}),
]);
if (input.run_in_background) {
agent.backgroundTerminals[handle.id] = {
handle,
lastOutput: null,
status: "started",
};
statusPromise.then(async ({ status, exitStatus }) => {
const bgTerm = agent.backgroundTerminals[handle.id];
if (bgTerm.status !== "started") {
return;
}
const currentOutput = await handle.currentOutput();
agent.backgroundTerminals[handle.id] = {
status,
pendingOutput: {
...currentOutput,
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
exitStatus: exitStatus ?? currentOutput.exitStatus,
},
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
return handle.release();
const toolCallId = extra._meta?.["claudecode/toolUseId"];
if (typeof toolCallId !== "string") {
throw new Error("No tool call ID found");
}
if (!agent.clientCapabilities?.terminal || !agent.client.createTerminal) {
throw new Error("unreachable");
}
const handle = await agent.client.createTerminal({
command: input.command,
env: [{ name: "CLAUDECODE", value: "1" }],
sessionId,
outputByteLimit: 32_000,
});
await agent.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: "in_progress",
title: input.description,
content: [{ type: "terminal", terminalId: handle.id }],
},
});
const abortPromise = new Promise((resolve) => {
if (extra.signal.aborted) {
resolve(null);
} else {
extra.signal.addEventListener("abort", () => {
resolve(null);
});
}
});
const statusPromise = Promise.race([
handle.waitForExit().then((exitStatus) => ({ status: "exited" as const, exitStatus })),
abortPromise.then(() => ({ status: "aborted" as const, exitStatus: null })),
sleep(input.timeout ?? 2 * 60 * 1000).then(async () => {
if (agent.backgroundTerminals[handle.id]?.status === "started") {
await handle.kill();
}
return { status: "timedOut" as const, exitStatus: null };
}),
]);
if (input.run_in_background) {
agent.backgroundTerminals[handle.id] = {
handle,
lastOutput: null,
status: "started",
};
statusPromise.then(async ({ status, exitStatus }) => {
const bgTerm = agent.backgroundTerminals[handle.id];
if (bgTerm.status !== "started") {
return;
}
const currentOutput = await handle.currentOutput();
agent.backgroundTerminals[handle.id] = {
status,
pendingOutput: {
...currentOutput,
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
exitStatus: exitStatus ?? currentOutput.exitStatus,
},
};
return handle.release();
});
return {
content: [
{
type: "text",
text: `Command started in background with id: ${handle.id}`,
},
],
};
}
await using terminal = handle;
const { status } = await statusPromise;
if (status === "aborted") {
return {
content: [{ type: "text", text: "Tool cancelled by user" }],
};
}
const output = await terminal.currentOutput();
return {
content: [{ type: "text", text: toolCommandOutput(status, output) }],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: `Command started in background with id: ${handle.id}`,
text: "Running bash command failed: " + formatErrorMessage(error),
},
],
};
}
await using terminal = handle;
const { status } = await statusPromise;
if (status === "aborted") {
return {
content: [{ type: "text", text: "Tool cancelled by user" }],
};
}
const output = await terminal.currentOutput();
return {
content: [{ type: "text", text: toolCommandOutput(status, output) }],
};
},
);
@@ -531,37 +560,49 @@ In sessions with ${acpToolNames.bashOutput} always use it for output from Bash c
},
},
async (input) => {
const bgTerm = agent.backgroundTerminals[input.bash_id];
try {
const bgTerm = agent.backgroundTerminals[input.bash_id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.bash_id}`);
}
if (!bgTerm) {
throw new Error(`Unknown shell ${input.bash_id}`);
}
if (bgTerm.status === "started") {
const newOutput = await bgTerm.handle.currentOutput();
const strippedOutput = stripCommonPrefix(
bgTerm.lastOutput?.output ?? "",
newOutput.output,
);
bgTerm.lastOutput = newOutput;
if (bgTerm.status === "started") {
const newOutput = await bgTerm.handle.currentOutput();
const strippedOutput = stripCommonPrefix(
bgTerm.lastOutput?.output ?? "",
newOutput.output,
);
bgTerm.lastOutput = newOutput;
return {
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, {
...newOutput,
output: strippedOutput,
}),
},
],
};
} else {
return {
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, bgTerm.pendingOutput),
},
],
};
}
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, {
...newOutput,
output: strippedOutput,
}),
},
],
};
} else {
return {
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, bgTerm.pendingOutput),
text: "Retrieving bash output failed: " + formatErrorMessage(error),
},
],
};
@@ -588,49 +629,61 @@ In sessions with ${acpToolNames.killShell} always use it instead of KillShell.`,
},
},
async (input: KillShellInput) => {
const bgTerm = agent.backgroundTerminals[input.shell_id];
try {
const bgTerm = agent.backgroundTerminals[input.shell_id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.shell_id}`);
}
if (!bgTerm) {
throw new Error(`Unknown shell ${input.shell_id}`);
}
switch (bgTerm.status) {
case "started": {
await bgTerm.handle.kill();
const currentOutput = await bgTerm.handle.currentOutput();
agent.backgroundTerminals[bgTerm.handle.id] = {
status: "killed",
pendingOutput: {
...currentOutput,
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
switch (bgTerm.status) {
case "started": {
await bgTerm.handle.kill();
const currentOutput = await bgTerm.handle.currentOutput();
agent.backgroundTerminals[bgTerm.handle.id] = {
status: "killed",
pendingOutput: {
...currentOutput,
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
},
};
await bgTerm.handle.release();
return {
content: [{ type: "text", text: "Command killed successfully." }],
};
}
case "aborted":
return {
content: [{ type: "text", text: "Command aborted by user." }],
};
case "exited":
return {
content: [{ type: "text", text: "Command had already exited." }],
};
case "killed":
return {
content: [{ type: "text", text: "Command was already killed." }],
};
case "timedOut":
return {
content: [{ type: "text", text: "Command killed by timeout." }],
};
default: {
unreachable(bgTerm);
throw new Error("Unexpected background terminal status");
}
}
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: "Killing shell failed: " + formatErrorMessage(error),
},
};
await bgTerm.handle.release();
return {
content: [{ type: "text", text: "Command killed successfully." }],
};
}
case "aborted":
return {
content: [{ type: "text", text: "Command aborted by user." }],
};
case "exited":
return {
content: [{ type: "text", text: "Command had already exited." }],
};
case "killed":
return {
content: [{ type: "text", text: "Command was already killed." }],
};
case "timedOut":
return {
content: [{ type: "text", text: "Command killed by timeout." }],
};
default: {
unreachable(bgTerm);
throw new Error("Unexpected background terminal status");
}
],
};
}
},
);

View File

@@ -166,7 +166,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
expect(commands).toContainEqual({
name: "say-hello",
description: "Say hello (project)",
input: { hint: "[name]" },
input: { hint: "name" },
});
await connection.prompt({
@@ -251,7 +251,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: "`rm README.md.rm`",
content: [
@@ -276,7 +276,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "search",
title: "Find `*/**.ts`",
content: [],
@@ -297,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: [
@@ -322,7 +322,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "search",
title: "List the `/Users/test/github/claude-code-acp` directory's contents",
content: [],
@@ -340,7 +340,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "search",
title: 'grep ".*"',
content: [],
@@ -358,7 +358,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "edit",
title: "Write /Users/test/project/example.txt",
content: [
@@ -384,7 +384,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "edit",
title: "Write /Users/test/project/config.json",
content: [
@@ -409,7 +409,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read File",
content: [],
@@ -427,7 +427,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/data.json",
content: [],
@@ -446,7 +446,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (1 - 100)",
content: [],
@@ -466,7 +466,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (51 - 150)",
content: [],
@@ -485,7 +485,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (from line 201)",
content: [],
@@ -503,7 +503,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: `Kill Process`,
content: [],
@@ -520,7 +520,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: `Tail Logs`,
content: [],
@@ -613,7 +613,6 @@ describe("tool conversions", () => {
received.message.role,
"test",
{},
{},
{} as AgentSideConnection,
console,
),
@@ -847,7 +846,7 @@ describe("permission requests", () => {
for (const testCase of testCases) {
// Get the tool info that would be used in requestPermission
const toolInfo = toolInfoFromToolUse(testCase.toolUse, {});
const toolInfo = toolInfoFromToolUse(testCase.toolUse);
// Verify toolInfo has a title
expect(toolInfo.title).toBeDefined();

View File

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

View File

@@ -1,5 +1,6 @@
import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
import { replaceAndCalculateLocation, SYSTEM_REMINDER } from "./mcp-server.js";
import { SYSTEM_REMINDER } from "./mcp-server.js";
import * as diff from "diff";
import { ToolResultBlockParam, WebSearchToolResultBlockParam } from "@anthropic-ai/sdk/resources";
const acpUnqualifiedToolNames = {
@@ -49,11 +50,7 @@ interface ToolUpdate {
locations?: ToolCallLocation[];
}
export function toolInfoFromToolUse(
toolUse: any,
cachedFileContent: { [key: string]: string },
logger: Logger = console,
): ToolInfo {
export function toolInfoFromToolUse(toolUse: any): ToolInfo {
const name = toolUse.name;
const input = toolUse.input;
@@ -178,27 +175,7 @@ export function toolInfoFromToolUse(
case acpToolNames.edit:
case "Edit": {
const path = input?.file_path ?? input?.file_path;
let oldText = input.old_string ?? null;
let newText = input.new_string ?? "";
let affectedLines: number[] = [];
if (path && oldText) {
try {
const oldContent = cachedFileContent[path] || "";
const newContent = replaceAndCalculateLocation(oldContent, [
{
oldText,
newText,
replaceAll: false,
},
]);
oldText = oldContent;
newText = newContent.newContent;
affectedLines = newContent.lineNumbers;
} catch (e) {
logger.error(e);
}
}
return {
title: path ? `Edit \`${path}\`` : "Edit",
kind: "edit",
@@ -208,16 +185,12 @@ export function toolInfoFromToolUse(
{
type: "diff",
path,
oldText,
newText,
oldText: input.old_string ?? null,
newText: input.new_string ?? "",
},
]
: [],
locations: path
? affectedLines.length > 0
? affectedLines.map((line) => ({ line, path }))
: [{ path }]
: [],
locations: path ? [{ path }] : undefined,
};
}
@@ -332,7 +305,9 @@ export function toolInfoFromToolUse(
label += " -P";
}
label += ` "${input.pattern}"`;
if (input.pattern) {
label += ` "${input.pattern}"`;
}
if (input.path) {
label += ` ${input.path}`;
@@ -441,6 +416,16 @@ export function toolUpdateFromToolResult(
| BetaToolSearchToolResultBlockParam,
toolUse: any | undefined,
): ToolUpdate {
if (
"is_error" in toolResult &&
toolResult.is_error &&
toolResult.content &&
toolResult.content.length > 0
) {
// Only return errors
return toAcpContentUpdate(toolResult.content, true);
}
switch (toolUse?.name) {
case "Read":
case acpToolNames.read:
@@ -472,21 +457,60 @@ export function toolUpdateFromToolResult(
}
return {};
case acpToolNames.edit: {
const content: ToolCallContent[] = [];
const locations: ToolCallLocation[] = [];
if (
Array.isArray(toolResult.content) &&
toolResult.content.length > 0 &&
"text" in toolResult.content[0] &&
typeof toolResult.content[0].text === "string"
) {
const patches = diff.parsePatch(toolResult.content[0].text);
console.error(JSON.stringify(patches));
for (const { oldFileName, newFileName, hunks } of patches) {
for (const { lines, newStart } of hunks) {
const oldText = [];
const newText = [];
for (const line of lines) {
if (line.startsWith("-")) {
oldText.push(line.slice(1));
} else if (line.startsWith("+")) {
newText.push(line.slice(1));
} else {
oldText.push(line.slice(1));
newText.push(line.slice(1));
}
}
if (oldText.length > 0 || newText.length > 0) {
locations.push({ path: newFileName || oldFileName, line: newStart });
content.push({
type: "diff",
path: newFileName || oldFileName,
oldText: oldText.join("\n") || null,
newText: newText.join("\n"),
});
}
}
}
}
const result: ToolUpdate = {};
if (content.length > 0) {
result.content = content;
}
if (locations.length > 0) {
result.locations = locations;
}
return result;
}
case acpToolNames.bash:
case "edit":
case "Edit":
case acpToolNames.edit:
case acpToolNames.write:
case "Write": {
if (
"is_error" in toolResult &&
toolResult.is_error &&
toolResult.content &&
toolResult.content.length > 0
) {
// Only return errors
return toAcpContentUpdate(toolResult.content, true);
}
return {};
}

View File

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