Merge remote-tracking branch 'origin/main' into fix/repl-startup-typing-suppression-merge

This commit is contained in:
OpenClaude Worker 3
2026-04-06 15:55:24 +05:30
22 changed files with 1624 additions and 359 deletions

3
.gitignore vendored
View File

@@ -6,4 +6,7 @@ dist/
!.env.example !.env.example
.openclaude-profile.json .openclaude-profile.json
reports/ reports/
GEMINI.md
package-lock.json
/.claude
coverage/ coverage/

View File

@@ -185,6 +185,41 @@ With Firecrawl enabled:
Free tier at [firecrawl.dev](https://firecrawl.dev) includes 500 credits. The key is optional. Free tier at [firecrawl.dev](https://firecrawl.dev) includes 500 credits. The key is optional.
---
## Headless gRPC Server
OpenClaude can be run as a headless gRPC service, allowing you to integrate its agentic capabilities (tools, bash, file editing) into other applications, CI/CD pipelines, or custom user interfaces. The server uses bidirectional streaming to send real-time text chunks, tool calls, and request permissions for sensitive commands.
### 1. Start the gRPC Server
Start the core engine as a gRPC service on `localhost:50051`:
```bash
npm run dev:grpc
```
#### Configuration
| Variable | Default | Description |
|-----------|-------------|------------------------------------------------|
| `GRPC_PORT` | `50051` | Port the gRPC server listens on |
| `GRPC_HOST` | `localhost` | Bind address. Use `0.0.0.0` to expose on all interfaces (not recommended without authentication) |
### 2. Run the Test CLI Client
We provide a lightweight CLI client that communicates exclusively over gRPC. It acts just like the main interactive CLI, rendering colors, streaming tokens, and prompting you for tool permissions (y/n) via the gRPC `action_required` event.
In a separate terminal, run:
```bash
npm run dev:grpc:cli
```
*Note: The gRPC definitions are located in `src/proto/openclaude.proto`. You can use this file to generate clients in Python, Go, Rust, or any other language.*
---
## Source Build And Local Development ## Source Build And Local Development
```bash ```bash

119
bun.lock
View File

@@ -13,6 +13,8 @@
"@anthropic-ai/vertex-sdk": "0.14.4", "@anthropic-ai/vertex-sdk": "0.14.4",
"@commander-js/extra-typings": "12.1.0", "@commander-js/extra-typings": "12.1.0",
"@growthbook/growthbook": "1.6.5", "@growthbook/growthbook": "1.6.5",
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"@mendable/firecrawl-js": "4.18.1", "@mendable/firecrawl-js": "4.18.1",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@opentelemetry/api": "1.9.1", "@opentelemetry/api": "1.9.1",
@@ -84,6 +86,7 @@
"@types/bun": "1.3.11", "@types/bun": "1.3.11",
"@types/node": "25.5.0", "@types/node": "25.5.0",
"@types/react": "19.2.14", "@types/react": "19.2.14",
"tsx": "^4.21.0",
"typescript": "5.9.3", "typescript": "5.9.3",
}, },
}, },
@@ -184,6 +187,58 @@
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
"@growthbook/growthbook": ["@growthbook/growthbook@1.6.5", "", { "dependencies": { "dom-mutator": "^0.6.0" } }, "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A=="], "@growthbook/growthbook": ["@growthbook/growthbook@1.6.5", "", { "dependencies": { "dom-mutator": "^0.6.0" } }, "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A=="],
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="], "@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="],
@@ -456,7 +511,7 @@
"cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="],
"cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
@@ -524,6 +579,8 @@
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
@@ -570,6 +627,8 @@
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="], "fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="],
@@ -588,6 +647,8 @@
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
"get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="],
"google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], "google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="],
"google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="], "google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="],
@@ -764,6 +825,8 @@
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
@@ -834,6 +897,8 @@
"tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"turndown": ["turndown@7.2.2", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ=="], "turndown": ["turndown@7.2.2", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
@@ -884,9 +949,9 @@
"yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
"yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
@@ -1086,8 +1151,6 @@
"@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@grpc/proto-loader/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@1.30.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ=="], "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@1.30.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ=="],
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.57.2", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/otlp-transformer": "0.57.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag=="], "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.57.2", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/otlp-transformer": "0.57.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag=="],
@@ -1306,6 +1369,8 @@
"cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"cli-highlight/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -1360,12 +1425,6 @@
"@aws-sdk/nested-clients/@smithy/util-base64/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="], "@aws-sdk/nested-clients/@smithy/util-base64/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
"@grpc/proto-loader/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"@grpc/proto-loader/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@grpc/proto-loader/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A=="], "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A=="],
@@ -1432,6 +1491,12 @@
"cli-highlight/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "cli-highlight/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="],
"cli-highlight/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cli-highlight/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
@@ -1472,16 +1537,6 @@
"@aws-sdk/nested-clients/@smithy/util-base64/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], "@aws-sdk/nested-clients/@smithy/util-base64/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="],
"@grpc/proto-loader/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@grpc/proto-loader/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"@grpc/proto-loader/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"@grpc/proto-loader/yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"@grpc/proto-loader/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], "@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], "@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
@@ -1502,6 +1557,16 @@
"@smithy/smithy-client/@smithy/util-stream/@smithy/node-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@2.2.0", "", { "dependencies": { "@smithy/types": "^2.12.0", "@smithy/util-uri-escape": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-L1kSeviUWL+emq3CUVSgdogoM/D9QMFaqxL/dd0X7PCNWmPXqt+ExtrBjqT0V7HLN03Vs9SuiLrG3zy3JGnE5A=="], "@smithy/smithy-client/@smithy/util-stream/@smithy/node-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@2.2.0", "", { "dependencies": { "@smithy/types": "^2.12.0", "@smithy/util-uri-escape": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-L1kSeviUWL+emq3CUVSgdogoM/D9QMFaqxL/dd0X7PCNWmPXqt+ExtrBjqT0V7HLN03Vs9SuiLrG3zy3JGnE5A=="],
"cli-highlight/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"cli-highlight/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"cli-highlight/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"cli-highlight/yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"cli-highlight/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"qrcode/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "qrcode/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
@@ -1514,16 +1579,16 @@
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@grpc/proto-loader/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@grpc/proto-loader/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@grpc/proto-loader/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@smithy/smithy-client/@smithy/util-stream/@smithy/fetch-http-handler/@smithy/querystring-builder/@smithy/util-uri-escape": ["@smithy/util-uri-escape@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-jtmJMyt1xMD/d8OtbVJ2gFZOSKc+ueYJZPW20ULW1GOp/q/YIM0wNh+u8ZFao9UaIGz4WoPW8hC64qlWLIfoDA=="], "@smithy/smithy-client/@smithy/util-stream/@smithy/fetch-http-handler/@smithy/querystring-builder/@smithy/util-uri-escape": ["@smithy/util-uri-escape@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-jtmJMyt1xMD/d8OtbVJ2gFZOSKc+ueYJZPW20ULW1GOp/q/YIM0wNh+u8ZFao9UaIGz4WoPW8hC64qlWLIfoDA=="],
"@smithy/smithy-client/@smithy/util-stream/@smithy/node-http-handler/@smithy/querystring-builder/@smithy/util-uri-escape": ["@smithy/util-uri-escape@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-jtmJMyt1xMD/d8OtbVJ2gFZOSKc+ueYJZPW20ULW1GOp/q/YIM0wNh+u8ZFao9UaIGz4WoPW8hC64qlWLIfoDA=="], "@smithy/smithy-client/@smithy/util-stream/@smithy/node-http-handler/@smithy/querystring-builder/@smithy/util-uri-escape": ["@smithy/util-uri-escape@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-jtmJMyt1xMD/d8OtbVJ2gFZOSKc+ueYJZPW20ULW1GOp/q/YIM0wNh+u8ZFao9UaIGz4WoPW8hC64qlWLIfoDA=="],
"cli-highlight/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"cli-highlight/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"cli-highlight/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"qrcode/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "qrcode/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],

144
docs/litellm-setup.md Normal file
View File

@@ -0,0 +1,144 @@
# LiteLLM Setup
OpenClaude can connect to LiteLLM through LiteLLM's OpenAI-compatible proxy.
## Overview
LiteLLM is an open-source LLM gateway that provides a unified API to 100+ model providers. By running the LiteLLM Proxy, you can route OpenClaude requests through LiteLLM to access any of its supported providers — all while using OpenClaude's existing OpenAI-compatible provider path.
## Prerequisites
- LiteLLM installed (`pip install litellm[proxy]`)
- A `litellm_config.yaml` or equivalent LiteLLM configuration
- LiteLLM Proxy running on a local or remote port
## 1. Start the LiteLLM Proxy
### Basic installation
```bash
pip install litellm[proxy]
```
### Configure LiteLLM
Create a `litellm_config.yaml` with your desired model aliases:
```yaml
model_list:
- model_name: gpt-4o
litellm_params:
model: openai/gpt-4o
api_key: os.environ/OPENAI_API_KEY
- model_name: claude-sonnet-4
litellm_params:
model: anthropic/claude-sonnet-4-5-20250929
api_key: os.environ/ANTHROPIC_API_KEY
- model_name: gemini-2.5-flash
litellm_params:
model: gemini/gemini-2.5-flash
api_key: os.environ/GEMINI_API_KEY
- model_name: llama-3.3-70b
litellm_params:
model: together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo
api_key: os.environ/TOGETHER_API_KEY
```
### Run the proxy
```bash
litellm --config litellm_config.yaml --port 4000
```
The proxy will start at `http://localhost:4000` by default.
## 2. Point OpenClaude to LiteLLM
### Option A: Environment Variables
```bash
export CLAUDE_CODE_USE_OPENAI=1
export OPENAI_BASE_URL=http://localhost:4000
export OPENAI_API_KEY=<your-master-key-or-placeholder>
export OPENAI_MODEL=<your-litellm-model-alias>
openclaude
```
Replace `<your-litellm-model-alias>` with a model name from your `litellm_config.yaml` (e.g., `gpt-4o`, `claude-sonnet-4`, `gemini-2.5-flash`).
### Option B: Using /provider
1. Run `openclaude`
2. Type `/provider` to open the provider setup flow
3. Choose the **OpenAI-compatible** option
4. When prompted for the API key, enter the key required by your LiteLLM proxy
If your local LiteLLM setup does not enforce auth, you may still need to enter a placeholder value
- 5. When prompted for the base URL, enter `http://localhost:4000`
6. 6. When prompted for the model, enter the LiteLLM model name or alias you configured
7. 7. Save the provider configuration
## 3. Example LiteLLM Configs
### Multi-provider routing with spend tracking
```yaml
model_list:
- model_name: gpt-4o
litellm_params:
model: openai/gpt-4o
api_key: os.environ/OPENAI_API_KEY
- model_name: claude-sonnet-4
litellm_params:
model: anthropic/claude-sonnet-4-5-20250929
api_key: os.environ/ANTHROPIC_API_KEY
- model_name: deepseek-chat
litellm_params:
model: deepseek/deepseek-chat
api_key: os.environ/DEEPSEEK_API_KEY
litellm_settings:
set_verbose: false
num_retries: 3
```
### With a master key for auth
```bash
# Start proxy with a master key
litellm --config litellm_config.yaml --port 4000 --master_key sk-my-master-key
# Connect OpenClaude
export CLAUDE_CODE_USE_OPENAI=1
export OPENAI_BASE_URL=http://localhost:4000
export OPENAI_API_KEY=sk-my-master-key
export OPENAI_MODEL=gpt-4o
openclaude
```
## 4. Notes
- `OPENAI_MODEL` must match the **LiteLLM model alias** defined in your config, not the upstream raw provider model name.
- If your proxy requires authentication, use the proxy key (or `master_key`) in `OPENAI_API_KEY`.
- LiteLLM's OpenAI-compatible endpoint accepts the same request format as OpenAI, so OpenClaude works without any code changes.
- You can switch between any provider configured in LiteLLM by simply changing the `OPENAI_MODEL` value — no need to reconfigure OpenClaude.
## 5. Troubleshooting
| Issue | Likely Cause | Fix |
|-------|--------------|-----|
| 404 or Model Not Found | Model alias doesn't exist in LiteLLM config | Verify the `model_name` in `litellm_config.yaml` matches `OPENAI_MODEL` |
| Connection Refused | LiteLLM proxy isn't running | Start the proxy with `litellm --config litellm_config.yaml --port 4000` |
| Auth Failed | Missing or wrong `master_key` | Set the correct key in `OPENAI_API_KEY` |
| Upstream provider error | The backend provider key is missing or invalid | Ensure the upstream API key (e.g., `OPENAI_API_KEY`) is set in your LiteLLM proxy process environment |
| Tools fail but chat works | The selected model has weak function/tool calling support | Switch to a model with strong tool support (e.g., GPT-4o, Claude Sonnet) |
## 6. Resources
- [LiteLLM Proxy Docs](https://docs.litellm.ai/docs/proxy/quick_start)
- [LiteLLM Provider List](https://docs.litellm.ai/docs/providers)
- [LiteLLM OpenAI-Compatible Endpoints](https://docs.litellm.ai/docs/proxy/openai_compatible_proxy)

View File

@@ -1,6 +1,6 @@
{ {
"name": "@gitlawb/openclaude", "name": "@gitlawb/openclaude",
"version": "0.1.7", "version": "0.1.8",
"description": "Claude Code opened to any LLM — OpenAI, Gemini, DeepSeek, Ollama, and 200+ models", "description": "Claude Code opened to any LLM — OpenAI, Gemini, DeepSeek, Ollama, and 200+ models",
"type": "module", "type": "module",
"bin": { "bin": {
@@ -30,6 +30,8 @@
"profile:code": "bun run profile:init -- --provider ollama --model qwen2.5-coder:7b", "profile:code": "bun run profile:init -- --provider ollama --model qwen2.5-coder:7b",
"dev:fast": "bun run profile:fast && bun run dev:ollama:fast", "dev:fast": "bun run profile:fast && bun run dev:ollama:fast",
"dev:code": "bun run profile:code && bun run dev:profile", "dev:code": "bun run profile:code && bun run dev:profile",
"dev:grpc": "bun run scripts/start-grpc.ts",
"dev:grpc:cli": "bun run scripts/grpc-cli.ts",
"start": "node dist/cli.mjs", "start": "node dist/cli.mjs",
"test": "bun test", "test": "bun test",
"test:coverage": "bun test --coverage --coverage-reporter=lcov --coverage-dir=coverage --max-concurrency=1 && bun run scripts/render-coverage-heatmap.ts", "test:coverage": "bun test --coverage --coverage-reporter=lcov --coverage-dir=coverage --max-concurrency=1 && bun run scripts/render-coverage-heatmap.ts",
@@ -57,6 +59,8 @@
"@anthropic-ai/vertex-sdk": "0.14.4", "@anthropic-ai/vertex-sdk": "0.14.4",
"@commander-js/extra-typings": "12.1.0", "@commander-js/extra-typings": "12.1.0",
"@growthbook/growthbook": "1.6.5", "@growthbook/growthbook": "1.6.5",
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"@mendable/firecrawl-js": "4.18.1", "@mendable/firecrawl-js": "4.18.1",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@opentelemetry/api": "1.9.1", "@opentelemetry/api": "1.9.1",
@@ -128,6 +132,7 @@
"@types/bun": "1.3.11", "@types/bun": "1.3.11",
"@types/node": "25.5.0", "@types/node": "25.5.0",
"@types/react": "19.2.14", "@types/react": "19.2.14",
"tsx": "^4.21.0",
"typescript": "5.9.3" "typescript": "5.9.3"
}, },
"engines": { "engines": {

121
scripts/grpc-cli.ts Normal file
View File

@@ -0,0 +1,121 @@
import * as grpc from '@grpc/grpc-js'
import * as protoLoader from '@grpc/proto-loader'
import path from 'path'
import * as readline from 'readline'
const PROTO_PATH = path.resolve(import.meta.dirname, '../src/proto/openclaude.proto')
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
})
const protoDescriptor = grpc.loadPackageDefinition(packageDefinition) as any
const openclaudeProto = protoDescriptor.openclaude.v1
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
function askQuestion(query: string): Promise<string> {
return new Promise(resolve => {
rl.question(query, resolve)
})
}
async function main() {
const host = process.env.GRPC_HOST || 'localhost'
const port = process.env.GRPC_PORT || '50051'
const client = new openclaudeProto.AgentService(
`${host}:${port}`,
grpc.credentials.createInsecure()
)
let call: grpc.ClientDuplexStream<any, any> | null = null
const startStream = () => {
call = client.Chat()
let textStreamed = false
call.on('data', async (serverMessage: any) => {
if (serverMessage.text_chunk) {
process.stdout.write(serverMessage.text_chunk.text)
textStreamed = true
} else if (serverMessage.tool_start) {
console.log(`\n\x1b[36m[Tool Call]\x1b[0m \x1b[1m${serverMessage.tool_start.tool_name}\x1b[0m`)
console.log(`\x1b[90m${serverMessage.tool_start.arguments_json}\x1b[0m\n`)
} else if (serverMessage.tool_result) {
console.log(`\n\x1b[32m[Tool Result]\x1b[0m \x1b[1m${serverMessage.tool_result.tool_name}\x1b[0m`)
const out = serverMessage.tool_result.output
if (out.length > 500) {
console.log(`\x1b[90m${out.substring(0, 500)}...\n(Output truncated, total length: ${out.length})\x1b[0m`)
} else {
console.log(`\x1b[90m${out}\x1b[0m`)
}
} else if (serverMessage.action_required) {
const action = serverMessage.action_required
console.log(`\n\x1b[33m[Action Required]\x1b[0m`)
const reply = await askQuestion(`\x1b[1m${action.question}\x1b[0m (y/n) > `)
call?.write({
input: {
prompt_id: action.prompt_id,
reply: reply.trim()
}
})
} else if (serverMessage.done) {
if (!textStreamed && serverMessage.done.full_text) {
process.stdout.write(serverMessage.done.full_text)
}
textStreamed = false
console.log('\n\x1b[32m[Generation Complete]\x1b[0m')
promptUser()
} else if (serverMessage.error) {
console.error(`\n\x1b[31m[Server Error]\x1b[0m ${serverMessage.error.message}`)
promptUser()
}
})
call.on('end', () => {
console.log('\n\x1b[90m[Stream closed by server]\x1b[0m')
// Don't prompt user here, let 'done' or 'error' handlers do it
})
call.on('error', (err: Error) => {
console.error('\n\x1b[31m[Stream Error]\x1b[0m', err.message)
promptUser()
})
}
const promptUser = async () => {
const message = await askQuestion('\n\x1b[35m> \x1b[0m')
if (message.trim().toLowerCase() === '/exit' || message.trim().toLowerCase() === '/quit') {
console.log('Bye!')
rl.close()
process.exit(0)
}
if (!call || call.destroyed) {
startStream()
}
call!.write({
request: {
session_id: 'cli-session-1',
message: message,
working_directory: process.cwd()
}
})
}
console.log('\x1b[32mOpenClaude gRPC CLI\x1b[0m')
console.log('\x1b[90mType /exit to quit.\x1b[0m')
promptUser()
}
main()

50
scripts/start-grpc.ts Normal file
View File

@@ -0,0 +1,50 @@
import { GrpcServer } from '../src/grpc/server.ts'
import { init } from '../src/entrypoints/init.ts'
// Polyfill MACRO which is normally injected by the bundler
Object.assign(globalThis, {
MACRO: {
VERSION: '0.1.7',
DISPLAY_VERSION: '0.1.7',
PACKAGE_URL: '@gitlawb/openclaude',
}
})
async function main() {
console.log('Starting OpenClaude gRPC Server...')
await init()
// Mirror CLI bootstrap: hydrate secure tokens and resolve provider profile
const { enableConfigs } = await import('../src/utils/config.js')
enableConfigs()
const { applySafeConfigEnvironmentVariables } = await import('../src/utils/managedEnv.js')
applySafeConfigEnvironmentVariables()
const { hydrateGeminiAccessTokenFromSecureStorage } = await import('../src/utils/geminiCredentials.js')
hydrateGeminiAccessTokenFromSecureStorage()
const { hydrateGithubModelsTokenFromSecureStorage } = await import('../src/utils/githubModelsCredentials.js')
hydrateGithubModelsTokenFromSecureStorage()
const { buildStartupEnvFromProfile, applyProfileEnvToProcessEnv } = await import('../src/utils/providerProfile.js')
const { getProviderValidationError, validateProviderEnvOrExit } = await import('../src/utils/providerValidation.js')
const startupEnv = await buildStartupEnvFromProfile({ processEnv: process.env })
if (startupEnv !== process.env) {
const startupProfileError = await getProviderValidationError(startupEnv)
if (startupProfileError) {
console.warn(`Warning: ignoring saved provider profile. ${startupProfileError}`)
} else {
applyProfileEnvToProcessEnv(process.env, startupEnv)
}
}
await validateProviderEnvOrExit()
const port = process.env.GRPC_PORT ? parseInt(process.env.GRPC_PORT, 10) : 50051
const host = process.env.GRPC_HOST || 'localhost'
const server = new GrpcServer()
server.start(port, host)
}
main().catch((err) => {
console.error('Fatal error starting gRPC server:', err)
process.exit(1)
})

View File

@@ -30,7 +30,7 @@ test('opens the model picker without awaiting local model discovery refresh', as
discoverOpenAICompatibleModelOptions, discoverOpenAICompatibleModelOptions,
})) }))
const { call } = await import('./model.js') const { call } = await import(`./model.js?ts=${Date.now()}-${Math.random()}`)
const result = await Promise.race([ const result = await Promise.race([
call(() => {}, {} as never, ''), call(() => {}, {} as never, ''),
new Promise(resolve => setTimeout(() => resolve('timeout'), 50)), new Promise(resolve => setTimeout(() => resolve('timeout'), 50)),
@@ -39,5 +39,4 @@ test('opens the model picker without awaiting local model discovery refresh', as
resolveDiscovery?.() resolveDiscovery?.()
expect(result).not.toBe('timeout') expect(result).not.toBe('timeout')
expect(discoverOpenAICompatibleModelOptions).toHaveBeenCalledTimes(1) })
})

View File

@@ -67,6 +67,7 @@ import { isBilledAsExtraUsage } from '../../utils/extraUsage.js';
import { getFastModeUnavailableReason, isFastModeAvailable, isFastModeCooldown, isFastModeEnabled, isFastModeSupportedByModel } from '../../utils/fastMode.js'; import { getFastModeUnavailableReason, isFastModeAvailable, isFastModeCooldown, isFastModeEnabled, isFastModeSupportedByModel } from '../../utils/fastMode.js';
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js';
import type { PromptInputHelpers } from '../../utils/handlePromptSubmit.js'; import type { PromptInputHelpers } from '../../utils/handlePromptSubmit.js';
import { extractDraggedFilePaths } from '../../utils/dragDropPaths.js';
import { getImageFromClipboard, PASTE_THRESHOLD } from '../../utils/imagePaste.js'; import { getImageFromClipboard, PASTE_THRESHOLD } from '../../utils/imagePaste.js';
import type { ImageDimensions } from '../../utils/imageResizer.js'; import type { ImageDimensions } from '../../utils/imageResizer.js';
import { cacheImagePath, storeImage } from '../../utils/imageStore.js'; import { cacheImagePath, storeImage } from '../../utils/imageStore.js';
@@ -1204,6 +1205,22 @@ function PromptInput({
// Clean up pasted text - strip ANSI escape codes and normalize line endings and tabs // Clean up pasted text - strip ANSI escape codes and normalize line endings and tabs
let text = stripAnsi(rawText).replace(/\r/g, '\n').replaceAll('\t', ' '); let text = stripAnsi(rawText).replace(/\r/g, '\n').replaceAll('\t', ' ');
// Detect file paths from drag-and-drop and convert to @mentions.
// When files are dragged into the terminal, the terminal sends their
// absolute paths via bracketed paste. Image files are handled by the
// image paste handler upstream; here we handle non-image files by
// converting them to @mentions so they get attached on submit.
const draggedPaths = extractDraggedFilePaths(text);
if (draggedPaths.length > 0) {
const mentions = draggedPaths
.map(p => (p.includes(' ') || p.includes(':') ? `@"${p}"` : `@${p}`))
.join(' ');
// Ensure spacing around the mention(s) relative to existing input
const charBefore = input[cursorOffset - 1];
const prefix = charBefore && !/\s/.test(charBefore) ? ' ' : '';
text = prefix + mentions + ' ';
}
// Match typed/auto-suggest: `!cmd` pasted into empty input enters bash mode. // Match typed/auto-suggest: `!cmd` pasted into empty input enters bash mode.
if (input.length === 0) { if (input.length === 0) {
const pastedMode = getModeFromInput(text); const pastedMode = getModeFromInput(text);
@@ -1245,12 +1262,23 @@ function PromptInput({
if (isNonSpacePrintable(input, key)) return ' ' + input; if (isNonSpacePrintable(input, key)) return ' ' + input;
return input; return input;
}, []); }, []);
// Ref mirrors cursorOffset for use in synchronous loops (e.g. multi-image
// paste) where React batches state updates and the closure value is stale.
const cursorOffsetRef = useRef(cursorOffset);
cursorOffsetRef.current = cursorOffset;
function insertTextAtCursor(text: string) { function insertTextAtCursor(text: string) {
// Push current state to buffer before inserting // Use refs for input/cursor so back-to-back calls in the same event
pushToBuffer(input, cursorOffset, pastedContents); // (e.g. onImagePaste loop for multiple dragged images) chain correctly
const newInput = input.slice(0, cursorOffset) + text + input.slice(cursorOffset); // instead of each reading the same stale closure values.
const currentInput = lastInternalInputRef.current;
const currentOffset = cursorOffsetRef.current;
pushToBuffer(currentInput, currentOffset, pastedContents);
const newInput = currentInput.slice(0, currentOffset) + text + currentInput.slice(currentOffset);
trackAndSetInput(newInput); trackAndSetInput(newInput);
setCursorOffset(cursorOffset + text.length); const newOffset = currentOffset + text.length;
cursorOffsetRef.current = newOffset;
setCursorOffset(newOffset);
} }
const doublePressEscFromEmpty = useDoublePress(() => {}, () => onShowMessageSelector()); const doublePressEscFromEmpty = useDoublePress(() => {}, () => onShowMessageSelector());

View File

@@ -123,8 +123,6 @@ const SuggestionItemRow = memo(function SuggestionItemRow({
maxColumnWidth ?? stringWidth(item.displayText) + 5, maxColumnWidth ?? stringWidth(item.displayText) + 5,
maxNameWidth, maxNameWidth,
) )
const displayTextColor = isSelected ? 'inverseText' : item.color
const shouldDim = !isSelected
let displayText = item.displayText let displayText = item.displayText
if (stringWidth(displayText) > displayTextWidth - 2) { if (stringWidth(displayText) > displayTextWidth - 2) {
@@ -144,21 +142,17 @@ const SuggestionItemRow = memo(function SuggestionItemRow({
const truncatedDescription = item.description const truncatedDescription = item.description
? truncateToWidth(item.description.replace(/\s+/g, ' '), descriptionWidth) ? truncateToWidth(item.description.replace(/\s+/g, ' '), descriptionWidth)
: '' : ''
const lineContent = `${paddedDisplayText}${tagText}${truncatedDescription}`
return ( return (
<Box width="100%" opaque={true} backgroundColor={rowBackgroundColor}> <Box width="100%" opaque={true} backgroundColor={rowBackgroundColor}>
<Text wrap="truncate"> <Text
<Text color={displayTextColor} dimColor={shouldDim} bold={isSelected}> color={textColor}
{paddedDisplayText} dimColor={!isSelected}
</Text> bold={isSelected}
{tagText ? ( wrap="truncate"
<Text color={textColor} dimColor={!isSelected}> >
{tagText} {lineContent}
</Text>
) : null}
<Text color={textColor} dimColor={!isSelected}>
{truncatedDescription}
</Text>
</Text> </Text>
</Box> </Box>
) )

View File

@@ -0,0 +1,113 @@
import { describe, expect, it, mock } from 'bun:test'
// We can't fully render ThemePicker due to complex dependencies
// But we can test the theme options generation logic
describe('ThemePicker', () => {
describe('theme options', () => {
it('generates correct theme options without AUTO_THEME feature flag', () => {
// Since we can't easily mock bun:bundle, test the options structure
// The real test would require integration testing
const expectedOptions = [
{ label: "Dark mode", value: "dark" },
{ label: "Light mode", value: "light" },
{ label: "Dark mode (colorblind-friendly)", value: "dark-daltonized" },
{ label: "Light mode (colorblind-friendly)", value: "light-daltonized" },
{ label: "Dark mode (ANSI colors only)", value: "dark-ansi" },
{ label: "Light mode (ANSI colors only)", value: "light-ansi" },
]
expect(expectedOptions.length).toBe(6)
})
it('includes auto theme when AUTO_THEME feature is enabled', () => {
// Test the structure when auto is present
const optionsWithAuto = [
{ label: "Auto (match terminal)", value: "auto" },
{ label: "Dark mode", value: "dark" },
]
expect(optionsWithAuto[0].value).toBe('auto')
})
})
describe('handleRowFocus callback', () => {
it('setPreviewTheme is called with theme setting', () => {
const setPreviewTheme = mock()
const handleRowFocus = (setting: string) => setPreviewTheme(setting)
handleRowFocus('dark')
expect(setPreviewTheme).toHaveBeenCalledWith('dark')
})
})
describe('handleSelect callback', () => {
it('calls savePreview and onThemeSelect', () => {
const savePreview = mock()
const onThemeSelect = mock()
const handleSelect = (setting: string) => {
savePreview()
onThemeSelect(setting)
}
handleSelect('light')
expect(savePreview).toHaveBeenCalled()
expect(onThemeSelect).toHaveBeenCalledWith('light')
})
})
describe('handleCancel callback', () => {
it('calls cancelPreview and gracefulShutdown when not skipExitHandling', () => {
const cancelPreview = mock()
const gracefulShutdown = mock()
const handleCancel = (skipExitHandling: boolean, onCancelProp?: () => void) => {
cancelPreview()
if (skipExitHandling) {
onCancelProp?.()
} else {
gracefulShutdown(0)
}
}
handleCancel(false)
expect(cancelPreview).toHaveBeenCalled()
expect(gracefulShutdown).toHaveBeenCalledWith(0)
})
it('calls onCancelProp when skipExitHandling is true', () => {
const cancelPreview = mock()
const onCancelProp = mock()
const handleCancel = (skipExitHandling: boolean, onCancelProp?: () => void) => {
cancelPreview()
if (skipExitHandling) {
onCancelProp?.()
}
}
handleCancel(true, onCancelProp)
expect(cancelPreview).toHaveBeenCalled()
expect(onCancelProp).toHaveBeenCalled()
})
})
describe('syntax hint logic', () => {
it('shows disabled hint when syntax highlighting is disabled', () => {
const syntaxHighlightingDisabled = true
const syntaxToggleShortcut = 'Ctrl+T'
const hint = syntaxHighlightingDisabled
? `Syntax highlighting disabled (${syntaxToggleShortcut} to enable)`
: `Syntax highlighting enabled (${syntaxToggleShortcut} to disable)`
expect(hint).toContain('disabled')
})
it('shows enabled hint when syntax highlighting is active', () => {
const syntaxHighlightingDisabled = false
const syntaxToggleShortcut = 'Ctrl+T'
const hint = !syntaxHighlightingDisabled
? `Syntax highlighting enabled (${syntaxToggleShortcut} to disable)`
: `Syntax highlighting disabled (${syntaxToggleShortcut} to enable)`
expect(hint).toContain('enabled')
})
})
})

View File

@@ -1,13 +1,14 @@
import { c as _c } from "react-compiler-runtime";
import { feature } from 'bun:bundle'; import { feature } from 'bun:bundle';
import type { StructuredPatchHunk } from 'diff';
import * as React from 'react'; import * as React from 'react';
import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js'
import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { Box, Text, usePreviewTheme, useTheme, useThemeSetting } from '../ink.js'; import { Box, Text, usePreviewTheme, useTheme, useThemeSetting } from '../ink.js';
import { useRegisterKeybindingContext } from '../keybindings/KeybindingContext.js'; import { useRegisterKeybindingContext } from '../keybindings/KeybindingContext.js';
import { useKeybinding } from '../keybindings/useKeybinding.js'; import { useKeybinding } from '../keybindings/useKeybinding.js';
import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js';
import { useAppState, useSetAppState } from '../state/AppState.js'; import { useAppState, useSetAppState } from '../state/AppState.js';
import type { AppState } from '../state/AppStateStore.js';
import { gracefulShutdown } from '../utils/gracefulShutdown.js'; import { gracefulShutdown } from '../utils/gracefulShutdown.js';
import { updateSettingsForSource } from '../utils/settings/settings.js'; import { updateSettingsForSource } from '../utils/settings/settings.js';
import type { ThemeSetting } from '../utils/theme.js'; import type { ThemeSetting } from '../utils/theme.js';
@@ -16,6 +17,17 @@ import { Byline } from './design-system/Byline.js';
import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js';
import { getColorModuleUnavailableReason, getSyntaxTheme } from './StructuredDiff/colorDiff.js'; import { getColorModuleUnavailableReason, getSyntaxTheme } from './StructuredDiff/colorDiff.js';
import { StructuredDiff } from './StructuredDiff.js'; import { StructuredDiff } from './StructuredDiff.js';
type StructuredDiffComponent = React.ComponentType<{
patch: StructuredPatchHunk
dim: boolean
filePath: string
firstLine: string | null
width: number
skipHighlighting?: boolean
}>
const StructuredDiffView = StructuredDiff as StructuredDiffComponent
export type ThemePickerProps = { export type ThemePickerProps = {
onThemeSelect: (setting: ThemeSetting) => void; onThemeSelect: (setting: ThemeSetting) => void;
showIntroText?: boolean; showIntroText?: boolean;
@@ -26,307 +38,224 @@ export type ThemePickerProps = {
skipExitHandling?: boolean; skipExitHandling?: boolean;
/** Called when the user cancels (presses Escape). If skipExitHandling is true and this is provided, it will be called instead of just saving the preview. */ /** Called when the user cancels (presses Escape). If skipExitHandling is true and this is provided, it will be called instead of just saving the preview. */
onCancel?: () => void; onCancel?: () => void;
}; }
export function ThemePicker(t0) {
const $ = _c(59); const DEMO_PATCH: StructuredPatchHunk = {
const { oldStart: 1,
onThemeSelect, newStart: 1,
showIntroText: t1, oldLines: 3,
helpText: t2, newLines: 3,
showHelpTextBelow: t3, lines: [
hideEscToCancel: t4, ' function greet() {',
skipExitHandling: t5, '- console.log("Hello, World!");',
onCancel: onCancelProp '+ console.log("Hello, Claude!");',
} = t0; ' }',
const showIntroText = t1 === undefined ? false : t1; ],
const helpText = t2 === undefined ? "" : t2; }
const showHelpTextBelow = t3 === undefined ? false : t3;
const hideEscToCancel = t4 === undefined ? false : t4; /**
const skipExitHandling = t5 === undefined ? false : t5; * Theme chooser with live preview. Implemented without react-compiler `_c` memo
* caches so preview/subtree reconciliation cannot stick on stale element refs when
* `setPreviewTheme` updates the resolved palette.
*/
export function ThemePicker({
onThemeSelect,
showIntroText = false,
helpText = '',
showHelpTextBelow = false,
hideEscToCancel = false,
skipExitHandling = false,
onCancel: onCancelProp,
}: ThemePickerProps) {
const [theme] = useTheme(); const [theme] = useTheme();
const themeSetting = useThemeSetting(); const themeSetting = useThemeSetting();
const { const { columns } = useTerminalSize();
columns const colorModuleUnavailableReason = React.useMemo(
} = useTerminalSize(); () => getColorModuleUnavailableReason(),
let t6; [],
if ($[0] === Symbol.for("react.memo_cache_sentinel")) { )
t6 = getColorModuleUnavailableReason(); const syntaxTheme =
$[0] = t6; colorModuleUnavailableReason === null ? getSyntaxTheme(theme) : null
} else { const { setPreviewTheme, savePreview, cancelPreview } = usePreviewTheme()
t6 = $[0]; const syntaxHighlightingDisabled = useAppState(
} (s: AppState) => s.settings.syntaxHighlightingDisabled ?? false
const colorModuleUnavailableReason = t6; );
let t7;
if ($[1] !== theme) {
t7 = colorModuleUnavailableReason === null ? getSyntaxTheme(theme) : null;
$[1] = theme;
$[2] = t7;
} else {
t7 = $[2];
}
const syntaxTheme = t7;
const {
setPreviewTheme,
savePreview,
cancelPreview
} = usePreviewTheme();
const syntaxHighlightingDisabled = useAppState(_temp) ?? false;
const setAppState = useSetAppState(); const setAppState = useSetAppState();
useRegisterKeybindingContext("ThemePicker"); useRegisterKeybindingContext("ThemePicker", true);
const syntaxToggleShortcut = useShortcutDisplay("theme:toggleSyntaxHighlighting", "ThemePicker", "ctrl+t"); const syntaxToggleShortcut = useShortcutDisplay("theme:toggleSyntaxHighlighting", "ThemePicker", "ctrl+t");
let t8;
if ($[3] !== setAppState || $[4] !== syntaxHighlightingDisabled) { const toggleSyntax = React.useCallback(() => {
t8 = () => { if (colorModuleUnavailableReason === null) {
if (colorModuleUnavailableReason === null) { const newValue = !syntaxHighlightingDisabled
const newValue = !syntaxHighlightingDisabled; updateSettingsForSource("userSettings", {
updateSettingsForSource("userSettings", { syntaxHighlightingDisabled: newValue
});
setAppState(prev => ({
...prev,
settings: {
...prev.settings,
syntaxHighlightingDisabled: newValue syntaxHighlightingDisabled: newValue
}); }
setAppState(prev => ({ }));
...prev, }
settings: { }, [
...prev.settings, colorModuleUnavailableReason,
syntaxHighlightingDisabled: newValue syntaxHighlightingDisabled,
} setAppState,
})); ])
}
}; useKeybinding("theme:toggleSyntaxHighlighting", toggleSyntax, {
$[3] = setAppState; context: "ThemePicker",
$[4] = syntaxHighlightingDisabled; })
$[5] = t8;
} else { const exitState = useExitOnCtrlCDWithKeybindings(
t8 = $[5]; skipExitHandling ? () => {} : undefined,
} )
let t9;
if ($[6] === Symbol.for("react.memo_cache_sentinel")) { const themeOptions = React.useMemo(
t9 = { () => [
context: "ThemePicker" ...(feature("AUTO_THEME")
}; ? [{ label: "Auto (match terminal)", value: "auto" as const }]
$[6] = t9; : []), {
} else { label: "Dark mode",
t9 = $[6]; value: "dark" as const
} }, {
useKeybinding("theme:toggleSyntaxHighlighting", t8, t9); label: "Light mode",
const exitState = useExitOnCtrlCDWithKeybindings(skipExitHandling ? _temp2 : undefined); value: "light" as const
let t10; }, {
if ($[7] === Symbol.for("react.memo_cache_sentinel")) { label: "Dark mode (colorblind-friendly)",
t10 = [...(feature("AUTO_THEME") ? [{ value: "dark-daltonized" as const,
label: "Auto (match terminal)", }, {
value: "auto" as const label: "Light mode (colorblind-friendly)",
}] : []), { value: "light-daltonized" as const,
label: "Dark mode", }, {
value: "dark" label: "Dark mode (ANSI colors only)",
}, { value: "dark-ansi" as const
label: "Light mode", }, {
value: "light" label: "Light mode (ANSI colors only)",
}, { value: "light-ansi" as const
label: "Dark mode (colorblind-friendly)", },],
value: "dark-daltonized" [],
}, { )
label: "Light mode (colorblind-friendly)",
value: "light-daltonized" const handleRowFocus = React.useCallback(
}, { (setting: ThemeSetting) => {
label: "Dark mode (ANSI colors only)", setPreviewTheme(setting)
value: "dark-ansi" },
}, { [setPreviewTheme],
label: "Light mode (ANSI colors only)", )
value: "light-ansi"
}]; const handleSelect = React.useCallback(
$[7] = t10; (setting: ThemeSetting) => {
} else { savePreview()
t10 = $[7]; onThemeSelect(setting)
} },
const themeOptions = t10; [savePreview, onThemeSelect],
let t11; )
if ($[8] !== showIntroText) {
t11 = showIntroText ? <Text>Let's get started.</Text> : <Text bold={true} color="permission">Theme</Text>; const handleCancel = React.useCallback(() => {
$[8] = showIntroText; cancelPreview()
$[9] = t11; if (skipExitHandling) {
} else { onCancelProp?.()
t11 = $[9]; } else {
} void gracefulShutdown(0)
let t12; }
if ($[10] === Symbol.for("react.memo_cache_sentinel")) { }, [cancelPreview, onCancelProp, skipExitHandling])
t12 = <Text bold={true}>Choose the text style that looks best with your terminal</Text>;
$[10] = t12; const syntaxHint =
} else { colorModuleUnavailableReason === 'env'
t12 = $[10]; ? `Syntax highlighting disabled (via CLAUDE_CODE_SYNTAX_HIGHLIGHT=${process.env.CLAUDE_CODE_SYNTAX_HIGHLIGHT})`
} : syntaxHighlightingDisabled
let t13; ? `Syntax highlighting disabled (${syntaxToggleShortcut} to enable)`
if ($[11] !== helpText || $[12] !== showHelpTextBelow) { : syntaxTheme
t13 = helpText && !showHelpTextBelow && <Text dimColor={true}>{helpText}</Text>; ? `Syntax theme: ${syntaxTheme.theme}${syntaxTheme.source ? ` (from ${syntaxTheme.source})` : ''} (${syntaxToggleShortcut} to disable)`
$[11] = helpText; : `Syntax highlighting enabled (${syntaxToggleShortcut} to disable)`
$[12] = showHelpTextBelow;
$[13] = t13; const header = showIntroText ? (
} else { <Text>{"Let's get started."}</Text>
t13 = $[13]; ) : (
} <Text bold color="permission">
let t14; Theme
if ($[14] !== t13) { </Text>
t14 = <Box flexDirection="column">{t12}{t13}</Box>; )
$[14] = t13;
$[15] = t14; const introBlock = (
} else { <Box flexDirection="column">
t14 = $[15]; <Text bold>Choose the text style that looks best with your terminal</Text>
} {helpText && !showHelpTextBelow ? (
let t15; <Text dimColor>{helpText}</Text>
if ($[16] !== setPreviewTheme) { ) : null}
t15 = setting => { </Box>
setPreviewTheme(setting as ThemeSetting); )
};
$[16] = setPreviewTheme; const content = (
$[17] = t15; <Box flexDirection="column" gap={1}>
} else { <Box flexDirection="column" gap={1}>
t15 = $[17]; {header}
} {introBlock}
let t16; <Select
if ($[18] !== onThemeSelect || $[19] !== savePreview) { options={themeOptions}
t16 = setting_0 => { onFocus={handleRowFocus}
savePreview(); onChange={handleSelect}
onThemeSelect(setting_0 as ThemeSetting); onCancel={handleCancel}
}; visibleOptionCount={themeOptions.length}
$[18] = onThemeSelect; defaultValue={themeSetting}
$[19] = savePreview; defaultFocusValue={themeSetting}
$[20] = t16; />
} else { </Box>
t16 = $[20]; <Box flexDirection="column" width="100%">
} <Box
let t17; key={theme}
if ($[21] !== cancelPreview || $[22] !== onCancelProp || $[23] !== skipExitHandling) { flexDirection="column"
t17 = skipExitHandling ? () => { borderTop
cancelPreview(); borderBottom
onCancelProp?.(); borderLeft={false}
} : async () => { borderRight={false}
cancelPreview(); borderStyle="dashed"
await gracefulShutdown(0); borderColor="subtle"
}; >
$[21] = cancelPreview; <StructuredDiffView
$[22] = onCancelProp; patch={DEMO_PATCH}
$[23] = skipExitHandling; dim={false}
$[24] = t17; filePath="demo.js"
} else { firstLine={null}
t17 = $[24]; width={columns}
} />
let t18; </Box>
if ($[25] !== t15 || $[26] !== t16 || $[27] !== t17 || $[28] !== themeSetting) { <Text dimColor>
t18 = <Select options={themeOptions} onFocus={t15} onChange={t16} onCancel={t17} visibleOptionCount={themeOptions.length} defaultValue={themeSetting} defaultFocusValue={themeSetting} />; {' '}
$[25] = t15; {syntaxHint}
$[26] = t16; </Text>
$[27] = t17; </Box>
$[28] = themeSetting; </Box>
$[29] = t18; )
} else {
t18 = $[29];
}
let t19;
if ($[30] !== t11 || $[31] !== t14 || $[32] !== t18) {
t19 = <Box flexDirection="column" gap={1}>{t11}{t14}{t18}</Box>;
$[30] = t11;
$[31] = t14;
$[32] = t18;
$[33] = t19;
} else {
t19 = $[33];
}
let t20;
if ($[34] === Symbol.for("react.memo_cache_sentinel")) {
t20 = {
oldStart: 1,
newStart: 1,
oldLines: 3,
newLines: 3,
lines: [" function greet() {", "- console.log(\"Hello, World!\");", "+ console.log(\"Hello, Claude!\");", " }"]
};
$[34] = t20;
} else {
t20 = $[34];
}
let t21;
if ($[35] !== columns) {
t21 = <Box flexDirection="column" borderTop={true} borderBottom={true} borderLeft={false} borderRight={false} borderStyle="dashed" borderColor="subtle"><StructuredDiff patch={t20} dim={false} filePath="demo.js" firstLine={null} width={columns} /></Box>;
$[35] = columns;
$[36] = t21;
} else {
t21 = $[36];
}
const t22 = colorModuleUnavailableReason === "env" ? `Syntax highlighting disabled (via CLAUDE_CODE_SYNTAX_HIGHLIGHT=${process.env.CLAUDE_CODE_SYNTAX_HIGHLIGHT})` : syntaxHighlightingDisabled ? `Syntax highlighting disabled (${syntaxToggleShortcut} to enable)` : syntaxTheme ? `Syntax theme: ${syntaxTheme.theme}${syntaxTheme.source ? ` (from ${syntaxTheme.source})` : ""} (${syntaxToggleShortcut} to disable)` : `Syntax highlighting enabled (${syntaxToggleShortcut} to disable)`;
let t23;
if ($[37] !== t22) {
t23 = <Text dimColor={true}>{" "}{t22}</Text>;
$[37] = t22;
$[38] = t23;
} else {
t23 = $[38];
}
let t24;
if ($[39] !== t21 || $[40] !== t23) {
t24 = <Box flexDirection="column" width="100%">{t21}{t23}</Box>;
$[39] = t21;
$[40] = t23;
$[41] = t24;
} else {
t24 = $[41];
}
let t25;
if ($[42] !== t19 || $[43] !== t24) {
t25 = <Box flexDirection="column" gap={1}>{t19}{t24}</Box>;
$[42] = t19;
$[43] = t24;
$[44] = t25;
} else {
t25 = $[44];
}
const content = t25;
if (!showIntroText) { if (!showIntroText) {
let t26; return (
if ($[45] !== content) { <>
t26 = <Box flexDirection="column">{content}</Box>; <Box flexDirection="column">{content}</Box>
$[45] = content; {showHelpTextBelow && helpText ? (
$[46] = t26; <Box marginLeft={3}>
} else { <Text dimColor>{helpText}</Text>
t26 = $[46]; </Box>
} ) : null}
let t27; {!hideEscToCancel ? (
if ($[47] !== helpText || $[48] !== showHelpTextBelow) { <Box marginTop={1}>
t27 = showHelpTextBelow && helpText && <Box marginLeft={3}><Text dimColor={true}>{helpText}</Text></Box>; <Text dimColor italic>
$[47] = helpText; {exitState.pending ? (
$[48] = showHelpTextBelow; <>Press {exitState.keyName} again to exit</>
$[49] = t27; ) : (
} else { <Byline>
t27 = $[49]; <KeyboardShortcutHint shortcut="Enter" action="select" />
} <KeyboardShortcutHint shortcut="Esc" action="cancel" />
let t28; </Byline>
if ($[50] !== exitState || $[51] !== hideEscToCancel) { )}
t28 = !hideEscToCancel && <Box><Text dimColor={true} italic={true}>{exitState.pending ? <>Press {exitState.keyName} again to exit</> : <Byline><KeyboardShortcutHint shortcut="Enter" action="select" /><KeyboardShortcutHint shortcut="Esc" action="cancel" /></Byline>}</Text></Box>; </Text>
$[50] = exitState; </Box>
$[51] = hideEscToCancel; ) : null}
$[52] = t28; </>
} else { )
t28 = $[52];
}
let t29;
if ($[53] !== t27 || $[54] !== t28) {
t29 = <Box marginTop={1}>{t27}{t28}</Box>;
$[53] = t27;
$[54] = t28;
$[55] = t29;
} else {
t29 = $[55];
}
let t30;
if ($[56] !== t26 || $[57] !== t29) {
t30 = <>{t26}{t29}</>;
$[56] = t26;
$[57] = t29;
$[58] = t30;
} else {
t30 = $[58];
}
return t30;
} }
return content;
} return content
function _temp2() {}
function _temp(s) {
return s.settings.syntaxHighlightingDisabled;
} }

252
src/grpc/server.ts Normal file
View File

@@ -0,0 +1,252 @@
import * as grpc from '@grpc/grpc-js'
import * as protoLoader from '@grpc/proto-loader'
import path from 'path'
import { randomUUID } from 'crypto'
import { QueryEngine } from '../QueryEngine.js'
import { getTools } from '../tools.js'
import { getDefaultAppState } from '../state/AppStateStore.js'
import { AppState } from '../state/AppState.js'
import { FileStateCache, READ_FILE_STATE_CACHE_SIZE } from '../utils/fileStateCache.js'
const PROTO_PATH = path.resolve(import.meta.dirname, '../proto/openclaude.proto')
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
})
const protoDescriptor = grpc.loadPackageDefinition(packageDefinition) as any
const openclaudeProto = protoDescriptor.openclaude.v1
const MAX_SESSIONS = 1000
export class GrpcServer {
private server: grpc.Server
private sessions: Map<string, any[]> = new Map()
constructor() {
this.server = new grpc.Server()
this.server.addService(openclaudeProto.AgentService.service, {
Chat: this.handleChat.bind(this),
})
}
start(port: number = 50051, host: string = 'localhost') {
this.server.bindAsync(
`${host}:${port}`,
grpc.ServerCredentials.createInsecure(),
(error, boundPort) => {
if (error) {
console.error('Failed to start gRPC server', error)
return
}
console.log(`gRPC Server running at ${host}:${boundPort}`)
}
)
}
private handleChat(call: grpc.ServerDuplexStream<any, any>) {
let engine: QueryEngine | null = null
let appState: AppState = getDefaultAppState()
const fileCache: FileStateCache = new FileStateCache(READ_FILE_STATE_CACHE_SIZE, 25 * 1024 * 1024)
// To handle ActionRequired (ask user for permission)
const pendingRequests = new Map<string, (reply: string) => void>()
// Accumulated messages from previous turns for multi-turn context
let previousMessages: any[] = []
let sessionId = ''
let interrupted = false
call.on('data', async (clientMessage) => {
try {
if (clientMessage.request) {
if (engine) {
call.write({
error: {
message: 'A request is already in progress on this stream',
code: 'ALREADY_EXISTS'
}
})
return
}
interrupted = false
const req = clientMessage.request
sessionId = req.session_id || ''
previousMessages = []
// Load previous messages from session store (cross-stream persistence)
if (sessionId && this.sessions.has(sessionId)) {
previousMessages = [...this.sessions.get(sessionId)!]
}
const toolNameById = new Map<string, string>()
engine = new QueryEngine({
cwd: req.working_directory || process.cwd(),
tools: getTools(appState.toolPermissionContext), // Gets all available tools
commands: [], // Slash commands
mcpClients: [],
agents: [],
...(previousMessages.length > 0 ? { initialMessages: previousMessages } : {}),
includePartialMessages: true,
canUseTool: async (tool, input, context, assistantMsg, toolUseID) => {
if (toolUseID) {
toolNameById.set(toolUseID, tool.name)
}
// Notify client of the tool call first
call.write({
tool_start: {
tool_name: tool.name,
arguments_json: JSON.stringify(input),
tool_use_id: toolUseID
}
})
// Ask user for permission
const promptId = randomUUID()
const question = `Approve ${tool.name}?`
call.write({
action_required: {
prompt_id: promptId,
question,
type: 'CONFIRM_COMMAND'
}
})
return new Promise((resolve) => {
pendingRequests.set(promptId, (reply) => {
if (reply.toLowerCase() === 'yes' || reply.toLowerCase() === 'y') {
resolve({ behavior: 'allow' })
} else {
resolve({ behavior: 'deny', reason: 'User denied via gRPC' })
}
})
})
},
getAppState: () => appState,
setAppState: (updater) => { appState = updater(appState) },
readFileCache: fileCache,
userSpecifiedModel: req.model,
fallbackModel: req.model,
})
// Track accumulated response data for FinalResponse
let fullText = ''
let promptTokens = 0
let completionTokens = 0
const generator = engine.submitMessage(req.message)
for await (const msg of generator) {
if (msg.type === 'stream_event') {
if (msg.event.type === 'content_block_delta' && msg.event.delta.type === 'text_delta') {
call.write({
text_chunk: {
text: msg.event.delta.text
}
})
fullText += msg.event.delta.text
}
} else if (msg.type === 'user') {
// Extract tool results
const content = msg.message.content
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'tool_result') {
let outputStr = ''
if (typeof block.content === 'string') {
outputStr = block.content
} else if (Array.isArray(block.content)) {
outputStr = block.content.map(c => c.type === 'text' ? c.text : '').join('\n')
}
call.write({
tool_result: {
tool_name: toolNameById.get(block.tool_use_id) ?? block.tool_use_id,
tool_use_id: block.tool_use_id,
output: outputStr,
is_error: block.is_error || false
}
})
}
}
}
} else if (msg.type === 'result') {
// Extract real token counts and final text from the result
if (msg.subtype === 'success') {
if (msg.result) {
fullText = msg.result
}
promptTokens = msg.usage?.input_tokens ?? 0
completionTokens = msg.usage?.output_tokens ?? 0
}
}
}
if (!interrupted) {
// Save messages for multi-turn context in subsequent requests
previousMessages = [...engine.getMessages()]
// Persist to session store for cross-stream resumption
if (sessionId) {
if (!this.sessions.has(sessionId) && this.sessions.size >= MAX_SESSIONS) {
// Evict oldest session (Map preserves insertion order)
this.sessions.delete(this.sessions.keys().next().value)
}
this.sessions.set(sessionId, previousMessages)
}
call.write({
done: {
full_text: fullText,
prompt_tokens: promptTokens,
completion_tokens: completionTokens
}
})
}
engine = null
} else if (clientMessage.input) {
const promptId = clientMessage.input.prompt_id
const reply = clientMessage.input.reply
if (pendingRequests.has(promptId)) {
pendingRequests.get(promptId)!(reply)
pendingRequests.delete(promptId)
}
} else if (clientMessage.cancel) {
interrupted = true
if (engine) {
engine.interrupt()
}
call.end()
}
} catch (err: any) {
console.error("Error processing stream:", err)
call.write({
error: {
message: err.message || "Internal server error",
code: "INTERNAL"
}
})
call.end()
}
})
call.on('end', () => {
interrupted = true
// Unblock any pending permission prompts so canUseTool can return
for (const resolve of pendingRequests.values()) {
resolve('no')
}
if (engine) {
engine.interrupt()
}
engine = null
pendingRequests.clear()
})
}
}

134
src/ink/termio/osc.test.ts Normal file
View File

@@ -0,0 +1,134 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { join } from 'node:path'
const originalEnv = { ...process.env }
const originalPlatform = process.platform
const mockedClipboardPath = join(process.cwd(), 'openclaude-clipboard.txt')
const generateTempFilePathMock = mock(() => mockedClipboardPath)
const execFileNoThrowMock = mock(
async () => ({ code: 0, stdout: '', stderr: '' }),
)
mock.module('../../utils/execFileNoThrow.js', () => ({
execFileNoThrow: execFileNoThrowMock,
}))
mock.module('../../utils/tempfile.js', () => ({
generateTempFilePath: generateTempFilePathMock,
}))
async function importFreshOscModule() {
return import(`./osc.ts?ts=${Date.now()}-${Math.random()}`)
}
async function flushClipboardCopy(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0))
}
describe('Windows clipboard fallback', () => {
beforeEach(() => {
execFileNoThrowMock.mockClear()
generateTempFilePathMock.mockClear()
process.env = { ...originalEnv }
delete process.env['SSH_CONNECTION']
delete process.env['TMUX']
Object.defineProperty(process, 'platform', { value: 'win32' })
})
afterEach(() => {
process.env = { ...originalEnv }
Object.defineProperty(process, 'platform', { value: originalPlatform })
})
test('uses PowerShell instead of clip.exe for local Windows copy', async () => {
const { setClipboard } = await importFreshOscModule()
await setClipboard('Привет мир')
await flushClipboardCopy()
expect(execFileNoThrowMock.mock.calls.some(([cmd]) => cmd === 'clip')).toBe(
false,
)
expect(
execFileNoThrowMock.mock.calls.some(([cmd]) => cmd === 'powershell'),
).toBe(true)
})
test('passes Windows clipboard text through a UTF-8 temp file instead of stdin', async () => {
const { setClipboard } = await importFreshOscModule()
await setClipboard('Привет мир')
await flushClipboardCopy()
const windowsCall = execFileNoThrowMock.mock.calls.find(
([cmd]) => cmd === 'powershell',
)
expect(windowsCall?.[2]).toMatchObject({
stdin: 'ignore',
})
expect(windowsCall?.[2]).not.toMatchObject({ input: 'Привет мир' })
expect(windowsCall?.[2]).not.toMatchObject({
env: expect.objectContaining({
OPENCLAUDE_CLIPBOARD_TEXT_B64: expect.any(String),
}),
})
expect(windowsCall?.[1]).toContain(
`$text = [System.IO.File]::ReadAllText('${mockedClipboardPath.replace(/'/g, "''")}', [System.Text.Encoding]::UTF8); Set-Clipboard -Value $text`,
)
})
})
describe('clipboard path behavior remains stable', () => {
beforeEach(() => {
execFileNoThrowMock.mockClear()
process.env = { ...originalEnv }
delete process.env['SSH_CONNECTION']
delete process.env['TMUX']
})
afterEach(() => {
process.env = { ...originalEnv }
Object.defineProperty(process, 'platform', { value: originalPlatform })
})
test('getClipboardPath stays native on local macOS', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' })
const { getClipboardPath } = await importFreshOscModule()
expect(getClipboardPath()).toBe('native')
})
test('getClipboardPath stays tmux-buffer when TMUX is set', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' })
process.env['TMUX'] = '/tmp/tmux-1000/default,123,0'
const { getClipboardPath } = await importFreshOscModule()
expect(getClipboardPath()).toBe('tmux-buffer')
})
test('Windows clipboard fallback is skipped over SSH', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
process.env['SSH_CONNECTION'] = '1 2 3 4'
const { setClipboard } = await importFreshOscModule()
await setClipboard('Привет мир')
expect(execFileNoThrowMock.mock.calls.some(([cmd]) => cmd === 'powershell')).toBe(
false,
)
})
test('local macOS clipboard fallback still uses pbcopy', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' })
const { setClipboard } = await importFreshOscModule()
await setClipboard('hello')
expect(execFileNoThrowMock.mock.calls.some(([cmd]) => cmd === 'pbcopy')).toBe(
true,
)
})
})

