Compare commits

...

13 Commits

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

---------

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

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

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

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

View File

@@ -1,6 +0,0 @@
node_modules/
dist/
coverage/
*.min.js
*.config.js
.github/

View File

@@ -1,33 +0,0 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"env": {
"node": true,
"es2020": true
},
"rules": {
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
],
"no-console": "off",
"no-constant-condition": "off",
"default-case": "error",
"prefer-const": "error",
"no-var": "error",
"eqeqeq": ["error", "always"],
"curly": ["error", "all"]
},
"ignorePatterns": ["dist", "node_modules", "*.js"]
}

View File

@@ -6,6 +6,7 @@ on:
jobs:
build:
runs-on: ubuntu-latest
environment: release # Optional: for enhanced security
permissions:
contents: read
id-token: write
@@ -16,8 +17,8 @@ jobs:
with:
node-version: "lts/*"
registry-url: "https://registry.npmjs.org"
- name: Update npm
run: npm install -g npm@latest
- run: npm ci
# Add back provenance once repo is public
- run: npm publish # --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: npm run build
- run: npm publish

14
CHANGELOG.md Normal file
View File

@@ -0,0 +1,14 @@
# Changelog
## v0.4.2
- Fix missing package.json metadata
## v0.4.1
- Add support for /compact command [ecfd36a](https://github.com/zed-industries/claude-code-acp/commit/ecfd36afa6c4e31f12e1daf9b8a2bdc12dda1794)
- Add default limits to read tool [7bd1638](https://github.com/zed-industries/claude-code-acp/commit/7bd163818bb959b11fd2c933eff73ad83c57abb8)
- Better rendering of Tool errors [491efe3](https://github.com/zed-industries/claude-code-acp/commit/491efe32e8547075842e448d873fc01b2ffabf3a)
- Load managed-settings.json [f691024](https://github.com/zed-industries/claude-code-acp/commit/f691024350362858e00b97248ac68e356d2331c2)
- Update to @anthropic-ai/claude-code@v1.0.113
- Update to @zed-industries/agent-client-protocol@v0.3.1

View File

@@ -33,6 +33,7 @@ Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) suppo
### Other clients
- Neovim via [CodeCompanion.nvim](https://codecompanion.olimorris.dev/configuration/adapters#setup-claude-code-via-acp)
- [marimo notebook](https://github.com/marimo-team/marimo)
[Submit a PR](https://github.com/zed-industries/claude-code-acp/pulls) to add yours!

48
eslint.config.js Normal file
View File

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

1096
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
"bin": {
"claude-code-acp": "./dist/index.js"
},
"version": "0.4.0",
"version": "0.4.2",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/index.js",
"type": "module",
@@ -40,30 +40,39 @@
"sdk",
"code"
],
"homepage": "https://github.com/zed-industries/claude-code-acp#readme",
"bugs": {
"url": "https://github.com/zed-industries/claude-code-acp/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/zed-industries/claude-code-acp.git"
},
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.100",
"@modelcontextprotocol/sdk": "^1.17.4",
"@zed-industries/agent-client-protocol": "0.2.0-alpha.8",
"diff": "^8.0.2",
"express": "^5.1.0",
"minimist": "^1.2.8",
"uuid": "11.1.0"
"@anthropic-ai/claude-code": "1.0.113",
"@modelcontextprotocol/sdk": "1.18.0",
"@zed-industries/agent-client-protocol": "0.3.1",
"diff": "8.0.2",
"express": "5.1.0",
"minimist": "1.2.8",
"uuid": "13.0.0"
},
"devDependencies": {
"@types/express": "^5.0.3",
"@types/minimist": "^1.2.5",
"@types/node": "^24.3.0",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.42.0",
"@typescript-eslint/parser": "^8.42.0",
"@vitest/ui": "^3.2.4",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.6.2",
"ts-node": "^10.9.2",
"typescript": "^5.4.0",
"vitest": "^3.2.4"
"@types/express": "5.0.3",
"@types/minimist": "1.2.5",
"@types/node": "24.4.0",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.43.0",
"@typescript-eslint/parser": "8.43.0",
"@vitest/ui": "3.2.4",
"eslint": "9.35.0",
"eslint-config-prettier": "10.1.8",
"globals": "16.4.0",
"prettier": "3.6.2",
"ts-node": "10.9.2",
"typescript": "5.9.2",
"vitest": "3.2.4"
}
}

View File

@@ -284,6 +284,24 @@ export class ClaudeAcpAgent implements Agent {
continue;
}
// Slash commands like /compact can generate invalid output... doesn't match
// their own docs: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-slash-commands#%2Fcompact-compact-conversation-history
if (
typeof message.message.content === "string" &&
message.message.content.includes("<local-command-stdout>")
) {
console.log(message.message.content);
break;
}
if (
typeof message.message.content === "string" &&
message.message.content.includes("<local-command-stderr>")
) {
console.error(message.message.content);
break;
}
if (
message.message.model === "<synthetic>" &&
message.message.content.length === 1 &&
@@ -302,7 +320,7 @@ export class ClaudeAcpAgent implements Agent {
break;
}
default:
unreachable(message);
unreachable(message as never);
}
}
throw new Error("Session did not end in result");
@@ -361,7 +379,6 @@ async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand
"bashes", // Modal
"bug", // Modal
"clear", // Escape Codes
"compact", // Not supported via SDK?
"config", // Modal
"context", // Escape Codes
"cost", // Escape Codes
@@ -390,14 +407,14 @@ async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand
"todos", // Escape Codes
"vim", // Not needed
];
//todo: Do not use `as any` once `supportedCommands` is exposed via the typescript interface
const commands = await (query as any).supportedCommands();
const commands = await query.supportedCommands();
return commands
.map((command: { name: string; description: string; argumentHint: string }) => {
.map((command) => {
const input = command.argumentHint ? { hint: command.argumentHint } : null;
return {
name: command.name,
// @ts-expect-error type in the ts interface
description: command.description || "",
input,
};

View File

@@ -1,5 +1,13 @@
#!/usr/bin/env node
// Load managed settings and apply environment variables
import { loadManagedSettings, applyEnvironmentSettings } from "./utils.js";
const managedSettings = loadManagedSettings();
if (managedSettings) {
applyEnvironmentSettings(managedSettings);
}
// stdout is used to send messages to the client
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
console.log = console.error;

View File

@@ -8,7 +8,7 @@ import { ClaudeAcpAgent } from "./acp-agent.js";
import { ClientCapabilities, TerminalOutputResponse } from "@zed-industries/agent-client-protocol";
import * as diff from "diff";
import { sleep, unreachable } from "./utils.js";
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
import { PermissionResult } from "@anthropic-ai/claude-code";
export const SYSTEM_REMINDER = `
@@ -17,6 +17,8 @@ export const 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: 1000 };
const unqualifiedToolNames = {
read: "read",
edit: "edit",
@@ -68,8 +70,17 @@ In sessions with ${toolNames.read} always use it instead of Read as it contains
offset: z
.number()
.optional()
.describe("Which line to start reading from. Omit to start from the beginning."),
limit: z.number().optional().describe("How many lines to read. Omit for the whole file."),
.default(0)
.describe(
"Which line to start reading from (0-based). Omit to start from the beginning.",
),
linesToRead: z
.number()
.optional()
.default(defaults.linesToRead)
.describe(
`How many lines to read. Omit for ${defaults.linesToRead}. (Reading files that are too large can fill up the context window unnecessarily, so be thoughtful about how much you read at a time.)`,
),
},
annotations: {
title: "Read file",
@@ -92,18 +103,43 @@ In sessions with ${toolNames.read} always use it instead of Read as it contains
],
};
}
const content = await agent.readTextFile({
sessionId,
path: input.abs_path,
limit: input.limit,
line: input.offset,
});
// Extract lines with byte limit enforcement
const result = extractLinesWithByteLimit(
content.content,
input.offset ?? 0,
input.linesToRead,
defaults.maxFileSize,
);
// Construct informative message about what was read
let readInfo = "";
if (input.offset > 0 || result.wasLimited) {
readInfo = "\n\n<file-read-info>";
if (result.wasLimited) {
readInfo += `Read ${result.linesRead} lines (hit 50KB limit). `;
} else {
readInfo += `Read lines ${(input.offset ?? 0) + 1}-${result.actualEndLine}. `;
}
if (result.wasLimited) {
readInfo += `Continue with offset=${result.actualEndLine}.`;
}
readInfo += "</file-read-info>";
}
return {
content: [
{
type: "text",
text: content.content + SYSTEM_REMINDER,
text: result.content + readInfo + SYSTEM_REMINDER,
},
],
};

View File

@@ -91,7 +91,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
this.files.set(params.path, params.content);
return null;
return {};
}
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
@@ -191,6 +191,50 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
expect(client.takeReceivedText()).toContain("Hello GPT-5");
}, 30000);
it("/compact works", async () => {
const { client, connection, newSessionResponse } = await setupTestSession(__dirname);
const commands = await client.availableCommandsPromise;
expect(commands).toContainEqual({
description:
"Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]",
input: {
hint: "<optional custom summarization instructions>",
},
name: "compact",
});
// Error case (no previous message)
await connection.prompt({
prompt: [{ type: "text", text: "/compact" }],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toBe("");
// Send something
await connection.prompt({
prompt: [{ type: "text", text: "Hi" }],
sessionId: newSessionResponse.sessionId,
});
// Clear response
client.takeReceivedText();
// Test with instruction
await connection.prompt({
prompt: [
{
type: "text",
text: "/compact greeting",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toContain("");
}, 30000);
});
describe("tool conversions", () => {
@@ -778,7 +822,10 @@ describe("tool conversions", () => {
// Should return empty object when parsing fails
expect(update).toEqual({
content: [
{ content: { type: "text", text: "Failed to find `old_string`" }, type: "content" },
{
content: { type: "text", text: "```\nFailed to find `old_string`\n```" },
type: "content",
},
],
});
});

View File

@@ -0,0 +1,174 @@
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, 0, 5, 1000);
expect(result.content).toBe(simpleContent);
expect(result.actualEndLine).toBe(4);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(5);
});
it("should extract partial lines with offset", () => {
const result = extractLinesWithByteLimit(simpleContent, 2, 2, 1000);
expect(result.content).toBe("Line 3\nLine 4");
expect(result.actualEndLine).toBe(3);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(2);
});
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, 0, 10, 250);
expect(result.wasLimited).toBe(true);
expect(result.actualEndLine).toBe(1); // Should only get 2 lines
expect(result.linesRead).toBe(2);
});
it("should handle offset beyond file length", () => {
const result = extractLinesWithByteLimit(simpleContent, 10, 5, 1000);
expect(result.content).toBe("");
expect(result.actualEndLine).toBe(10);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(0);
});
it("should handle empty content", () => {
const result = extractLinesWithByteLimit("", 0, 10, 1000);
expect(result.content).toBe("");
expect(result.actualEndLine).toBe(0); // Empty string splits to [""] and we process that one empty line
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, 0, 10, 1000);
expect(result.content).toBe(singleLine);
expect(result.actualEndLine).toBe(0);
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, 0, 10, 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, 0, 3, 85);
expect(result.content).toBe(`${line1}\n${line2}`);
expect(result.actualEndLine).toBe(1);
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, 0, 3, 17);
expect(result.content).toBe(exactContent);
expect(result.actualEndLine).toBe(2);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(3);
});
it("should handle reading from middle to end", () => {
const result = extractLinesWithByteLimit(simpleContent, 3, 100, 1000);
expect(result.content).toBe("Line 4\nLine 5");
expect(result.actualEndLine).toBe(4);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(2);
});
it("should handle large content with typical parameters", () => {
const longContent = Array(2000).fill("Line content here").join("\n");
const result = extractLinesWithByteLimit(longContent, 0, 1000, 50000);
expect(result.actualEndLine).toBeLessThanOrEqual(1000); // Should be limited by line count
});
it("should provide correct data for partial read at start", () => {
const result = extractLinesWithByteLimit(simpleContent, 0, 2, 1000);
expect(result.actualEndLine).toBe(1);
expect(result.linesRead).toBe(2);
});
it("should provide correct data for partial read with offset", () => {
const result = extractLinesWithByteLimit(simpleContent, 1, 2, 1000);
expect(result.actualEndLine).toBe(2);
expect(result.linesRead).toBe(2);
});
it("should not add newline after last line", () => {
const result = extractLinesWithByteLimit("Line 1\nLine 2\nLine 3", 2, 1, 1000);
expect(result.content).toBe("Line 3"); // No trailing newline
});
it("should handle Windows-style line endings", () => {
const windowsContent = "Line 1\r\nLine 2\r\nLine 3";
const result = extractLinesWithByteLimit(windowsContent, 0, 3, 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, 0, 200, 50000);
expect(result.wasLimited).toBe(true);
expect(result.actualEndLine).toBeLessThan(50); // Should stop well before 200 lines
});
it("should handle line limit of 0", () => {
const result = extractLinesWithByteLimit(simpleContent, 0, 0, 1000);
expect(result.content).toBe("");
expect(result.actualEndLine).toBe(0);
expect(result.linesRead).toBe(0);
expect(result.wasLimited).toBe(false);
});
it("should allow at least one line even if it exceeds byte limit", () => {
const veryLongLine = "x".repeat(100000); // 100KB line
const result = extractLinesWithByteLimit(veryLongLine, 0, 1, 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.actualEndLine).toBe(0);
expect(result.linesRead).toBe(1);
expect(result.wasLimited).toBe(false);
});
});

View File

@@ -487,7 +487,7 @@ export function toolUpdateFromToolResult(
case "Write": {
if (toolResult.is_error && toolResult.content?.length > 0) {
// Only return errors
return toAcpContentUpdate(toolResult.content);
return toAcpContentUpdate(toolResult.content, true);
}
return {};
}
@@ -511,17 +511,26 @@ export function toolUpdateFromToolResult(
case "WebSearch":
case "Other":
default: {
return toAcpContentUpdate(toolResult.content);
return toAcpContentUpdate(toolResult.content, toolResult.is_error);
}
}
}
function toAcpContentUpdate(content: any): { content?: ToolCallContent[] } {
function toAcpContentUpdate(
content: any,
isError: boolean = false,
): { content?: ToolCallContent[] } {
if (Array.isArray(content) && content.length > 0) {
return {
content: content.map((content: any) => ({
type: "content",
content,
content:
isError && content.type === "text"
? {
...content,
text: `\`\`\`\n${content.text}\n\`\`\``,
}
: content,
})),
};
} else if (typeof content === "string" && content.length > 0) {
@@ -531,7 +540,7 @@ function toAcpContentUpdate(content: any): { content?: ToolCallContent[] } {
type: "content",
content: {
type: "text",
text: content,
text: isError ? `\`\`\`\n${content}\n\`\`\`` : content,
},
},
],

