mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
Switch over to built-in Claude Code tools (#316)
Given a number of recent issues stemming from Claude Code using more subagents that don't play nicely with our custom mcp server, plus some slowly diverging behavior, we made the decision to just switch back over to the built-in tools and do more processing on the tool input/output There are some downsides, but also a lot of upsides, on the whole, it feels like a win for now. Closes #310, #279, #305, #236, #94 --------- Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
This commit is contained in:
1209
package-lock.json
generated
1209
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -62,12 +62,10 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.14.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.45",
|
||||
"@modelcontextprotocol/sdk": "1.26.0",
|
||||
"diff": "8.0.3",
|
||||
"minimatch": "10.2.1"
|
||||
"diff": "8.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.75.0",
|
||||
"@anthropic-ai/sdk": "0.76.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.0",
|
||||
|
||||
217
src/acp-agent.ts
217
src/acp-agent.ts
@@ -74,8 +74,6 @@ import {
|
||||
Pushable,
|
||||
unreachable,
|
||||
} from "./utils.js";
|
||||
import { createMcpServer } from "./mcp-server.js";
|
||||
import { EDIT_TOOL_NAMES, acpToolNames } from "./tools.js";
|
||||
import {
|
||||
toolInfoFromToolUse,
|
||||
planEntries,
|
||||
@@ -83,7 +81,6 @@ import {
|
||||
ClaudePlanEntry,
|
||||
registerHookCallback,
|
||||
createPostToolUseHook,
|
||||
createPreToolUseHook,
|
||||
} from "./tools.js";
|
||||
import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
@@ -203,6 +200,19 @@ export type ToolUpdateMeta = {
|
||||
/* The structured output provided by Claude Code. */
|
||||
toolResponse?: unknown;
|
||||
};
|
||||
/* Terminal metadata for Bash tool execution, matching codex-acp's _meta protocol. */
|
||||
terminal_info?: {
|
||||
terminal_id: string;
|
||||
};
|
||||
terminal_output?: {
|
||||
terminal_id: string;
|
||||
data: string;
|
||||
};
|
||||
terminal_exit?: {
|
||||
terminal_id: string;
|
||||
exit_code: number;
|
||||
signal: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type ToolUseCache = {
|
||||
@@ -665,6 +675,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
this.toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
{ clientCapabilities: this.clientCapabilities },
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
@@ -693,6 +704,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
this.toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
{ clientCapabilities: this.clientCapabilities },
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
@@ -743,6 +755,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
this.toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
{ clientCapabilities: this.clientCapabilities },
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
@@ -863,7 +876,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
{ registerHooks: false },
|
||||
{ registerHooks: false, clientCapabilities: this.clientCapabilities },
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
@@ -885,6 +898,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
canUseTool(sessionId: string): CanUseTool {
|
||||
return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
|
||||
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
@@ -909,7 +923,10 @@ export class ClaudeAcpAgent implements Agent {
|
||||
toolCall: {
|
||||
toolCallId: toolUseID,
|
||||
rawInput: toolInput,
|
||||
title: toolInfoFromToolUse({ name: toolName, input: toolInput }).title,
|
||||
...toolInfoFromToolUse(
|
||||
{ name: toolName, input: toolInput, id: toolUseID },
|
||||
supportsTerminalOutput,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -945,10 +962,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
session.permissionMode === "bypassPermissions" ||
|
||||
(session.permissionMode === "acceptEdits" && EDIT_TOOL_NAMES.includes(toolName))
|
||||
) {
|
||||
if (session.permissionMode === "bypassPermissions") {
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: toolInput,
|
||||
@@ -972,7 +986,10 @@ export class ClaudeAcpAgent implements Agent {
|
||||
toolCall: {
|
||||
toolCallId: toolUseID,
|
||||
rawInput: toolInput,
|
||||
title: toolInfoFromToolUse({ name: toolName, input: toolInput }).title,
|
||||
...toolInfoFromToolUse(
|
||||
{ name: toolName, input: toolInput, id: toolUseID },
|
||||
supportsTerminalOutput,
|
||||
),
|
||||
},
|
||||
});
|
||||
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
||||
@@ -1070,16 +1087,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
// Only add the acp MCP server if built-in tools are not disabled
|
||||
if (!params._meta?.disableBuiltInTools) {
|
||||
const server = createMcpServer(this, sessionId, this.clientCapabilities);
|
||||
mcpServers["acp"] = {
|
||||
type: "sdk",
|
||||
name: "acp",
|
||||
instance: server,
|
||||
};
|
||||
}
|
||||
|
||||
let systemPrompt: Options["systemPrompt"] = { type: "preset", preset: "claude_code" };
|
||||
if (params._meta?.systemPrompt) {
|
||||
const customPrompt = params._meta.systemPrompt;
|
||||
@@ -1104,6 +1111,30 @@ export class ClaudeAcpAgent implements Agent {
|
||||
? parseInt(process.env.MAX_THINKING_TOKENS, 10)
|
||||
: undefined;
|
||||
|
||||
// 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
|
||||
if (params._meta?.disableBuiltInTools === true) {
|
||||
// When built-in tools are disabled, explicitly disallow all of them
|
||||
disallowedTools.push(
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Bash",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Task",
|
||||
"TodoWrite",
|
||||
"ExitPlanMode",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
"SlashCommand",
|
||||
"Skill",
|
||||
"NotebookEdit",
|
||||
);
|
||||
}
|
||||
|
||||
const options: Options = {
|
||||
systemPrompt,
|
||||
settingSources: ["user", "project", "local"],
|
||||
@@ -1125,15 +1156,10 @@ export class ClaudeAcpAgent implements Agent {
|
||||
...(process.env.CLAUDE_CODE_EXECUTABLE && {
|
||||
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
|
||||
}),
|
||||
disallowedTools,
|
||||
tools: { type: "preset", preset: "claude_code" },
|
||||
hooks: {
|
||||
...userProvidedOptions?.hooks,
|
||||
PreToolUse: [
|
||||
...(userProvidedOptions?.hooks?.PreToolUse || []),
|
||||
{
|
||||
hooks: [createPreToolUseHook(settingsManager, this.logger)],
|
||||
},
|
||||
],
|
||||
PostToolUse: [
|
||||
...(userProvidedOptions?.hooks?.PostToolUse || []),
|
||||
{
|
||||
@@ -1165,61 +1191,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
options.sessionId = sessionId;
|
||||
}
|
||||
|
||||
const allowedTools = [];
|
||||
// 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;
|
||||
|
||||
if (!disableBuiltInTools) {
|
||||
if (this.clientCapabilities?.fs?.readTextFile) {
|
||||
allowedTools.push(acpToolNames.read);
|
||||
disallowedTools.push("Read");
|
||||
}
|
||||
if (this.clientCapabilities?.fs?.writeTextFile) {
|
||||
disallowedTools.push("Write", "Edit");
|
||||
}
|
||||
if (this.clientCapabilities?.terminal) {
|
||||
allowedTools.push(acpToolNames.bashOutput, acpToolNames.killShell);
|
||||
disallowedTools.push("Bash", "BashOutput", "KillShell");
|
||||
}
|
||||
} else {
|
||||
// When built-in tools are disabled, explicitly disallow all of them
|
||||
disallowedTools.push(
|
||||
acpToolNames.read,
|
||||
acpToolNames.write,
|
||||
acpToolNames.edit,
|
||||
acpToolNames.bash,
|
||||
acpToolNames.bashOutput,
|
||||
acpToolNames.killShell,
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Bash",
|
||||
"BashOutput",
|
||||
"KillShell",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Task",
|
||||
"TodoWrite",
|
||||
"ExitPlanMode",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
"AskUserQuestion",
|
||||
"SlashCommand",
|
||||
"Skill",
|
||||
"NotebookEdit",
|
||||
);
|
||||
}
|
||||
|
||||
if (allowedTools.length > 0) {
|
||||
options.allowedTools = allowedTools;
|
||||
}
|
||||
if (disallowedTools.length > 0) {
|
||||
options.disallowedTools = [...(options.disallowedTools || []), ...disallowedTools];
|
||||
}
|
||||
|
||||
// Handle abort controller from meta options
|
||||
const abortController = userProvidedOptions?.abortController;
|
||||
if (abortController?.signal.aborted) {
|
||||
@@ -1460,9 +1431,10 @@ export function toAcpNotifications(
|
||||
toolUseCache: ToolUseCache,
|
||||
client: AgentSideConnection,
|
||||
logger: Logger,
|
||||
options?: { registerHooks?: boolean },
|
||||
options?: { registerHooks?: boolean; clientCapabilities?: ClientCapabilities },
|
||||
): SessionNotification[] {
|
||||
const registerHooks = options?.registerHooks !== false;
|
||||
const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
|
||||
if (typeof content === "string") {
|
||||
return [
|
||||
{
|
||||
@@ -1517,6 +1489,7 @@ export function toAcpNotifications(
|
||||
case "tool_use":
|
||||
case "server_tool_use":
|
||||
case "mcp_tool_use": {
|
||||
const alreadyCached = chunk.id in toolUseCache;
|
||||
toolUseCache[chunk.id] = chunk;
|
||||
if (chunk.name === "TodoWrite") {
|
||||
// @ts-expect-error - sometimes input is empty object
|
||||
@@ -1527,7 +1500,8 @@ export function toAcpNotifications(
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (registerHooks) {
|
||||
// Only register hooks on first encounter to avoid double-firing
|
||||
if (registerHooks && !alreadyCached) {
|
||||
registerHookCallback(chunk.id, {
|
||||
onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
|
||||
const toolUse = toolUseCache[toolUseId];
|
||||
@@ -1561,18 +1535,41 @@ export function toAcpNotifications(
|
||||
} catch {
|
||||
// ignore if we can't turn it to JSON
|
||||
}
|
||||
update = {
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolName: chunk.name,
|
||||
},
|
||||
} satisfies ToolUpdateMeta,
|
||||
toolCallId: chunk.id,
|
||||
sessionUpdate: "tool_call",
|
||||
rawInput,
|
||||
status: "pending",
|
||||
...toolInfoFromToolUse(chunk),
|
||||
};
|
||||
|
||||
if (alreadyCached) {
|
||||
// Second encounter (full assistant message after streaming) —
|
||||
// send as tool_call_update to refine the existing tool_call
|
||||
// rather than emitting a duplicate tool_call.
|
||||
update = {
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolName: chunk.name,
|
||||
},
|
||||
} satisfies ToolUpdateMeta,
|
||||
toolCallId: chunk.id,
|
||||
sessionUpdate: "tool_call_update",
|
||||
rawInput,
|
||||
...toolInfoFromToolUse(chunk, supportsTerminalOutput),
|
||||
};
|
||||
} else {
|
||||
// First encounter (streaming content_block_start or replay) —
|
||||
// send as tool_call with terminal_info for Bash tools.
|
||||
update = {
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolName: chunk.name,
|
||||
},
|
||||
...(chunk.name === "Bash" && supportsTerminalOutput
|
||||
? { terminal_info: { terminal_id: chunk.id } }
|
||||
: {}),
|
||||
} satisfies ToolUpdateMeta,
|
||||
toolCallId: chunk.id,
|
||||
sessionUpdate: "tool_call",
|
||||
rawInput,
|
||||
status: "pending",
|
||||
...toolInfoFromToolUse(chunk, supportsTerminalOutput),
|
||||
};
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1594,17 +1591,42 @@ export function toAcpNotifications(
|
||||
}
|
||||
|
||||
if (toolUse.name !== "TodoWrite") {
|
||||
const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(
|
||||
chunk,
|
||||
toolUseCache[chunk.tool_use_id],
|
||||
supportsTerminalOutput,
|
||||
);
|
||||
|
||||
// When terminal output is supported, send terminal_output as a
|
||||
// separate notification to match codex-acp's streaming lifecycle:
|
||||
// 1. tool_call → _meta.terminal_info (already sent above)
|
||||
// 2. tool_call_update → _meta.terminal_output (sent here)
|
||||
// 3. tool_call_update → _meta.terminal_exit (sent below with status)
|
||||
if (toolMeta?.terminal_output) {
|
||||
output.push({
|
||||
sessionId,
|
||||
update: {
|
||||
_meta: {
|
||||
terminal_output: toolMeta.terminal_output,
|
||||
},
|
||||
toolCallId: chunk.tool_use_id,
|
||||
sessionUpdate: "tool_call_update" as const,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
update = {
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolName: toolUse.name,
|
||||
},
|
||||
...(toolMeta?.terminal_exit ? { terminal_exit: toolMeta.terminal_exit } : {}),
|
||||
} satisfies ToolUpdateMeta,
|
||||
toolCallId: chunk.tool_use_id,
|
||||
sessionUpdate: "tool_call_update",
|
||||
status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
|
||||
rawOutput: chunk.content,
|
||||
...toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id]),
|
||||
...toolUpdate,
|
||||
};
|
||||
}
|
||||
break;
|
||||
@@ -1639,6 +1661,7 @@ export function streamEventToAcpNotifications(
|
||||
toolUseCache: ToolUseCache,
|
||||
client: AgentSideConnection,
|
||||
logger: Logger,
|
||||
options?: { clientCapabilities?: ClientCapabilities },
|
||||
): SessionNotification[] {
|
||||
const event = message.event;
|
||||
switch (event.type) {
|
||||
@@ -1650,6 +1673,7 @@ export function streamEventToAcpNotifications(
|
||||
toolUseCache,
|
||||
client,
|
||||
logger,
|
||||
{ clientCapabilities: options?.clientCapabilities },
|
||||
);
|
||||
case "content_block_delta":
|
||||
return toAcpNotifications(
|
||||
@@ -1659,6 +1683,7 @@ export function streamEventToAcpNotifications(
|
||||
toolUseCache,
|
||||
client,
|
||||
logger,
|
||||
{ clientCapabilities: options?.clientCapabilities },
|
||||
);
|
||||
// No content
|
||||
case "message_start":
|
||||
|
||||
12
src/lib.ts
12
src/lib.ts
@@ -15,20 +15,10 @@ export {
|
||||
Pushable,
|
||||
unreachable,
|
||||
} from "./utils.js";
|
||||
export { createMcpServer } from "./mcp-server.js";
|
||||
export {
|
||||
toolInfoFromToolUse,
|
||||
planEntries,
|
||||
toolUpdateFromToolResult,
|
||||
createPreToolUseHook,
|
||||
acpToolNames as toolNames,
|
||||
} from "./tools.js";
|
||||
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
|
||||
export {
|
||||
SettingsManager,
|
||||
type ClaudeCodeSettings,
|
||||
type PermissionSettings,
|
||||
type PermissionDecision,
|
||||
type PermissionCheckResult,
|
||||
type SettingsManagerOptions,
|
||||
} from "./settings.js";
|
||||
|
||||
|
||||
@@ -1,911 +0,0 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import {
|
||||
BashInput,
|
||||
FileEditInput,
|
||||
FileReadInput,
|
||||
FileWriteInput,
|
||||
TaskOutputInput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
import { z } from "zod";
|
||||
import { CLAUDE_CONFIG_DIR, ClaudeAcpAgent } from "./acp-agent.js";
|
||||
import {
|
||||
ClientCapabilities,
|
||||
ReadTextFileResponse,
|
||||
TerminalOutputResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import * as diff from "diff";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs/promises";
|
||||
|
||||
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
|
||||
import { acpToolNames } from "./tools.js";
|
||||
|
||||
export const SYSTEM_REMINDER = `
|
||||
|
||||
<system-reminder>
|
||||
Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.
|
||||
</system-reminder>`;
|
||||
|
||||
const defaults = { maxFileSize: 50000, linesToRead: 2000 };
|
||||
|
||||
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",
|
||||
write: "Write",
|
||||
bash: "Bash",
|
||||
killShell: "KillShell",
|
||||
bashOutput: "BashOutput",
|
||||
};
|
||||
|
||||
export function createMcpServer(
|
||||
agent: ClaudeAcpAgent,
|
||||
sessionId: string,
|
||||
clientCapabilities: ClientCapabilities | undefined,
|
||||
): McpServer {
|
||||
/**
|
||||
* This checks if a given path is related to internal agent persistence and if the agent should be allowed to read/write from here.
|
||||
* We let the agent do normal fs operations on these paths so that it can persist its state.
|
||||
* However, we block access to settings files for security reasons.
|
||||
*/
|
||||
function internalPath(file_path: string) {
|
||||
return (
|
||||
file_path.startsWith(CLAUDE_CONFIG_DIR) &&
|
||||
!file_path.startsWith(path.join(CLAUDE_CONFIG_DIR, "settings.json")) &&
|
||||
!file_path.startsWith(path.join(CLAUDE_CONFIG_DIR, "session-env"))
|
||||
);
|
||||
}
|
||||
|
||||
async function readTextFile(input: FileReadInput): Promise<ReadTextFileResponse> {
|
||||
if (internalPath(input.file_path)) {
|
||||
const content = await fs.readFile(input.file_path, "utf8");
|
||||
|
||||
// eslint-disable-next-line eqeqeq
|
||||
if (input.offset != null || input.limit != null) {
|
||||
const lines = content.split("\n");
|
||||
|
||||
// Apply offset and limit if provided
|
||||
const offset = input.offset ?? 1;
|
||||
const limit = input.limit ?? lines.length;
|
||||
|
||||
// Extract the requested lines (offset is 1-based)
|
||||
const startIndex = Math.max(0, offset - 1);
|
||||
const endIndex = Math.min(lines.length, startIndex + limit);
|
||||
const selectedLines = lines.slice(startIndex, endIndex);
|
||||
|
||||
return { content: selectedLines.join("\n") };
|
||||
} else {
|
||||
return { content };
|
||||
}
|
||||
}
|
||||
|
||||
return agent.readTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
line: input.offset,
|
||||
limit: input.limit,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeTextFile(input: FileWriteInput): Promise<void> {
|
||||
if (internalPath(input.file_path)) {
|
||||
await fs.writeFile(input.file_path, input.content, "utf8");
|
||||
} else {
|
||||
await agent.writeTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
content: input.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create MCP server
|
||||
const server = new McpServer({ name: "acp", version: "1.0.0" }, { capabilities: { tools: {} } });
|
||||
|
||||
if (clientCapabilities?.fs?.readTextFile) {
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.read,
|
||||
{
|
||||
title: unqualifiedToolNames.read,
|
||||
description: `Reads the content of the given file in the project.
|
||||
|
||||
In sessions with ${acpToolNames.read} always use it instead of Read as it contains the most up-to-date contents.
|
||||
|
||||
Reads a file from the local filesystem. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
||||
|
||||
Usage:
|
||||
- The file_path parameter must be an absolute path, not a relative path
|
||||
- By default, it reads up to ${defaults.linesToRead} lines starting from the beginning of the file
|
||||
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
||||
- Any files larger than ${defaults.maxFileSize} bytes will be truncated
|
||||
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.
|
||||
- This tool can only read files, not directories. To read a directory, use an ls command via the ${acpToolNames.bash} tool.
|
||||
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.`,
|
||||
inputSchema: {
|
||||
file_path: z.string().describe("The absolute path to the file to read"),
|
||||
offset: z
|
||||
.number()
|
||||
.optional()
|
||||
.default(1)
|
||||
.describe(
|
||||
"The line number to start reading from. Only provide if the file is too large to read at once",
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.optional()
|
||||
.default(defaults.linesToRead)
|
||||
.describe(
|
||||
`The number of lines to read. Only provide if the file is too large to read at once.`,
|
||||
),
|
||||
},
|
||||
annotations: {
|
||||
title: "Read file",
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input: FileReadInput) => {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const readResponse = await readTextFile(input);
|
||||
|
||||
if (typeof readResponse?.content !== "string") {
|
||||
throw new Error(`No file contents for ${input.file_path}.`);
|
||||
}
|
||||
|
||||
// Extract lines with byte limit enforcement
|
||||
const result = extractLinesWithByteLimit(readResponse.content, defaults.maxFileSize);
|
||||
|
||||
// Construct informative message about what was read
|
||||
let readInfo = "";
|
||||
if ((input.offset && input.offset > 1) || result.wasLimited) {
|
||||
readInfo = "\n\n<file-read-info>";
|
||||
|
||||
if (result.wasLimited) {
|
||||
readInfo += `Read ${result.linesRead} lines (hit 50KB limit). `;
|
||||
} else if (input.offset && input.offset > 1) {
|
||||
readInfo += `Read lines ${input.offset}-${input.offset + result.linesRead}.`;
|
||||
}
|
||||
|
||||
if (result.wasLimited) {
|
||||
readInfo += `Continue with offset=${result.linesRead}.`;
|
||||
}
|
||||
|
||||
readInfo += "</file-read-info>";
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: result.content + readInfo + SYSTEM_REMINDER,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Reading file failed: " + formatErrorMessage(error),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (clientCapabilities?.fs?.writeTextFile) {
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.write,
|
||||
{
|
||||
title: unqualifiedToolNames.write,
|
||||
description: `Writes a file to the local filesystem..
|
||||
|
||||
In sessions with ${acpToolNames.write} always use it instead of Write as it will
|
||||
allow the user to conveniently review changes.
|
||||
|
||||
Usage:
|
||||
- This tool will overwrite the existing file if there is one at the provided path.
|
||||
- If this is an existing file, you MUST use the ${acpToolNames.read} tool first to read the file's contents. This tool will fail if you did not read the file first.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
|
||||
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.`,
|
||||
inputSchema: {
|
||||
file_path: z
|
||||
.string()
|
||||
.describe("The absolute path to the file to write (must be absolute, not relative)"),
|
||||
content: z.string().describe("The content to write to the file"),
|
||||
},
|
||||
annotations: {
|
||||
title: "Write file",
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input: FileWriteInput) => {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
await writeTextFile(input);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `The file ${input.file_path} has been updated successfully.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Writing file failed: " + formatErrorMessage(error),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.edit,
|
||||
{
|
||||
title: unqualifiedToolNames.edit,
|
||||
description: `Performs exact string replacements in files.
|
||||
|
||||
In sessions with ${acpToolNames.edit} always use it instead of Edit as it will
|
||||
allow the user to conveniently review changes.
|
||||
|
||||
Usage:
|
||||
- You must use your \`${acpToolNames.read}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
|
||||
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
||||
- The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
|
||||
- Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`,
|
||||
inputSchema: {
|
||||
file_path: z.string().describe("The absolute path to the file to modify"),
|
||||
old_string: z.string().describe("The text to replace"),
|
||||
new_string: z
|
||||
.string()
|
||||
.describe("The text to replace it with (must be different from old_string)"),
|
||||
replace_all: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.optional()
|
||||
.describe("Replace all occurrences of old_string (default false)"),
|
||||
},
|
||||
annotations: {
|
||||
title: "Edit file",
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input: FileEditInput) => {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const readResponse = await readTextFile({
|
||||
file_path: input.file_path,
|
||||
});
|
||||
|
||||
if (typeof readResponse?.content !== "string") {
|
||||
throw new Error(`No file contents for ${input.file_path}.`);
|
||||
}
|
||||
|
||||
const { newContent } = replaceAndCalculateLocation(readResponse.content, [
|
||||
{
|
||||
oldText: input.old_string,
|
||||
newText: input.new_string,
|
||||
replaceAll: input.replace_all,
|
||||
},
|
||||
]);
|
||||
|
||||
const patch = diff.createPatch(input.file_path, readResponse.content, newContent);
|
||||
|
||||
await writeTextFile({ file_path: input.file_path, content: newContent });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: patch,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Editing file failed: " + formatErrorMessage(error),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (agent.clientCapabilities?.terminal) {
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.bash,
|
||||
{
|
||||
title: unqualifiedToolNames.bash,
|
||||
description: `Executes a bash command
|
||||
|
||||
In sessions with ${acpToolNames.bash} always use it instead of Bash`,
|
||||
inputSchema: {
|
||||
command: z.string().describe("The command to execute"),
|
||||
timeout: z.number().describe(`Optional timeout in milliseconds (max ${2 * 60 * 1000})`),
|
||||
description: z.string().optional()
|
||||
.describe(`Clear, concise description of what this command does in 5-10 words, in active voice. Examples:
|
||||
Input: ls
|
||||
Output: List files in current directory
|
||||
|
||||
Input: git status
|
||||
Output: Show working tree status
|
||||
|
||||
Input: npm install
|
||||
Output: Install package dependencies
|
||||
|
||||
Input: mkdir foo
|
||||
Output: Create directory 'foo'`),
|
||||
run_in_background: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
`Set to true to run this command in the background. The tool returns an \`id\` that can be used with the \`${acpToolNames.bashOutput}\` tool to retrieve the current output, or the \`${acpToolNames.killShell}\` tool to stop it early.`,
|
||||
),
|
||||
},
|
||||
},
|
||||
async (input: BashInput, extra) => {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const toolCallId = extra._meta?.["claudecode/toolUseId"];
|
||||
|
||||
if (typeof toolCallId !== "string") {
|
||||
throw new Error("No tool call ID found");
|
||||
}
|
||||
|
||||
if (!agent.clientCapabilities?.terminal || !agent.client.createTerminal) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
const handle = await agent.client.createTerminal({
|
||||
command: input.command,
|
||||
env: [{ name: "CLAUDECODE", value: "1" }],
|
||||
sessionId,
|
||||
outputByteLimit: 32_000,
|
||||
});
|
||||
|
||||
await agent.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId,
|
||||
status: "in_progress",
|
||||
title: input.description,
|
||||
content: [{ type: "terminal", terminalId: handle.id }],
|
||||
},
|
||||
});
|
||||
|
||||
const abortPromise = new Promise((resolve) => {
|
||||
if (extra.signal.aborted) {
|
||||
resolve(null);
|
||||
} else {
|
||||
extra.signal.addEventListener("abort", () => {
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const statusPromise = Promise.race([
|
||||
handle.waitForExit().then((exitStatus) => ({ status: "exited" as const, exitStatus })),
|
||||
abortPromise.then(() => ({ status: "aborted" as const, exitStatus: null })),
|
||||
sleep(input.timeout ?? 2 * 60 * 1000).then(async () => {
|
||||
if (agent.backgroundTerminals[handle.id]?.status === "started") {
|
||||
await handle.kill();
|
||||
}
|
||||
return { status: "timedOut" as const, exitStatus: null };
|
||||
}),
|
||||
]);
|
||||
|
||||
if (input.run_in_background) {
|
||||
agent.backgroundTerminals[handle.id] = {
|
||||
handle,
|
||||
lastOutput: null,
|
||||
status: "started",
|
||||
};
|
||||
|
||||
statusPromise.then(async ({ status, exitStatus }) => {
|
||||
const bgTerm = agent.backgroundTerminals[handle.id];
|
||||
|
||||
if (bgTerm.status !== "started") {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentOutput = await handle.currentOutput();
|
||||
|
||||
agent.backgroundTerminals[handle.id] = {
|
||||
status,
|
||||
pendingOutput: {
|
||||
...currentOutput,
|
||||
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
|
||||
exitStatus: exitStatus ?? currentOutput.exitStatus,
|
||||
},
|
||||
};
|
||||
|
||||
return handle.release();
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Command started in background with id: ${handle.id}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
await using terminal = handle;
|
||||
|
||||
const { status } = await statusPromise;
|
||||
|
||||
if (status === "aborted") {
|
||||
return {
|
||||
content: [{ type: "text", text: "Tool cancelled by user" }],
|
||||
};
|
||||
}
|
||||
|
||||
const output = await terminal.currentOutput();
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: toolCommandOutput(status, output) }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Running bash command failed: " + formatErrorMessage(error),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.bashOutput,
|
||||
{
|
||||
title: unqualifiedToolNames.bashOutput,
|
||||
description: `- Retrieves output from a running or completed background bash shell
|
||||
- Takes a bash_id parameter identifying the shell
|
||||
- Always returns only new output since the last check
|
||||
- Returns stdout and stderr output along with shell status
|
||||
- Use this tool when you need to monitor or check the output of a long-running shell
|
||||
|
||||
In sessions with ${acpToolNames.bashOutput} always use it for output from Bash commands instead of TaskOutput.`,
|
||||
inputSchema: {
|
||||
task_id: z
|
||||
.string()
|
||||
.describe(
|
||||
`The id of the background bash command as returned by \`${acpToolNames.bash}\``,
|
||||
),
|
||||
block: z.boolean().describe("Whether to wait for completion"),
|
||||
timeout: z.number().describe("Max wait time in ms"),
|
||||
},
|
||||
},
|
||||
async (input: TaskOutputInput) => {
|
||||
try {
|
||||
const bgTerm = agent.backgroundTerminals[input.task_id];
|
||||
|
||||
if (!bgTerm) {
|
||||
throw new Error(`Unknown shell ${input.task_id}`);
|
||||
}
|
||||
|
||||
if (input.block && bgTerm.status === "started") {
|
||||
const statusPromise = Promise.race([
|
||||
bgTerm.handle
|
||||
.waitForExit()
|
||||
.then((exitStatus) => ({ status: "exited" as const, exitStatus })),
|
||||
sleep(input.timeout ?? 2 * 60 * 1000).then(async () => {
|
||||
if (bgTerm.status === "started") {
|
||||
await bgTerm.handle.kill();
|
||||
}
|
||||
return { status: "timedOut" as const, exitStatus: null };
|
||||
}),
|
||||
]);
|
||||
|
||||
const { status, exitStatus } = await statusPromise;
|
||||
const currentOutput = await bgTerm.handle.currentOutput();
|
||||
const strippedOutput = stripCommonPrefix(
|
||||
bgTerm.lastOutput?.output ?? "",
|
||||
currentOutput.output,
|
||||
);
|
||||
|
||||
agent.backgroundTerminals[input.task_id] = {
|
||||
status,
|
||||
pendingOutput: {
|
||||
...currentOutput,
|
||||
output: strippedOutput,
|
||||
exitStatus: exitStatus ?? currentOutput.exitStatus,
|
||||
},
|
||||
};
|
||||
|
||||
await bgTerm.handle.release();
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: toolCommandOutput(status, {
|
||||
...currentOutput,
|
||||
output: strippedOutput,
|
||||
exitStatus: exitStatus ?? currentOutput.exitStatus,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
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: "Retrieving bash output failed: " + formatErrorMessage(error),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.killShell,
|
||||
{
|
||||
title: unqualifiedToolNames.killShell,
|
||||
description: `- Kills a running background bash shell by its ID
|
||||
- Takes a shell_id parameter identifying the shell to kill
|
||||
- Returns a success or failure status
|
||||
- Use this tool when you need to terminate a long-running shell
|
||||
|
||||
In sessions with ${acpToolNames.killShell} always use it instead of KillShell.`,
|
||||
inputSchema: {
|
||||
shell_id: z
|
||||
.string()
|
||||
.describe(
|
||||
`The id of the background bash command as returned by \`${acpToolNames.bash}\``,
|
||||
),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const bgTerm = agent.backgroundTerminals[input.shell_id];
|
||||
|
||||
if (!bgTerm) {
|
||||
throw new Error(`Unknown shell ${input.shell_id}`);
|
||||
}
|
||||
|
||||
switch (bgTerm.status) {
|
||||
case "started": {
|
||||
await bgTerm.handle.kill();
|
||||
const currentOutput = await bgTerm.handle.currentOutput();
|
||||
agent.backgroundTerminals[bgTerm.handle.id] = {
|
||||
status: "killed",
|
||||
pendingOutput: {
|
||||
...currentOutput,
|
||||
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
|
||||
},
|
||||
};
|
||||
await bgTerm.handle.release();
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: "Command killed successfully." }],
|
||||
};
|
||||
}
|
||||
case "aborted":
|
||||
return {
|
||||
content: [{ type: "text", text: "Command aborted by user." }],
|
||||
};
|
||||
case "exited":
|
||||
return {
|
||||
content: [{ type: "text", text: "Command had already exited." }],
|
||||
};
|
||||
case "killed":
|
||||
return {
|
||||
content: [{ type: "text", text: "Command was already killed." }],
|
||||
};
|
||||
case "timedOut":
|
||||
return {
|
||||
content: [{ type: "text", text: "Command killed by timeout." }],
|
||||
};
|
||||
default: {
|
||||
unreachable(bgTerm);
|
||||
throw new Error("Unexpected background terminal status");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Killing shell failed: " + formatErrorMessage(error),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function stripCommonPrefix(a: string, b: string): string {
|
||||
let i = 0;
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) {
|
||||
i++;
|
||||
}
|
||||
return b.slice(i);
|
||||
}
|
||||
|
||||
function toolCommandOutput(
|
||||
status: "started" | "aborted" | "exited" | "killed" | "timedOut",
|
||||
output: TerminalOutputResponse,
|
||||
): string {
|
||||
const { exitStatus, output: commandOutput, truncated } = output;
|
||||
|
||||
let toolOutput = "";
|
||||
|
||||
switch (status) {
|
||||
case "started":
|
||||
case "exited": {
|
||||
if (exitStatus && (exitStatus.exitCode ?? null) === null) {
|
||||
toolOutput += `Interrupted by the user. `;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "killed":
|
||||
toolOutput += `Killed. `;
|
||||
break;
|
||||
case "timedOut":
|
||||
toolOutput += `Timed out. `;
|
||||
break;
|
||||
case "aborted":
|
||||
break;
|
||||
default: {
|
||||
const unreachable: never = status;
|
||||
return unreachable;
|
||||
}
|
||||
}
|
||||
|
||||
if (exitStatus) {
|
||||
if (typeof exitStatus.exitCode === "number") {
|
||||
toolOutput += `Exited with code ${exitStatus.exitCode}.`;
|
||||
}
|
||||
|
||||
if (typeof exitStatus.signal === "string") {
|
||||
toolOutput += `Signal \`${exitStatus.signal}\`. `;
|
||||
}
|
||||
|
||||
toolOutput += "Final output:\n\n";
|
||||
} else {
|
||||
toolOutput += "New output:\n\n";
|
||||
}
|
||||
|
||||
toolOutput += commandOutput;
|
||||
|
||||
if (truncated) {
|
||||
toolOutput += `\n\nCommand output was too long, so it was truncated to ${commandOutput.length} bytes.`;
|
||||
}
|
||||
|
||||
return toolOutput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace text in a file and calculate the line numbers where the edits occurred.
|
||||
*
|
||||
* @param fileContent - The full file content
|
||||
* @param edits - Array of edit operations to apply sequentially
|
||||
* @returns the new content and the line numbers where replacements occurred in the final content
|
||||
*/
|
||||
export function replaceAndCalculateLocation(
|
||||
fileContent: string,
|
||||
edits: Array<{
|
||||
oldText: string;
|
||||
newText: string;
|
||||
replaceAll?: boolean;
|
||||
}>,
|
||||
): { newContent: string; lineNumbers: number[] } {
|
||||
let currentContent = fileContent;
|
||||
|
||||
// Use unique markers to track where replacements happen
|
||||
const markerPrefix = `__REPLACE_MARKER_${Math.random().toString(36).substr(2, 9)}_`;
|
||||
let markerCounter = 0;
|
||||
const markers: string[] = [];
|
||||
|
||||
// Apply edits sequentially, inserting markers at replacement positions
|
||||
for (const edit of edits) {
|
||||
// Skip empty oldText
|
||||
if (edit.oldText === "") {
|
||||
throw new Error(`The provided \`old_string\` is empty.\n\nNo edits were applied.`);
|
||||
}
|
||||
|
||||
if (edit.replaceAll) {
|
||||
// Replace all occurrences with marker + newText
|
||||
const parts: string[] = [];
|
||||
let lastIndex = 0;
|
||||
let searchIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const index = currentContent.indexOf(edit.oldText, searchIndex);
|
||||
if (index === -1) {
|
||||
if (searchIndex === 0) {
|
||||
throw new Error(
|
||||
`The provided \`old_string\` does not appear in the file: "${edit.oldText}".\n\nNo edits were applied.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Add content before the match
|
||||
parts.push(currentContent.substring(lastIndex, index));
|
||||
|
||||
// Add marker and replacement
|
||||
const marker = `${markerPrefix}${markerCounter++}__`;
|
||||
markers.push(marker);
|
||||
parts.push(marker + edit.newText);
|
||||
|
||||
lastIndex = index + edit.oldText.length;
|
||||
searchIndex = lastIndex;
|
||||
}
|
||||
|
||||
// Add remaining content
|
||||
parts.push(currentContent.substring(lastIndex));
|
||||
currentContent = parts.join("");
|
||||
} else {
|
||||
// Replace first occurrence only
|
||||
const index = currentContent.indexOf(edit.oldText);
|
||||
if (index === -1) {
|
||||
throw new Error(
|
||||
`The provided \`old_string\` does not appear in the file: "${edit.oldText}".\n\nNo edits were applied.`,
|
||||
);
|
||||
} else {
|
||||
const marker = `${markerPrefix}${markerCounter++}__`;
|
||||
markers.push(marker);
|
||||
currentContent =
|
||||
currentContent.substring(0, index) +
|
||||
marker +
|
||||
edit.newText +
|
||||
currentContent.substring(index + edit.oldText.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find line numbers where markers appear in the content
|
||||
const lineNumbers: number[] = [];
|
||||
for (const marker of markers) {
|
||||
const index = currentContent.indexOf(marker);
|
||||
if (index !== -1) {
|
||||
const lineNumber = Math.max(
|
||||
0,
|
||||
currentContent.substring(0, index).split(/\r\n|\r|\n/).length - 1,
|
||||
);
|
||||
lineNumbers.push(lineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all markers from the final content
|
||||
let finalContent = currentContent;
|
||||
for (const marker of markers) {
|
||||
finalContent = finalContent.replace(marker, "");
|
||||
}
|
||||
|
||||
// Dedupe and sort line numbers
|
||||
const uniqueLineNumbers = [...new Set(lineNumbers)].sort();
|
||||
|
||||
return { newContent: finalContent, lineNumbers: uniqueLineNumbers };
|
||||
}
|
||||
261
src/settings.ts
261
src/settings.ts
@@ -1,8 +1,5 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { minimatch } from "minimatch";
|
||||
import { ACP_TOOL_NAME_PREFIX, acpToolNames } from "./tools.js";
|
||||
import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
|
||||
|
||||
/**
|
||||
@@ -17,194 +14,11 @@ import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
|
||||
* 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>;
|
||||
model?: 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);
|
||||
}
|
||||
// Convert backslashes to forward slashes for minimatch compatibility on Windows
|
||||
return path.normalize(filePath).replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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" && toolName === acpToolNames.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
|
||||
*/
|
||||
@@ -338,36 +152,9 @@ export class SettingsManager {
|
||||
this.enterpriseSettings,
|
||||
];
|
||||
|
||||
const merged: ClaudeCodeSettings = {
|
||||
permissions: {
|
||||
allow: [],
|
||||
deny: [],
|
||||
ask: [],
|
||||
},
|
||||
};
|
||||
const merged: ClaudeCodeSettings = {};
|
||||
|
||||
for (const settings of allSettings) {
|
||||
if (settings.permissions) {
|
||||
if (settings.permissions.allow) {
|
||||
merged.permissions!.allow!.push(...settings.permissions.allow);
|
||||
}
|
||||
if (settings.permissions.deny) {
|
||||
merged.permissions!.deny!.push(...settings.permissions.deny);
|
||||
}
|
||||
if (settings.permissions.ask) {
|
||||
merged.permissions!.ask!.push(...settings.permissions.ask);
|
||||
}
|
||||
if (settings.permissions.additionalDirectories) {
|
||||
merged.permissions!.additionalDirectories = [
|
||||
...(merged.permissions!.additionalDirectories || []),
|
||||
...settings.permissions.additionalDirectories,
|
||||
];
|
||||
}
|
||||
if (settings.permissions.defaultMode) {
|
||||
merged.permissions!.defaultMode = settings.permissions.defaultMode;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.env) {
|
||||
merged.env = { ...merged.env, ...settings.env };
|
||||
}
|
||||
@@ -436,52 +223,6 @@ export class SettingsManager {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tool invocation is allowed based on the loaded settings.
|
||||
*
|
||||
* @param toolName - The tool name (can be ACP-prefixed like mcp__acp__Read or plain like Read)
|
||||
* @param toolInput - The tool input object
|
||||
* @returns The permission decision and matching rule info
|
||||
*/
|
||||
checkPermission(toolName: string, toolInput: unknown): PermissionCheckResult {
|
||||
if (!toolName.startsWith(ACP_TOOL_NAME_PREFIX)) {
|
||||
return { decision: "ask" };
|
||||
}
|
||||
|
||||
const permissions = this.mergedSettings.permissions;
|
||||
|
||||
if (!permissions) {
|
||||
return { decision: "ask" };
|
||||
}
|
||||
|
||||
// Check deny rules first (highest priority)
|
||||
for (const rule of permissions.deny || []) {
|
||||
const parsed = parseRule(rule);
|
||||
if (matchesRule(parsed, toolName, toolInput, this.cwd)) {
|
||||
return { decision: "deny", rule, source: "deny" };
|
||||
}
|
||||
}
|
||||
|
||||
// Check allow rules
|
||||
for (const rule of permissions.allow || []) {
|
||||
const parsed = parseRule(rule);
|
||||
if (matchesRule(parsed, toolName, toolInput, this.cwd)) {
|
||||
return { decision: "allow", rule, source: "allow" };
|
||||
}
|
||||
}
|
||||
|
||||
// Check ask rules
|
||||
for (const rule of permissions.ask || []) {
|
||||
const parsed = parseRule(rule);
|
||||
if (matchesRule(parsed, toolName, toolInput, this.cwd)) {
|
||||
return { decision: "ask", rule, source: "ask" };
|
||||
}
|
||||
}
|
||||
|
||||
// No matching rule - default to ask
|
||||
return { decision: "ask" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current merged settings
|
||||
*/
|
||||
|
||||
@@ -260,7 +260,7 @@ describe("tool conversions", () => {
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "execute",
|
||||
title: "`rm README.md.rm`",
|
||||
title: "rm README.md.rm",
|
||||
content: [
|
||||
{
|
||||
content: {
|
||||
@@ -319,24 +319,6 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle LS tool calls", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01EEqsX7Eb9hpx87KAHVPTey",
|
||||
name: "LS",
|
||||
input: {
|
||||
path: "/Users/test/github/claude-agent-acp",
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "search",
|
||||
title: "List the `/Users/test/github/claude-agent-acp` directory's contents",
|
||||
content: [],
|
||||
locations: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle Grep tool calls", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
@@ -380,11 +362,11 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__Write tool calls", () => {
|
||||
it("should handle Write tool calls", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01GHI789JKL456",
|
||||
name: "mcp__acp__Write",
|
||||
name: "Write",
|
||||
input: {
|
||||
file_path: "/Users/test/project/config.json",
|
||||
content: '{"version": "1.0.0"}',
|
||||
@@ -406,6 +388,81 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle Edit tool calls", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01EDIT123",
|
||||
name: "Edit",
|
||||
input: {
|
||||
file_path: "/Users/test/project/test.txt",
|
||||
old_string: "old text",
|
||||
new_string: "new text",
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "edit",
|
||||
title: "Edit /Users/test/project/test.txt",
|
||||
content: [
|
||||
{
|
||||
type: "diff",
|
||||
path: "/Users/test/project/test.txt",
|
||||
oldText: "old text",
|
||||
newText: "new text",
|
||||
},
|
||||
],
|
||||
locations: [{ path: "/Users/test/project/test.txt" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle Edit tool calls with replace_all", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01EDIT456",
|
||||
name: "Edit",
|
||||
input: {
|
||||
replace_all: false,
|
||||
file_path: "/Users/benbrandt/github/codex-acp/src/thread.rs",
|
||||
old_string:
|
||||
"struct PromptState {\n active_command: Option<ActiveCommand>,\n active_web_search: Option<String>,\n}",
|
||||
new_string:
|
||||
"struct PromptState {\n active_commands: HashMap<String, ActiveCommand>,\n active_web_search: Option<String>,\n}",
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "edit",
|
||||
title: "Edit /Users/benbrandt/github/codex-acp/src/thread.rs",
|
||||
content: [
|
||||
{
|
||||
type: "diff",
|
||||
path: "/Users/benbrandt/github/codex-acp/src/thread.rs",
|
||||
oldText:
|
||||
"struct PromptState {\n active_command: Option<ActiveCommand>,\n active_web_search: Option<String>,\n}",
|
||||
newText:
|
||||
"struct PromptState {\n active_commands: HashMap<String, ActiveCommand>,\n active_web_search: Option<String>,\n}",
|
||||
},
|
||||
],
|
||||
locations: [{ path: "/Users/benbrandt/github/codex-acp/src/thread.rs" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle Edit tool calls without file_path", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01EDIT789",
|
||||
name: "Edit",
|
||||
input: {},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "edit",
|
||||
title: "Edit",
|
||||
content: [],
|
||||
locations: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle Read tool calls", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
@@ -418,17 +475,17 @@ describe("tool conversions", () => {
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "read",
|
||||
title: "Read File",
|
||||
title: "Read /Users/test/project/readme.md",
|
||||
content: [],
|
||||
locations: [{ path: "/Users/test/project/readme.md", line: 0 }],
|
||||
locations: [{ path: "/Users/test/project/readme.md", line: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__Read tool calls", () => {
|
||||
it("should handle Read tool calls", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01YZA789BCD123",
|
||||
name: "mcp__acp__Read",
|
||||
name: "Read",
|
||||
input: {
|
||||
file_path: "/Users/test/project/data.json",
|
||||
},
|
||||
@@ -438,15 +495,15 @@ describe("tool conversions", () => {
|
||||
kind: "read",
|
||||
title: "Read /Users/test/project/data.json",
|
||||
content: [],
|
||||
locations: [{ path: "/Users/test/project/data.json", line: 0 }],
|
||||
locations: [{ path: "/Users/test/project/data.json", line: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__Read with limit", () => {
|
||||
it("should handle Read with limit", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01EFG456HIJ789",
|
||||
name: "mcp__acp__Read",
|
||||
name: "Read",
|
||||
input: {
|
||||
file_path: "/Users/test/project/large.txt",
|
||||
limit: 100,
|
||||
@@ -457,15 +514,15 @@ describe("tool conversions", () => {
|
||||
kind: "read",
|
||||
title: "Read /Users/test/project/large.txt (1 - 100)",
|
||||
content: [],
|
||||
locations: [{ path: "/Users/test/project/large.txt", line: 0 }],
|
||||
locations: [{ path: "/Users/test/project/large.txt", line: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__Read with offset and limit", () => {
|
||||
it("should handle Read with offset and limit", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01KLM789NOP456",
|
||||
name: "mcp__acp__Read",
|
||||
name: "Read",
|
||||
input: {
|
||||
file_path: "/Users/test/project/large.txt",
|
||||
offset: 50,
|
||||
@@ -475,17 +532,17 @@ describe("tool conversions", () => {
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "read",
|
||||
title: "Read /Users/test/project/large.txt (51 - 150)",
|
||||
title: "Read /Users/test/project/large.txt (50 - 149)",
|
||||
content: [],
|
||||
locations: [{ path: "/Users/test/project/large.txt", line: 50 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__Read with only offset", () => {
|
||||
it("should handle Read with only offset", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01QRS123TUV789",
|
||||
name: "mcp__acp__Read",
|
||||
name: "Read",
|
||||
input: {
|
||||
file_path: "/Users/test/project/large.txt",
|
||||
offset: 200,
|
||||
@@ -494,46 +551,12 @@ describe("tool conversions", () => {
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "read",
|
||||
title: "Read /Users/test/project/large.txt (from line 201)",
|
||||
title: "Read /Users/test/project/large.txt (from line 200)",
|
||||
content: [],
|
||||
locations: [{ path: "/Users/test/project/large.txt", line: 200 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle KillBash entries", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01PhLms5fuvmdjy2bb6dfUKT",
|
||||
name: "KillShell",
|
||||
input: {
|
||||
shell_id: "bash_1",
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "execute",
|
||||
title: `Kill Process`,
|
||||
content: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle BashOutput entries", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01SJUWPtj1QspgANgtpqGPuN",
|
||||
name: "BashOutput",
|
||||
input: {
|
||||
bash_id: "bash_1",
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||
kind: "execute",
|
||||
title: `Tail Logs`,
|
||||
content: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle plan entries", () => {
|
||||
const received: SDKAssistantMessage = {
|
||||
type: "assistant",
|
||||
@@ -682,7 +705,7 @@ describe("tool conversions", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01MNO345",
|
||||
name: "mcp__acp__Edit",
|
||||
name: "Edit",
|
||||
input: {
|
||||
file_path: "/Users/test/project/test.txt",
|
||||
old_string: "old",
|
||||
@@ -712,7 +735,7 @@ describe("tool conversions", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01MNO345",
|
||||
name: "mcp__acp__Edit",
|
||||
name: "Edit",
|
||||
input: {
|
||||
file_path: "/Users/test/project/test.txt",
|
||||
old_string: "old",
|
||||
@@ -1035,13 +1058,13 @@ describe("permission requests", () => {
|
||||
name: "Bash",
|
||||
input: { command: "ls -la", description: "List files" },
|
||||
},
|
||||
expectedTitlePart: "`ls -la`",
|
||||
expectedTitlePart: "ls -la",
|
||||
},
|
||||
{
|
||||
toolUse: {
|
||||
type: "tool_use" as const,
|
||||
id: "test-3",
|
||||
name: "mcp__acp__Read",
|
||||
name: "Read",
|
||||
input: { file_path: "/test/data.json" },
|
||||
},
|
||||
expectedTitlePart: "/test/data.json",
|
||||
@@ -1057,17 +1080,26 @@ describe("permission requests", () => {
|
||||
expect(toolInfo.title).toContain(testCase.expectedTitlePart);
|
||||
|
||||
// Verify the structure that our fix creates for requestPermission
|
||||
// We now spread the full toolInfo (title, kind, content, locations)
|
||||
const requestStructure = {
|
||||
toolCall: {
|
||||
toolCallId: testCase.toolUse.id,
|
||||
rawInput: testCase.toolUse.input,
|
||||
title: toolInfo.title, // This is what commit 1785d86 adds
|
||||
...toolInfo,
|
||||
},
|
||||
};
|
||||
|
||||
// Ensure the title field is present and populated
|
||||
expect(requestStructure.toolCall.title).toBeDefined();
|
||||
expect(requestStructure.toolCall.title).toContain(testCase.expectedTitlePart);
|
||||
|
||||
// Ensure kind is included so the client can render appropriate UI
|
||||
expect(requestStructure.toolCall.kind).toBeDefined();
|
||||
expect(typeof requestStructure.toolCall.kind).toBe("string");
|
||||
|
||||
// Ensure content is included so the client always has tool call details
|
||||
expect(requestStructure.toolCall.content).toBeDefined();
|
||||
expect(Array.isArray(requestStructure.toolCall.content)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractLinesWithByteLimit } from "../utils.js";
|
||||
|
||||
describe("extractLinesWithByteLimit", () => {
|
||||
const simpleContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
|
||||
|
||||
it("should extract all lines when under byte limit", () => {
|
||||
const result = extractLinesWithByteLimit(simpleContent, 1000);
|
||||
|
||||
expect(result.content).toBe(simpleContent);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(5);
|
||||
});
|
||||
|
||||
it("should limit output when exceeding byte limit", () => {
|
||||
// Create content that will exceed byte limit
|
||||
const longLine = "x".repeat(100);
|
||||
const manyLines = Array(10).fill(longLine).join("\n");
|
||||
|
||||
const result = extractLinesWithByteLimit(manyLines, 250);
|
||||
|
||||
expect(result.wasLimited).toBe(true);
|
||||
expect(result.linesRead).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle empty content", () => {
|
||||
const result = extractLinesWithByteLimit("", 1000);
|
||||
|
||||
expect(result.content).toBe("");
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(1); // We read the one empty line
|
||||
});
|
||||
|
||||
it("should handle single line file", () => {
|
||||
const singleLine = "This is a single line without newline";
|
||||
const result = extractLinesWithByteLimit(singleLine, 1000);
|
||||
|
||||
expect(result.content).toBe(singleLine);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(1);
|
||||
});
|
||||
|
||||
it("should correctly count bytes with multi-byte characters", () => {
|
||||
const unicodeContent = "Hello 世界\n你好 World\nEmoji: 🌍\nNormal line";
|
||||
const result = extractLinesWithByteLimit(unicodeContent, 1000);
|
||||
|
||||
expect(result.content).toBe(unicodeContent);
|
||||
expect(result.linesRead).toBe(4);
|
||||
});
|
||||
|
||||
it("should stop at byte limit even with one more line available", () => {
|
||||
// Create lines where adding one more would exceed limit
|
||||
const line1 = "a".repeat(40);
|
||||
const line2 = "b".repeat(40);
|
||||
const line3 = "c".repeat(40);
|
||||
const content = `${line1}\n${line2}\n${line3}`;
|
||||
|
||||
const result = extractLinesWithByteLimit(content, 85);
|
||||
|
||||
expect(result.content).toBe(`${line1}\n${line2}\n`);
|
||||
expect(result.wasLimited).toBe(true);
|
||||
expect(result.linesRead).toBe(2);
|
||||
});
|
||||
|
||||
it("should read exactly to limit when possible", () => {
|
||||
const exactContent = "12345\n67890\n12345"; // 17 bytes total
|
||||
const result = extractLinesWithByteLimit(exactContent, 17);
|
||||
|
||||
expect(result.content).toBe(exactContent);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(3);
|
||||
});
|
||||
|
||||
it("should handle Windows-style line endings", () => {
|
||||
const windowsContent = "Line 1\r\nLine 2\r\nLine 3";
|
||||
const result = extractLinesWithByteLimit(windowsContent, 1000);
|
||||
|
||||
// Note: split("\n") will keep the \r characters
|
||||
expect(result.content).toBe("Line 1\r\nLine 2\r\nLine 3");
|
||||
expect(result.linesRead).toBe(3);
|
||||
});
|
||||
|
||||
it("should handle very large files efficiently", () => {
|
||||
// Create a 100KB file
|
||||
const largeLine = "x".repeat(1000);
|
||||
const largeContent = Array(110).fill(largeLine).join("\n");
|
||||
|
||||
const result = extractLinesWithByteLimit(largeContent, 50000);
|
||||
|
||||
expect(result.wasLimited).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow at least one line even if it exceeds byte limit", () => {
|
||||
const veryLongLine = "x".repeat(100000); // 100KB line
|
||||
const result = extractLinesWithByteLimit(veryLongLine, 50000);
|
||||
|
||||
// Should return the line even though it exceeds the byte limit
|
||||
// because we always allow at least one line if no lines have been added yet
|
||||
expect(result.content).toBe(veryLongLine);
|
||||
expect(result.linesRead).toBe(1);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,334 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { replaceAndCalculateLocation } from "../mcp-server.js";
|
||||
|
||||
describe("replaceAndCalculateLocation", () => {
|
||||
it("should replace first occurrence and return correct line number", () => {
|
||||
const content = "line 1\nline 2 with text\nline 3 with text\nline 4";
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nline 2 with replaced\nline 3 with text\nline 4");
|
||||
expect(result.lineNumbers).toEqual([1]); // Line 2 (0-based indexing)
|
||||
});
|
||||
|
||||
it("should replace all occurrences when replaceAll is true", () => {
|
||||
const content = "line 1\nline 2 with text\nline 3 with text\nline 4";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nline 2 with replaced\nline 3 with replaced\nline 4");
|
||||
expect(result.lineNumbers).toEqual([1, 2]); // Lines 2 and 3 (0-based)
|
||||
});
|
||||
|
||||
it("should return empty line numbers array when no match found", () => {
|
||||
const content = "line 1\nline 2\nline 3";
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content, [{ oldText: "notfound", newText: "replaced" }]);
|
||||
}).toThrow('The provided `old_string` does not appear in the file: "notfound"');
|
||||
});
|
||||
|
||||
it("should handle Windows line endings (CRLF)", () => {
|
||||
const content = "line 1\r\nline 2 with text\r\nline 3 with text\r\nline 4";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe(
|
||||
"line 1\r\nline 2 with replaced\r\nline 3 with replaced\r\nline 4",
|
||||
);
|
||||
expect(result.lineNumbers).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("should handle old Mac line endings (CR)", () => {
|
||||
const content = "line 1\rline 2 with text\rline 3 with text\rline 4";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\rline 2 with replaced\rline 3 with replaced\rline 4");
|
||||
expect(result.lineNumbers).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("should handle mixed line endings", () => {
|
||||
const content = "line 1\nline 2 with text\r\nline 3 with text\rline 4";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nline 2 with replaced\r\nline 3 with replaced\rline 4");
|
||||
expect(result.lineNumbers).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("should handle text at the beginning of file", () => {
|
||||
const content = "text at start\nline 2\nline 3";
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe("replaced at start\nline 2\nline 3");
|
||||
expect(result.lineNumbers).toEqual([0]); // First line (0-based)
|
||||
});
|
||||
|
||||
it("should handle text at the end of file", () => {
|
||||
const content = "line 1\nline 2\nlast line with text";
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nline 2\nlast line with replaced");
|
||||
expect(result.lineNumbers).toEqual([2]); // Last line (0-based)
|
||||
});
|
||||
|
||||
it("should handle multiple occurrences on the same line", () => {
|
||||
const content = "line 1\ntext text text\nline 3";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nreplaced replaced replaced\nline 3");
|
||||
expect(result.lineNumbers).toEqual([1]); // All on line 2 (0-based), deduplicated
|
||||
});
|
||||
|
||||
it("should handle empty file content", () => {
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation("", [{ oldText: "text", newText: "replaced" }]);
|
||||
}).toThrowError(
|
||||
'The provided `old_string` does not appear in the file: "text".\n\nNo edits were applied.',
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty search text", () => {
|
||||
// Test with replaceAll false (default)
|
||||
const content1 = "line 1\nline 2\nline 3";
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content1, [{ oldText: "", newText: "replaced" }]);
|
||||
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
|
||||
|
||||
// Test with replaceAll true on single line
|
||||
const content2 = "abc";
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content2, [{ oldText: "", newText: "X", replaceAll: true }]);
|
||||
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
|
||||
|
||||
// Test with replaceAll true on multiline content
|
||||
const content3 = "ab\ncd";
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content3, [{ oldText: "", newText: "X", replaceAll: true }]);
|
||||
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
|
||||
});
|
||||
|
||||
it("should handle single line content", () => {
|
||||
const content = "single line with text here";
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe("single line with replaced here");
|
||||
expect(result.lineNumbers).toEqual([0]); // Line 1 (0-based)
|
||||
});
|
||||
|
||||
it("should handle special characters in search text", () => {
|
||||
const content = "line 1\nline with $special.chars*\nline 3";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "$special.chars*", newText: "replaced" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nline with replaced\nline 3");
|
||||
expect(result.lineNumbers).toEqual([1]);
|
||||
});
|
||||
|
||||
it("should handle newlines in search text", () => {
|
||||
const content = "line 1\nline 2\nline 3";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "1\nline", newText: "1\nmodified line" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nmodified line 2\nline 3");
|
||||
expect(result.lineNumbers).toEqual([0]); // Match starts at line 1 (0-based)
|
||||
});
|
||||
|
||||
it("should handle newlines in replacement text", () => {
|
||||
const content = "line 1\nline 2\nline 3";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "line 2", newText: "line 2a\nline 2b" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nline 2a\nline 2b\nline 3");
|
||||
expect(result.lineNumbers).toEqual([1]);
|
||||
});
|
||||
|
||||
it("should handle very long lines", () => {
|
||||
const longLine = "a".repeat(10000) + "text" + "b".repeat(10000);
|
||||
const content = `line 1\n${longLine}\nline 3`;
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe(
|
||||
`line 1\n${"a".repeat(10000)}replaced${"b".repeat(10000)}\nline 3`,
|
||||
);
|
||||
expect(result.lineNumbers).toEqual([1]);
|
||||
});
|
||||
|
||||
it("should handle many lines", () => {
|
||||
const lines = Array.from({ length: 1000 }, (_, i) => `line ${i}`);
|
||||
lines[500] = "line with text";
|
||||
const content = lines.join("\n");
|
||||
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.lineNumbers).toEqual([500]);
|
||||
expect(result.newContent).includes("line with replaced");
|
||||
});
|
||||
|
||||
it("should handle overlapping matches correctly", () => {
|
||||
const content = "aaaa\nbbbb\ncccc";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "aa", newText: "xx", replaceAll: true },
|
||||
]);
|
||||
|
||||
// Should replace non-overlapping occurrences
|
||||
expect(result.newContent).toBe("xxxx\nbbbb\ncccc");
|
||||
expect(result.lineNumbers).toEqual([0]); // Two matches on first line, deduplicated
|
||||
});
|
||||
|
||||
it("should preserve content after replacement with different lengths", () => {
|
||||
const content = "short\nmedium line\nlong line here";
|
||||
|
||||
// Replacing with longer text
|
||||
const result1 = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "short", newText: "very very long text" },
|
||||
]);
|
||||
expect(result1.newContent).toBe("very very long text\nmedium line\nlong line here");
|
||||
expect(result1.lineNumbers).toEqual([0]);
|
||||
|
||||
// Replacing with shorter text
|
||||
const result2 = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "medium line", newText: "m" },
|
||||
]);
|
||||
expect(result2.newContent).toBe("short\nm\nlong line here");
|
||||
expect(result2.lineNumbers).toEqual([1]);
|
||||
});
|
||||
|
||||
it("should handle consecutive replacements", () => {
|
||||
const content = "text1 text2 text3";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("replaced1 replaced2 replaced3");
|
||||
expect(result.lineNumbers).toEqual([0]); // All on first line, deduplicated
|
||||
});
|
||||
|
||||
it("should handle case-sensitive replacements", () => {
|
||||
const content = "Text TEXT text";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
// Should only replace exact matches
|
||||
expect(result.newContent).toBe("Text TEXT replaced");
|
||||
expect(result.lineNumbers).toEqual([0]); // Only lowercase "text" matched
|
||||
});
|
||||
|
||||
it("should handle replacement at exact end of line", () => {
|
||||
const content = "line ends with text\nnext line";
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe("line ends with replaced\nnext line");
|
||||
expect(result.lineNumbers).toEqual([0]);
|
||||
});
|
||||
|
||||
it("should handle replacement spanning multiple lines", () => {
|
||||
const content = "line 1\nspan\nacross\nlines\nline 5";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "span\nacross\nlines", newText: "single" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nsingle\nline 5");
|
||||
expect(result.lineNumbers).toEqual([1]); // Match starts at line 2 (0-based)
|
||||
});
|
||||
|
||||
it("should default replaceAll to false", () => {
|
||||
const content = "match match match";
|
||||
|
||||
// Without passing replaceAll parameter
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "match", newText: "replaced" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("replaced match match");
|
||||
expect(result.lineNumbers).toEqual([0]); // Only first match
|
||||
});
|
||||
|
||||
it("should handle multiple edits with correct line numbers", () => {
|
||||
const content = "line 1\nfoo bar\nline 3\nfoo baz\nline 5\nbar foo\nline 7";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "foo", newText: "FOO", replaceAll: true },
|
||||
{ oldText: "bar", newText: "BAR", replaceAll: false },
|
||||
{ oldText: "line 3", newText: "LINE THREE" },
|
||||
]);
|
||||
|
||||
// Should replace:
|
||||
// - All "foo" occurrences (lines 1, 3, 5 in original)
|
||||
// - First "bar" occurrence (line 1 after first edit)
|
||||
// - "line 3" (line 2 after previous edits)
|
||||
expect(result.newContent).toBe("line 1\nFOO BAR\nLINE THREE\nFOO baz\nline 5\nbar FOO\nline 7");
|
||||
|
||||
// Line numbers reflect position in final content, deduplicated and sorted
|
||||
expect(result.lineNumbers).toEqual([
|
||||
1, // FOO and BAR on line 1
|
||||
2, // LINE THREE on line 2
|
||||
3, // FOO on line 3
|
||||
5, // FOO on line 5
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle multiple edits with overlapping text", () => {
|
||||
const content = "hello world\nhello hello\nworld hello world";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "hello", newText: "hi", replaceAll: false },
|
||||
{ oldText: "world", newText: "earth", replaceAll: true },
|
||||
{ oldText: "hello", newText: "greetings", replaceAll: true },
|
||||
]);
|
||||
|
||||
// First edit replaces first "hello" only
|
||||
// Second edit replaces all "world" in the modified content
|
||||
// Third edit replaces remaining "hello"s in the modified content
|
||||
expect(result.newContent).toBe("hi earth\ngreetings greetings\nearth greetings earth");
|
||||
|
||||
// Line numbers reflect position in final content, deduplicated and sorted
|
||||
expect(result.lineNumbers).toEqual([
|
||||
0, // hi and earth on line 0
|
||||
1, // greetings (multiple) on line 1
|
||||
2, // earth and greetings on line 2
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle complex scenarios with marker-like text", () => {
|
||||
// Test that our marker approach doesn't get confused by similar text
|
||||
const content = "line 1\n__MARKER__text\nline 3\n__REPLACE_MARKER_abc__\nline 5";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "__MARKER__", newText: "REPLACED", replaceAll: true },
|
||||
{ oldText: "text", newText: "content" },
|
||||
{ oldText: "__REPLACE_MARKER_abc__", newText: "DONE" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nREPLACEDcontent\nline 3\nDONE\nline 5");
|
||||
|
||||
// Line numbers should be correct even with marker-like text
|
||||
expect(result.lineNumbers).toEqual([
|
||||
1, // REPLACED on line 1
|
||||
3, // DONE on line 3
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle edits where newText matches subsequent oldText", () => {
|
||||
const content = "foo\nbar\nbaz";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "foo", newText: "bar" },
|
||||
{ oldText: "bar", newText: "baz", replaceAll: true },
|
||||
{ oldText: "baz", newText: "qux", replaceAll: true },
|
||||
]);
|
||||
|
||||
// First edit: foo -> bar
|
||||
// Second edit: both original bar and new bar -> baz
|
||||
// Third edit: all baz instances -> qux
|
||||
expect(result.newContent).toBe("qux\nqux\nqux");
|
||||
|
||||
// All replacements end up on their respective lines
|
||||
expect(result.lineNumbers).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -17,455 +17,7 @@ describe("SettingsManager", () => {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("permission checking", () => {
|
||||
it("should return 'ask' when no settings exist", async () => {
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: "/some/file.txt",
|
||||
});
|
||||
expect(result.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should return 'ask' for non-ACP tools (permission checks only apply to mcp__acp__* tools)", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Non-ACP tools should always return 'ask' regardless of rules
|
||||
const result = settingsManager.checkPermission("Read", { file_path: "/some/file.txt" });
|
||||
expect(result.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should allow tool use when matching allow rule exists", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: "/some/file.txt",
|
||||
});
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.rule).toBe("Read");
|
||||
});
|
||||
|
||||
it("should deny tool use when matching deny rule exists", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read(./.env)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env"),
|
||||
});
|
||||
expect(result.decision).toBe("deny");
|
||||
expect(result.rule).toBe("Read(./.env)");
|
||||
});
|
||||
|
||||
it("should prioritize deny rules over allow rules", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read"],
|
||||
deny: ["Read(./.env)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// .env should be denied
|
||||
const envResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env"),
|
||||
});
|
||||
expect(envResult.decision).toBe("deny");
|
||||
|
||||
// other files should be allowed
|
||||
const otherResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, "other.txt"),
|
||||
});
|
||||
expect(otherResult.decision).toBe("allow");
|
||||
});
|
||||
|
||||
it("should handle ACP-prefixed tool names", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: "/some/file.txt",
|
||||
});
|
||||
expect(result.decision).toBe("allow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bash permission rules", () => {
|
||||
it("should match exact Bash commands without :* wildcard", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
// Per docs: Bash(npm run build) matches the EXACT command "npm run build"
|
||||
allow: ["Bash(npm run lint)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Exact match should be allowed
|
||||
const exactResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run lint",
|
||||
});
|
||||
expect(exactResult.decision).toBe("allow");
|
||||
|
||||
// Command with extra arguments should NOT match (exact match only)
|
||||
const withArgsResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run lint --fix",
|
||||
});
|
||||
expect(withArgsResult.decision).toBe("ask");
|
||||
|
||||
// Similar command should NOT match (exact match only)
|
||||
const similarResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run linting",
|
||||
});
|
||||
expect(similarResult.decision).toBe("ask");
|
||||
|
||||
// Different command should not match
|
||||
const differentResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run test",
|
||||
});
|
||||
expect(differentResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should match Bash commands with :* wildcard suffix (prefix matching)", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
// The :* suffix is a convention to make prefix matching explicit
|
||||
allow: ["Bash(npm run:*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Any command starting with "npm run" should match (prefix matching with :*)
|
||||
const lintResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run lint",
|
||||
});
|
||||
expect(lintResult.decision).toBe("allow");
|
||||
|
||||
const testResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run test",
|
||||
});
|
||||
expect(testResult.decision).toBe("allow");
|
||||
|
||||
// Commands with additional args also match
|
||||
const withArgsResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm run test --watch",
|
||||
});
|
||||
expect(withArgsResult.decision).toBe("allow");
|
||||
|
||||
// Non-matching command
|
||||
const installResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "npm install",
|
||||
});
|
||||
expect(installResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should not allow shell operators to bypass prefix matching", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Bash(safe-cmd:*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Normal prefix match should work
|
||||
const normalResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd --flag",
|
||||
});
|
||||
expect(normalResult.decision).toBe("allow");
|
||||
|
||||
// Shell operators should NOT be allowed (per docs: Claude Code is aware of shell operators)
|
||||
const andResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd && malicious-cmd",
|
||||
});
|
||||
expect(andResult.decision).toBe("ask");
|
||||
|
||||
const orResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd || malicious-cmd",
|
||||
});
|
||||
expect(orResult.decision).toBe("ask");
|
||||
|
||||
const semicolonResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd; malicious-cmd",
|
||||
});
|
||||
expect(semicolonResult.decision).toBe("ask");
|
||||
|
||||
const pipeResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd | malicious-cmd",
|
||||
});
|
||||
expect(pipeResult.decision).toBe("ask");
|
||||
|
||||
const subshellResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd $(malicious-cmd)",
|
||||
});
|
||||
expect(subshellResult.decision).toBe("ask");
|
||||
|
||||
const backtickResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd `malicious-cmd`",
|
||||
});
|
||||
expect(backtickResult.decision).toBe("ask");
|
||||
|
||||
const newlineResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "safe-cmd\nmalicious-cmd",
|
||||
});
|
||||
expect(newlineResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should deny dangerous Bash commands", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Bash(curl:*)", "Bash(wget:*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const curlResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "curl https://example.com",
|
||||
});
|
||||
expect(curlResult.decision).toBe("deny");
|
||||
|
||||
const wgetResult = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "wget https://example.com",
|
||||
});
|
||||
expect(wgetResult.decision).toBe("deny");
|
||||
|
||||
const lsResult = settingsManager.checkPermission("mcp__acp__Bash", { command: "ls -la" });
|
||||
expect(lsResult.decision).toBe("ask");
|
||||
});
|
||||
});
|
||||
|
||||
describe("file path glob matching", () => {
|
||||
it("should match exact file paths", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read(./.env)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const envResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env"),
|
||||
});
|
||||
expect(envResult.decision).toBe("deny");
|
||||
|
||||
const otherResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".envrc"),
|
||||
});
|
||||
expect(otherResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should match glob patterns with single wildcard", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read(./.env.*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const envLocalResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env.local"),
|
||||
});
|
||||
expect(envLocalResult.decision).toBe("deny");
|
||||
|
||||
const envProdResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env.production"),
|
||||
});
|
||||
expect(envProdResult.decision).toBe("deny");
|
||||
|
||||
// Plain .env should not match .env.*
|
||||
const plainEnvResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, ".env"),
|
||||
});
|
||||
expect(plainEnvResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should match glob patterns with double wildcard (recursive)", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
deny: ["Read(./secrets/**)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const secretResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, "secrets", "api-key.txt"),
|
||||
});
|
||||
expect(secretResult.decision).toBe("deny");
|
||||
|
||||
const nestedSecretResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, "secrets", "deep", "nested", "key.txt"),
|
||||
});
|
||||
expect(nestedSecretResult.decision).toBe("deny");
|
||||
|
||||
const otherResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, "public", "file.txt"),
|
||||
});
|
||||
expect(otherResult.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should handle home directory expansion", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read(~/.zshrc)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const zshrcResult = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(os.homedir(), ".zshrc"),
|
||||
});
|
||||
expect(zshrcResult.decision).toBe("allow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings merging", () => {
|
||||
it("should merge project and local settings", async () => {
|
||||
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");
|
||||
});
|
||||
|
||||
it("should merge model setting with later sources taking precedence", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
@@ -501,267 +53,4 @@ describe("SettingsManager", () => {
|
||||
expect(settings.model).toBe("claude-3-5-haiku");
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-tool rule isolation", () => {
|
||||
// These tests verify the fix in commit d471ccf:
|
||||
// Permission rules should only match their intended tool type.
|
||||
// A Bash rule should not match Edit/Write/Read operations, and vice versa.
|
||||
|
||||
it("should not apply Bash rules to Edit tool calls", 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: {
|
||||
// This Bash rule has an argument that looks like a glob pattern.
|
||||
// Before the fix, it would incorrectly match Edit operations
|
||||
// because the argument would be interpreted as a file glob.
|
||||
allow: ["Bash(./**:*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Bash rule should NOT allow Edit operations
|
||||
const result = settingsManager.checkPermission("mcp__acp__Edit", {
|
||||
file_path: path.join(tempDir, "any-file.txt"),
|
||||
});
|
||||
expect(result.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should not apply Bash rules to Write tool calls", 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(./**:*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Write", {
|
||||
file_path: path.join(tempDir, "any-file.txt"),
|
||||
});
|
||||
expect(result.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should not apply Bash rules to Read tool calls", 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(./**:*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, "any-file.txt"),
|
||||
});
|
||||
expect(result.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should not apply Edit rules to Bash tool calls", 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"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "echo hello",
|
||||
});
|
||||
expect(result.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should not apply Read rules to Bash tool calls", 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__Bash", {
|
||||
command: "ls -la",
|
||||
});
|
||||
expect(result.decision).toBe("ask");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
import { AgentSideConnection, ClientCapabilities } from "@agentclientprotocol/sdk";
|
||||
import { ImageBlockParam, ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import {
|
||||
BetaMCPToolResultBlock,
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
BetaWebSearchToolResultBlock,
|
||||
BetaBashCodeExecutionToolResultBlock,
|
||||
BetaBashCodeExecutionResultBlock,
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
|
||||
import { toolUpdateFromToolResult, createPostToolUseHook } from "../tools.js";
|
||||
|
||||
describe("rawOutput in tool call updates", () => {
|
||||
const mockClient = {} as AgentSideConnection;
|
||||
@@ -410,3 +412,635 @@ describe("rawOutput in tool call updates", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bash terminal output", () => {
|
||||
const mockClient = {} as AgentSideConnection;
|
||||
const mockLogger: Logger = { log: () => {}, error: () => {} };
|
||||
|
||||
const bashToolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_bash",
|
||||
name: "Bash",
|
||||
input: { command: "ls -la" },
|
||||
};
|
||||
|
||||
const makeBashResult = (
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
return_code: number,
|
||||
): BetaBashCodeExecutionToolResultBlockParam => ({
|
||||
type: "bash_code_execution_tool_result",
|
||||
tool_use_id: "toolu_bash",
|
||||
content: {
|
||||
type: "bash_code_execution_result",
|
||||
stdout,
|
||||
stderr,
|
||||
return_code,
|
||||
content: [],
|
||||
},
|
||||
});
|
||||
|
||||
describe("toolUpdateFromToolResult", () => {
|
||||
it("should return formatted content without _meta when supportsTerminalOutput is false", () => {
|
||||
const toolResult = makeBashResult("file1.txt\nfile2.txt", "", 0);
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, false);
|
||||
|
||||
expect(update).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "```sh\nfile1.txt\nfile2.txt\n```",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(update._meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return formatted content with _meta when supportsTerminalOutput is true", () => {
|
||||
const toolResult = makeBashResult("file1.txt\nfile2.txt", "", 0);
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
|
||||
|
||||
expect(update.content).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "```sh\nfile1.txt\nfile2.txt\n```",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(update._meta).toEqual({
|
||||
terminal_info: {
|
||||
terminal_id: "toolu_bash",
|
||||
},
|
||||
terminal_output: {
|
||||
terminal_id: "toolu_bash",
|
||||
data: "file1.txt\nfile2.txt",
|
||||
},
|
||||
terminal_exit: {
|
||||
terminal_id: "toolu_bash",
|
||||
exit_code: 0,
|
||||
signal: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should include exit_code from return_code in terminal_exit", () => {
|
||||
const toolResult = makeBashResult("", "command not found", 127);
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
|
||||
|
||||
expect(update._meta?.terminal_exit).toEqual({
|
||||
terminal_id: "toolu_bash",
|
||||
exit_code: 127,
|
||||
signal: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should fall back to stderr when stdout is empty", () => {
|
||||
const toolResult = makeBashResult("", "some error output", 1);
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, false);
|
||||
|
||||
expect(update.content).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "```sh\nsome error output\n```",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return empty content array with _meta when output is empty and supportsTerminalOutput is true", () => {
|
||||
const toolResult = makeBashResult("", "", 0);
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
|
||||
|
||||
expect(update.content).toEqual([]);
|
||||
expect(update._meta).toEqual({
|
||||
terminal_info: {
|
||||
terminal_id: "toolu_bash",
|
||||
},
|
||||
terminal_output: {
|
||||
terminal_id: "toolu_bash",
|
||||
data: "",
|
||||
},
|
||||
terminal_exit: {
|
||||
terminal_id: "toolu_bash",
|
||||
exit_code: 0,
|
||||
signal: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty object when output is empty and supportsTerminalOutput is false", () => {
|
||||
const toolResult = makeBashResult("", "", 0);
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, false);
|
||||
|
||||
expect(update).toEqual({});
|
||||
});
|
||||
|
||||
it("should default supportsTerminalOutput to false when not provided", () => {
|
||||
const toolResult = makeBashResult("hello", "", 0);
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse);
|
||||
|
||||
expect(update._meta).toBeUndefined();
|
||||
expect(update.content).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "```sh\nhello\n```",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should trim trailing whitespace from output in content but preserve it in _meta data", () => {
|
||||
const toolResult = makeBashResult("hello\n\n\n", "", 0);
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
|
||||
|
||||
expect(update.content).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "```sh\nhello\n```",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(update._meta?.terminal_output?.data).toBe("hello\n\n\n");
|
||||
});
|
||||
|
||||
describe("with plain string tool_result (production format)", () => {
|
||||
const makeStringBashResult = (
|
||||
content: string,
|
||||
is_error: boolean = false,
|
||||
): ToolResultBlockParam => ({
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_bash",
|
||||
content,
|
||||
is_error,
|
||||
});
|
||||
|
||||
it("should format string content as sh code block without _meta when supportsTerminalOutput is false", () => {
|
||||
const toolResult = makeStringBashResult("Cargo.lock\nCargo.toml\nREADME.md");
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, false);
|
||||
|
||||
expect(update).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "```sh\nCargo.lock\nCargo.toml\nREADME.md\n```",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(update._meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should format string content with _meta when supportsTerminalOutput is true", () => {
|
||||
const toolResult = makeStringBashResult("Cargo.lock\nCargo.toml\nREADME.md");
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
|
||||
|
||||
expect(update.content).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "```sh\nCargo.lock\nCargo.toml\nREADME.md\n```",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(update._meta).toEqual({
|
||||
terminal_info: { terminal_id: "toolu_bash" },
|
||||
terminal_output: { terminal_id: "toolu_bash", data: "Cargo.lock\nCargo.toml\nREADME.md" },
|
||||
terminal_exit: { terminal_id: "toolu_bash", exit_code: 0, signal: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("should use error handler when is_error is true (early return before Bash case)", () => {
|
||||
const toolResult = makeStringBashResult("command not found: bad_cmd", true);
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
|
||||
|
||||
// is_error with content hits the early error return at the top of
|
||||
// toolUpdateFromToolResult, before reaching the Bash switch case.
|
||||
// So there's no terminal _meta, just error-formatted content.
|
||||
expect(update._meta).toBeUndefined();
|
||||
expect(update.content).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "```\ncommand not found: bad_cmd\n```",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return empty object for empty string content without terminal support", () => {
|
||||
const toolResult = makeStringBashResult("");
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, false);
|
||||
|
||||
expect(update).toEqual({});
|
||||
});
|
||||
|
||||
it("should return empty content with _meta for empty string content with terminal support", () => {
|
||||
const toolResult = makeStringBashResult("");
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
|
||||
|
||||
expect(update.content).toEqual([]);
|
||||
expect(update._meta).toEqual({
|
||||
terminal_info: { terminal_id: "toolu_bash" },
|
||||
terminal_output: { terminal_id: "toolu_bash", data: "" },
|
||||
terminal_exit: { terminal_id: "toolu_bash", exit_code: 0, signal: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle array content with text blocks", () => {
|
||||
const toolResult: ToolResultBlockParam = {
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_bash",
|
||||
content: [{ type: "text", text: "line1\nline2" }],
|
||||
is_error: false,
|
||||
};
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, false);
|
||||
|
||||
expect(update).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "```sh\nline1\nline2\n```",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAcpNotifications with clientCapabilities", () => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
toolu_bash: {
|
||||
type: "tool_use",
|
||||
id: "toolu_bash",
|
||||
name: "Bash",
|
||||
input: { command: "ls -la" },
|
||||
},
|
||||
};
|
||||
|
||||
const bashResult: BetaBashCodeExecutionResultBlock = {
|
||||
type: "bash_code_execution_result",
|
||||
stdout: "file1.txt\nfile2.txt",
|
||||
stderr: "",
|
||||
return_code: 0,
|
||||
content: [],
|
||||
};
|
||||
|
||||
const toolResult: BetaBashCodeExecutionToolResultBlock = {
|
||||
type: "bash_code_execution_tool_result",
|
||||
tool_use_id: "toolu_bash",
|
||||
content: bashResult,
|
||||
};
|
||||
|
||||
it("should include terminal _meta when client declares terminal_output support", () => {
|
||||
const clientCapabilities: ClientCapabilities = {
|
||||
_meta: { terminal_output: true },
|
||||
};
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[toolResult],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ clientCapabilities },
|
||||
);
|
||||
|
||||
// Split into 2 notifications: terminal_output, then terminal_exit + completion
|
||||
expect(notifications).toHaveLength(2);
|
||||
|
||||
// First notification: terminal_output only
|
||||
const outputUpdate = notifications[0].update;
|
||||
expect(outputUpdate).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "toolu_bash",
|
||||
});
|
||||
expect((outputUpdate as any)._meta).toEqual({
|
||||
terminal_output: { terminal_id: "toolu_bash", data: "file1.txt\nfile2.txt" },
|
||||
});
|
||||
expect((outputUpdate as any).status).toBeUndefined();
|
||||
|
||||
// Second notification: terminal_exit + status + content
|
||||
const exitUpdate = notifications[1].update;
|
||||
expect(exitUpdate).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "toolu_bash",
|
||||
status: "completed",
|
||||
});
|
||||
expect((exitUpdate as any)._meta).toMatchObject({
|
||||
terminal_exit: { terminal_id: "toolu_bash", exit_code: 0, signal: null },
|
||||
});
|
||||
// terminal_info and terminal_output should NOT be on the exit notification
|
||||
expect((exitUpdate as any)._meta).not.toHaveProperty("terminal_info");
|
||||
expect((exitUpdate as any)._meta).not.toHaveProperty("terminal_output");
|
||||
});
|
||||
|
||||
it("should not include terminal _meta when client does not declare terminal_output support", () => {
|
||||
const notifications = toAcpNotifications(
|
||||
[toolResult],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
const update = notifications[0].update;
|
||||
expect(update).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "toolu_bash",
|
||||
status: "completed",
|
||||
});
|
||||
expect((update as any)._meta).not.toHaveProperty("terminal_info");
|
||||
expect((update as any)._meta).not.toHaveProperty("terminal_output");
|
||||
expect((update as any)._meta).not.toHaveProperty("terminal_exit");
|
||||
});
|
||||
|
||||
it("should not include terminal _meta when _meta.terminal_output is false", () => {
|
||||
const clientCapabilities: ClientCapabilities = {
|
||||
_meta: { terminal_output: false },
|
||||
};
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[toolResult],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ clientCapabilities },
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect((notifications[0].update as any)._meta).not.toHaveProperty("terminal_output");
|
||||
});
|
||||
|
||||
it("should still include formatted content regardless of terminal_output support", () => {
|
||||
const withSupport = toAcpNotifications(
|
||||
[toolResult],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ clientCapabilities: { _meta: { terminal_output: true } } },
|
||||
);
|
||||
|
||||
const withoutSupport = toAcpNotifications(
|
||||
[toolResult],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
const expectedContent = [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "```sh\nfile1.txt\nfile2.txt\n```",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// With support: content is on the second (completion) notification
|
||||
expect(withSupport).toHaveLength(2);
|
||||
expect((withSupport[1].update as any).content).toEqual(expectedContent);
|
||||
// Without support: content is on the only notification
|
||||
expect((withoutSupport[0].update as any).content).toEqual(expectedContent);
|
||||
});
|
||||
|
||||
it("should preserve claudeCode in _meta alongside terminal_exit on completion notification", () => {
|
||||
const clientCapabilities: ClientCapabilities = {
|
||||
_meta: { terminal_output: true },
|
||||
};
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[toolResult],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ clientCapabilities },
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(2);
|
||||
|
||||
// First notification (terminal_output) has no claudeCode
|
||||
const outputMeta = (notifications[0].update as any)._meta;
|
||||
expect(outputMeta.terminal_output).toBeDefined();
|
||||
expect(outputMeta.claudeCode).toBeUndefined();
|
||||
|
||||
// Second notification (completion) has claudeCode + terminal_exit
|
||||
const exitMeta = (notifications[1].update as any)._meta;
|
||||
expect(exitMeta.claudeCode).toEqual({ toolName: "Bash" });
|
||||
expect(exitMeta.terminal_exit).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("post-tool-use hook preserves terminal _meta", () => {
|
||||
it("should send terminal_output and terminal_exit as separate notifications, and hook should only have claudeCode", async () => {
|
||||
const clientCapabilities: ClientCapabilities = {
|
||||
_meta: { terminal_output: true },
|
||||
};
|
||||
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
|
||||
// Capture session updates sent by the hook callback
|
||||
const hookUpdates: any[] = [];
|
||||
const mockClientWithUpdate = {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
hookUpdates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
// Step 1: Process tool_use chunk — registers the PostToolUse hook callback
|
||||
const toolUseChunk = {
|
||||
type: "tool_use" as const,
|
||||
id: "toolu_bash_hook",
|
||||
name: "Bash",
|
||||
input: { command: "ls -la" },
|
||||
};
|
||||
const toolUseNotifications = toAcpNotifications(
|
||||
[toolUseChunk],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClientWithUpdate,
|
||||
mockLogger,
|
||||
{ clientCapabilities },
|
||||
);
|
||||
|
||||
// The initial tool_call should include terminal_info in _meta
|
||||
expect(toolUseNotifications).toHaveLength(1);
|
||||
expect((toolUseNotifications[0].update as any)._meta).toMatchObject({
|
||||
terminal_info: { terminal_id: "toolu_bash_hook" },
|
||||
});
|
||||
|
||||
// Step 2: Process bash result — produces separate terminal_output and terminal_exit notifications
|
||||
const bashResult: BetaBashCodeExecutionResultBlock = {
|
||||
type: "bash_code_execution_result",
|
||||
stdout: "file1.txt",
|
||||
stderr: "",
|
||||
return_code: 0,
|
||||
content: [],
|
||||
};
|
||||
const toolResult: BetaBashCodeExecutionToolResultBlock = {
|
||||
type: "bash_code_execution_tool_result",
|
||||
tool_use_id: "toolu_bash_hook",
|
||||
content: bashResult,
|
||||
};
|
||||
const resultNotifications = toAcpNotifications(
|
||||
[toolResult],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClientWithUpdate,
|
||||
mockLogger,
|
||||
{ clientCapabilities },
|
||||
);
|
||||
|
||||
// Should produce 2 notifications: terminal_output, then terminal_exit + completion
|
||||
expect(resultNotifications).toHaveLength(2);
|
||||
|
||||
// First: terminal_output only
|
||||
expect((resultNotifications[0].update as any)._meta).toEqual({
|
||||
terminal_output: { terminal_id: "toolu_bash_hook", data: "file1.txt" },
|
||||
});
|
||||
|
||||
// Second: terminal_exit + status
|
||||
expect((resultNotifications[1].update as any)._meta).toMatchObject({
|
||||
terminal_exit: { terminal_id: "toolu_bash_hook", exit_code: 0, signal: null },
|
||||
});
|
||||
expect((resultNotifications[1].update as any).status).toBe("completed");
|
||||
|
||||
// Step 3: Fire the PostToolUse hook (simulates what Claude Code SDK does)
|
||||
const hook = createPostToolUseHook(mockLogger);
|
||||
await hook(
|
||||
{
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "ls -la" },
|
||||
tool_response: "file1.txt",
|
||||
tool_use_id: "toolu_bash_hook",
|
||||
session_id: "test-session",
|
||||
transcript_path: "/tmp/test",
|
||||
cwd: "/tmp",
|
||||
},
|
||||
"toolu_bash_hook",
|
||||
{ signal: AbortSignal.abort() },
|
||||
);
|
||||
|
||||
// Step 4: Hook update should only have claudeCode, no terminal fields
|
||||
// (terminal events were already sent as separate notifications)
|
||||
expect(hookUpdates).toHaveLength(1);
|
||||
const hookMeta = hookUpdates[0].update._meta;
|
||||
expect(hookMeta.claudeCode).toMatchObject({
|
||||
toolName: "Bash",
|
||||
toolResponse: "file1.txt",
|
||||
});
|
||||
expect(hookMeta.terminal_info).toBeUndefined();
|
||||
expect(hookMeta.terminal_output).toBeUndefined();
|
||||
expect(hookMeta.terminal_exit).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not include terminal _meta in hook update when client lacks terminal_output support", async () => {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
|
||||
const hookUpdates: any[] = [];
|
||||
const mockClientWithUpdate = {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
hookUpdates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
// Process tool_use (registers hook)
|
||||
toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use" as const,
|
||||
id: "toolu_bash_no_term",
|
||||
name: "Bash",
|
||||
input: { command: "echo hi" },
|
||||
},
|
||||
],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClientWithUpdate,
|
||||
mockLogger,
|
||||
// No clientCapabilities — terminal_output not supported
|
||||
);
|
||||
|
||||
// Process bash result
|
||||
const bashResult: BetaBashCodeExecutionResultBlock = {
|
||||
type: "bash_code_execution_result",
|
||||
stdout: "hi",
|
||||
stderr: "",
|
||||
return_code: 0,
|
||||
content: [],
|
||||
};
|
||||
toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "bash_code_execution_tool_result",
|
||||
tool_use_id: "toolu_bash_no_term",
|
||||
content: bashResult,
|
||||
} as BetaBashCodeExecutionToolResultBlock,
|
||||
],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClientWithUpdate,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
// Fire hook
|
||||
const hook = createPostToolUseHook(mockLogger);
|
||||
await hook(
|
||||
{
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "echo hi" },
|
||||
tool_response: "hi",
|
||||
tool_use_id: "toolu_bash_no_term",
|
||||
session_id: "test-session",
|
||||
transcript_path: "/tmp/test",
|
||||
cwd: "/tmp",
|
||||
},
|
||||
"toolu_bash_no_term",
|
||||
{ signal: AbortSignal.abort() },
|
||||
);
|
||||
|
||||
// Hook update should only have claudeCode, no terminal fields
|
||||
expect(hookUpdates).toHaveLength(1);
|
||||
const hookMeta = hookUpdates[0].update._meta;
|
||||
expect(hookMeta.claudeCode).toBeDefined();
|
||||
expect(hookMeta.terminal_info).toBeUndefined();
|
||||
expect(hookMeta.terminal_output).toBeUndefined();
|
||||
expect(hookMeta.terminal_exit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
384
src/tools.ts
384
src/tools.ts
@@ -5,7 +5,6 @@ import {
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { SYSTEM_REMINDER } from "./mcp-server.js";
|
||||
import * as diff from "diff";
|
||||
import {
|
||||
ImageBlockParam,
|
||||
@@ -40,27 +39,6 @@ import {
|
||||
BetaImageBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
|
||||
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];
|
||||
|
||||
/**
|
||||
* Union of all possible content types that can appear in tool results from the Anthropic SDK.
|
||||
* These are transformed to valid ACP ContentBlock types by toValidAcpContent().
|
||||
@@ -86,7 +64,18 @@ type ToolResultContent =
|
||||
| BetaTextEditorCodeExecutionToolResultError;
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { Logger } from "./acp-agent.js";
|
||||
import { SettingsManager } from "./settings.js";
|
||||
import {
|
||||
AgentInput,
|
||||
BashInput,
|
||||
FileEditInput,
|
||||
FileReadInput,
|
||||
FileWriteInput,
|
||||
GlobInput,
|
||||
GrepInput,
|
||||
TodoWriteInput,
|
||||
WebFetchInput,
|
||||
WebSearchInput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
|
||||
interface ToolInfo {
|
||||
title: string;
|
||||
@@ -99,19 +88,36 @@ interface ToolUpdate {
|
||||
title?: string;
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
_meta?: {
|
||||
terminal_info?: {
|
||||
terminal_id: string;
|
||||
};
|
||||
terminal_output?: {
|
||||
terminal_id: string;
|
||||
data: string;
|
||||
};
|
||||
terminal_exit?: {
|
||||
terminal_id: string;
|
||||
exit_code: number;
|
||||
signal: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function toolInfoFromToolUse(toolUse: any): ToolInfo {
|
||||
export function toolInfoFromToolUse(
|
||||
toolUse: any,
|
||||
supportsTerminalOutput: boolean = false,
|
||||
): ToolInfo {
|
||||
const name = toolUse.name;
|
||||
const input = toolUse.input;
|
||||
|
||||
switch (name) {
|
||||
case "Task":
|
||||
case "Task": {
|
||||
const input = toolUse.input as AgentInput | BashInput;
|
||||
return {
|
||||
title: input?.description ? input.description : "Task",
|
||||
kind: "think",
|
||||
content:
|
||||
input && input.prompt
|
||||
input && "prompt" in input
|
||||
? [
|
||||
{
|
||||
type: "content",
|
||||
@@ -120,38 +126,16 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "NotebookRead":
|
||||
case "Bash": {
|
||||
const input = toolUse.input as BashInput;
|
||||
return {
|
||||
title: input?.notebook_path ? `Read Notebook ${input.notebook_path}` : "Read Notebook",
|
||||
kind: "read",
|
||||
content: [],
|
||||
locations: input?.notebook_path ? [{ path: input.notebook_path }] : [],
|
||||
};
|
||||
|
||||
case "NotebookEdit":
|
||||
return {
|
||||
title: input?.notebook_path ? `Edit Notebook ${input.notebook_path}` : "Edit Notebook",
|
||||
kind: "edit",
|
||||
content:
|
||||
input && input.new_source
|
||||
? [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: input.new_source },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
locations: input?.notebook_path ? [{ path: input.notebook_path }] : [],
|
||||
};
|
||||
|
||||
case "Bash":
|
||||
case acpToolNames.bash:
|
||||
return {
|
||||
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
|
||||
title: input?.command ? input.command : "Terminal",
|
||||
kind: "execute",
|
||||
content:
|
||||
input && input.description
|
||||
content: supportsTerminalOutput
|
||||
? [{ type: "terminal" as const, terminalId: toolUse.id }]
|
||||
: input && input.description
|
||||
? [
|
||||
{
|
||||
type: "content",
|
||||
@@ -160,30 +144,15 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "BashOutput":
|
||||
case acpToolNames.bashOutput:
|
||||
return {
|
||||
title: "Tail Logs",
|
||||
kind: "execute",
|
||||
content: [],
|
||||
};
|
||||
|
||||
case "KillShell":
|
||||
case acpToolNames.killShell:
|
||||
return {
|
||||
title: "Kill Process",
|
||||
kind: "execute",
|
||||
content: [],
|
||||
};
|
||||
|
||||
case acpToolNames.read: {
|
||||
case "Read": {
|
||||
const input = toolUse.input as FileReadInput;
|
||||
let limit = "";
|
||||
if (input.limit) {
|
||||
limit =
|
||||
" (" + ((input.offset ?? 0) + 1) + " - " + ((input.offset ?? 0) + input.limit) + ")";
|
||||
if (input.limit && input.limit > 0) {
|
||||
limit = " (" + (input.offset ?? 1) + " - " + ((input.offset ?? 1) + input.limit - 1) + ")";
|
||||
} else if (input.offset) {
|
||||
limit = " (from line " + (input.offset + 1) + ")";
|
||||
limit = " (from line " + input.offset + ")";
|
||||
}
|
||||
return {
|
||||
title: "Read " + (input.file_path ?? "File") + limit,
|
||||
@@ -192,7 +161,7 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
|
||||
? [
|
||||
{
|
||||
path: input.file_path,
|
||||
line: input.offset ?? 0,
|
||||
line: input.offset ?? 1,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -200,52 +169,8 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
|
||||
};
|
||||
}
|
||||
|
||||
case "Read":
|
||||
return {
|
||||
title: "Read File",
|
||||
kind: "read",
|
||||
content: [],
|
||||
locations: input.file_path
|
||||
? [
|
||||
{
|
||||
path: input.file_path,
|
||||
line: input.offset ?? 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
|
||||
case "LS":
|
||||
return {
|
||||
title: `List the ${input?.path ? "`" + input.path + "`" : "current"} directory's contents`,
|
||||
kind: "search",
|
||||
content: [],
|
||||
locations: [],
|
||||
};
|
||||
|
||||
case acpToolNames.edit:
|
||||
case "Edit": {
|
||||
const path = input?.file_path ?? input?.file_path;
|
||||
|
||||
return {
|
||||
title: path ? `Edit \`${path}\`` : "Edit",
|
||||
kind: "edit",
|
||||
content:
|
||||
input && path
|
||||
? [
|
||||
{
|
||||
type: "diff",
|
||||
path,
|
||||
oldText: input.old_string ?? null,
|
||||
newText: input.new_string ?? "",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
locations: path ? [{ path }] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
case acpToolNames.write: {
|
||||
case "Write": {
|
||||
const input = toolUse.input as FileWriteInput;
|
||||
let content: ToolCallContent[] = [];
|
||||
if (input && input.file_path) {
|
||||
content = [
|
||||
@@ -272,25 +197,29 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
|
||||
};
|
||||
}
|
||||
|
||||
case "Write":
|
||||
case "Edit": {
|
||||
const input = toolUse.input as FileEditInput;
|
||||
let content: ToolCallContent[] = [];
|
||||
if (input && input.file_path && (input.old_string || input.new_string)) {
|
||||
content = [
|
||||
{
|
||||
type: "diff",
|
||||
path: input.file_path,
|
||||
oldText: input.old_string || null,
|
||||
newText: input.new_string ?? "",
|
||||
},
|
||||
];
|
||||
}
|
||||
return {
|
||||
title: input?.file_path ? `Write ${input.file_path}` : "Write",
|
||||
title: input?.file_path ? `Edit ${input.file_path}` : "Edit",
|
||||
kind: "edit",
|
||||
content:
|
||||
input && input.file_path
|
||||
? [
|
||||
{
|
||||
type: "diff",
|
||||
path: input.file_path,
|
||||
oldText: null,
|
||||
newText: input.content,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
content,
|
||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
case "Glob": {
|
||||
const input = toolUse.input as GlobInput;
|
||||
let label = "Find";
|
||||
if (input.path) {
|
||||
label += ` \`${input.path}\``;
|
||||
@@ -307,6 +236,7 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
|
||||
}
|
||||
|
||||
case "Grep": {
|
||||
const input = toolUse.input as GrepInput;
|
||||
let label = "grep";
|
||||
|
||||
if (input["-i"]) {
|
||||
@@ -328,13 +258,13 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
|
||||
|
||||
if (input.output_mode) {
|
||||
switch (input.output_mode) {
|
||||
case "FilesWithMatches":
|
||||
case "files_with_matches":
|
||||
label += " -l";
|
||||
break;
|
||||
case "Count":
|
||||
case "count":
|
||||
label += " -c";
|
||||
break;
|
||||
case "Content":
|
||||
case "content":
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -371,7 +301,8 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
|
||||
};
|
||||
}
|
||||
|
||||
case "WebFetch":
|
||||
case "WebFetch": {
|
||||
const input = toolUse.input as WebFetchInput;
|
||||
return {
|
||||
title: input?.url ? `Fetch ${input.url}` : "Fetch",
|
||||
kind: "fetch",
|
||||
@@ -385,8 +316,10 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "WebSearch": {
|
||||
const input = toolUse.input as WebSearchInput;
|
||||
let label = `"${input.query}"`;
|
||||
|
||||
if (input.allowed_domains && input.allowed_domains.length > 0) {
|
||||
@@ -404,7 +337,8 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
|
||||
};
|
||||
}
|
||||
|
||||
case "TodoWrite":
|
||||
case "TodoWrite": {
|
||||
const input = toolUse.input as TodoWriteInput;
|
||||
return {
|
||||
title: Array.isArray(input?.todos)
|
||||
? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}`
|
||||
@@ -412,18 +346,18 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
|
||||
kind: "think",
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "ExitPlanMode":
|
||||
case "ExitPlanMode": {
|
||||
return {
|
||||
title: "Ready to code?",
|
||||
kind: "switch_mode",
|
||||
content:
|
||||
input && input.plan
|
||||
? [{ type: "content", content: { type: "text", text: input.plan } }]
|
||||
: [],
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "Other": {
|
||||
const input = toolUse.input;
|
||||
let output;
|
||||
try {
|
||||
output = JSON.stringify(input, null, 2);
|
||||
@@ -467,6 +401,7 @@ export function toolUpdateFromToolResult(
|
||||
| BetaRequestMCPToolResultBlockParam
|
||||
| BetaToolSearchToolResultBlockParam,
|
||||
toolUse: any | undefined,
|
||||
supportsTerminalOutput: boolean = false,
|
||||
): ToolUpdate {
|
||||
if (
|
||||
"is_error" in toolResult &&
|
||||
@@ -480,7 +415,6 @@ export function toolUpdateFromToolResult(
|
||||
|
||||
switch (toolUse?.name) {
|
||||
case "Read":
|
||||
case acpToolNames.read:
|
||||
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
|
||||
return {
|
||||
content: toolResult.content.map((content: any) => ({
|
||||
@@ -489,7 +423,7 @@ export function toolUpdateFromToolResult(
|
||||
content.type === "text"
|
||||
? {
|
||||
type: "text",
|
||||
text: markdownEscape(content.text.replace(SYSTEM_REMINDER, "")),
|
||||
text: markdownEscape(content.text),
|
||||
}
|
||||
: toAcpContentBlock(content, false),
|
||||
})),
|
||||
@@ -501,7 +435,7 @@ export function toolUpdateFromToolResult(
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: markdownEscape(toolResult.content.replace(SYSTEM_REMINDER, "")),
|
||||
text: markdownEscape(toolResult.content),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -509,7 +443,7 @@ export function toolUpdateFromToolResult(
|
||||
}
|
||||
return {};
|
||||
|
||||
case acpToolNames.edit: {
|
||||
case "Edit": {
|
||||
const content: ToolCallContent[] = [];
|
||||
const locations: ToolCallLocation[] = [];
|
||||
|
||||
@@ -520,7 +454,6 @@ export function toolUpdateFromToolResult(
|
||||
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 = [];
|
||||
@@ -558,10 +491,84 @@ export function toolUpdateFromToolResult(
|
||||
return result;
|
||||
}
|
||||
|
||||
case acpToolNames.bash:
|
||||
case "edit":
|
||||
case "Edit":
|
||||
case acpToolNames.write:
|
||||
case "Bash": {
|
||||
const result = toolResult.content;
|
||||
const terminalId = "tool_use_id" in toolResult ? String(toolResult.tool_use_id) : "";
|
||||
const isError = "is_error" in toolResult && toolResult.is_error;
|
||||
|
||||
// Extract output and exit code from either format:
|
||||
// 1. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code }
|
||||
// 2. Plain string content from a regular tool_result
|
||||
// 3. Array content (e.g. [{ type: "text", text: "..." }])
|
||||
let output = "";
|
||||
let exitCode = isError ? 1 : 0;
|
||||
|
||||
if (
|
||||
result &&
|
||||
typeof result === "object" &&
|
||||
"type" in result &&
|
||||
result.type === "bash_code_execution_result"
|
||||
) {
|
||||
const bashResult = result as BetaBashCodeExecutionResultBlock;
|
||||
output = bashResult.stdout || bashResult.stderr || "";
|
||||
exitCode = bashResult.return_code;
|
||||
} else if (typeof result === "string") {
|
||||
output = result;
|
||||
} else if (
|
||||
Array.isArray(result) &&
|
||||
result.length > 0 &&
|
||||
"text" in result[0] &&
|
||||
typeof result[0].text === "string"
|
||||
) {
|
||||
output = result.map((c: any) => c.text).join("\n");
|
||||
}
|
||||
|
||||
if (supportsTerminalOutput) {
|
||||
return {
|
||||
content: output.trim()
|
||||
? [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `\`\`\`sh\n${output.trimEnd()}\n\`\`\``,
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
_meta: {
|
||||
terminal_info: {
|
||||
terminal_id: terminalId,
|
||||
},
|
||||
terminal_output: {
|
||||
terminal_id: terminalId,
|
||||
data: output,
|
||||
},
|
||||
terminal_exit: {
|
||||
terminal_id: terminalId,
|
||||
exit_code: exitCode,
|
||||
signal: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
// Fallback: format output as a code block without terminal _meta
|
||||
if (output.trim()) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `\`\`\`sh\n${output.trimEnd()}\n\`\`\``,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
case "Write": {
|
||||
return {};
|
||||
}
|
||||
@@ -570,20 +577,6 @@ export function toolUpdateFromToolResult(
|
||||
return { title: "Exited Plan Mode" };
|
||||
}
|
||||
|
||||
case "Task":
|
||||
case "NotebookEdit":
|
||||
case "NotebookRead":
|
||||
case "TodoWrite":
|
||||
case "exit_plan_mode":
|
||||
case "Bash":
|
||||
case "BashOutput":
|
||||
case "KillBash":
|
||||
case "LS":
|
||||
case "Glob":
|
||||
case "Grep":
|
||||
case "WebFetch":
|
||||
case "WebSearch":
|
||||
case "Other":
|
||||
default: {
|
||||
return toAcpContentUpdate(
|
||||
toolResult.content,
|
||||
@@ -778,54 +771,3 @@ export const createPostToolUseHook =
|
||||
}
|
||||
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 };
|
||||
}
|
||||
};
|
||||
|
||||
63
src/utils.ts
63
src/utils.ts
@@ -106,69 +106,6 @@ export function applyEnvironmentSettings(settings: ClaudeCodeSettings): void {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExtractLinesResult {
|
||||
content: string;
|
||||
wasLimited: boolean;
|
||||
linesRead: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts lines from file content with byte limit enforcement.
|
||||
*
|
||||
* @param fullContent - The complete file content
|
||||
* @param maxContentLength - Maximum number of UTF-16 Code Units to return
|
||||
* @returns Object containing extracted content and metadata
|
||||
*/
|
||||
export function extractLinesWithByteLimit(
|
||||
fullContent: string,
|
||||
maxContentLength: number,
|
||||
): ExtractLinesResult {
|
||||
if (fullContent === "") {
|
||||
return {
|
||||
content: "",
|
||||
wasLimited: false,
|
||||
linesRead: 1,
|
||||
};
|
||||
}
|
||||
|
||||
let linesSeen = 0;
|
||||
let index = 0;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to encode a path like Claude does:
|
||||
// - Unix: "/Users/test" -> "-Users-test"
|
||||
// - Windows: "C:\Users\test" -> "C--Users-test"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"target": "ES2021",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2020"],
|
||||
"lib": ["ES2021"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
Reference in New Issue
Block a user