View File

@@ -3,8 +3,10 @@
*/ */
import { Buffer } from 'buffer' import { Buffer } from 'buffer'
import { unlink, writeFile } from 'node:fs/promises'
import { env } from '../../utils/env.js' import { env } from '../../utils/env.js'
import { execFileNoThrow } from '../../utils/execFileNoThrow.js' import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
import { generateTempFilePath } from '../../utils/tempfile.js'
import { BEL, ESC, ESC_TYPE, SEP } from './ansi.js' import { BEL, ESC, ESC_TYPE, SEP } from './ansi.js'
import type { Action, Color, TabStatusAction } from './types.js' import type { Action, Color, TabStatusAction } from './types.js'
@@ -129,7 +131,7 @@ export async function tmuxLoadBuffer(text: string): Promise<boolean> {
* Local (no SSH_CONNECTION): also shell out to a native clipboard utility. * Local (no SSH_CONNECTION): also shell out to a native clipboard utility.
* OSC 52 and tmux -w both depend on terminal settings — iTerm2 disables * OSC 52 and tmux -w both depend on terminal settings — iTerm2 disables
* OSC 52 by default, VS Code shows a permission prompt on first use. Native * OSC 52 by default, VS Code shows a permission prompt on first use. Native
* utilities (pbcopy/wl-copy/xclip/xsel/clip.exe) always work locally. Over * utilities (pbcopy/wl-copy/xclip/xsel/PowerShell Set-Clipboard) always work locally. Over
* SSH these would write to the remote clipboard — OSC 52 is the right path there. * SSH these would write to the remote clipboard — OSC 52 is the right path there.
* *
* Returns the sequence for the caller to write to stdout (raw OSC 52 * Returns the sequence for the caller to write to stdout (raw OSC 52
@@ -211,9 +213,32 @@ function copyNative(text: string): void {
return return
} }
case 'win32': case 'win32':
// clip.exe is always available on Windows. Unicode handling is // Avoid piping non-ASCII text through the Windows stdin/codepage
// imperfect (system locale encoding) but good enough for a fallback. // boundary. Write UTF-8 text to a temp file and let PowerShell read it
void execFileNoThrow('clip', [], opts) // directly as UTF-8 before calling Set-Clipboard.
void (async () => {
const tempPath = generateTempFilePath('openclaude-clipboard', '.txt')
const escapedTempPath = tempPath.replace(/'/g, "''")
try {
await writeFile(tempPath, text, { encoding: 'utf8' })
await execFileNoThrow(
'powershell',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`$text = [System.IO.File]::ReadAllText('${escapedTempPath}', [System.Text.Encoding]::UTF8); Set-Clipboard -Value $text`,
],
{
useCwd: false,
timeout: opts.timeout,
stdin: 'ignore',
},
)
} finally {
await unlink(tempPath).catch(() => {})
}
})().catch(() => {})
return return
} }
} }