View File

@@ -2,6 +2,8 @@
import { Readable, Writable } from "node:stream";
import { WritableStream, ReadableStream } from "node:stream/web";
import { readFileSync } from "node:fs";
import { platform } from "node:os";
// Useful for bridging push-based and async-iterator-based code.
export class Pushable<T> implements AsyncIterable<T> {
@@ -80,3 +82,153 @@ export function unreachable(value: never): never {
export function sleep(time: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, time));
}
interface ManagedSettings {
permissions?: {
allow?: string[];
deny?: string[];
};
env?: Record<string, string>;
}
// Following the rules in https://docs.anthropic.com/en/docs/claude-code/settings#settings-files
// This can be removed once the SDK supports it natively.
function getManagedSettingsPath(): string {
const os = platform();
switch (os) {
case "darwin":
return "/Library/Application Support/ClaudeCode/managed-settings.json";
case "linux": // including WSL
return "/etc/claude-code/managed-settings.json";
case "win32":
return "C:\\ProgramData\\ClaudeCode\\managed-settings.json";
default:
return "/etc/claude-code/managed-settings.json";
}
}
export function loadManagedSettings(): ManagedSettings | null {
try {
return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ManagedSettings;
} catch {
return null;
}
}
export function applyEnvironmentSettings(settings: ManagedSettings): void {
if (settings.env) {
for (const [key, value] of Object.entries(settings.env)) {
process.env[key] = value;
}
}
}
export interface ExtractLinesResult {
content: string;
actualEndLine: number;
wasLimited: boolean;
linesRead: number;
}
/**
* Extracts lines from file content with byte limit enforcement.
*
* @param fullContent - The complete file content
* @param linesToSkip - Starting line number (0-based)
* @param linesToRead - Maximum number of lines to read
* @param maxContentLength - Maximum number of UTF-16 Code Units to return
* @returns Object containing extracted content and metadata
*/
export function extractLinesWithByteLimit(
fullContent: string,
linesToSkip: number,
linesToRead: number,
maxContentLength: number,
): ExtractLinesResult {
if (fullContent === "" || linesToRead === 0) {
if (linesToSkip === 0 && linesToRead > 0) {
return {
content: "",
actualEndLine: 0,
wasLimited: false,
linesRead: 1,
};
} else {
return {
content: "",
actualEndLine: linesToSkip,
wasLimited: false,
linesRead: 0,
};
}
}
let linesSeen = 0;
let index = 0;
while (linesSeen < linesToSkip) {
const nextIndex = fullContent.indexOf("\n", index);
// There were not enough lines to skip.
if (nextIndex < 0) {
return {
content: "",
actualEndLine: linesToSkip,
wasLimited: false,
linesRead: 0,
};
}
linesSeen += 1;
index = nextIndex + 1;
}
// We've successfully skipped over all the lines we were supposed to.
// Now we can actually start reading!
const startIndex = index;
linesSeen = 0;
let contentLength = 0;
let wasLimited = false;
while (linesSeen < linesToRead) {
const nextIndex = fullContent.indexOf("\n", index);
if (nextIndex < 0) {
// Last line in file (no trailing newline)
const newContentLength = fullContent.length - startIndex;
if (linesSeen > 0 && newContentLength > maxContentLength) {
wasLimited = true;
break;
}
linesSeen += 1;
contentLength = newContentLength;
break;
} else {
// Line with newline - include up to the newline
const newContentLength = nextIndex + 1 - startIndex;
if (linesSeen > 0 && newContentLength > maxContentLength) {
wasLimited = true;
break;
}
linesSeen += 1;
contentLength = newContentLength;
index = nextIndex + 1;
}
}
// If we ended with a newline and we stopped due to byte limit or line limit (not end of file), remove the trailing newline
if (contentLength > 0 && fullContent[startIndex + contentLength - 1] === "\n") {
// Check if there's more content after our current position that we didn't read
if (startIndex + contentLength < fullContent.length) {
contentLength -= 1;
}
}
return {
content: fullContent.slice(startIndex, startIndex + contentLength),
actualEndLine: linesSeen > 0 ? linesToSkip + linesSeen - 1 : linesToSkip,
wasLimited,
linesRead: linesSeen,
};
}