diff --git a/package-lock.json b/package-lock.json index 68f4ead0..4396ca78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,15 +11,13 @@ "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" }, "bin": { "claude-agent-acp": "dist/index.js" }, "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", @@ -65,9 +63,9 @@ } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.75.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.75.0.tgz", - "integrity": "sha512-Xr/BESTYMOnpyOK7Gxie2LWc+G5f9+xZEYVAuWtDdYDtINocoe046gszFMeRJZ2FfySyGxdF3OHKYwB6/+p4uQ==", + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.76.0.tgz", + "integrity": "sha512-Nycgyfk6EwUnkozG321qNE7Vya46YPm2E0IB0+MmEBNEboOgXblvMmnDb5qNmsxAo9feJzDTFDgxioWwyoIy2g==", "dev": true, "license": "MIT", "dependencies": { @@ -594,6 +592,48 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", + "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jackspeak": "^4.2.3" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.1.tgz", + "integrity": "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@eslint/config-helpers": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", @@ -665,18 +705,6 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1018,6 +1046,7 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -1051,46 +1080,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -1703,39 +1692,6 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", @@ -1902,19 +1858,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1952,38 +1895,22 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -2002,89 +1929,20 @@ } }, "node_modules/balanced-match": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", - "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", - "license": "MIT", - "dependencies": { - "jackspeak": "^4.2.3" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "balanced-match": "^1.0.0" } }, "node_modules/chai": { @@ -2097,63 +1955,6 @@ "node": ">=18" } }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -2165,6 +1966,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2179,6 +1981,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2199,15 +2002,6 @@ "dev": true, "license": "MIT" }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/diff": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", @@ -2217,53 +2011,6 @@ "node": ">=0.3.1" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2271,18 +2018,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -2325,12 +2060,6 @@ "@esbuild/win32-x64": "0.27.3" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2448,21 +2177,30 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", + "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "jackspeak": "^4.2.3" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { @@ -2488,12 +2226,21 @@ "node": ">= 4" } }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.1.tgz", + "integrity": "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==", "dev": true, - "license": "MIT" + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, "node_modules/espree": { "version": "11.1.0", @@ -2582,36 +2329,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2622,71 +2339,11 @@ "node": ">=12.0.0" } }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", - "license": "MIT", - "dependencies": { - "ip-address": "10.0.1" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -2703,22 +2360,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2750,27 +2391,6 @@ "node": ">=16.0.0" } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2809,24 +2429,6 @@ "dev": true, "license": "ISC" }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2842,52 +2444,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2914,87 +2470,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.11.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", - "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -3015,30 +2490,6 @@ "node": ">=0.8.19" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3062,22 +2513,18 @@ "node": ">=0.10.0" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/jackspeak": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^9.0.0" @@ -3089,15 +2536,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3120,17 +2558,12 @@ } }, "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, "license": "MIT" }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -3195,71 +2628,17 @@ "dev": true, "license": "ISC" }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/minimatch": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.1.tgz", - "integrity": "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==", - "license": "BlueOak-1.0.0", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "20 || >=22" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3269,6 +2648,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -3297,36 +2677,6 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -3338,27 +2688,6 @@ ], "license": "MIT" }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3409,15 +2738,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3432,21 +2752,12 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3474,15 +2785,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -3538,19 +2840,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3561,54 +2850,6 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/rollup": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", @@ -3654,28 +2895,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -3689,61 +2908,11 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -3756,83 +2925,12 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3857,15 +2955,6 @@ "dev": true, "license": "MIT" }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -3917,15 +3006,6 @@ "node": ">=14.0.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/ts-algebra": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", @@ -4013,20 +3093,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4048,15 +3114,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -4074,15 +3131,6 @@ "dev": true, "license": "MIT" }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -4240,6 +3288,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -4278,12 +3327,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -4312,18 +3355,10 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } } } } diff --git a/package.json b/package.json index 5677539d..6cd6e1c8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 410d3570..0d777aaa 100644 --- a/src/acp-agent.ts +++ b/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": diff --git a/src/lib.ts b/src/lib.ts index 060fd674..87421a8f 100644 --- a/src/lib.ts +++ b/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"; diff --git a/src/mcp-server.ts b/src/mcp-server.ts deleted file mode 100644 index 0cefff0d..00000000 --- a/src/mcp-server.ts +++ /dev/null @@ -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 = ` - - -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. -`; - -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 { - 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 { - 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"; - - 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 += ""; - } - - 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 }; -} diff --git a/src/settings.ts b/src/settings.ts index cc4e997c..b43aa8e4 100644 --- a/src/settings.ts +++ b/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; 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 | 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 */ diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 60ae042d..ae3679e5 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -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,\n active_web_search: Option,\n}", + new_string: + "struct PromptState {\n active_commands: HashMap,\n active_web_search: Option,\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,\n active_web_search: Option,\n}", + newText: + "struct PromptState {\n active_commands: HashMap,\n active_web_search: Option,\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); } }); }); diff --git a/src/tests/extract-lines.test.ts b/src/tests/extract-lines.test.ts deleted file mode 100644 index d1b549b2..00000000 --- a/src/tests/extract-lines.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/tests/replace-and-calculate-location.test.ts b/src/tests/replace-and-calculate-location.test.ts deleted file mode 100644 index 2246eaf5..00000000 --- a/src/tests/replace-and-calculate-location.test.ts +++ /dev/null @@ -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]); - }); -}); diff --git a/src/tests/settings.test.ts b/src/tests/settings.test.ts index 0fa24997..74ad55bf 100644 --- a/src/tests/settings.test.ts +++ b/src/tests/settings.test.ts @@ -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"); - }); - }); }); diff --git a/src/tests/tools.test.ts b/src/tests/tools.test.ts index 40f97bb5..4476a949 100644 --- a/src/tests/tools.test.ts +++ b/src/tests/tools.test.ts @@ -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(); + }); + }); +}); diff --git a/src/tools.ts b/src/tools.ts index 8078762c..c1b9424e 100644 --- a/src/tools.ts +++ b/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 }; - } - }; diff --git a/src/utils.ts b/src/utils.ts index 8d561631..fc9fbfd9 100644 --- a/src/utils.ts +++ b/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" diff --git a/tsconfig.json b/tsconfig.json index 6d7f01e9..2d4ddd98 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,9 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2021", "module": "NodeNext", "moduleResolution": "NodeNext", - "lib": ["ES2020"], + "lib": ["ES2021"], "strict": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true,