101
src/proto/openclaude.proto Normal file
View File

@@ -0,0 +1,101 @@
syntax = "proto3";
package openclaude.v1;
// Main Agent Service
service AgentService {
// Bidirectional stream: client sends tasks and answers to agent prompts,
// server streams text tokens, tool states, and requests permissions.
rpc Chat(stream ClientMessage) returns (stream ServerMessage);
}
// ---------------------------------------------------------
// MESSAGES FROM CLIENT (Input)
// ---------------------------------------------------------
message ClientMessage {
oneof payload {
// 1. Initial request (first message in the stream)
ChatRequest request = 2;
// 2. User response to an agent prompt (e.g., command confirmation)
UserInput input = 3;
// 3. Interrupt signal (if the user clicks "Stop generation")
CancelSignal cancel = 4;
}
}
message ChatRequest {
string message = 1;
string working_directory = 2; // Where the agent should execute commands
reserved 3; // Reserved to prevent accidental reuse
optional string model = 4;
string session_id = 5; // Non-empty = cross-stream session persistence
}
message UserInput {
string reply = 1; // Text response (e.g., "y", "no", or clarification)
string prompt_id = 2; // ID of the prompt we are responding to
}
message CancelSignal {
string reason = 1;
}
// ---------------------------------------------------------
// MESSAGES FROM SERVER (Output / Events)
// ---------------------------------------------------------
message ServerMessage {
// Using oneof guarantees that only one type of event arrives at a time
oneof event {
TextChunk text_chunk = 1; // Chunk of text from LLM
ToolCallStart tool_start = 2; // Agent started using a tool
ToolCallResult tool_result = 3; // Tool returned a result
ActionRequired action_required = 4;// Agent requires human intervention
FinalResponse done = 5; // Generation successfully completed
ErrorResponse error = 6; // A critical error occurred
}
}
// Stream text chunk
message TextChunk {
string text = 1;
}
// Agent decided to use a tool (bash, read_file, etc.)
message ToolCallStart {
string tool_name = 1;
string arguments_json = 2; // Arguments in JSON format
string tool_use_id = 3; // Correlation ID matching ToolCallResult
}
// Result of tool execution
message ToolCallResult {
string tool_name = 1;
string output = 2; // stdout/stderr or file contents
bool is_error = 3; // Did the command itself fail
string tool_use_id = 4; // Correlation ID matching ToolCallStart
}
// Agent paused work and is waiting for user decision
message ActionRequired {
string prompt_id = 1; // Client must return this ID in UserInput
string question = 2; // Question text (e.g., "Execute 'rm -rf /'?")
enum ActionType {
CONFIRM_COMMAND = 0; // Yes/No
REQUEST_INFORMATION = 1; // Text input
}
ActionType type = 3;
}
// Final statistics
message FinalResponse {
string full_text = 1; // The entire generated text
int32 prompt_tokens = 2;
int32 completion_tokens = 3;
}
message ErrorResponse {
string message = 1;
string code = 2;
}

View File

@@ -0,0 +1,85 @@
import { describe, expect, test } from 'bun:test'
import {
extractAtMentionedFiles,
extractMcpResourceMentions,
} from './attachments.js'
// Contract tests for the two @-mention extractors.
//
// Scope: the narrow contract between `extractAtMentionedFiles` and
// `extractMcpResourceMentions` where both are called on the same input
// and must not both claim the same token. The motivating bug is that
// `extractMcpResourceMentions`'s `\b` anchor lets it backtrack over the
// closing quote of a quoted file mention, producing a ghost match for
// `@"C:\Users\..."`. These tests pin the boundary so any regression in
// the MCP regex is caught immediately.
describe('extractor contract', () => {
describe('extractMcpResourceMentions must return empty for', () => {
const cases: Array<[string, string]> = [
// Primary bug: the quoted form that PromptInput emits for Windows
// paths today. `\b` backtracks past the trailing `"` and produces
// a ghost MCP match on current HEAD.
['a quoted Windows drive-letter path', '@"C:\\Users\\me\\file.txt"'],
// Even if the quote layer were stripped, a bare drive letter
// followed by a path separator is never an MCP resource.
['an unquoted Windows drive-letter path', '@C:\\Users\\me\\file.txt'],
// Sanity: quoted POSIX paths with no `:` at all never matched the
// MCP regex and must keep not matching after the fix.
['a quoted POSIX path with a space', '@"/Users/foo/my file.ts"'],
['an unquoted POSIX path', '@/Users/foo/bar.ts'],
// Quoted POSIX path that embeds a `:` in the filename — the quote
// layer must shield it from MCP matching, same as the Windows case.
['a quoted POSIX path with a colon in the name', '@"/tmp/weird:name.txt"'],
]
test.each(cases)('%s', (_label, input) => {
expect(extractMcpResourceMentions(input)).toEqual([])
})
})
describe('extractMcpResourceMentions still matches legitimate MCP mentions', () => {
// Regression guard for the fix. If someone tightens the MCP regex
// too aggressively, these break and the intent is clear.
const cases: Array<[string, string, string[]]> = [
[
'a simple server:resource token',
'@server:resource/path',
['server:resource/path'],
],
[
'a plugin-scoped server name with a dash',
'@asana-plugin:project-status/123',
['asana-plugin:project-status/123'],
],
[
'an MCP mention inline in prose',
'please check @server:res here',
['server:res'],
],
]
test.each(cases)('%s', (_label, input, expected) => {
expect(extractMcpResourceMentions(input)).toEqual(expected)
})
})
describe('extractAtMentionedFiles extracts the file paths it should', () => {
// Asserted separately from the MCP side: the bug is purely in the
// MCP extractor over-matching, so these assertions are the
// "baseline still works" half of the contract.
const cases: Array<[string, string, string[]]> = [
[
'a quoted Windows drive-letter path',
'@"C:\\Users\\me\\file.txt"',
['C:\\Users\\me\\file.txt'],
],
[
'a quoted POSIX path with a space',
'@"/Users/foo/my file.ts"',
['/Users/foo/my file.ts'],
],
['an unquoted POSIX path', '@/Users/foo/bar.ts', ['/Users/foo/bar.ts']],
]
test.each(cases)('%s', (_label, input, expected) => {
expect(extractAtMentionedFiles(input)).toEqual(expected)
})
})
})

View File

@@ -2793,11 +2793,30 @@ export function extractAtMentionedFiles(content: string): string[] {
export function extractMcpResourceMentions(content: string): string[] { export function extractMcpResourceMentions(content: string): string[] {
// Extract MCP resources mentioned with @ symbol in format @server:uri // Extract MCP resources mentioned with @ symbol in format @server:uri
// Example: "@server1:resource/path" would extract "server1:resource/path" // Example: "@server1:resource/path" would extract "server1:resource/path"
const atMentionRegex = /(^|\s)@([^\s]+:[^\s]+)\b/g //
// Two guards against Windows-path / quoted-file collisions (see
// `attachments.extractors.test.ts`):
//
// 1. `(?!")` right after `@` drops quoted tokens entirely. The earlier
// form (without the lookahead and with `[^\s]` character classes)
// backtracked past the closing `"` at the `\b` anchor and produced
// ghost matches like `"C:\Users\...\file.txt` for any quoted file
// mention containing a colon.
// 2. The `"` added to the character classes is belt-and-braces: even
// if the lookahead were later removed or bypassed, the engine can
// no longer consume a quote character mid-match.
const atMentionRegex = /(^|\s)@(?!")([^\s"]+:[^\s"]+)\b/g
const matches = content.match(atMentionRegex) || [] const matches = content.match(atMentionRegex) || []
// Remove the prefix (everything before @) from each match return uniq(
return uniq(matches.map(match => match.slice(match.indexOf('@') + 1))) matches
.map(match => match.slice(match.indexOf('@') + 1))
// Post-match filter: a single-letter "server" followed by `:\` or
// `:/` is always a Windows drive-letter prefix, never a real MCP
// resource. This covers the unquoted `@C:\Users\...` case that
// the regex alone cannot disambiguate from `@server:resource`.
.filter(m => !/^[A-Za-z]:[\\/]/.test(m)),
)
} }
export function extractAgentMentions(content: string): string[] { export function extractAgentMentions(content: string): string[] {

View File

@@ -0,0 +1,100 @@
import { afterAll, describe, expect, test } from 'bun:test'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { extractDraggedFilePaths } from './dragDropPaths.js'
describe('extractDraggedFilePaths', () => {
// Paths that exist on any system.
const thisFile = import.meta.path
const packageJson = `${process.cwd()}/package.json`
// Fixtures created synchronously at describe-load time (not in
// `beforeAll`) so their paths are available to `test.each` tables,
// which are built before any hook runs.
const tmpDir = mkdtempSync(join(tmpdir(), 'dragdrop-test-'))
const spacedFile = join(tmpDir, 'my file.txt')
writeFileSync(spacedFile, 'test')
const scopedDir = join(tmpDir, '@types')
mkdirSync(scopedDir)
const atSignFile = join(scopedDir, 'index.d.ts')
writeFileSync(atSignFile, 'test')
afterAll(() => {
rmSync(tmpDir, { recursive: true, force: true })
})
describe('returns an empty array', () => {
const emptyCases: Array<[string, string]> = [
['a non-absolute path', 'relative/path/file.ts'],
['a plain image path', '/Users/foo/image.png'],
['an uppercase image extension', '/Users/foo/SHOT.PNG'],
['a double-quoted image path', '"/Users/foo/shot.png"'],
['a single-quoted image path', "'/Users/foo/shot.jpg'"],
['regular prose text', 'hello world this is text'],
['a nonexistent absolute path', '/definitely/nonexistent/file.ts'],
['a single-quoted nonexistent path', "'/definitely/nonexistent.ts'"],
['an empty string', ''],
['whitespace only', ' \n '],
// Mixed-segment cases: all-or-nothing policy means a single bad
// entry disqualifies the whole paste.
['a mix where one path does not exist', `${thisFile}\n/nonexistent/file.ts`],
['a mix where one segment is an image', `${thisFile}\n/Users/foo/shot.png`],
]
test.each(emptyCases)('for %s', (_label, input) => {
expect(extractDraggedFilePaths(input)).toEqual([])
})
})
describe('resolves a single path', () => {
const singleCases: Array<[string, string, string]> = [
['a plain absolute path', thisFile, thisFile],
['a double-quoted path', `"${thisFile}"`, thisFile],
['a single-quoted path', `'${thisFile}'`, thisFile],
['a path with leading/trailing whitespace', ` ${thisFile} `, thisFile],
// Realistic: dragging something under `node_modules/@types/...`.
// `@` inside the path must not collide with the mention prefix
// that the caller prepends downstream.
['a path containing an `@` segment', atSignFile, atSignFile],
]
test.each(singleCases)('from %s', (_label, input, expected) => {
expect(extractDraggedFilePaths(input)).toEqual([expected])
})
})
describe('resolves multiple paths', () => {
const multiCases: Array<[string, string, string[]]> = [
[
'newline-separated',
`${thisFile}\n${packageJson}`,
[thisFile, packageJson],
],
[
'space-separated (Finder drag)',
`${thisFile} ${packageJson}`,
[thisFile, packageJson],
],
]
test.each(multiCases)('when input is %s', (_label, input, expected) => {
expect(extractDraggedFilePaths(input)).toEqual(expected)
})
})
// Backslash-escaped paths are a Finder/macOS + Linux convention — on
// Windows the shell-escape step is skipped, so these cases do not apply.
if (process.platform !== 'win32') {
describe('handles backslash-escaped paths', () => {
test('returns empty for an escaped image path', () => {
// The image check must apply after escape stripping so Finder
// image drags still route to the image paste handler.
expect(extractDraggedFilePaths('/Users/foo/my\\ shot.png')).toEqual([])
})
test('resolves an escaped real file with a space in its name', () => {
// Raw form matches what a terminal delivers on Finder drag.
const escaped = spacedFile.replace(/ /g, '\\ ')
expect(extractDraggedFilePaths(escaped)).toEqual([spacedFile])
})
})
}
})

View File

@@ -0,0 +1,55 @@
import { existsSync } from 'fs'
import { isAbsolute } from 'path'
// Inlined to avoid pulling the full `imagePaste.ts` module (which imports
// `bun:bundle`) into this file's dependency graph. Must stay in sync with
// `IMAGE_EXTENSION_REGEX` in `./imagePaste.ts`.
const IMAGE_EXTENSION_REGEX = /\.(png|jpe?g|gif|webp)$/i
/**
* Detect absolute file paths in pasted text (typically from drag-and-drop).
* Returns the cleaned paths if ALL segments are existing non-image files,
* or an empty array otherwise.
*
* Splitting logic mirrors usePasteHandler: space preceding `/` or a Windows
* drive letter, plus newline separators.
*/
export function extractDraggedFilePaths(text: string): string[] {
const segments = text
.split(/ (?=\/|[A-Za-z]:\\)/)
.flatMap(part => part.split('\n'))
.map(s => s.trim())
.filter(Boolean)
if (segments.length === 0) return []
const cleaned: string[] = []
for (const raw of segments) {
// Strip outer quotes and shell-escape backslashes
let p = raw
if (
(p.startsWith('"') && p.endsWith('"')) ||
(p.startsWith("'") && p.endsWith("'"))
) {
p = p.slice(1, -1)
}
if (process.platform !== 'win32') {
p = p.replace(/\\(.)/g, '$1')
}
// Image files are handled by the upstream image paste handler.
// Check against the cleaned path so quoted/escaped image paths like
// `"/foo/shot.png"` or `/foo/my\ shot.png` are reliably excluded.
if (IMAGE_EXTENSION_REGEX.test(p)) return []
if (!isAbsolute(p)) return []
// Verify the path actually exists on disk. Plain `fs.existsSync` is
// used intentionally here instead of the wrapped `getFsImplementation`
// to keep this module free of the heavy `fsOperations` dependency
// chain — this is a pure existence check with no permission semantics.
if (!existsSync(p)) return []
cleaned.push(p)
}
return cleaned
}

View File

@@ -1,4 +1,5 @@
import { feature } from 'bun:bundle' import { feature } from 'bun:bundle'
import { getAPIProvider } from './model/providers.js'
import type { BetaUsage as Usage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' import type { BetaUsage as Usage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import type { import type {
ContentBlock, ContentBlock,
@@ -1765,6 +1766,7 @@ export function stripCallerFieldFromAssistantMessage(
id: block.id, id: block.id,
name: block.name, name: block.name,
input: block.input, input: block.input,
...(getAPIProvider() === 'gemini' && (block as any).extra_content ? { extra_content: (block as any).extra_content } : {})
} }
}), }),
}, },
@@ -2221,21 +2223,24 @@ export function normalizeMessagesForAPI(
// When tool search is enabled, preserve all fields including 'caller' // When tool search is enabled, preserve all fields including 'caller'
if (toolSearchEnabled) { if (toolSearchEnabled) {
const { extra_content, ...restBlock } = block as any
return { return {
...block, ...restBlock,
name: canonicalName, name: canonicalName,
input: normalizedInput, input: normalizedInput,
...(getAPIProvider() === 'gemini' && extra_content ? { extra_content } : {})
} }
} }
// When tool search is NOT enabled, explicitly construct tool_use // When tool search is NOT enabled, explicitly construct tool_use
// block with only standard API fields to avoid sending fields like // block with only standard API fields to avoid sending fields like
// 'caller' that may be stored in sessions from tool search runs // 'caller' that may be stored in sessions from tool search runs
return { return {
type: 'tool_use' as const, type: 'tool_use' as const,
id: block.id, id: block.id,
name: canonicalName, name: canonicalName,
input: normalizedInput, input: normalizedInput,
...(getAPIProvider() === 'gemini' && (block as any).extra_content ? { extra_content: (block as any).extra_content } : {})
} }
} }
return block return block

View File

@@ -79,28 +79,31 @@ test('GEMINI takes precedence over GitHub when both are set', async () => {
expect(getAPIProvider()).toBe('gemini') expect(getAPIProvider()).toBe('gemini')
}) })
test('explicit local openai-compatible base URLs stay on the openai provider', () => { test('explicit local openai-compatible base URLs stay on the openai provider', async () => {
clearProviderEnv() clearProviderEnv()
process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'http://127.0.0.1:8080/v1' process.env.OPENAI_BASE_URL = 'http://127.0.0.1:8080/v1'
process.env.OPENAI_MODEL = 'gpt-5.4' process.env.OPENAI_MODEL = 'gpt-5.4'
const { getAPIProvider } = await importFreshProvidersModule()
expect(getAPIProvider()).toBe('openai') expect(getAPIProvider()).toBe('openai')
}) })
test('codex aliases still resolve to the codex provider without a non-codex base URL', () => { test('codex aliases still resolve to the codex provider without a non-codex base URL', async () => {
clearProviderEnv() clearProviderEnv()
process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_MODEL = 'codexplan' process.env.OPENAI_MODEL = 'codexplan'
const { getAPIProvider } = await importFreshProvidersModule()
expect(getAPIProvider()).toBe('codex') expect(getAPIProvider()).toBe('codex')
}) })
test('official OpenAI base URLs now keep provider detection on openai for aliases', () => { test('official OpenAI base URLs now keep provider detection on openai for aliases', async () => {
clearProviderEnv() clearProviderEnv()
process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_MODEL = 'gpt-5.4' process.env.OPENAI_MODEL = 'gpt-5.4'
const { getAPIProvider } = await importFreshProvidersModule()
expect(getAPIProvider()).toBe('openai') expect(getAPIProvider()).toBe('openai')
}) })