mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-20 15:25:50 +00:00
Compare commits
26 Commits
fix/acp-pr
...
feat/cloud
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00f019bc60 | ||
|
|
433f9dd308 | ||
|
|
14f43e9676 | ||
|
|
2e29e362b1 | ||
|
|
cc2fceaefd | ||
|
|
6234bad6af | ||
|
|
1c29b571c5 | ||
|
|
8fc21f9f9a | ||
|
|
6e6a7419f2 | ||
|
|
388840a4b4 | ||
|
|
ac21f40453 | ||
|
|
bdd023d0af | ||
|
|
5a715b504a | ||
|
|
c4d3367922 | ||
|
|
0e2d8bd583 | ||
|
|
015b2da30c | ||
|
|
1e1d2c0427 | ||
|
|
901fe0357a | ||
|
|
0ef7bae78c | ||
|
|
e2e6f4bd87 | ||
|
|
4584a43736 | ||
|
|
0ef3b5ac36 | ||
|
|
11675f343f | ||
|
|
1ac7d57904 | ||
|
|
617254b2b5 | ||
|
|
5d74071ebf |
@@ -172,6 +172,7 @@ bun run docs:dev
|
|||||||
| `packages/acp-link/` | ACP 代理服务器(WebSocket → ACP agent 桥接) |
|
| `packages/acp-link/` | ACP 代理服务器(WebSocket → ACP agent 桥接) |
|
||||||
| `packages/mcp-client/` | MCP 客户端库 |
|
| `packages/mcp-client/` | MCP 客户端库 |
|
||||||
| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI)— Web UI 已重构为 React + Vite + Radix UI,支持 ACP agent 接入 |
|
| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI)— Web UI 已重构为 React + Vite + Radix UI,支持 ACP agent 接入 |
|
||||||
|
| `packages/cloud-artifacts/` | 独立 Cloudflare Worker + R2 服务:POST `/upload` HTML 上传返回 hash URL,GET `/<7d\|30d>/<id>.html` 由 Worker 代理读取;R2 lifecycle rule 自动 7/30 天过期 |
|
||||||
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
|
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
|
||||||
| `packages/color-diff-napi/` | 颜色差异计算(完整实现,11 tests) |
|
| `packages/color-diff-napi/` | 颜色差异计算(完整实现,11 tests) |
|
||||||
| `packages/image-processor-napi/` | 图像处理(已恢复) |
|
| `packages/image-processor-napi/` | 图像处理(已恢复) |
|
||||||
@@ -188,6 +189,10 @@ bun run docs:dev
|
|||||||
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。
|
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。
|
||||||
- 详见 `docs/features/remote-control-self-hosting.md`。
|
- 详见 `docs/features/remote-control-self-hosting.md`。
|
||||||
|
|
||||||
|
### HTML Artifact Hosting
|
||||||
|
|
||||||
|
- **`packages/cloud-artifacts/`** — 独立 Cloudflare Worker + R2 服务,类似 `remote-control-server/` 的"独立部署服务"定位,**不被主 CLI import**。Worker 处理 `POST /upload`(Bearer token 鉴权 + text/html 校验 + 10MB 上限 + ttl∈{7,30})和 `GET /<7d|30d>/<id>.html`(从 R2 读 + Cache-Control: max-age=86400)。R2 用 prefix + lifecycle rule 实现 TTL(`7d/` 删 7 天、`30d/` 删 30 天),Worker 不参与过期处理。ID 默认 `nanoid(21)`(126 bit 熵),可指定 `?hash=` 自定义 ID(覆盖语义:先删 7d/30d prefix 旧 key 再写新 key)。Worker 用 `wrangler types` 生成的全局 `Env` 类型(`worker-configuration.d.ts`,已 gitignore),不依赖 `@cloudflare/workers-types`。部署用 `npm create cloudflare@latest` 初始化 + `bun run setup`(创建 bucket + lifecycle + secret)+ `bun run deploy`。生产出口经 Deno Deploy 边缘代理(`https://cloud-artifacts.claude-code-best.win`),副作用是 HTTP status code 被抹平为 200(body 的 `{error}` 字段仍保留)。详见 `packages/cloud-artifacts/README.md`。
|
||||||
|
|
||||||
### ACP Protocol (Agent Client Protocol)
|
### ACP Protocol (Agent Client Protocol)
|
||||||
|
|
||||||
- **`src/services/acp/`** — ACP agent 实现,包含 `agent.ts`(AcpAgent 类)、`bridge.ts`(Claude Code ↔ ACP 桥接)、`permissions.ts`(权限处理)、`entry.ts`(入口)。
|
- **`src/services/acp/`** — ACP agent 实现,包含 `agent.ts`(AcpAgent 类)、`bridge.ts`(Claude Code ↔ ACP 桥接)、`permissions.ts`(权限处理)、`entry.ts`(入口)。
|
||||||
|
|||||||
125
bun.lock
125
bun.lock
@@ -239,6 +239,17 @@
|
|||||||
"@claude-code-best/agent-tools": "workspace:*",
|
"@claude-code-best/agent-tools": "workspace:*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/cloud-artifacts": {
|
||||||
|
"name": "cloud-artifacts",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "^5.0.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^6.0.0",
|
||||||
|
"wrangler": "^4.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/color-diff-napi": {
|
"packages/color-diff-napi": {
|
||||||
"name": "color-diff-napi",
|
"name": "color-diff-napi",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -599,8 +610,26 @@
|
|||||||
|
|
||||||
"@claude-code-best/workflow-engine": ["@claude-code-best/workflow-engine@workspace:packages/workflow-engine"],
|
"@claude-code-best/workflow-engine": ["@claude-code-best/workflow-engine@workspace:packages/workflow-engine"],
|
||||||
|
|
||||||
|
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
|
||||||
|
|
||||||
|
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
|
||||||
|
|
||||||
|
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260617.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-jWwmgEVVWbsHNrLSNXzwjJaH90VzRxq1cWkQFUidxyeUPnMxemeNE8I9qFAfrpzGgE11e9sKDcE3ettJW08swQ=="],
|
||||||
|
|
||||||
|
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260617.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LHH7b565g9znfCUOkwbec6FG2rmRbsgCy6aJiU9KN662mNheWl5sw/iKleiFSiljPKQQP3HkjnC/NSkdgi/aSA=="],
|
||||||
|
|
||||||
|
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260617.1", "", { "os": "linux", "cpu": "x64" }, "sha512-FMnaAKXe4Cfd8TQurCVd9fs2XQVBFRCsP+Id/SRdUv89MlwYu9zXfoyx6BxM+brPTIUK38SHbo8iaxiwzLi9JQ=="],
|
||||||
|
|
||||||
|
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260617.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MRoifFYcqbxxIIQy7PqO5tFY/qPFSnjXzakWl0sO93l+HLyG35jRAgOi6jfqa4kBxc7gKKtH861DcewjxUfkjA=="],
|
||||||
|
|
||||||
|
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260617.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rgBV9wQrv0OSKgCTTbhFUFY3sLGNANZ88aqaLvtmEn2gmbFVb1J4PDGochVUdB7NSEp4D/ghHva6/8SZmbONpw=="],
|
||||||
|
|
||||||
|
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260620.1", "", {}, "sha512-WB81w9u1bAS7KcekpC7/nYhLpIXAEtgybso7XgGJV8CQKNkNPYcyjvICLdghOlDBi/9Ivk+f7NRckV2Bkq1bDg=="],
|
||||||
|
|
||||||
"@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "https://registry.npmmirror.com/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="],
|
"@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "https://registry.npmmirror.com/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="],
|
||||||
|
|
||||||
|
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||||
|
|
||||||
"@emnapi/core": ["@emnapi/core@1.9.2", "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.2.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
"@emnapi/core": ["@emnapi/core@1.9.2", "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.2.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||||
|
|
||||||
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.9.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.9.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||||
@@ -1001,6 +1030,12 @@
|
|||||||
|
|
||||||
"@pondwader/socks5-server": ["@pondwader/socks5-server@1.0.10", "https://registry.npmmirror.com/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz", {}, "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg=="],
|
"@pondwader/socks5-server": ["@pondwader/socks5-server@1.0.10", "https://registry.npmmirror.com/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz", {}, "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg=="],
|
||||||
|
|
||||||
|
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
|
||||||
|
|
||||||
|
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
|
||||||
|
|
||||||
|
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
|
||||||
|
|
||||||
"@prisma/instrumentation": ["@prisma/instrumentation@7.6.0", "https://registry.npmmirror.com/@prisma/instrumentation/-/instrumentation-7.6.0.tgz", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ=="],
|
"@prisma/instrumentation": ["@prisma/instrumentation@7.6.0", "https://registry.npmmirror.com/@prisma/instrumentation/-/instrumentation-7.6.0.tgz", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ=="],
|
||||||
|
|
||||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "https://registry.npmmirror.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "https://registry.npmmirror.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||||
@@ -1249,6 +1284,8 @@
|
|||||||
|
|
||||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "https://registry.npmmirror.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "https://registry.npmmirror.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||||
|
|
||||||
|
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
||||||
|
|
||||||
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "https://registry.npmmirror.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
|
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "https://registry.npmmirror.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
|
||||||
|
|
||||||
"@smithy/abort-controller": ["@smithy/abort-controller@2.2.0", "https://registry.npmmirror.com/@smithy/abort-controller/-/abort-controller-2.2.0.tgz", { "dependencies": { "@smithy/types": "^2.12.0", "tslib": "^2.6.2" } }, "sha512-wRlta7GuLWpTqtFfGo+nZyOO1vEvewdNR1R4rTxpC8XU6vG/NDyrFBhwLZsqg1NUoR1noVaXJPC/7ZK47QCySw=="],
|
"@smithy/abort-controller": ["@smithy/abort-controller@2.2.0", "https://registry.npmmirror.com/@smithy/abort-controller/-/abort-controller-2.2.0.tgz", { "dependencies": { "@smithy/types": "^2.12.0", "tslib": "^2.6.2" } }, "sha512-wRlta7GuLWpTqtFfGo+nZyOO1vEvewdNR1R4rTxpC8XU6vG/NDyrFBhwLZsqg1NUoR1noVaXJPC/7ZK47QCySw=="],
|
||||||
@@ -1341,6 +1378,8 @@
|
|||||||
|
|
||||||
"@smithy/uuid": ["@smithy/uuid@1.1.2", "https://registry.npmmirror.com/@smithy/uuid/-/uuid-1.1.2.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="],
|
"@smithy/uuid": ["@smithy/uuid@1.1.2", "https://registry.npmmirror.com/@smithy/uuid/-/uuid-1.1.2.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="],
|
||||||
|
|
||||||
|
"@speed-highlight/core": ["@speed-highlight/core@1.2.17", "", {}, "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg=="],
|
||||||
|
|
||||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@stricli/auto-complete": ["@stricli/auto-complete@1.2.6", "https://registry.npmmirror.com/@stricli/auto-complete/-/auto-complete-1.2.6.tgz", { "dependencies": { "@stricli/core": "^1.2.6" }, "bin": { "auto-complete": "dist/bin/cli.js" } }, "sha512-H7dectwnLBoyDrp4Vek1gTNdUWzqkEDt5X6oFoOPxPVbca5FA9ttBZ/OlfNvt14aeiZUsg1rC7GEHjIh3tjn2A=="],
|
"@stricli/auto-complete": ["@stricli/auto-complete@1.2.6", "https://registry.npmmirror.com/@stricli/auto-complete/-/auto-complete-1.2.6.tgz", { "dependencies": { "@stricli/core": "^1.2.6" }, "bin": { "auto-complete": "dist/bin/cli.js" } }, "sha512-H7dectwnLBoyDrp4Vek1gTNdUWzqkEDt5X6oFoOPxPVbca5FA9ttBZ/OlfNvt14aeiZUsg1rC7GEHjIh3tjn2A=="],
|
||||||
@@ -1589,6 +1628,8 @@
|
|||||||
|
|
||||||
"bignumber.js": ["bignumber.js@9.3.1", "https://registry.npmmirror.com/bignumber.js/-/bignumber.js-9.3.1.tgz", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
"bignumber.js": ["bignumber.js@9.3.1", "https://registry.npmmirror.com/bignumber.js/-/bignumber.js-9.3.1.tgz", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||||
|
|
||||||
|
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
|
||||||
|
|
||||||
"body-parser": ["body-parser@2.2.2", "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
"body-parser": ["body-parser@2.2.2", "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||||
|
|
||||||
"bowser": ["bowser@2.14.1", "https://registry.npmmirror.com/bowser/-/bowser-2.14.1.tgz", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
|
"bowser": ["bowser@2.14.1", "https://registry.npmmirror.com/bowser/-/bowser-2.14.1.tgz", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
|
||||||
@@ -1659,6 +1700,8 @@
|
|||||||
|
|
||||||
"cliui": ["cliui@7.0.4", "https://registry.npmmirror.com/cliui/-/cliui-7.0.4.tgz", { "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@7.0.4", "https://registry.npmmirror.com/cliui/-/cliui-7.0.4.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="],
|
||||||
|
|
||||||
|
"cloud-artifacts": ["cloud-artifacts@workspace:packages/cloud-artifacts"],
|
||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
"cmdk": ["cmdk@1.1.1", "https://registry.npmmirror.com/cmdk/-/cmdk-1.1.1.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
|
"cmdk": ["cmdk@1.1.1", "https://registry.npmmirror.com/cmdk/-/cmdk-1.1.1.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
|
||||||
@@ -1843,6 +1886,8 @@
|
|||||||
|
|
||||||
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
|
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
|
||||||
|
|
||||||
|
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
|
||||||
|
|
||||||
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||||
|
|
||||||
"es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
"es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
@@ -2137,6 +2182,8 @@
|
|||||||
|
|
||||||
"khroma": ["khroma@2.1.0", "https://registry.npmmirror.com/khroma/-/khroma-2.1.0.tgz", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
|
"khroma": ["khroma@2.1.0", "https://registry.npmmirror.com/khroma/-/khroma-2.1.0.tgz", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
|
||||||
|
|
||||||
|
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
"knip": ["knip@6.4.1", "", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "get-tsconfig": "4.13.7", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-parser": "^0.121.0", "oxc-resolver": "^11.19.1", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "unbash": "^2.2.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-Ry+ywmDFSZvKp/jx7LxMgsZWRTs931alV84e60lh0Stf6kSRYqSIUTkviyyDFRcSO3yY1Kpbi83OirN+4lA2Xw=="],
|
"knip": ["knip@6.4.1", "", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "get-tsconfig": "4.13.7", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-parser": "^0.121.0", "oxc-resolver": "^11.19.1", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "unbash": "^2.2.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-Ry+ywmDFSZvKp/jx7LxMgsZWRTs931alV84e60lh0Stf6kSRYqSIUTkviyyDFRcSO3yY1Kpbi83OirN+4lA2Xw=="],
|
||||||
|
|
||||||
"langium": ["langium@4.2.2", "https://registry.npmmirror.com/langium/-/langium-4.2.2.tgz", { "dependencies": { "@chevrotain/regexp-to-ast": "~12.0.0", "chevrotain": "~12.0.0", "chevrotain-allstar": "~0.4.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ=="],
|
"langium": ["langium@4.2.2", "https://registry.npmmirror.com/langium/-/langium-4.2.2.tgz", { "dependencies": { "@chevrotain/regexp-to-ast": "~12.0.0", "chevrotain": "~12.0.0", "chevrotain-allstar": "~0.4.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ=="],
|
||||||
@@ -2327,6 +2374,8 @@
|
|||||||
|
|
||||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||||
|
|
||||||
|
"miniflare": ["miniflare@4.20260617.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260617.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-Go3/gzStm99QHptsSgU+q1S+xDfLoRgwjJNY80kaTVi0ENhTyqKq+sc4xZiWBSbM7uUcJwmzm8+QFKtcYLJ9nw=="],
|
||||||
|
|
||||||
"minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
"minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["minimist@1.2.8", "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
@@ -2427,7 +2476,7 @@
|
|||||||
|
|
||||||
"path-scurry": ["path-scurry@2.0.2", "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.2.tgz", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
|
"path-scurry": ["path-scurry@2.0.2", "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.2.tgz", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
|
||||||
|
|
||||||
"path-to-regexp": ["path-to-regexp@8.4.2", "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||||
|
|
||||||
"pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
"pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
|
|
||||||
@@ -2747,6 +2796,8 @@
|
|||||||
|
|
||||||
"undici-types": ["undici-types@7.19.2", "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
"undici-types": ["undici-types@7.19.2", "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||||
|
|
||||||
|
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
|
||||||
|
|
||||||
"unicorn-magic": ["unicorn-magic@0.3.0", "https://registry.npmmirror.com/unicorn-magic/-/unicorn-magic-0.3.0.tgz", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
|
"unicorn-magic": ["unicorn-magic@0.3.0", "https://registry.npmmirror.com/unicorn-magic/-/unicorn-magic-0.3.0.tgz", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
|
||||||
|
|
||||||
"unified": ["unified@11.0.5", "https://registry.npmmirror.com/unified/-/unified-11.0.5.tgz", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
"unified": ["unified@11.0.5", "https://registry.npmmirror.com/unified/-/unified-11.0.5.tgz", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||||
@@ -2823,6 +2874,10 @@
|
|||||||
|
|
||||||
"which-module": ["which-module@2.0.1", "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
"which-module": ["which-module@2.0.1", "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
||||||
|
|
||||||
|
"workerd": ["workerd@1.20260617.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260617.1", "@cloudflare/workerd-darwin-arm64": "1.20260617.1", "@cloudflare/workerd-linux-64": "1.20260617.1", "@cloudflare/workerd-linux-arm64": "1.20260617.1", "@cloudflare/workerd-windows-64": "1.20260617.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-Re5pl6pdowt3ZmWUzGlOuB7jbRIIPetgKalmo4cYmucQnVhpo7/3e4MfpekbhLi2EhZZz5EY9NWRu8zFzuEZew=="],
|
||||||
|
|
||||||
|
"wrangler": ["wrangler@4.103.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260617.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260617.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260617.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-3Lv1P5t2xcSEkSTKtG+Lz+3JFryuU7YPLkaCUj7gNe+CJsjZJLtUwqsh1x595QBxkIbCE0GAvDx2DCJUU4+oqw=="],
|
||||||
|
|
||||||
"wrap-ansi": ["wrap-ansi@10.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-10.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="],
|
"wrap-ansi": ["wrap-ansi@10.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-10.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="],
|
||||||
|
|
||||||
"wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
"wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
@@ -2849,6 +2904,10 @@
|
|||||||
|
|
||||||
"yoctocolors": ["yoctocolors@2.1.2", "https://registry.npmmirror.com/yoctocolors/-/yoctocolors-2.1.2.tgz", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
"yoctocolors": ["yoctocolors@2.1.2", "https://registry.npmmirror.com/yoctocolors/-/yoctocolors-2.1.2.tgz", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||||
|
|
||||||
|
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||||
|
|
||||||
|
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||||
|
|
||||||
"zod": ["zod@4.3.6", "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
"zod": ["zod@4.3.6", "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||||
@@ -3083,6 +3142,8 @@
|
|||||||
|
|
||||||
"@claude-code-best/mcp-client/zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
"@claude-code-best/mcp-client/zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
|
|
||||||
|
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||||
|
|
||||||
"@fastify/otel/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "https://registry.npmmirror.com/@opentelemetry/core/-/core-2.6.1.tgz", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="],
|
"@fastify/otel/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "https://registry.npmmirror.com/@opentelemetry/core/-/core-2.6.1.tgz", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="],
|
||||||
|
|
||||||
"@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "https://registry.npmmirror.com/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="],
|
"@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "https://registry.npmmirror.com/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="],
|
||||||
@@ -3337,6 +3398,10 @@
|
|||||||
|
|
||||||
"micromatch/picomatch": ["picomatch@2.3.2", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
"micromatch/picomatch": ["picomatch@2.3.2", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||||
|
|
||||||
|
"miniflare/undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
||||||
|
|
||||||
|
"miniflare/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||||
|
|
||||||
"minipass-flush/minipass": ["minipass@3.3.6", "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
"minipass-flush/minipass": ["minipass@3.3.6", "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||||
|
|
||||||
"minipass-pipeline/minipass": ["minipass@3.3.6", "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
"minipass-pipeline/minipass": ["minipass@3.3.6", "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||||
@@ -3367,6 +3432,8 @@
|
|||||||
|
|
||||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
|
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
|
||||||
|
|
||||||
|
"router/path-to-regexp": ["path-to-regexp@8.4.2", "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||||
|
|
||||||
"streamdown/lucide-react": ["lucide-react@0.542.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.542.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="],
|
"streamdown/lucide-react": ["lucide-react@0.542.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.542.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="],
|
||||||
|
|
||||||
"streamdown/marked": ["marked@16.4.2", "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
"streamdown/marked": ["marked@16.4.2", "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||||
@@ -3375,10 +3442,14 @@
|
|||||||
|
|
||||||
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
|
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||||
|
|
||||||
"xss/commander": ["commander@2.20.3", "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
"xss/commander": ["commander@2.20.3", "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||||
|
|
||||||
"yargs/string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
"yargs/string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
|
"youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||||
|
|
||||||
"@anthropic-ai/vertex-sdk/google-auth-library/gaxios": ["gaxios@6.7.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" } }, "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ=="],
|
"@anthropic-ai/vertex-sdk/google-auth-library/gaxios": ["gaxios@6.7.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" } }, "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ=="],
|
||||||
|
|
||||||
"@anthropic-ai/vertex-sdk/google-auth-library/gcp-metadata": ["gcp-metadata@6.1.1", "", { "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" } }, "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A=="],
|
"@anthropic-ai/vertex-sdk/google-auth-library/gcp-metadata": ["gcp-metadata@6.1.1", "", { "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" } }, "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A=="],
|
||||||
@@ -3629,6 +3700,58 @@
|
|||||||
|
|
||||||
"qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
"qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||||
|
|
||||||
|
"wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||||
|
|
||||||
"yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
"yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
"yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||||
|
|||||||
1325
docs/superpowers/plans/2026-06-20-artifacts-feature.md
Normal file
1325
docs/superpowers/plans/2026-06-20-artifacts-feature.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,7 @@ export { MonitorTool } from './tools/MonitorTool/MonitorTool.js'
|
|||||||
export { PowerShellTool } from './tools/PowerShellTool/PowerShellTool.js'
|
export { PowerShellTool } from './tools/PowerShellTool/PowerShellTool.js'
|
||||||
export { PushNotificationTool } from './tools/PushNotificationTool/PushNotificationTool.js'
|
export { PushNotificationTool } from './tools/PushNotificationTool/PushNotificationTool.js'
|
||||||
export { REPLTool } from './tools/REPLTool/REPLTool.js'
|
export { REPLTool } from './tools/REPLTool/REPLTool.js'
|
||||||
|
export { ArtifactTool } from './tools/ArtifactTool/ArtifactTool.js'
|
||||||
export { RemoteTriggerTool } from './tools/RemoteTriggerTool/RemoteTriggerTool.js'
|
export { RemoteTriggerTool } from './tools/RemoteTriggerTool/RemoteTriggerTool.js'
|
||||||
export { ReviewArtifactTool } from './tools/ReviewArtifactTool/ReviewArtifactTool.js'
|
export { ReviewArtifactTool } from './tools/ReviewArtifactTool/ReviewArtifactTool.js'
|
||||||
export { CronCreateTool } from './tools/ScheduleCronTool/CronCreateTool.js'
|
export { CronCreateTool } from './tools/ScheduleCronTool/CronCreateTool.js'
|
||||||
|
|||||||
177
packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts
Normal file
177
packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { stat, readFile } from 'fs/promises'
|
||||||
|
import { z } from 'zod/v4'
|
||||||
|
import type { ToolResultBlockParam } from 'src/Tool.js'
|
||||||
|
import { buildTool } from 'src/Tool.js'
|
||||||
|
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||||
|
import {
|
||||||
|
ARTIFACT_TOOL_NAME,
|
||||||
|
describeArtifactTool,
|
||||||
|
getArtifactToolPrompt,
|
||||||
|
} from './prompt.js'
|
||||||
|
import { getArtifactsToken, getUploadUrl } from './config.js'
|
||||||
|
import { uploadArtifact } from './client.js'
|
||||||
|
import { renderToolResultMessage } from './UI.js'
|
||||||
|
|
||||||
|
const inputSchema = lazySchema(() =>
|
||||||
|
z.strictObject({
|
||||||
|
file_path: z
|
||||||
|
.string()
|
||||||
|
.describe('Absolute path to a local HTML file to upload.'),
|
||||||
|
hash: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[A-Za-z0-9_-]{1,128}$/, 'must match ^[A-Za-z0-9_-]{1,128}$')
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'If provided, overwrites the existing artifact with this hash (URL stays stable). If omitted, a new random id is generated.',
|
||||||
|
),
|
||||||
|
ttl: z
|
||||||
|
.union([z.literal(7), z.literal(30)])
|
||||||
|
.default(7)
|
||||||
|
.describe('Lifetime in days. Must be 7 or 30. Default 7.'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
type InputSchema = ReturnType<typeof inputSchema>
|
||||||
|
type ArtifactInput = z.infer<InputSchema>
|
||||||
|
|
||||||
|
const outputSchema = lazySchema(() =>
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
url: z.string(),
|
||||||
|
expiresAt: z.string(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
type OutputSchema = ReturnType<typeof outputSchema>
|
||||||
|
export type ArtifactOutput = z.infer<OutputSchema>
|
||||||
|
|
||||||
|
export const ArtifactTool = buildTool({
|
||||||
|
name: ARTIFACT_TOOL_NAME,
|
||||||
|
searchHint:
|
||||||
|
'upload html artifact share url cloud publish progress report public link',
|
||||||
|
maxResultSizeChars: 2_000,
|
||||||
|
shouldDefer: true,
|
||||||
|
strict: true,
|
||||||
|
|
||||||
|
get inputSchema(): InputSchema {
|
||||||
|
return inputSchema()
|
||||||
|
},
|
||||||
|
get outputSchema(): OutputSchema {
|
||||||
|
return outputSchema()
|
||||||
|
},
|
||||||
|
|
||||||
|
async description() {
|
||||||
|
return describeArtifactTool()
|
||||||
|
},
|
||||||
|
async prompt() {
|
||||||
|
return getArtifactToolPrompt()
|
||||||
|
},
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
isConcurrencySafe() {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
isReadOnly() {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
requiresUserInteraction() {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
userFacingName() {
|
||||||
|
return 'Artifact'
|
||||||
|
},
|
||||||
|
|
||||||
|
renderToolUseMessage(input: Partial<ArtifactInput>) {
|
||||||
|
const hashPart = input.hash ? ` (hash=${input.hash})` : ''
|
||||||
|
return `Upload artifact: ${input.file_path ?? '...'}${hashPart}`
|
||||||
|
},
|
||||||
|
|
||||||
|
mapToolResultToToolResultBlockParam(
|
||||||
|
content: ArtifactOutput,
|
||||||
|
toolUseID: string,
|
||||||
|
): ToolResultBlockParam {
|
||||||
|
if (content.error) {
|
||||||
|
return {
|
||||||
|
tool_use_id: toolUseID,
|
||||||
|
type: 'tool_result',
|
||||||
|
is_error: true,
|
||||||
|
content: content.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tool_use_id: toolUseID,
|
||||||
|
type: 'tool_result',
|
||||||
|
content: `Artifact uploaded: ${content.url} (id: ${content.id}, expires: ${content.expiresAt})`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderToolResultMessage,
|
||||||
|
|
||||||
|
async call(input: ArtifactInput) {
|
||||||
|
const { file_path, hash, ttl } = input
|
||||||
|
|
||||||
|
let size: number
|
||||||
|
try {
|
||||||
|
const fileStat = await stat(file_path)
|
||||||
|
if (!fileStat.isFile()) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
id: '',
|
||||||
|
url: '',
|
||||||
|
expiresAt: '',
|
||||||
|
error: `Path is not a regular file: ${file_path}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
size = fileStat.size
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
id: '',
|
||||||
|
url: '',
|
||||||
|
expiresAt: '',
|
||||||
|
error: `File does not exist or is not readable: ${file_path}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size > 10 * 1024 * 1024) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
id: '',
|
||||||
|
url: '',
|
||||||
|
expiresAt: '',
|
||||||
|
error: `File is ${size} bytes; backend limit is 10MB.`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let html: string
|
||||||
|
try {
|
||||||
|
html = await readFile(file_path, 'utf8')
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
id: '',
|
||||||
|
url: '',
|
||||||
|
expiresAt: '',
|
||||||
|
error: `Failed to read file: ${file_path}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadArtifact({
|
||||||
|
html,
|
||||||
|
token: getArtifactsToken(),
|
||||||
|
uploadUrl: getUploadUrl(),
|
||||||
|
hash,
|
||||||
|
ttl,
|
||||||
|
})
|
||||||
|
return { data: result }
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e)
|
||||||
|
return { data: { id: '', url: '', expiresAt: '', error: message } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
37
packages/builtin-tools/src/tools/ArtifactTool/UI.tsx
Normal file
37
packages/builtin-tools/src/tools/ArtifactTool/UI.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Box, Link, Text } from '@anthropic/ink';
|
||||||
|
import type { ToolProgressData } from 'src/Tool.js';
|
||||||
|
import type { ProgressMessage } from 'src/types/message.js';
|
||||||
|
import type { ArtifactOutput } from './ArtifactTool.js';
|
||||||
|
|
||||||
|
export function renderToolResultMessage(
|
||||||
|
content: ArtifactOutput,
|
||||||
|
_progressMessagesForMessage: ProgressMessage<ToolProgressData>[],
|
||||||
|
_options: { verbose: boolean; theme?: string },
|
||||||
|
): React.ReactNode {
|
||||||
|
if (content.error) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text color="error">⚠ Artifact upload failed: {content.error}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!content.url) return null;
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Box>
|
||||||
|
<Text>
|
||||||
|
<Text color="success">↑</Text> Artifact uploaded:{' '}
|
||||||
|
<Link url={content.url}>
|
||||||
|
<Text color="warning">{content.url}</Text>
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{content.expiresAt ? (
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>expires: {content.expiresAt}</Text>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||||
|
import { ArtifactTool } from '../ArtifactTool.js'
|
||||||
|
|
||||||
|
const TEST_DIR = join(tmpdir(), 'artifact-tool-test')
|
||||||
|
const TEST_FILE = join(TEST_DIR, 'report.html')
|
||||||
|
const MISSING_FILE = join(TEST_DIR, 'does-not-exist.html')
|
||||||
|
const DIR_AS_FILE = TEST_DIR
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
|
||||||
|
function mockFetchSuccess(body: object): typeof fetch {
|
||||||
|
return mock(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
) as unknown as typeof fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ArtifactTool.call', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mkdirSync(TEST_DIR, { recursive: true })
|
||||||
|
writeFileSync(TEST_FILE, '<h1>test report</h1>', 'utf8')
|
||||||
|
process.env.CLAUDE_ARTIFACTS_TOKEN = 'test-token'
|
||||||
|
process.env.CLAUDE_ARTIFACTS_URL = 'https://example.test'
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true })
|
||||||
|
delete process.env.CLAUDE_ARTIFACTS_TOKEN
|
||||||
|
delete process.env.CLAUDE_ARTIFACTS_URL
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uploads existing HTML file and returns id/url/expiresAt', async () => {
|
||||||
|
globalThis.fetch = mockFetchSuccess({
|
||||||
|
id: 'abc123',
|
||||||
|
url: 'https://example.test/7d/abc123.html',
|
||||||
|
expiresAt: '2026-06-27T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await ArtifactTool.call({ file_path: TEST_FILE, ttl: 7 })
|
||||||
|
|
||||||
|
expect(result.data).toMatchObject({
|
||||||
|
id: 'abc123',
|
||||||
|
url: 'https://example.test/7d/abc123.html',
|
||||||
|
expiresAt: '2026-06-27T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
expect((result.data as { error?: string }).error).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('passes hash through when overwriting', async () => {
|
||||||
|
const fetchMock = mockFetchSuccess({
|
||||||
|
id: 'stable-id',
|
||||||
|
url: 'https://example.test/7d/stable-id.html',
|
||||||
|
expiresAt: '2026-06-27T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
globalThis.fetch = fetchMock
|
||||||
|
|
||||||
|
await ArtifactTool.call({ file_path: TEST_FILE, hash: 'stable-id', ttl: 7 })
|
||||||
|
|
||||||
|
const calledUrl = (
|
||||||
|
fetchMock as unknown as { mock: { calls: [string | URL | Request][] } }
|
||||||
|
).mock.calls[0][0]
|
||||||
|
expect(calledUrl.toString()).toContain('hash=stable-id')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns error when file does not exist (no HTTP call)', async () => {
|
||||||
|
let fetchCalled = false
|
||||||
|
globalThis.fetch = mock(() => {
|
||||||
|
fetchCalled = true
|
||||||
|
return Promise.resolve(new Response('{}'))
|
||||||
|
}) as unknown as typeof fetch
|
||||||
|
|
||||||
|
const result = await ArtifactTool.call({ file_path: MISSING_FILE, ttl: 7 })
|
||||||
|
|
||||||
|
expect(fetchCalled).toBe(false)
|
||||||
|
expect((result.data as { error?: string }).error).toContain(
|
||||||
|
'does not exist',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns error when path is a directory', async () => {
|
||||||
|
const result = await ArtifactTool.call({ file_path: DIR_AS_FILE, ttl: 7 })
|
||||||
|
|
||||||
|
expect((result.data as { error?: string }).error).toContain(
|
||||||
|
'not a regular file',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns error verbatim when backend rejects', async () => {
|
||||||
|
globalThis.fetch = mock(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ error: 'payload_too_large' }), {
|
||||||
|
status: 200,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
) as unknown as typeof fetch
|
||||||
|
|
||||||
|
// Force the size guard to pass by writing a small file but having backend complain.
|
||||||
|
const result = await ArtifactTool.call({ file_path: TEST_FILE, ttl: 7 })
|
||||||
|
|
||||||
|
expect((result.data as { error?: string }).error).toContain(
|
||||||
|
'payload_too_large',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { ProgressMessage } from 'src/types/message.js';
|
||||||
|
import type { ToolProgressData } from 'src/Tool.js';
|
||||||
|
import { renderToolResultMessage } from '../UI.js';
|
||||||
|
import type { ArtifactOutput } from '../ArtifactTool.js';
|
||||||
|
|
||||||
|
const NO_PROGRESS: ProgressMessage<ToolProgressData>[] = [];
|
||||||
|
const OPTIONS = { verbose: false, theme: 'dark' } as never;
|
||||||
|
|
||||||
|
/** Walk a React element tree and concatenate all string/number children. */
|
||||||
|
function extractText(node: React.ReactNode): string {
|
||||||
|
if (node == null || typeof node === 'boolean') return '';
|
||||||
|
if (typeof node === 'string') return node;
|
||||||
|
if (typeof node === 'number') return String(node);
|
||||||
|
if (Array.isArray(node)) return node.map(extractText).join('');
|
||||||
|
if (React.isValidElement(node)) {
|
||||||
|
const children = (node.props as { children?: React.ReactNode }).children;
|
||||||
|
return extractText(children);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ArtifactTool UI.renderToolResultMessage', () => {
|
||||||
|
test('renders the uploaded URL and expiry on success', () => {
|
||||||
|
const content: ArtifactOutput = {
|
||||||
|
id: 'abc123',
|
||||||
|
url: 'https://cloud-artifacts.claude-code-best.win/7d/abc123.html',
|
||||||
|
expiresAt: '2026-06-27T10:00:00.000Z',
|
||||||
|
};
|
||||||
|
const node = renderToolResultMessage(content, NO_PROGRESS, OPTIONS);
|
||||||
|
expect(React.isValidElement(node)).toBe(true);
|
||||||
|
const text = extractText(node);
|
||||||
|
expect(text).toContain(content.url);
|
||||||
|
expect(text).toContain(content.expiresAt);
|
||||||
|
expect(text).toContain('Artifact uploaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the error message on failure', () => {
|
||||||
|
const content: ArtifactOutput = {
|
||||||
|
id: '',
|
||||||
|
url: '',
|
||||||
|
expiresAt: '',
|
||||||
|
error: 'File does not exist or is not readable: /tmp/missing.html',
|
||||||
|
};
|
||||||
|
const node = renderToolResultMessage(content, NO_PROGRESS, OPTIONS);
|
||||||
|
expect(React.isValidElement(node)).toBe(true);
|
||||||
|
const text = extractText(node);
|
||||||
|
expect(text).toContain('Artifact upload failed');
|
||||||
|
expect(text).toContain('/tmp/missing.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when url is empty without error', () => {
|
||||||
|
const content: ArtifactOutput = { id: '', url: '', expiresAt: '' };
|
||||||
|
const node = renderToolResultMessage(content, NO_PROGRESS, OPTIONS);
|
||||||
|
expect(node).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omits the expiry line when expiresAt is empty', () => {
|
||||||
|
const content: ArtifactOutput = {
|
||||||
|
id: 'abc',
|
||||||
|
url: 'https://cloud-artifacts.claude-code-best.win/7d/abc.html',
|
||||||
|
expiresAt: '',
|
||||||
|
};
|
||||||
|
const node = renderToolResultMessage(content, NO_PROGRESS, OPTIONS);
|
||||||
|
expect(React.isValidElement(node)).toBe(true);
|
||||||
|
// Sanity: still renders URL even without expiry
|
||||||
|
expect(extractText(node)).toContain(content.url);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
||||||
|
import { uploadArtifact } from '../client.js'
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
|
||||||
|
function mockFetch(body: object, status = 200): typeof fetch {
|
||||||
|
return mock((_url: string | URL | Request, _init?: RequestInit) =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
) as unknown as typeof fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('uploadArtifact', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns id/url/expiresAt on successful upload', async () => {
|
||||||
|
globalThis.fetch = mockFetch({
|
||||||
|
id: 'V1StGXR8_Z5jdHi6B',
|
||||||
|
url: 'https://cloud-artifacts.claude-code-best.win/7d/V1StGXR8_Z5jdHi6B.html',
|
||||||
|
expiresAt: '2026-06-27T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await uploadArtifact({
|
||||||
|
html: '<h1>hello</h1>',
|
||||||
|
token: 'test-token',
|
||||||
|
uploadUrl: 'https://example.test/upload',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: 'V1StGXR8_Z5jdHi6B',
|
||||||
|
url: 'https://cloud-artifacts.claude-code-best.win/7d/V1StGXR8_Z5jdHi6B.html',
|
||||||
|
expiresAt: '2026-06-27T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('passes hash as query param when provided', async () => {
|
||||||
|
const fetchMock = mockFetch({
|
||||||
|
id: 'my-id',
|
||||||
|
url: 'https://x/y.html',
|
||||||
|
expiresAt: '2026-06-27T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
globalThis.fetch = fetchMock
|
||||||
|
|
||||||
|
await uploadArtifact({
|
||||||
|
html: '<p>x</p>',
|
||||||
|
token: 't',
|
||||||
|
uploadUrl: 'https://example.test/upload',
|
||||||
|
hash: 'my-id',
|
||||||
|
})
|
||||||
|
|
||||||
|
const calledUrl = (
|
||||||
|
fetchMock as unknown as { mock: { calls: [string | URL | Request][] } }
|
||||||
|
).mock.calls[0][0]
|
||||||
|
expect(calledUrl.toString()).toContain('hash=my-id')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('passes ttl=30 query param when provided', async () => {
|
||||||
|
const fetchMock = mockFetch({
|
||||||
|
id: 'x',
|
||||||
|
url: 'https://x',
|
||||||
|
expiresAt: '2026-07-20T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
globalThis.fetch = fetchMock
|
||||||
|
|
||||||
|
await uploadArtifact({
|
||||||
|
html: '<p>x</p>',
|
||||||
|
token: 't',
|
||||||
|
uploadUrl: 'https://example.test/upload',
|
||||||
|
ttl: 30,
|
||||||
|
})
|
||||||
|
|
||||||
|
const calledUrl = (
|
||||||
|
fetchMock as unknown as { mock: { calls: [string | URL | Request][] } }
|
||||||
|
).mock.calls[0][0]
|
||||||
|
expect(calledUrl.toString()).toContain('ttl=30')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws with error code when body contains {error} (Deno Deploy flattens status)', async () => {
|
||||||
|
globalThis.fetch = mockFetch({ error: 'payload_too_large' }, 200)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
uploadArtifact({
|
||||||
|
html: 'x'.repeat(100),
|
||||||
|
token: 't',
|
||||||
|
uploadUrl: 'https://example.test/upload',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/payload_too_large/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws on non-JSON body', async () => {
|
||||||
|
globalThis.fetch = mock((_u: string | URL | Request) =>
|
||||||
|
Promise.resolve(new Response('Internal Server Error', { status: 500 })),
|
||||||
|
) as unknown as typeof fetch
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
uploadArtifact({
|
||||||
|
html: '<p/>',
|
||||||
|
token: 't',
|
||||||
|
uploadUrl: 'https://example.test/upload',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
59
packages/builtin-tools/src/tools/ArtifactTool/client.ts
Normal file
59
packages/builtin-tools/src/tools/ArtifactTool/client.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
export type UploadResult = {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
expiresAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UploadParams = {
|
||||||
|
html: string
|
||||||
|
token: string
|
||||||
|
uploadUrl: string
|
||||||
|
hash?: string
|
||||||
|
ttl?: 7 | 30
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadArtifact(
|
||||||
|
params: UploadParams,
|
||||||
|
): Promise<UploadResult> {
|
||||||
|
const url = new URL(params.uploadUrl)
|
||||||
|
if (params.hash) url.searchParams.set('hash', params.hash)
|
||||||
|
if (params.ttl) url.searchParams.set('ttl', String(params.ttl))
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${params.token}`,
|
||||||
|
'Content-Type': 'text/html',
|
||||||
|
},
|
||||||
|
body: params.html,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deno Deploy proxy flattens upstream status to 200; the Worker embeds the
|
||||||
|
// real error in the body as `{ "error": "<code>" }`. Always parse body first.
|
||||||
|
const text = await response.text()
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`Artifact upload failed: HTTP ${response.status} (non-JSON body)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed && typeof parsed === 'object' && 'error' in parsed) {
|
||||||
|
const code = (parsed as { error: unknown }).error
|
||||||
|
throw new Error(`Artifact upload failed: ${String(code)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed as Partial<UploadResult>
|
||||||
|
if (
|
||||||
|
typeof data.id !== 'string' ||
|
||||||
|
typeof data.url !== 'string' ||
|
||||||
|
typeof data.expiresAt !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Artifact upload returned malformed body: ${text.slice(0, 200)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return { id: data.id, url: data.url, expiresAt: data.expiresAt }
|
||||||
|
}
|
||||||
21
packages/builtin-tools/src/tools/ArtifactTool/config.ts
Normal file
21
packages/builtin-tools/src/tools/ArtifactTool/config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Cloud Artifacts service configuration.
|
||||||
|
* Token/URL have hardcoded production defaults; env vars override for self-hosted deployments.
|
||||||
|
*/
|
||||||
|
export const ARTIFACTS_DEFAULT_TOKEN = 'claude-code-best'
|
||||||
|
export const ARTIFACTS_DEFAULT_URL =
|
||||||
|
'https://cloud-artifacts.claude-code-best.win'
|
||||||
|
|
||||||
|
export function getArtifactsToken(): string {
|
||||||
|
return process.env.CLAUDE_ARTIFACTS_TOKEN ?? ARTIFACTS_DEFAULT_TOKEN
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getArtifactsBaseUrl(): string {
|
||||||
|
return process.env.CLAUDE_ARTIFACTS_URL ?? ARTIFACTS_DEFAULT_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip trailing slash so `${base}/upload` is well-formed. */
|
||||||
|
export function getUploadUrl(): string {
|
||||||
|
const base = getArtifactsBaseUrl()
|
||||||
|
return base.endsWith('/') ? `${base}upload` : `${base}/upload`
|
||||||
|
}
|
||||||
25
packages/builtin-tools/src/tools/ArtifactTool/prompt.ts
Normal file
25
packages/builtin-tools/src/tools/ArtifactTool/prompt.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export const ARTIFACT_TOOL_NAME = 'artifact'
|
||||||
|
|
||||||
|
export async function describeArtifactTool(): Promise<string> {
|
||||||
|
return 'Upload an HTML file to the cloud-artifacts hosting service and get back a public URL. Pass `hash` to overwrite a previously-uploaded artifact (keeps URL stable).'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArtifactToolPrompt(): Promise<string> {
|
||||||
|
return `Upload an HTML file to a public hosting service and return a shareable URL plus an internal \`id\` (the "hash").
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
- \`file_path\` (required): absolute path to a local HTML file.
|
||||||
|
- \`hash\` (optional): if provided, overwrites the artifact with the same hash (URL stays the same). If omitted, a new random id is generated.
|
||||||
|
- \`ttl\` (optional, default \`7\`): artifact lifetime in days. Must be \`7\` or \`30\`.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
\`{ id, url, expiresAt }\` — \`id\` is the hash (save it for future overwrite calls), \`url\` is publicly accessible.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
1. Use the Write tool to create a local HTML file.
|
||||||
|
2. Call this tool with its \`file_path\`.
|
||||||
|
3. If iterating on the same artifact, pass back the \`id\` returned from the first call as \`hash\` so the URL stays stable.
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
The tool surfaces backend error codes verbatim (e.g. \`payload_too_large\`, \`unauthorized\`). If the file does not exist or is not a regular file, the tool returns an \`error\` field without making an HTTP request.`
|
||||||
|
}
|
||||||
@@ -236,4 +236,29 @@ export const ExecuteTool = buildTool({
|
|||||||
content: JSON.stringify(content),
|
content: JSON.stringify(content),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Output shape: { result: <inner tool output>, tool_name: string }.
|
||||||
|
// Delegate rendering to the inner tool when it defines its own
|
||||||
|
// renderToolResultMessage so deferred tools can show their own UI
|
||||||
|
// (e.g. ArtifactTool displays its uploaded URL). Without this, the
|
||||||
|
// ExecuteExtraTool tool_result row renders nothing below the tool_use
|
||||||
|
// line. The inner tool expects its own input shape, so unwrap params.
|
||||||
|
//
|
||||||
|
// Inline the lookup rather than calling findToolByName — deferred tools
|
||||||
|
// are matched by exact name (no aliases needed), and avoiding the
|
||||||
|
// shared helper keeps this method resilient to src/Tool.js mocks in
|
||||||
|
// co-located test files (process-global mock.module pollution).
|
||||||
|
renderToolResultMessage(content, progressMessages, options) {
|
||||||
|
const innerTool = options.tools.find(t => t.name === content.tool_name)
|
||||||
|
if (!innerTool?.renderToolResultMessage) return null
|
||||||
|
const innerInput = (options.input as { params?: unknown } | undefined)
|
||||||
|
?.params
|
||||||
|
return innerTool.renderToolResultMessage(
|
||||||
|
content.result as never,
|
||||||
|
progressMessages,
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
input: innerInput,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
} satisfies ToolDef<InputSchema, Output>)
|
} satisfies ToolDef<InputSchema, Output>)
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { describe, expect, test, mock } from 'bun:test'
|
||||||
|
import { logMock } from '../../../../../../tests/mocks/log'
|
||||||
|
import { debugMock } from '../../../../../../tests/mocks/debug'
|
||||||
|
|
||||||
|
// Same mock setup as ExecuteTool.runner.ts — ExecuteTool's import chain
|
||||||
|
// (growthbook, searchExtraTools, messages) loads real modules with side
|
||||||
|
// effects otherwise. mock.module is process-global; identical setup in
|
||||||
|
// sibling test files in this directory is safe (last-write-wins, same stubs).
|
||||||
|
mock.module('src/utils/log.ts', logMock)
|
||||||
|
mock.module('src/utils/debug.ts', debugMock)
|
||||||
|
|
||||||
|
mock.module('src/services/analytics/growthbook.js', () => ({
|
||||||
|
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
|
||||||
|
checkStatsigFeatureGate_CACHED_MAY_BE_STALE: () => false,
|
||||||
|
getFeatureValue_DEPRECATED: async () => undefined,
|
||||||
|
getFeatureValue_CACHED_WITH_REFRESH: async () => undefined,
|
||||||
|
hasGrowthBookEnvOverride: () => false,
|
||||||
|
getAllGrowthBookFeatures: () => ({}),
|
||||||
|
getGrowthBookConfigOverrides: () => ({}),
|
||||||
|
setGrowthBookConfigOverride: () => {},
|
||||||
|
clearGrowthBookConfigOverrides: () => {},
|
||||||
|
getApiBaseUrlHost: () => undefined,
|
||||||
|
onGrowthBookRefresh: () => {},
|
||||||
|
initializeGrowthBook: async () => {},
|
||||||
|
checkSecurityRestrictionGate: async () => false,
|
||||||
|
checkGate_CACHED_OR_BLOCKING: async () => false,
|
||||||
|
refreshGrowthBookAfterAuthChange: () => {},
|
||||||
|
resetGrowthBook: () => {},
|
||||||
|
refreshGrowthBookFeatures: async () => {},
|
||||||
|
setupPeriodicGrowthBookRefresh: () => {},
|
||||||
|
stopPeriodicGrowthBookRefresh: () => {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('src/utils/searchExtraTools.js', () => ({
|
||||||
|
isSearchExtraToolsEnabledOptimistic: () => true,
|
||||||
|
getAutoSearchExtraToolsCharThreshold: () => 100,
|
||||||
|
getSearchExtraToolsMode: () => 'tst' as const,
|
||||||
|
isSearchExtraToolsToolAvailable: () => true,
|
||||||
|
isSearchExtraToolsEnabled: async () => true,
|
||||||
|
isToolReferenceBlock: () => false,
|
||||||
|
extractDiscoveredToolNames: () => new Set<string>(),
|
||||||
|
isDeferredToolsDeltaEnabled: () => false,
|
||||||
|
getDeferredToolsDelta: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('src/constants/tools.js', () => ({
|
||||||
|
CORE_TOOLS: new Set(['ExecuteExtraTool', 'SearchExtraTools']),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('src/utils/messages.js', () => ({
|
||||||
|
createUserMessage: ({ content }: { content: string }) => ({
|
||||||
|
type: 'user' as const,
|
||||||
|
content,
|
||||||
|
uuid: 'test-uuid',
|
||||||
|
}),
|
||||||
|
INTERRUPT_MESSAGE_FOR_TOOL_USE: '[Request interrupted]',
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('src/utils/toolErrors.js', () => ({
|
||||||
|
formatZodValidationError: (_name: string, error: unknown) =>
|
||||||
|
`validation error: ${JSON.stringify(error)}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { ExecuteTool } = await import('../ExecuteTool.js')
|
||||||
|
|
||||||
|
type RenderResult = React.ReactNode
|
||||||
|
|
||||||
|
describe('ExecuteTool.renderToolResultMessage delegation', () => {
|
||||||
|
test('delegates to inner tool with content.result and unwrapped params', () => {
|
||||||
|
const seen: Array<{
|
||||||
|
content: unknown
|
||||||
|
input: unknown
|
||||||
|
}> = []
|
||||||
|
const innerRender = (
|
||||||
|
content: unknown,
|
||||||
|
_progress: unknown,
|
||||||
|
options: { input?: unknown },
|
||||||
|
): RenderResult => {
|
||||||
|
seen.push({ content, input: options.input })
|
||||||
|
return 'RENDERED' as unknown as RenderResult
|
||||||
|
}
|
||||||
|
const innerTool = {
|
||||||
|
name: 'artifact',
|
||||||
|
renderToolResultMessage: innerRender,
|
||||||
|
}
|
||||||
|
const tools = [innerTool] as never
|
||||||
|
|
||||||
|
const result = ExecuteTool.renderToolResultMessage(
|
||||||
|
{
|
||||||
|
result: {
|
||||||
|
id: 'abc',
|
||||||
|
url: 'https://example.com/x.html',
|
||||||
|
expiresAt: 'T',
|
||||||
|
},
|
||||||
|
tool_name: 'artifact',
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
tools,
|
||||||
|
input: {
|
||||||
|
tool_name: 'artifact',
|
||||||
|
params: { file_path: '/tmp/x.html', ttl: 7 },
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toBe('RENDERED')
|
||||||
|
expect(seen).toHaveLength(1)
|
||||||
|
expect(seen[0]?.content).toEqual({
|
||||||
|
id: 'abc',
|
||||||
|
url: 'https://example.com/x.html',
|
||||||
|
expiresAt: 'T',
|
||||||
|
})
|
||||||
|
// Inner tool should see its own params shape, not the ExecuteExtraTool wrapper
|
||||||
|
expect(seen[0]?.input).toEqual({ file_path: '/tmp/x.html', ttl: 7 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null when inner tool has no renderToolResultMessage', () => {
|
||||||
|
const innerTool = { name: 'bare' }
|
||||||
|
const tools = [innerTool] as never
|
||||||
|
|
||||||
|
const result = ExecuteTool.renderToolResultMessage(
|
||||||
|
{ result: { ok: true }, tool_name: 'bare' },
|
||||||
|
[],
|
||||||
|
{ tools, input: { tool_name: 'bare', params: {} } } as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null when inner tool is not found in tools list', () => {
|
||||||
|
const tools = [] as never
|
||||||
|
|
||||||
|
const result = ExecuteTool.renderToolResultMessage(
|
||||||
|
{ result: { ok: true }, tool_name: 'missing' },
|
||||||
|
[],
|
||||||
|
{ tools, input: { tool_name: 'missing', params: {} } } as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('passes through undefined input safely when input is missing', () => {
|
||||||
|
const seen: unknown[] = []
|
||||||
|
const innerTool = {
|
||||||
|
name: 'artifact',
|
||||||
|
renderToolResultMessage: (
|
||||||
|
_content: unknown,
|
||||||
|
_progress: unknown,
|
||||||
|
options: { input?: unknown },
|
||||||
|
): RenderResult => {
|
||||||
|
seen.push(options.input)
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const tools = [innerTool] as never
|
||||||
|
|
||||||
|
const result = ExecuteTool.renderToolResultMessage(
|
||||||
|
{ result: { ok: true }, tool_name: 'artifact' },
|
||||||
|
[],
|
||||||
|
{ tools } as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(seen[0]).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
1
packages/cloud-artifacts/.dev.vars.example
Normal file
1
packages/cloud-artifacts/.dev.vars.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
TOKEN=replace-with-your-bearer-token
|
||||||
171
packages/cloud-artifacts/.gitignore
vendored
Normal file
171
packages/cloud-artifacts/.gitignore
vendored
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# Logs
|
||||||
|
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
npm-debug.log_
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
|
||||||
|
pids
|
||||||
|
_.pid
|
||||||
|
_.seed
|
||||||
|
\*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
|
||||||
|
coverage
|
||||||
|
\*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
|
||||||
|
\*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
|
||||||
|
\*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.\*
|
||||||
|
|
||||||
|
# wrangler project
|
||||||
|
|
||||||
|
.dev.vars*
|
||||||
|
!.dev.vars.example
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
.wrangler/
|
||||||
|
|
||||||
|
# wrangler types 生成物(每次 wrangler types / dev / deploy 后会刷新,含 Cloudflare 运行时类型,体积大、会触发 biome lint)
|
||||||
|
worker-configuration.d.ts
|
||||||
|
|
||||||
202
packages/cloud-artifacts/README.md
Normal file
202
packages/cloud-artifacts/README.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# cloud-artifacts
|
||||||
|
|
||||||
|
> **生产出口**:`https://cloud-artifacts.claude-code-best.win`
|
||||||
|
>
|
||||||
|
> 服务端(CLI / RCS 后台)通过单一 bearer token 上传 HTML,得到一个公开可访问的 URL。
|
||||||
|
> 文件到期由 R2 lifecycle rule 自动删除(默认 7 天,最长 30 天)。
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 上传一份 html(默认随机 ID + 7 天 TTL)
|
||||||
|
echo '<h1>hello</h1>' > /tmp/t.html
|
||||||
|
curl -X POST "https://cloud-artifacts.claude-code-best.win/upload" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: text/html" \
|
||||||
|
--data-binary @/tmp/t.html
|
||||||
|
# {"id":"V1StGXR8_Z5jdHi6B-myT",
|
||||||
|
# "url":"https://cloud-artifacts.claude-code-best.win/7d/V1StGXR8_Z5jdHi6B-myT.html",
|
||||||
|
# "expiresAt":"2026-06-27T10:00:00.000Z"}
|
||||||
|
|
||||||
|
# 任何人拿到 url 都能访问
|
||||||
|
curl "https://cloud-artifacts.claude-code-best.win/7d/V1StGXR8_Z5jdHi6B-myT.html"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐
|
||||||
|
客户端 --POST /upload----▶│ Deno Deploy 边缘代理 │
|
||||||
|
│ cloud-artifacts.ccb.win │
|
||||||
|
└────────────┬─────────────┘
|
||||||
|
│ 透传
|
||||||
|
▼
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ Cloudflare Worker │
|
||||||
|
│ - 鉴权 + MIME + 大小校验 │
|
||||||
|
│ - ttl∈{7,30} + hash 校验 │
|
||||||
|
│ - R2 put / R2 get │
|
||||||
|
└────────────┬─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ R2 bucket │
|
||||||
|
│ key: <7d|30d>/<id>.html │
|
||||||
|
│ lifecycle: │
|
||||||
|
│ 7d/ -> expire 7 days │
|
||||||
|
│ 30d/ -> expire 30 days │
|
||||||
|
└──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **POST /upload**:Bearer 鉴权 → text/html 校验 → 10MB 上限 → ttl ∈ {7,30} → R2 put
|
||||||
|
- **GET /<7d\|30d>/<id>.html**:Worker 从 R2 读 → 返回 `text/html; charset=utf-8` + `Cache-Control: public, max-age=86400`
|
||||||
|
- **TTL**:R2 prefix + lifecycle rule 实现,Worker 不参与过期处理(零额外代码)
|
||||||
|
- **覆盖**:指定 `?hash=` 时,先删 `7d/<hash>.html` 和 `30d/<hash>.html` 旧 key,再写新 key
|
||||||
|
- **ID**:默认 `nanoid(21)`(126 bit 熵),可指定 `?hash=<custom-id>`
|
||||||
|
|
||||||
|
## 为什么套一层 Deno Deploy
|
||||||
|
|
||||||
|
国内直连 Cloudflare Workers 边缘节点延迟高、丢包严重(DNS 污染 + 路由问题)。在 `cloud-artifacts.claude-code-best.win` 上套 Deno Deploy 边缘代理后:
|
||||||
|
|
||||||
|
- 国内访问延迟显著降低(Deno Deploy 在国内可达性好)
|
||||||
|
- POST/GET body 完整透传
|
||||||
|
- **副作用**:Deno Deploy 代理会把上游 HTTP status code 抹平为 200(但 body 内的 `{error: ...}` 字段完整保留)。客户端若依赖 status code 判断错误类型,应改为解析 body 中的 `error` 字段。直连 Worker 自身(如 `*.workers.dev`)时 status code 正常透传。
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `POST /upload`
|
||||||
|
|
||||||
|
| Header / Query | 必填 | 说明 |
|
||||||
|
|----------------|------|------|
|
||||||
|
| `Authorization: Bearer <TOKEN>` | 是 | 与 Worker secret `TOKEN` 完全相等 |
|
||||||
|
| `Content-Type: text/html` | 是 | 不接受其他类型 |
|
||||||
|
| `?ttl=7\|30` | 否 | 默认 7,**只允许 7 或 30**(与 R2 lifecycle prefix 对应) |
|
||||||
|
| `?hash=<custom-id>` | 否 | 自定义 ID,校验 `^[A-Za-z0-9_-]{1,128}$`;指定时覆盖同 ID 旧版本 |
|
||||||
|
| body | 是 | 原始 HTML(`--data-binary @file.html`),≤10MB |
|
||||||
|
|
||||||
|
成功 200:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "V1StGXR8_Z5jdHi6B-myT",
|
||||||
|
"url": "https://cloud-artifacts.claude-code-best.win/7d/V1StGXR8_Z5jdHi6B-myT.html",
|
||||||
|
"expiresAt": "2026-06-27T10:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
错误(统一 `{ "error": "<code>" }`,状态码见下):
|
||||||
|
|
||||||
|
| 状态码(直连) | error code | 触发条件 |
|
||||||
|
|--------|------------|----------|
|
||||||
|
| 400 | `invalid_ttl` | `ttl` 非 7 或 30 |
|
||||||
|
| 400 | `invalid_hash` | `hash` 不匹配 `^[A-Za-z0-9_-]{1,128}$` |
|
||||||
|
| 401 | `unauthorized` | 缺 Authorization / token 不匹配 |
|
||||||
|
| 404 | `not_found` | 非 `/upload` 路径或 GET 路径不匹配 `/<7d\|30d>/<id>.html` |
|
||||||
|
| 413 | `payload_too_large` | body > 10MB |
|
||||||
|
| 415 | `unsupported_media_type` | Content-Type 非 `text/html` |
|
||||||
|
|
||||||
|
> **经 Deno Deploy 代理时**:以上所有错误状态码统一返回 **200**,但 body 仍是上表中的 `{error: ...}` JSON。客户端解析逻辑应以 body 的 `error` 字段为准。
|
||||||
|
|
||||||
|
### `GET /<ttl-prefix>/<id>.html`
|
||||||
|
|
||||||
|
`ttl-prefix` 只能是 `7d` 或 `30d`(其他路径返回 404/not_found)。返回 `text/html; charset=utf-8` + `Cache-Control: public, max-age=86400`。任何人拿到 URL 都可访问,hash 即秘密。
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 默认随机 ID + 7 天
|
||||||
|
curl -X POST "https://cloud-artifacts.claude-code-best.win/upload" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: text/html" \
|
||||||
|
--data-binary @/tmp/t.html
|
||||||
|
|
||||||
|
# 自定义 hash + 30 天(再次上传同 hash 覆盖)
|
||||||
|
curl -X POST "https://cloud-artifacts.claude-code-best.win/upload?ttl=30&hash=my-report" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: text/html" \
|
||||||
|
--data-binary @/tmp/report.html
|
||||||
|
|
||||||
|
# 访问
|
||||||
|
curl "https://cloud-artifacts.claude-code-best.win/7d/V1StGXR8_Z5jdHi6B-myT.html"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 覆盖语义
|
||||||
|
|
||||||
|
指定 `?hash=` 时:
|
||||||
|
|
||||||
|
1. 校验 hash 字符集(`^[A-Za-z0-9_-]{1,128}$`)
|
||||||
|
2. 删除 `7d/<hash>.html` 和 `30d/<hash>.html` 两个 key(R2 delete 不存在的 key 不报错,零成本)
|
||||||
|
3. 按 `?ttl=` 写入新 key
|
||||||
|
4. 返回新的 `expiresAt`
|
||||||
|
|
||||||
|
不指定 `?hash=` 时:用 `nanoid(21)` 随机 ID,几乎不可能碰撞,不做碰撞检查。
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
前置:本机已 `npx wrangler login` 登录目标 Cloudflare 账号。Deno Deploy 代理层由部署者另配(CNAME `cloud-artifacts.<your-domain>` → `alias.deno.net`,并在 Deno Deploy 项目里把上游设为 `https://<worker>.<account>.workers.dev`)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/cloud-artifacts
|
||||||
|
bun install # 在 monorepo 根执行也行(workspace 自动识别)
|
||||||
|
|
||||||
|
cp .dev.vars.example .dev.vars # 填本地 dev 用的 TOKEN(仅 wrangler dev 读)
|
||||||
|
bun run setup # 创建 bucket + 加 lifecycle rule + 设生产 TOKEN secret
|
||||||
|
|
||||||
|
# 绑 Worker custom domain(如要在 Cloudflare 直连域名上访问):
|
||||||
|
# Dashboard: Workers & Pages > cloud-artifacts > Settings > Domains & Routes > Add > Custom Domain
|
||||||
|
|
||||||
|
# 改 wrangler.toml 中 [vars] PUBLIC_URL 为对外出口域名(生产用 https://cloud-artifacts.claude-code-best.win)
|
||||||
|
|
||||||
|
bun run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
`scripts/test.sh` 覆盖 7 个错误用例 + 3 个成功用例 + R2 写入验证。**支持双模式**:直连 Worker 时按 HTTP status code 断言;经 Deno Deploy 代理(status 抹平为 200)时自动按 body 的 `error` 字段断言(标记 `[via body]`)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WORKER_URL=https://cloud-artifacts.claude-code-best.win \
|
||||||
|
TOKEN=<your-token> \
|
||||||
|
bash scripts/test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .dev.vars.example .dev.vars
|
||||||
|
# 编辑 .dev.vars 填 TOKEN
|
||||||
|
|
||||||
|
bun run dev # wrangler dev,启动本地 Miniflare + 本地 R2 模拟
|
||||||
|
curl -X POST "http://localhost:8787/upload" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-H "Content-Type: text/html" \
|
||||||
|
--data-binary @/tmp/t.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安全注意事项
|
||||||
|
|
||||||
|
- **TOKEN 是上传侧唯一鉴权**:值泄露后任何人可上传/覆盖。生产应使用 ≥32 字符的随机串,定期轮换(`wrangler secret put TOKEN` 即时生效,无需 redeploy)。
|
||||||
|
- **GET 完全公开**:URL 形如 `/<ttl>/<id>.html`,hash(21 字符 nanoId)即唯一秘密。不要把 URL 贴到公开频道再期望它"私密"。
|
||||||
|
- **覆盖即写**:知道 hash 的任何持 token 者都能覆盖该 ID 的内容。若需要"创建后不可改"语义,应在客户端自行约束(不传 `?hash=`)。
|
||||||
|
- **不校验 HTML 内容**:上传的 html 会被原样返回,浏览器渲染时会执行其中的 `<script>`。本服务定位是"托管自己产出的 html",不要作为任意用户上传入口。
|
||||||
|
- **TTL 上限 30 天**:lifecycle rule 是 prefix 级全局规则,所有对象最多保留 30 天,无法延长。
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| 现象 | 原因 / 处理 |
|
||||||
|
|------|-------------|
|
||||||
|
| 所有请求返 HTTP 200 但业务出错 | 经 Deno Deploy 代理时正常现象,看 body 的 `error` 字段判断真实状态 |
|
||||||
|
| `curl` 到 `*.workers.dev` 超时 | 国内 DNS 污染 + 路由问题,走 `cloud-artifacts.claude-code-best.win` 出口或挂代理 |
|
||||||
|
| 响应 html 多一段 `<a href="/cdn-cgi/content...">` 和 `<script>` | Cloudflare 默认注入的 Browser Insights(RUM),不影响内容渲染。要纯净响应:dashboard → Workers & Pages → cloud-artifacts → 关 Web Analytics |
|
||||||
|
| 上传 413 但文件不到 10MB | 检查 `Content-Length` header 是否被中间层改写;Worker 同时按 `Content-Length` 和 `arrayBuffer().byteLength` 双重校验 |
|
||||||
|
| `?ttl=14` 返 400 | 设计如此,只允许 7 或 30(对应 R2 lifecycle prefix) |
|
||||||
|
| `wrangler secret list` 看到 TOKEN 但上传 401 | token 值不一致。重新 `wrangler secret put TOKEN` 设正确值 |
|
||||||
|
|
||||||
|
## 依赖
|
||||||
|
|
||||||
|
- `wrangler` ^4 — Cloudflare Workers CLI
|
||||||
|
- `nanoid` ^5 — ID 生成(纯 ESM,Worker 兼容)
|
||||||
|
|
||||||
|
## 不被主 CLI 引用
|
||||||
|
|
||||||
|
这是独立 Cloudflare Worker 服务,类似 `packages/remote-control-server/` 的定位。Monorepo 根 `package.json` 的 `workspaces: ["packages/*", ...]` 自动识别本包,但主 CLI 不会 import 它。
|
||||||
19
packages/cloud-artifacts/package.json
Normal file
19
packages/cloud-artifacts/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "cloud-artifacts",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Cloudflare Worker + R2 HTML artifact host (POST /upload → hash URL)",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
|
"setup": "bash scripts/setup.sh",
|
||||||
|
"cf-typegen": "wrangler types"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^6.0.0",
|
||||||
|
"wrangler": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
packages/cloud-artifacts/scripts/setup.sh
Executable file
30
packages/cloud-artifacts/scripts/setup.sh
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BUCKET="${BUCKET:-cloud-artifacts}"
|
||||||
|
|
||||||
|
echo "==> Creating R2 bucket: $BUCKET"
|
||||||
|
npx wrangler r2 bucket create "$BUCKET" || echo "(already exists or creation deferred)"
|
||||||
|
|
||||||
|
echo "==> Adding lifecycle rule: prefix '7d/' -> expire after 7 days"
|
||||||
|
npx wrangler r2 bucket lifecycle add "$BUCKET" delete-7d "7d/" --expire-days 7 --force
|
||||||
|
|
||||||
|
echo "==> Adding lifecycle rule: prefix '30d/' -> expire after 30 days"
|
||||||
|
npx wrangler r2 bucket lifecycle add "$BUCKET" delete-30d "30d/" --expire-days 30 --force
|
||||||
|
|
||||||
|
echo "==> Setting secret TOKEN (paste value, then Enter)"
|
||||||
|
npx wrangler secret put TOKEN
|
||||||
|
|
||||||
|
cat <<'NEXT'
|
||||||
|
|
||||||
|
==> Done. Remaining manual steps:
|
||||||
|
|
||||||
|
1. Bind a custom domain to the Worker (POST + GET 都走 Worker,单一域名):
|
||||||
|
Dashboard: Workers & Pages > cloud-artifacts > Settings > Domains & Routes > Add > Custom Domain
|
||||||
|
填入你的 domain(如 artifacts.example.com),Cloudflare 会自动加 DNS 记录和 SSL。
|
||||||
|
|
||||||
|
2. Update wrangler.toml [vars] PUBLIC_URL 为上一步的 domain(带 https://,如 https://artifacts.example.com)。
|
||||||
|
|
||||||
|
3. Deploy:
|
||||||
|
bun run deploy
|
||||||
|
NEXT
|
||||||
162
packages/cloud-artifacts/scripts/test.sh
Executable file
162
packages/cloud-artifacts/scripts/test.sh
Executable file
@@ -0,0 +1,162 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# cloud-artifacts 端到端测试脚本
|
||||||
|
# 用法:
|
||||||
|
# WORKER_URL=https://cloud-artifacts.claude-code-best.workers.dev \
|
||||||
|
# TOKEN=claude-code-best \
|
||||||
|
# bash scripts/test.sh
|
||||||
|
#
|
||||||
|
# 如本机连不上 workers.dev,可通过代理:
|
||||||
|
# HTTPS_PROXY=http://127.0.0.1:7890 bash scripts/test.sh ...
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
WORKER_URL="${WORKER_URL:-https://cloud-artifacts.claude-code-best.win}"
|
||||||
|
TOKEN="${TOKEN:-claude-code-best}"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
# 颜色
|
||||||
|
G=$'\033[32m'; R=$'\033[31m'; Y=$'\033[33m'; D=$'\033[0m'
|
||||||
|
|
||||||
|
# 准备测试 html
|
||||||
|
echo '<!doctype html><title>t</title><h1>hello v1</h1>' > "$TMP/v1.html"
|
||||||
|
echo '<!doctype html><title>t</title><h1>hello v2 (overwritten)</h1>' > "$TMP/v2.html"
|
||||||
|
|
||||||
|
# 11MB 的 html(用于 413 测试)
|
||||||
|
yes '<p>x</p>' | head -c 11000000 > "$TMP/big.html"
|
||||||
|
|
||||||
|
pass=0; fail=0
|
||||||
|
# expect: 主断言 status code;如代理把所有 status 抹平为 200 但 body 仍是 error JSON,
|
||||||
|
# 则按 body 中的 error 字段做 fallback 断言(标 [via body])。
|
||||||
|
expect() {
|
||||||
|
local label="$1" want_code="$2" resp="$3" code="$4" body="$5"
|
||||||
|
if [[ "$code" == "$want_code" ]]; then
|
||||||
|
printf "${G}✓ %s -> HTTP %s${D}\n" "$label" "$code"
|
||||||
|
[[ -n "$resp" ]] && printf " body: %s\n" "$body"
|
||||||
|
pass=$((pass+1))
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
# 代理透传 fallback:HTTP 200 + body 是 {"error":"..."} JSON
|
||||||
|
if [[ "$code" == "200" && "$body" == {\"error\":* ]]; then
|
||||||
|
local want_error=""
|
||||||
|
case "$want_code" in
|
||||||
|
401) want_error="unauthorized" ;;
|
||||||
|
415) want_error="unsupported_media_type" ;;
|
||||||
|
413) want_error="payload_too_large" ;;
|
||||||
|
404) want_error="not_found" ;;
|
||||||
|
400) want_error="invalid_" ;; # invalid_ttl 或 invalid_hash,前缀匹配
|
||||||
|
esac
|
||||||
|
if [[ -z "$want_error" ]] || echo "$body" | grep -q "\"error\":\"$want_error"; then
|
||||||
|
printf "${G}✓ %s -> HTTP 200 [via body] %s${D}\n" "$label" "$body"
|
||||||
|
pass=$((pass+1))
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
printf "${R}✗ %s -> HTTP %s (expected %s)${D}\n" "$label" "$code" "$want_code"
|
||||||
|
printf " body: %s\n" "$body"
|
||||||
|
fail=$((fail+1))
|
||||||
|
}
|
||||||
|
|
||||||
|
call() {
|
||||||
|
local label="$1" want="$2"
|
||||||
|
shift 2
|
||||||
|
curl -sS -o "$TMP/resp" -w "%{http_code}" "$@" > "$TMP/code"
|
||||||
|
expect "$label" "$want" "" "$(cat "$TMP/code")" "$(cat "$TMP/resp")"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "===== 错误用例 ====="
|
||||||
|
|
||||||
|
# 1. 401 未授权
|
||||||
|
call "no token" 401 \
|
||||||
|
-X POST "$WORKER_URL/upload" \
|
||||||
|
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||||
|
|
||||||
|
# 2. 401 token 错
|
||||||
|
call "wrong token" 401 \
|
||||||
|
-X POST "$WORKER_URL/upload" \
|
||||||
|
-H "Authorization: Bearer wrong" \
|
||||||
|
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||||
|
|
||||||
|
# 3. 415 错误 MIME
|
||||||
|
call "wrong content-type" 415 \
|
||||||
|
-X POST "$WORKER_URL/upload" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" --data-binary '{"x":1}'
|
||||||
|
|
||||||
|
# 4. 400 invalid_ttl
|
||||||
|
call "ttl=999" 400 \
|
||||||
|
-X POST "$WORKER_URL/upload?ttl=999" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||||
|
|
||||||
|
# 5. 400 invalid_ttl (负数)
|
||||||
|
call "ttl=0" 400 \
|
||||||
|
-X POST "$WORKER_URL/upload?ttl=0" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||||
|
|
||||||
|
# 6. 400 invalid_hash
|
||||||
|
call "hash=bad/slash" 400 \
|
||||||
|
-X POST "$WORKER_URL/upload?hash=bad/slash" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||||
|
|
||||||
|
# 7. 413 payload_too_large (11MB > 10MB)
|
||||||
|
call "11MB body" 413 \
|
||||||
|
-X POST "$WORKER_URL/upload" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: text/html" --data-binary "@$TMP/big.html"
|
||||||
|
|
||||||
|
# 8. 404 not_found (错路径)
|
||||||
|
call "wrong path" 404 \
|
||||||
|
-X POST "$WORKER_URL/notupload" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== 成功用例 ====="
|
||||||
|
|
||||||
|
# 9. 200 随机 ID + 7 天(默认)
|
||||||
|
echo "--- 默认上传(随机 ID + 7 天)---"
|
||||||
|
curl -sS -o "$TMP/resp" -w "HTTP %{http_code}\n" \
|
||||||
|
-X POST "$WORKER_URL/upload" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||||
|
cat "$TMP/resp"; echo
|
||||||
|
RANDOM_ID=$(python3 -c "import json,sys;print(json.load(open('$TMP/resp'))['id'])" 2>/dev/null || echo "")
|
||||||
|
[[ -n "$RANDOM_ID" ]] && printf "${G}随机 ID: %s${D}\n" "$RANDOM_ID"
|
||||||
|
|
||||||
|
# 10. 200 自定义 hash + 30 天
|
||||||
|
echo "--- 自定义 hash + 30 天 ---"
|
||||||
|
curl -sS -o "$TMP/resp" -w "HTTP %{http_code}\n" \
|
||||||
|
-X POST "$WORKER_URL/upload?ttl=30&hash=test-artifact-v1" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||||
|
cat "$TMP/resp"; echo
|
||||||
|
|
||||||
|
# 11. 覆盖(同 hash)
|
||||||
|
echo "--- 覆盖:同 hash 上传 v2 ---"
|
||||||
|
curl -sS -o "$TMP/resp" -w "HTTP %{http_code}\n" \
|
||||||
|
-X POST "$WORKER_URL/upload?ttl=30&hash=test-artifact-v1" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: text/html" --data-binary "@$TMP/v2.html"
|
||||||
|
cat "$TMP/resp"; echo
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== R2 写入验证(不走 CDN,走 Cloudflare API) ====="
|
||||||
|
|
||||||
|
# 用 wrangler r2 object get 验证文件实际写入了 R2
|
||||||
|
if [[ -n "$RANDOM_ID" ]]; then
|
||||||
|
echo "--- 验证随机 ID 文件存在: 7d/$RANDOM_ID.html ---"
|
||||||
|
npx wrangler r2 object get "cloud-artifacts/7d/$RANDOM_ID.html" --remote --file "$TMP/got.html" 2>&1 | tail -5
|
||||||
|
echo "下载内容:" ; cat "$TMP/got.html" 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- 验证覆盖后 test-artifact-v1 是 v2 内容 ---"
|
||||||
|
npx wrangler r2 object get "cloud-artifacts/30d/test-artifact-v1.html" --remote --file "$TMP/got2.html" 2>&1 | tail -5
|
||||||
|
echo "下载内容:" ; cat "$TMP/got2.html" 2>/dev/null
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== 汇总 ====="
|
||||||
|
printf "${G}pass=%d${D} ${R}fail=%d${D}\n" "$pass" "$fail"
|
||||||
|
[[ "$fail" -gt 0 ]] && exit 1 || exit 0
|
||||||
119
packages/cloud-artifacts/src/index.ts
Normal file
119
packages/cloud-artifacts/src/index.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
|
||||||
|
// TOKEN 通过 `wrangler secret put TOKEN` 注入,wrangler types 不为 secret 生成类型
|
||||||
|
// 所以这里显式扩展全局 Env(与 worker-configuration.d.ts 合并)
|
||||||
|
declare global {
|
||||||
|
interface Env {
|
||||||
|
TOKEN: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const HASH_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
|
||||||
|
const TTL_PREFIXES = ['7d', '30d']
|
||||||
|
const ALLOWED_TTLS = [7, 30]
|
||||||
|
const HTML_CONTENT_TYPE = 'text/html; charset=utf-8'
|
||||||
|
// GET /<prefix>/<id>.html —— prefix 与 lifecycle rule 对应,限制只能是 7d 或 30d
|
||||||
|
const GET_PATH_PATTERN = /^\/(7d|30d)\/([A-Za-z0-9_-]{1,128})\.html$/
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(req: Request, env: Env): Promise<Response> {
|
||||||
|
const url = new URL(req.url)
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
return handleGet(url, env)
|
||||||
|
}
|
||||||
|
if (url.pathname === '/upload' && req.method === 'POST') {
|
||||||
|
return handleUpload(req, env, url)
|
||||||
|
}
|
||||||
|
return json({ error: 'not_found' }, 404)
|
||||||
|
},
|
||||||
|
} satisfies ExportedHandler<Env>
|
||||||
|
|
||||||
|
// GET /7d/<id>.html 或 /30d/<id>.html —— 从 R2 读,返回 text/html
|
||||||
|
async function handleGet(url: URL, env: Env): Promise<Response> {
|
||||||
|
const match = GET_PATH_PATTERN.exec(url.pathname)
|
||||||
|
if (!match) {
|
||||||
|
return json({ error: 'not_found' }, 404)
|
||||||
|
}
|
||||||
|
const [, prefix, id] = match
|
||||||
|
const obj = await env.BUCKET.get(`${prefix}/${id}.html`)
|
||||||
|
if (obj === null) {
|
||||||
|
return new Response('Not Found', { status: 404 })
|
||||||
|
}
|
||||||
|
const headers = new Headers()
|
||||||
|
obj.writeHttpMetadata(headers)
|
||||||
|
headers.set('content-type', HTML_CONTENT_TYPE)
|
||||||
|
headers.set('cache-control', 'public, max-age=86400')
|
||||||
|
return new Response(obj.body, { headers, status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(
|
||||||
|
req: Request,
|
||||||
|
env: Env,
|
||||||
|
url: URL,
|
||||||
|
): Promise<Response> {
|
||||||
|
const auth = req.headers.get('authorization') ?? ''
|
||||||
|
const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''
|
||||||
|
if (!env.TOKEN || !token || token !== env.TOKEN) {
|
||||||
|
return json({ error: 'unauthorized' }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = (req.headers.get('content-type') ?? '').toLowerCase()
|
||||||
|
if (!contentType.startsWith('text/html')) {
|
||||||
|
return json({ error: 'unsupported_media_type' }, 415)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = Number.parseInt(env.MAX_BYTES, 10) || 10 * 1024 * 1024
|
||||||
|
const declaredLength = Number.parseInt(
|
||||||
|
req.headers.get('content-length') ?? '',
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||||
|
return json({ error: 'payload_too_large' }, 413)
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTtl = Number.parseInt(env.DEFAULT_TTL_DAYS, 10) || 7
|
||||||
|
const ttlParam = url.searchParams.get('ttl')
|
||||||
|
const ttl = ttlParam === null ? defaultTtl : Number.parseInt(ttlParam, 10)
|
||||||
|
if (!Number.isFinite(ttl) || !ALLOWED_TTLS.includes(ttl)) {
|
||||||
|
return json({ error: 'invalid_ttl' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashParam = url.searchParams.get('hash')
|
||||||
|
let id: string
|
||||||
|
if (hashParam !== null) {
|
||||||
|
if (!HASH_PATTERN.test(hashParam)) {
|
||||||
|
return json({ error: 'invalid_hash' }, 400)
|
||||||
|
}
|
||||||
|
id = hashParam
|
||||||
|
// 覆盖:先删所有 ttl prefix 下可能的旧 key(R2 delete 不存在的 key 不报错)
|
||||||
|
await Promise.all(
|
||||||
|
TTL_PREFIXES.map(p => env.BUCKET.delete(`${p}/${id}.html`)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
id = nanoid(21)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.arrayBuffer()
|
||||||
|
if (body.byteLength > maxBytes) {
|
||||||
|
return json({ error: 'payload_too_large' }, 413)
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${ttl}d/${id}.html`
|
||||||
|
await env.BUCKET.put(key, body, {
|
||||||
|
httpMetadata: { contentType: HTML_CONTENT_TYPE },
|
||||||
|
})
|
||||||
|
|
||||||
|
const expiresAt = new Date(Date.now() + ttl * 24 * 60 * 60 * 1000)
|
||||||
|
return json(
|
||||||
|
{ id, url: `${env.PUBLIC_URL}/${key}`, expiresAt: expiresAt.toISOString() },
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(body: unknown, status: number): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
104
packages/cloud-artifacts/src/types.d.ts
vendored
Normal file
104
packages/cloud-artifacts/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Minimal Cloudflare Workers type stubs for cloud-artifacts Worker.
|
||||||
|
*
|
||||||
|
* The canonical types are in worker-configuration.d.ts (generated by `wrangler types`,
|
||||||
|
* gitignored). This file provides just enough so `tsc --noEmit` passes even when that
|
||||||
|
* generated file is absent (e.g. CI, fresh clone).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// -- R2 types ---------------------------------------------------------------
|
||||||
|
|
||||||
|
interface R2Checksums {
|
||||||
|
readonly md5?: ArrayBuffer
|
||||||
|
readonly sha1?: ArrayBuffer
|
||||||
|
readonly sha256?: ArrayBuffer
|
||||||
|
readonly sha384?: ArrayBuffer
|
||||||
|
readonly sha512?: ArrayBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
interface R2HTTPMetadata {
|
||||||
|
contentType?: string
|
||||||
|
contentLanguage?: string
|
||||||
|
contentDisposition?: string
|
||||||
|
contentEncoding?: string
|
||||||
|
cacheControl?: string
|
||||||
|
cacheExpiry?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
interface R2Range {
|
||||||
|
offset: number
|
||||||
|
length?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
declare abstract class R2Object {
|
||||||
|
readonly key: string
|
||||||
|
readonly version: string
|
||||||
|
readonly size: number
|
||||||
|
readonly etag: string
|
||||||
|
readonly httpEtag: string
|
||||||
|
readonly checksums: R2Checksums
|
||||||
|
readonly uploaded: Date
|
||||||
|
readonly httpMetadata?: R2HTTPMetadata
|
||||||
|
readonly customMetadata?: Record<string, string>
|
||||||
|
readonly range?: R2Range
|
||||||
|
readonly storageClass: string
|
||||||
|
readonly ssecKeyMd5?: string
|
||||||
|
writeHttpMetadata(headers: Headers): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface R2ObjectBody extends R2Object {
|
||||||
|
get body(): ReadableStream
|
||||||
|
get bodyUsed(): boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface R2PutOptions {
|
||||||
|
httpMetadata?: R2HTTPMetadata | Headers
|
||||||
|
customMetadata?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface R2Bucket {
|
||||||
|
head(key: string): Promise<R2Object | null>
|
||||||
|
get(key: string, options?: R2GetOptions): Promise<R2ObjectBody | null>
|
||||||
|
put(
|
||||||
|
key: string,
|
||||||
|
value:
|
||||||
|
| ReadableStream
|
||||||
|
| ArrayBuffer
|
||||||
|
| ArrayBufferView
|
||||||
|
| string
|
||||||
|
| null
|
||||||
|
| Blob,
|
||||||
|
options?: R2PutOptions,
|
||||||
|
): Promise<R2Object>
|
||||||
|
delete(keys: string | string[]): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty placeholder — R2GetOptions is unused beyond an optional parameter
|
||||||
|
type R2GetOptions = {}
|
||||||
|
|
||||||
|
// -- ExportedHandler -------------------------------------------------------
|
||||||
|
|
||||||
|
interface ExportedHandler<Env = unknown> {
|
||||||
|
fetch?: (
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
ctx: ExecutionContext,
|
||||||
|
) => Response | Promise<Response>
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Env -------------------------------------------------------------------
|
||||||
|
// Wrangler-generated worker-configuration.d.ts supplies TOKEN via `wrangler secret put`.
|
||||||
|
// This declaration provides the R2 binding + wrangler vars so the Worker compiles
|
||||||
|
// without the generated file.
|
||||||
|
//
|
||||||
|
// NOTE: 这个文件是脚本(没有 top-level import/export),顶层 interface 自动是 global
|
||||||
|
// ambient,会和 worker-configuration.d.ts 的 `interface Env` 走 interface declaration
|
||||||
|
// merging。不要用 `declare global { ... }` 包裹——脚本文件里那种写法是 TS2669 错误,
|
||||||
|
// 在 .d.ts 里甚至会被静默吞掉,导致 Env 桩完全不生效(CI 上就是这种情况)。
|
||||||
|
interface Env {
|
||||||
|
BUCKET: R2Bucket
|
||||||
|
TOKEN: string
|
||||||
|
MAX_BYTES: string
|
||||||
|
DEFAULT_TTL_DAYS: string
|
||||||
|
PUBLIC_URL: string
|
||||||
|
}
|
||||||
17
packages/cloud-artifacts/tsconfig.json
Normal file
17
packages/cloud-artifacts/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["worker-configuration.d.ts", "src/**/*.ts"]
|
||||||
|
}
|
||||||
16
packages/cloud-artifacts/wrangler.toml
Normal file
16
packages/cloud-artifacts/wrangler.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
name = "cloud-artifacts"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2026-06-20"
|
||||||
|
|
||||||
|
[[r2_buckets]]
|
||||||
|
binding = "BUCKET"
|
||||||
|
bucket_name = "cloud-artifacts"
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
PUBLIC_URL = "https://cloud-artifacts.claude-code-best.win"
|
||||||
|
DEFAULT_TTL_DAYS = "7"
|
||||||
|
MAX_TTL_DAYS = "30"
|
||||||
|
MAX_BYTES = "10485760"
|
||||||
|
|
||||||
|
[observability]
|
||||||
|
enabled = true
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test'
|
import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test'
|
||||||
|
|
||||||
|
// res.json() returns Promise<unknown> in strict mode; this helper narrows to any for test assertions
|
||||||
|
function resJson(res: Response) {
|
||||||
|
return res.json() as Promise<any>
|
||||||
|
}
|
||||||
|
|
||||||
// Mock config before imports
|
// Mock config before imports
|
||||||
const mockConfig = {
|
const mockConfig = {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
@@ -87,7 +92,7 @@ describe('Auth Middleware', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.username).toBe('alice')
|
expect(body.username).toBe('alice')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -96,7 +101,7 @@ describe('Auth Middleware', () => {
|
|||||||
headers: { Authorization: 'Bearer test-api-key' },
|
headers: { Authorization: 'Bearer test-api-key' },
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.username).toBe('bob')
|
expect(body.username).toBe('bob')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -107,7 +112,7 @@ describe('Auth Middleware', () => {
|
|||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.username).toBe('charlie')
|
expect(body.username).toBe('charlie')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -162,7 +167,7 @@ describe('Auth Middleware', () => {
|
|||||||
headers: { Authorization: `Bearer ${jwt}` },
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.jwtPayload).not.toBeNull()
|
expect(body.jwtPayload).not.toBeNull()
|
||||||
expect(body.jwtPayload.session_id).toBe('ses_123')
|
expect(body.jwtPayload.session_id).toBe('ses_123')
|
||||||
})
|
})
|
||||||
@@ -191,7 +196,7 @@ describe('Auth Middleware', () => {
|
|||||||
describe('extractWebSocketAuthToken', () => {
|
describe('extractWebSocketAuthToken', () => {
|
||||||
test('does not read tokens from query params', async () => {
|
test('does not read tokens from query params', async () => {
|
||||||
const res = await app.request('/ws-auth-token?token=test-api-key')
|
const res = await app.request('/ws-auth-token?token=test-api-key')
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.token).toBeNull()
|
expect(body.token).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -201,7 +206,7 @@ describe('Auth Middleware', () => {
|
|||||||
'Sec-WebSocket-Protocol': encodeWebSocketAuthProtocol('test-api-key'),
|
'Sec-WebSocket-Protocol': encodeWebSocketAuthProtocol('test-api-key'),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.token).toBe('test-api-key')
|
expect(body.token).toBe('test-api-key')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -210,7 +215,7 @@ describe('Auth Middleware', () => {
|
|||||||
test('accepts UUID from query param', async () => {
|
test('accepts UUID from query param', async () => {
|
||||||
const res = await app.request('/uuid-test?uuid=test-uuid-1')
|
const res = await app.request('/uuid-test?uuid=test-uuid-1')
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.uuid).toBe('test-uuid-1')
|
expect(body.uuid).toBe('test-uuid-1')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -219,7 +224,7 @@ describe('Auth Middleware', () => {
|
|||||||
headers: { 'X-UUID': 'test-uuid-2' },
|
headers: { 'X-UUID': 'test-uuid-2' },
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.uuid).toBe('test-uuid-2')
|
expect(body.uuid).toBe('test-uuid-2')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -232,7 +237,7 @@ describe('Auth Middleware', () => {
|
|||||||
describe('getUuidFromRequest', () => {
|
describe('getUuidFromRequest', () => {
|
||||||
test('extracts from query param', async () => {
|
test('extracts from query param', async () => {
|
||||||
const res = await app.request('/uuid-extract?uuid=from-query')
|
const res = await app.request('/uuid-extract?uuid=from-query')
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.uuid).toBe('from-query')
|
expect(body.uuid).toBe('from-query')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -240,13 +245,13 @@ describe('Auth Middleware', () => {
|
|||||||
const res = await app.request('/uuid-extract', {
|
const res = await app.request('/uuid-extract', {
|
||||||
headers: { 'X-UUID': 'from-header' },
|
headers: { 'X-UUID': 'from-header' },
|
||||||
})
|
})
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.uuid).toBe('from-header')
|
expect(body.uuid).toBe('from-header')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns undefined when no UUID', async () => {
|
test('returns undefined when no UUID', async () => {
|
||||||
const res = await app.request('/uuid-extract')
|
const res = await app.request('/uuid-extract')
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.uuid).toBeUndefined()
|
expect(body.uuid).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, test, expect, beforeEach, mock } from 'bun:test'
|
import { describe, test, expect, beforeEach, mock } from 'bun:test'
|
||||||
|
|
||||||
|
// res.json() returns Promise<unknown> in strict mode; this helper narrows for test assertions
|
||||||
|
function resJson(res: Response) {
|
||||||
|
return res.json() as Promise<any>
|
||||||
|
}
|
||||||
|
|
||||||
// Mock config
|
// Mock config
|
||||||
const mockConfig = {
|
const mockConfig = {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
@@ -106,7 +111,7 @@ describe('V1 Session Routes', () => {
|
|||||||
body: JSON.stringify({ title: 'Test Session' }),
|
body: JSON.stringify({ title: 'Test Session' }),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.id).toMatch(/^session_/)
|
expect(body.id).toMatch(/^session_/)
|
||||||
expect(body.title).toBe('Test Session')
|
expect(body.title).toBe('Test Session')
|
||||||
expect(body.status).toBe('idle')
|
expect(body.status).toBe('idle')
|
||||||
@@ -127,13 +132,13 @@ describe('V1 Session Routes', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
const getRes = await app.request(`/v1/sessions/${id}`, {
|
const getRes = await app.request(`/v1/sessions/${id}`, {
|
||||||
headers: AUTH_HEADERS,
|
headers: AUTH_HEADERS,
|
||||||
})
|
})
|
||||||
expect(getRes.status).toBe(200)
|
expect(getRes.status).toBe(200)
|
||||||
const body = await getRes.json()
|
const body = await resJson(getRes)
|
||||||
expect(body.id).toBe(id)
|
expect(body.id).toBe(id)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -152,13 +157,13 @@ describe('V1 Session Routes', () => {
|
|||||||
})
|
})
|
||||||
const {
|
const {
|
||||||
session: { id },
|
session: { id },
|
||||||
} = await createRes.json()
|
} = await resJson(createRes)
|
||||||
|
|
||||||
const getRes = await app.request(`/v1/sessions/${toWebSessionId(id)}`, {
|
const getRes = await app.request(`/v1/sessions/${toWebSessionId(id)}`, {
|
||||||
headers: AUTH_HEADERS,
|
headers: AUTH_HEADERS,
|
||||||
})
|
})
|
||||||
expect(getRes.status).toBe(200)
|
expect(getRes.status).toBe(200)
|
||||||
const body = await getRes.json()
|
const body = await resJson(getRes)
|
||||||
expect(body.id).toBe(id)
|
expect(body.id).toBe(id)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -168,7 +173,7 @@ describe('V1 Session Routes', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
const patchRes = await app.request(`/v1/sessions/${id}`, {
|
const patchRes = await app.request(`/v1/sessions/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -176,7 +181,7 @@ describe('V1 Session Routes', () => {
|
|||||||
body: JSON.stringify({ title: 'Updated Title' }),
|
body: JSON.stringify({ title: 'Updated Title' }),
|
||||||
})
|
})
|
||||||
expect(patchRes.status).toBe(200)
|
expect(patchRes.status).toBe(200)
|
||||||
const body = await patchRes.json()
|
const body = await resJson(patchRes)
|
||||||
expect(body.title).toBe('Updated Title')
|
expect(body.title).toBe('Updated Title')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -186,7 +191,7 @@ describe('V1 Session Routes', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
const archiveRes = await app.request(`/v1/sessions/${id}/archive`, {
|
const archiveRes = await app.request(`/v1/sessions/${id}/archive`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -203,7 +208,7 @@ describe('V1 Session Routes', () => {
|
|||||||
})
|
})
|
||||||
const {
|
const {
|
||||||
session: { id },
|
session: { id },
|
||||||
} = await createRes.json()
|
} = await resJson(createRes)
|
||||||
const compatId = toWebSessionId(id)
|
const compatId = toWebSessionId(id)
|
||||||
|
|
||||||
const archiveRes = await app.request(`/v1/sessions/${compatId}/archive`, {
|
const archiveRes = await app.request(`/v1/sessions/${compatId}/archive`, {
|
||||||
@@ -216,7 +221,7 @@ describe('V1 Session Routes', () => {
|
|||||||
headers: AUTH_HEADERS,
|
headers: AUTH_HEADERS,
|
||||||
})
|
})
|
||||||
expect(getRes.status).toBe(200)
|
expect(getRes.status).toBe(200)
|
||||||
const body = await getRes.json()
|
const body = await resJson(getRes)
|
||||||
expect(body.id).toBe(id)
|
expect(body.id).toBe(id)
|
||||||
expect(body.status).toBe('archived')
|
expect(body.status).toBe('archived')
|
||||||
})
|
})
|
||||||
@@ -227,7 +232,7 @@ describe('V1 Session Routes', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
const eventsRes = await app.request(`/v1/sessions/${id}/events`, {
|
const eventsRes = await app.request(`/v1/sessions/${id}/events`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -235,7 +240,7 @@ describe('V1 Session Routes', () => {
|
|||||||
body: JSON.stringify({ events: [{ type: 'user', content: 'hello' }] }),
|
body: JSON.stringify({ events: [{ type: 'user', content: 'hello' }] }),
|
||||||
})
|
})
|
||||||
expect(eventsRes.status).toBe(200)
|
expect(eventsRes.status).toBe(200)
|
||||||
const body = await eventsRes.json()
|
const body = await resJson(eventsRes)
|
||||||
expect(body.events).toBe(1)
|
expect(body.events).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -247,7 +252,7 @@ describe('V1 Session Routes', () => {
|
|||||||
})
|
})
|
||||||
const {
|
const {
|
||||||
session: { id },
|
session: { id },
|
||||||
} = await createRes.json()
|
} = await resJson(createRes)
|
||||||
const compatId = toWebSessionId(id)
|
const compatId = toWebSessionId(id)
|
||||||
|
|
||||||
const eventsRes = await app.request(`/v1/sessions/${compatId}/events`, {
|
const eventsRes = await app.request(`/v1/sessions/${compatId}/events`, {
|
||||||
@@ -274,7 +279,7 @@ describe('V1 Session Routes', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ machine_name: 'test' }),
|
body: JSON.stringify({ machine_name: 'test' }),
|
||||||
})
|
})
|
||||||
const { environment_id } = await envRes.json()
|
const { environment_id } = await resJson(envRes)
|
||||||
|
|
||||||
const sessRes = await app.request('/v1/sessions', {
|
const sessRes = await app.request('/v1/sessions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -282,7 +287,7 @@ describe('V1 Session Routes', () => {
|
|||||||
body: JSON.stringify({ environment_id }),
|
body: JSON.stringify({ environment_id }),
|
||||||
})
|
})
|
||||||
expect(sessRes.status).toBe(200)
|
expect(sessRes.status).toBe(200)
|
||||||
const body = await sessRes.json()
|
const body = await resJson(sessRes)
|
||||||
expect(body.environment_id).toBe(environment_id)
|
expect(body.environment_id).toBe(environment_id)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -293,7 +298,7 @@ describe('V1 Session Routes', () => {
|
|||||||
body: JSON.stringify({ environment_id: 'env_nonexistent' }),
|
body: JSON.stringify({ environment_id: 'env_nonexistent' }),
|
||||||
})
|
})
|
||||||
expect(sessRes.status).toBe(200)
|
expect(sessRes.status).toBe(200)
|
||||||
const body = await sessRes.json()
|
const body = await resJson(sessRes)
|
||||||
expect(body.id).toMatch(/^session_/)
|
expect(body.id).toMatch(/^session_/)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -322,7 +327,7 @@ describe('V1 Environment Routes', () => {
|
|||||||
body: JSON.stringify({ machine_name: 'mac1', directory: '/home' }),
|
body: JSON.stringify({ machine_name: 'mac1', directory: '/home' }),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.environment_id).toMatch(/^env_/)
|
expect(body.environment_id).toMatch(/^env_/)
|
||||||
expect(body.status).toBe('active')
|
expect(body.status).toBe('active')
|
||||||
})
|
})
|
||||||
@@ -333,7 +338,7 @@ describe('V1 Environment Routes', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { environment_id } = await envRes.json()
|
const { environment_id } = await resJson(envRes)
|
||||||
|
|
||||||
const delRes = await app.request(
|
const delRes = await app.request(
|
||||||
`/v1/environments/bridge/${environment_id}`,
|
`/v1/environments/bridge/${environment_id}`,
|
||||||
@@ -351,7 +356,7 @@ describe('V1 Environment Routes', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { environment_id } = await envRes.json()
|
const { environment_id } = await resJson(envRes)
|
||||||
|
|
||||||
const reconnectRes = await app.request(
|
const reconnectRes = await app.request(
|
||||||
`/v1/environments/${environment_id}/bridge/reconnect`,
|
`/v1/environments/${environment_id}/bridge/reconnect`,
|
||||||
@@ -377,7 +382,7 @@ describe('V1 Work Routes', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
envId = (await envRes.json()).environment_id
|
envId = (await resJson(envRes)).environment_id
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /v1/environments/:id/work/poll — returns 204 when no work', async () => {
|
test('GET /v1/environments/:id/work/poll — returns 204 when no work', async () => {
|
||||||
@@ -394,14 +399,14 @@ describe('V1 Work Routes', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ environment_id: envId }),
|
body: JSON.stringify({ environment_id: envId }),
|
||||||
})
|
})
|
||||||
const sessionId = (await sessRes.json()).id
|
const sessionId = (await resJson(sessRes)).id
|
||||||
|
|
||||||
// Poll for work
|
// Poll for work
|
||||||
const pollRes = await app.request(`/v1/environments/${envId}/work/poll`, {
|
const pollRes = await app.request(`/v1/environments/${envId}/work/poll`, {
|
||||||
headers: AUTH_HEADERS,
|
headers: AUTH_HEADERS,
|
||||||
})
|
})
|
||||||
expect(pollRes.status).toBe(200)
|
expect(pollRes.status).toBe(200)
|
||||||
const work = await pollRes.json()
|
const work = await resJson(pollRes)
|
||||||
expect(work.id).toMatch(/^work_/)
|
expect(work.id).toMatch(/^work_/)
|
||||||
expect(work.data.id).toBe(sessionId)
|
expect(work.data.id).toBe(sessionId)
|
||||||
|
|
||||||
@@ -436,7 +441,7 @@ describe('V1 Work Routes', () => {
|
|||||||
const pollRes = await app.request(`/v1/environments/${envId}/work/poll`, {
|
const pollRes = await app.request(`/v1/environments/${envId}/work/poll`, {
|
||||||
headers: AUTH_HEADERS,
|
headers: AUTH_HEADERS,
|
||||||
})
|
})
|
||||||
const work = await pollRes.json()
|
const work = await resJson(pollRes)
|
||||||
|
|
||||||
const hbRes = await app.request(
|
const hbRes = await app.request(
|
||||||
`/v1/environments/${envId}/work/${work.id}/heartbeat`,
|
`/v1/environments/${envId}/work/${work.id}/heartbeat`,
|
||||||
@@ -446,7 +451,7 @@ describe('V1 Work Routes', () => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
expect(hbRes.status).toBe(200)
|
expect(hbRes.status).toBe(200)
|
||||||
const body = await hbRes.json()
|
const body = await resJson(hbRes)
|
||||||
expect(body.lease_extended).toBe(true)
|
expect(body.lease_extended).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -467,7 +472,7 @@ describe('V2 Code Session Routes', () => {
|
|||||||
body: JSON.stringify({ title: 'Code Session' }),
|
body: JSON.stringify({ title: 'Code Session' }),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.session.id).toMatch(/^cse_/)
|
expect(body.session.id).toMatch(/^cse_/)
|
||||||
expect(body.session.title).toBe('Code Session')
|
expect(body.session.title).toBe('Code Session')
|
||||||
})
|
})
|
||||||
@@ -479,14 +484,14 @@ describe('V2 Code Session Routes', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = (await createRes.json()).session
|
const { id } = (await resJson(createRes)).session
|
||||||
|
|
||||||
const bridgeRes = await app.request(`/v1/code/sessions/${id}/bridge`, {
|
const bridgeRes = await app.request(`/v1/code/sessions/${id}/bridge`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: AUTH_HEADERS,
|
headers: AUTH_HEADERS,
|
||||||
})
|
})
|
||||||
expect(bridgeRes.status).toBe(200)
|
expect(bridgeRes.status).toBe(200)
|
||||||
const body = await bridgeRes.json()
|
const body = await resJson(bridgeRes)
|
||||||
expect(body.api_base_url).toBe('http://localhost:3000')
|
expect(body.api_base_url).toBe('http://localhost:3000')
|
||||||
expect(body.worker_epoch).toBe(1)
|
expect(body.worker_epoch).toBe(1)
|
||||||
expect(body.worker_jwt).toBeTruthy()
|
expect(body.worker_jwt).toBeTruthy()
|
||||||
@@ -518,7 +523,7 @@ describe('V2 Worker Routes', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
const regRes = await app.request(
|
const regRes = await app.request(
|
||||||
`/v1/code/sessions/${id}/worker/register`,
|
`/v1/code/sessions/${id}/worker/register`,
|
||||||
@@ -528,7 +533,7 @@ describe('V2 Worker Routes', () => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
expect(regRes.status).toBe(200)
|
expect(regRes.status).toBe(200)
|
||||||
const body = await regRes.json()
|
const body = await resJson(regRes)
|
||||||
expect(body.worker_epoch).toBe(1)
|
expect(body.worker_epoch).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -556,7 +561,7 @@ describe('Web Auth Routes', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await sessRes.json()
|
const { id } = await resJson(sessRes)
|
||||||
|
|
||||||
const bindRes = await app.request('/web/bind?uuid=test-uuid', {
|
const bindRes = await app.request('/web/bind?uuid=test-uuid', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -564,7 +569,7 @@ describe('Web Auth Routes', () => {
|
|||||||
body: JSON.stringify({ sessionId: id }),
|
body: JSON.stringify({ sessionId: id }),
|
||||||
})
|
})
|
||||||
expect(bindRes.status).toBe(200)
|
expect(bindRes.status).toBe(200)
|
||||||
const body = await bindRes.json()
|
const body = await resJson(bindRes)
|
||||||
expect(body.ok).toBe(true)
|
expect(body.ok).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -574,7 +579,7 @@ describe('Web Auth Routes', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const body = await sessRes.json()
|
const body = await resJson(sessRes)
|
||||||
const compatId = toWebSessionId(body.session.id)
|
const compatId = toWebSessionId(body.session.id)
|
||||||
|
|
||||||
const bindRes = await app.request('/web/bind?uuid=test-uuid', {
|
const bindRes = await app.request('/web/bind?uuid=test-uuid', {
|
||||||
@@ -583,7 +588,7 @@ describe('Web Auth Routes', () => {
|
|||||||
body: JSON.stringify({ sessionId: compatId }),
|
body: JSON.stringify({ sessionId: compatId }),
|
||||||
})
|
})
|
||||||
expect(bindRes.status).toBe(200)
|
expect(bindRes.status).toBe(200)
|
||||||
const bindBody = await bindRes.json()
|
const bindBody = await resJson(bindRes)
|
||||||
expect(bindBody.ok).toBe(true)
|
expect(bindBody.ok).toBe(true)
|
||||||
expect(bindBody.sessionId).toBe(compatId)
|
expect(bindBody.sessionId).toBe(compatId)
|
||||||
})
|
})
|
||||||
@@ -625,7 +630,7 @@ describe('Web Session Routes', () => {
|
|||||||
body: JSON.stringify({ title: 'Web Session' }),
|
body: JSON.stringify({ title: 'Web Session' }),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.id).toMatch(/^session_/)
|
expect(body.id).toMatch(/^session_/)
|
||||||
expect(body.source).toBe('web')
|
expect(body.source).toBe('web')
|
||||||
})
|
})
|
||||||
@@ -637,11 +642,11 @@ describe('Web Session Routes', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
const listRes = await app.request('/web/sessions?uuid=user-1')
|
const listRes = await app.request('/web/sessions?uuid=user-1')
|
||||||
expect(listRes.status).toBe(200)
|
expect(listRes.status).toBe(200)
|
||||||
const sessions = await listRes.json()
|
const sessions = await resJson(listRes)
|
||||||
expect(sessions).toHaveLength(1)
|
expect(sessions).toHaveLength(1)
|
||||||
expect(sessions[0].id).toBe(id)
|
expect(sessions[0].id).toBe(id)
|
||||||
})
|
})
|
||||||
@@ -653,13 +658,13 @@ describe('Web Session Routes', () => {
|
|||||||
|
|
||||||
const listRes = await app.request('/web/sessions?uuid=user-1')
|
const listRes = await app.request('/web/sessions?uuid=user-1')
|
||||||
expect(listRes.status).toBe(200)
|
expect(listRes.status).toBe(200)
|
||||||
const sessions = await listRes.json()
|
const sessions = await resJson(listRes)
|
||||||
expect(sessions).toHaveLength(1)
|
expect(sessions).toHaveLength(1)
|
||||||
expect(sessions[0].id).toBe(compatId)
|
expect(sessions[0].id).toBe(compatId)
|
||||||
|
|
||||||
const allRes = await app.request('/web/sessions/all?uuid=user-1')
|
const allRes = await app.request('/web/sessions/all?uuid=user-1')
|
||||||
expect(allRes.status).toBe(200)
|
expect(allRes.status).toBe(200)
|
||||||
const summaries = await allRes.json()
|
const summaries = await resJson(allRes)
|
||||||
expect(summaries).toHaveLength(1)
|
expect(summaries).toHaveLength(1)
|
||||||
expect(summaries[0].id).toBe(compatId)
|
expect(summaries[0].id).toBe(compatId)
|
||||||
})
|
})
|
||||||
@@ -684,7 +689,7 @@ describe('Web Session Routes', () => {
|
|||||||
|
|
||||||
const allRes = await app.request('/web/sessions/all?uuid=user-1')
|
const allRes = await app.request('/web/sessions/all?uuid=user-1')
|
||||||
expect(allRes.status).toBe(200)
|
expect(allRes.status).toBe(200)
|
||||||
const sessions = await allRes.json()
|
const sessions = await resJson(allRes)
|
||||||
expect(sessions).toHaveLength(1) // only user-1's session, not user-2's
|
expect(sessions).toHaveLength(1) // only user-1's session, not user-2's
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -706,14 +711,14 @@ describe('Web Session Routes', () => {
|
|||||||
|
|
||||||
const listRes = await app.request('/web/sessions?uuid=user-1')
|
const listRes = await app.request('/web/sessions?uuid=user-1')
|
||||||
expect(listRes.status).toBe(200)
|
expect(listRes.status).toBe(200)
|
||||||
const sessions = await listRes.json()
|
const sessions = await resJson(listRes)
|
||||||
expect(sessions.map((session: { id: string }) => session.id)).toEqual([
|
expect(sessions.map((session: { id: string }) => session.id)).toEqual([
|
||||||
open.id,
|
open.id,
|
||||||
])
|
])
|
||||||
|
|
||||||
const allRes = await app.request('/web/sessions/all?uuid=user-1')
|
const allRes = await app.request('/web/sessions/all?uuid=user-1')
|
||||||
expect(allRes.status).toBe(200)
|
expect(allRes.status).toBe(200)
|
||||||
const summaries = await allRes.json()
|
const summaries = await resJson(allRes)
|
||||||
expect(summaries.map((session: { id: string }) => session.id)).toEqual([
|
expect(summaries.map((session: { id: string }) => session.id)).toEqual([
|
||||||
open.id,
|
open.id,
|
||||||
])
|
])
|
||||||
@@ -725,7 +730,7 @@ describe('Web Session Routes', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
const getRes = await app.request(`/web/sessions/${id}?uuid=user-1`)
|
const getRes = await app.request(`/web/sessions/${id}?uuid=user-1`)
|
||||||
expect(getRes.status).toBe(200)
|
expect(getRes.status).toBe(200)
|
||||||
@@ -739,7 +744,7 @@ describe('Web Session Routes', () => {
|
|||||||
})
|
})
|
||||||
const {
|
const {
|
||||||
session: { id },
|
session: { id },
|
||||||
} = await createRes.json()
|
} = await resJson(createRes)
|
||||||
storeBindSession(id, 'user-1')
|
storeBindSession(id, 'user-1')
|
||||||
|
|
||||||
await app.request(`/v1/code/sessions/${id}/worker`, {
|
await app.request(`/v1/code/sessions/${id}/worker`, {
|
||||||
@@ -762,7 +767,7 @@ describe('Web Session Routes', () => {
|
|||||||
`/web/sessions/${toWebSessionId(id)}?uuid=user-1`,
|
`/web/sessions/${toWebSessionId(id)}?uuid=user-1`,
|
||||||
)
|
)
|
||||||
expect(getRes.status).toBe(200)
|
expect(getRes.status).toBe(200)
|
||||||
const body = await getRes.json()
|
const body = await resJson(getRes)
|
||||||
expect(body.automation_state).toEqual({
|
expect(body.automation_state).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
phase: 'standby',
|
phase: 'standby',
|
||||||
@@ -777,7 +782,7 @@ describe('Web Session Routes', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
const getRes = await app.request(`/web/sessions/${id}?uuid=user-2`)
|
const getRes = await app.request(`/web/sessions/${id}?uuid=user-2`)
|
||||||
expect(getRes.status).toBe(403)
|
expect(getRes.status).toBe(403)
|
||||||
@@ -789,11 +794,11 @@ describe('Web Session Routes', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`)
|
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`)
|
||||||
expect(histRes.status).toBe(200)
|
expect(histRes.status).toBe(200)
|
||||||
const body = await histRes.json()
|
const body = await resJson(histRes)
|
||||||
expect(body.events).toEqual([])
|
expect(body.events).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -803,7 +808,7 @@ describe('Web Session Routes', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
publishSessionEvent(
|
publishSessionEvent(
|
||||||
id,
|
id,
|
||||||
@@ -817,7 +822,7 @@ describe('Web Session Routes', () => {
|
|||||||
|
|
||||||
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`)
|
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`)
|
||||||
expect(histRes.status).toBe(200)
|
expect(histRes.status).toBe(200)
|
||||||
const body = await histRes.json()
|
const body = await resJson(histRes)
|
||||||
expect(body.events).toHaveLength(1)
|
expect(body.events).toHaveLength(1)
|
||||||
expect(body.events[0]?.type).toBe('task_state')
|
expect(body.events[0]?.type).toBe('task_state')
|
||||||
expect(body.events[0]?.payload.task_list_id).toBe('team-alpha')
|
expect(body.events[0]?.payload.task_list_id).toBe('team-alpha')
|
||||||
@@ -833,14 +838,14 @@ describe('Web Session Routes', () => {
|
|||||||
|
|
||||||
const getRes = await app.request(`/web/sessions/${compatId}?uuid=user-1`)
|
const getRes = await app.request(`/web/sessions/${compatId}?uuid=user-1`)
|
||||||
expect(getRes.status).toBe(200)
|
expect(getRes.status).toBe(200)
|
||||||
const session = await getRes.json()
|
const session = await resJson(getRes)
|
||||||
expect(session.id).toBe(compatId)
|
expect(session.id).toBe(compatId)
|
||||||
|
|
||||||
const histRes = await app.request(
|
const histRes = await app.request(
|
||||||
`/web/sessions/${compatId}/history?uuid=user-1`,
|
`/web/sessions/${compatId}/history?uuid=user-1`,
|
||||||
)
|
)
|
||||||
expect(histRes.status).toBe(200)
|
expect(histRes.status).toBe(200)
|
||||||
const history = await histRes.json()
|
const history = await resJson(histRes)
|
||||||
expect(history.events).toEqual([])
|
expect(history.events).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -850,7 +855,7 @@ describe('Web Session Routes', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-2`)
|
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-2`)
|
||||||
expect(histRes.status).toBe(403)
|
expect(histRes.status).toBe(403)
|
||||||
@@ -862,7 +867,7 @@ describe('Web Session Routes', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
// Archive/delete the session via v1
|
// Archive/delete the session via v1
|
||||||
await app.request(`/v1/sessions/${id}/archive`, {
|
await app.request(`/v1/sessions/${id}/archive`, {
|
||||||
@@ -884,7 +889,7 @@ describe('Web Session Routes', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
// Delete the session from store directly
|
// Delete the session from store directly
|
||||||
const { storeDeleteSession } = await import('../store')
|
const { storeDeleteSession } = await import('../store')
|
||||||
@@ -902,7 +907,7 @@ describe('Web Session Routes', () => {
|
|||||||
})
|
})
|
||||||
// Session is still created even if work item fails
|
// Session is still created even if work item fails
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.id).toMatch(/^session_/)
|
expect(body.id).toMatch(/^session_/)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -912,7 +917,7 @@ describe('Web Session Routes', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
const eventsRes = await app.request(
|
const eventsRes = await app.request(
|
||||||
`/web/sessions/${id}/events?uuid=user-1`,
|
`/web/sessions/${id}/events?uuid=user-1`,
|
||||||
@@ -956,7 +961,7 @@ describe('Web Session Routes', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
const eventsRes = await app.request(
|
const eventsRes = await app.request(
|
||||||
`/web/sessions/${id}/events?uuid=user-2`,
|
`/web/sessions/${id}/events?uuid=user-2`,
|
||||||
@@ -970,7 +975,7 @@ describe('Web Session Routes', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
await app.request(`/v1/sessions/${id}/archive`, {
|
await app.request(`/v1/sessions/${id}/archive`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -979,7 +984,7 @@ describe('Web Session Routes', () => {
|
|||||||
|
|
||||||
const res = await app.request(`/web/sessions/${id}/events?uuid=user-1`)
|
const res = await app.request(`/web/sessions/${id}/events?uuid=user-1`)
|
||||||
expect(res.status).toBe(409)
|
expect(res.status).toBe(409)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.error.type).toBe('session_closed')
|
expect(body.error.type).toBe('session_closed')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1001,7 +1006,7 @@ describe('Web Control Routes', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
sessionId = (await createRes.json()).id
|
sessionId = (await resJson(createRes)).id
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /web/sessions/:id/events — sends user message', async () => {
|
test('POST /web/sessions/:id/events — sends user message', async () => {
|
||||||
@@ -1014,7 +1019,7 @@ describe('Web Control Routes', () => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.status).toBe('ok')
|
expect(body.status).toBe('ok')
|
||||||
expect(body.event).toBeTruthy()
|
expect(body.event).toBeTruthy()
|
||||||
})
|
})
|
||||||
@@ -1191,7 +1196,7 @@ describe('Web Environment Routes', () => {
|
|||||||
|
|
||||||
const res = await app.request('/web/environments?uuid=user-1')
|
const res = await app.request('/web/environments?uuid=user-1')
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const envs = await res.json()
|
const envs = await resJson(res)
|
||||||
expect(envs).toHaveLength(1)
|
expect(envs).toHaveLength(1)
|
||||||
expect(envs[0].machine_name).toBe('mac1')
|
expect(envs[0].machine_name).toBe('mac1')
|
||||||
})
|
})
|
||||||
@@ -1221,7 +1226,7 @@ describe('V1 Session Ingress Routes (HTTP)', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await sessRes.json()
|
const { id } = await resJson(sessRes)
|
||||||
|
|
||||||
const res = await app.request(`/v2/session_ingress/session/${id}/events`, {
|
const res = await app.request(`/v2/session_ingress/session/${id}/events`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1231,7 +1236,7 @@ describe('V1 Session Ingress Routes (HTTP)', () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.status).toBe('ok')
|
expect(body.status).toBe('ok')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1261,7 +1266,7 @@ describe('V1 Session Ingress Routes (HTTP)', () => {
|
|||||||
})
|
})
|
||||||
const {
|
const {
|
||||||
session: { id },
|
session: { id },
|
||||||
} = await sessRes.json()
|
} = await resJson(sessRes)
|
||||||
const compatId = toWebSessionId(id)
|
const compatId = toWebSessionId(id)
|
||||||
|
|
||||||
const res = await app.request(
|
const res = await app.request(
|
||||||
@@ -1292,7 +1297,7 @@ describe('V1 Session Ingress Routes (HTTP)', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await sessRes.json()
|
const { id } = await resJson(sessRes)
|
||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: 0,
|
port: 0,
|
||||||
@@ -1380,7 +1385,7 @@ describe('V1 Session Ingress Routes (HTTP)', () => {
|
|||||||
})
|
})
|
||||||
const {
|
const {
|
||||||
session: { id },
|
session: { id },
|
||||||
} = await sessRes.json()
|
} = await resJson(sessRes)
|
||||||
const compatId = toWebSessionId(id)
|
const compatId = toWebSessionId(id)
|
||||||
|
|
||||||
publishSessionEvent(id, 'user', { content: 'compat ws replay' }, 'outbound')
|
publishSessionEvent(id, 'user', { content: 'compat ws replay' }, 'outbound')
|
||||||
@@ -1468,7 +1473,7 @@ describe('ACP Routes', () => {
|
|||||||
headers: AUTH_HEADERS,
|
headers: AUTH_HEADERS,
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body).toHaveLength(1)
|
expect(body).toHaveLength(1)
|
||||||
expect(body[0].agent_name).toBe('agent-one')
|
expect(body[0].agent_name).toBe('agent-one')
|
||||||
})
|
})
|
||||||
@@ -1495,7 +1500,7 @@ describe('ACP Routes', () => {
|
|||||||
headers: AUTH_HEADERS,
|
headers: AUTH_HEADERS,
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body).toHaveLength(1)
|
expect(body).toHaveLength(1)
|
||||||
expect(body[0].channel_group_id).toBe('group-one')
|
expect(body[0].channel_group_id).toBe('group-one')
|
||||||
})
|
})
|
||||||
@@ -1550,7 +1555,7 @@ describe('ACP Routes', () => {
|
|||||||
headers: AUTH_HEADERS,
|
headers: AUTH_HEADERS,
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.channel_group_id).toBe('group-one')
|
expect(body.channel_group_id).toBe('group-one')
|
||||||
expect(body.member_count).toBe(1)
|
expect(body.member_count).toBe(1)
|
||||||
})
|
})
|
||||||
@@ -1579,14 +1584,14 @@ describe('ACP Routes', () => {
|
|||||||
|
|
||||||
test('ACP relay auth rejects UUID-only auth', async () => {
|
test('ACP relay auth rejects UUID-only auth', async () => {
|
||||||
const res = await createRelayAuthApp().request('/relay-auth?uuid=user-1')
|
const res = await createRelayAuthApp().request('/relay-auth?uuid=user-1')
|
||||||
expect(await res.json()).toEqual({ ok: false })
|
expect(await resJson(res)).toEqual({ ok: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('ACP relay auth accepts API key header', async () => {
|
test('ACP relay auth accepts API key header', async () => {
|
||||||
const res = await createRelayAuthApp().request('/relay-auth', {
|
const res = await createRelayAuthApp().request('/relay-auth', {
|
||||||
headers: AUTH_HEADERS,
|
headers: AUTH_HEADERS,
|
||||||
})
|
})
|
||||||
expect(await res.json()).toEqual({ ok: true })
|
expect(await resJson(res)).toEqual({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('ACP relay auth accepts WebSocket protocol auth', async () => {
|
test('ACP relay auth accepts WebSocket protocol auth', async () => {
|
||||||
@@ -1595,7 +1600,7 @@ describe('ACP Routes', () => {
|
|||||||
'Sec-WebSocket-Protocol': encodeWebSocketAuthProtocol('test-api-key'),
|
'Sec-WebSocket-Protocol': encodeWebSocketAuthProtocol('test-api-key'),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(await res.json()).toEqual({ ok: true })
|
expect(await resJson(res)).toEqual({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('ACP WebSocket rejects legacy query-token auth on the real upgrade path', async () => {
|
test('ACP WebSocket rejects legacy query-token auth on the real upgrade path', async () => {
|
||||||
@@ -1845,7 +1850,7 @@ describe('V2 Worker Events Routes', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await sessRes.json()
|
const { id } = await resJson(sessRes)
|
||||||
|
|
||||||
const res = await app.request(`/v1/code/sessions/${id}/worker/events`, {
|
const res = await app.request(`/v1/code/sessions/${id}/worker/events`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1853,7 +1858,7 @@ describe('V2 Worker Events Routes', () => {
|
|||||||
body: JSON.stringify([{ type: 'assistant', content: 'response' }]),
|
body: JSON.stringify([{ type: 'assistant', content: 'response' }]),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.status).toBe('ok')
|
expect(body.status).toBe('ok')
|
||||||
expect(body.count).toBe(1)
|
expect(body.count).toBe(1)
|
||||||
})
|
})
|
||||||
@@ -1866,7 +1871,7 @@ describe('V2 Worker Events Routes', () => {
|
|||||||
})
|
})
|
||||||
const {
|
const {
|
||||||
session: { id },
|
session: { id },
|
||||||
} = await sessRes.json()
|
} = await resJson(sessRes)
|
||||||
|
|
||||||
const res = await app.request(`/v1/code/sessions/${id}/worker/events`, {
|
const res = await app.request(`/v1/code/sessions/${id}/worker/events`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1877,7 +1882,7 @@ describe('V2 Worker Events Routes', () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json()
|
const body = await resJson(res)
|
||||||
expect(body.count).toBe(1)
|
expect(body.count).toBe(1)
|
||||||
|
|
||||||
const events = getEventBus(id).getEventsSince(0)
|
const events = getEventBus(id).getEventsSince(0)
|
||||||
@@ -1896,7 +1901,7 @@ describe('V2 Worker Events Routes', () => {
|
|||||||
})
|
})
|
||||||
const {
|
const {
|
||||||
session: { id },
|
session: { id },
|
||||||
} = await sessRes.json()
|
} = await resJson(sessRes)
|
||||||
|
|
||||||
const putRes = await app.request(`/v1/code/sessions/${id}/worker`, {
|
const putRes = await app.request(`/v1/code/sessions/${id}/worker`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -1921,7 +1926,7 @@ describe('V2 Worker Events Routes', () => {
|
|||||||
headers: AUTH_HEADERS,
|
headers: AUTH_HEADERS,
|
||||||
})
|
})
|
||||||
expect(getRes.status).toBe(200)
|
expect(getRes.status).toBe(200)
|
||||||
const body = await getRes.json()
|
const body = await resJson(getRes)
|
||||||
expect(body.worker.worker_status).toBe('running')
|
expect(body.worker.worker_status).toBe('running')
|
||||||
expect(body.worker.external_metadata.permission_mode).toBe('default')
|
expect(body.worker.external_metadata.permission_mode).toBe('default')
|
||||||
expect(body.worker.external_metadata.automation_state).toEqual({
|
expect(body.worker.external_metadata.automation_state).toEqual({
|
||||||
@@ -1949,7 +1954,7 @@ describe('V2 Worker Events Routes', () => {
|
|||||||
})
|
})
|
||||||
const {
|
const {
|
||||||
session: { id },
|
session: { id },
|
||||||
} = await sessRes.json()
|
} = await resJson(sessRes)
|
||||||
|
|
||||||
const heartbeatRes = await app.request(
|
const heartbeatRes = await app.request(
|
||||||
`/v1/code/sessions/${id}/worker/heartbeat`,
|
`/v1/code/sessions/${id}/worker/heartbeat`,
|
||||||
@@ -1964,7 +1969,7 @@ describe('V2 Worker Events Routes', () => {
|
|||||||
const getRes = await app.request(`/v1/code/sessions/${id}/worker`, {
|
const getRes = await app.request(`/v1/code/sessions/${id}/worker`, {
|
||||||
headers: AUTH_HEADERS,
|
headers: AUTH_HEADERS,
|
||||||
})
|
})
|
||||||
const body = await getRes.json()
|
const body = await resJson(getRes)
|
||||||
expect(body.worker.last_heartbeat_at).toBeTruthy()
|
expect(body.worker.last_heartbeat_at).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1976,7 +1981,7 @@ describe('V2 Worker Events Routes', () => {
|
|||||||
})
|
})
|
||||||
const {
|
const {
|
||||||
session: { id },
|
session: { id },
|
||||||
} = await sessRes.json()
|
} = await resJson(sessRes)
|
||||||
|
|
||||||
const streamRes = await app.request(
|
const streamRes = await app.request(
|
||||||
`/v1/code/sessions/${id}/worker/events/stream`,
|
`/v1/code/sessions/${id}/worker/events/stream`,
|
||||||
@@ -2016,7 +2021,7 @@ describe('V2 Worker Events Routes', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
const streamRes = await app.request(
|
const streamRes = await app.request(
|
||||||
`/v1/code/sessions/${id}/worker/events/stream`,
|
`/v1/code/sessions/${id}/worker/events/stream`,
|
||||||
@@ -2062,7 +2067,7 @@ describe('V2 Worker Events Routes', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
const streamRes = await app.request(
|
const streamRes = await app.request(
|
||||||
`/v1/code/sessions/${id}/worker/events/stream`,
|
`/v1/code/sessions/${id}/worker/events/stream`,
|
||||||
@@ -2111,7 +2116,7 @@ describe('V2 Worker Events Routes', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await createRes.json()
|
const { id } = await resJson(createRes)
|
||||||
|
|
||||||
const streamRes = await app.request(
|
const streamRes = await app.request(
|
||||||
`/v1/code/sessions/${id}/worker/events/stream`,
|
`/v1/code/sessions/${id}/worker/events/stream`,
|
||||||
@@ -2151,7 +2156,7 @@ describe('V2 Worker Events Routes', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await sessRes.json()
|
const { id } = await resJson(sessRes)
|
||||||
|
|
||||||
const res = await app.request(`/v1/code/sessions/${id}/worker/state`, {
|
const res = await app.request(`/v1/code/sessions/${id}/worker/state`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -2167,7 +2172,7 @@ describe('V2 Worker Events Routes', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await sessRes.json()
|
const { id } = await resJson(sessRes)
|
||||||
|
|
||||||
const res = await app.request(
|
const res = await app.request(
|
||||||
`/v1/code/sessions/${id}/worker/external_metadata`,
|
`/v1/code/sessions/${id}/worker/external_metadata`,
|
||||||
@@ -2186,7 +2191,7 @@ describe('V2 Worker Events Routes', () => {
|
|||||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const { id } = await sessRes.json()
|
const { id } = await resJson(sessRes)
|
||||||
|
|
||||||
const res = await app.request(
|
const res = await app.request(
|
||||||
`/v1/code/sessions/${id}/worker/events/evt123/delivery`,
|
`/v1/code/sessions/${id}/worker/events/evt123/delivery`,
|
||||||
@@ -2207,7 +2212,7 @@ describe('V2 Worker Events Routes', () => {
|
|||||||
})
|
})
|
||||||
const {
|
const {
|
||||||
session: { id },
|
session: { id },
|
||||||
} = await sessRes.json()
|
} = await resJson(sessRes)
|
||||||
|
|
||||||
const res = await app.request(
|
const res = await app.request(
|
||||||
`/v1/code/sessions/${id}/worker/events/delivery`,
|
`/v1/code/sessions/${id}/worker/events/delivery`,
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ import privacySettings from './commands/privacy-settings/index.js'
|
|||||||
import hooks from './commands/hooks/index.js'
|
import hooks from './commands/hooks/index.js'
|
||||||
import files from './commands/files/index.js'
|
import files from './commands/files/index.js'
|
||||||
import branch from './commands/branch/index.js'
|
import branch from './commands/branch/index.js'
|
||||||
|
import artifacts from './commands/artifacts/index.js'
|
||||||
import agents from './commands/agents/index.js'
|
import agents from './commands/agents/index.js'
|
||||||
import plugin from './commands/plugin/index.js'
|
import plugin from './commands/plugin/index.js'
|
||||||
import reloadPlugins from './commands/reload-plugins/index.js'
|
import reloadPlugins from './commands/reload-plugins/index.js'
|
||||||
@@ -305,6 +306,7 @@ const COMMANDS = memoize((): Command[] => [
|
|||||||
localMemoryCommand,
|
localMemoryCommand,
|
||||||
autonomy,
|
autonomy,
|
||||||
provider,
|
provider,
|
||||||
|
artifacts,
|
||||||
agents,
|
agents,
|
||||||
branch,
|
branch,
|
||||||
btw,
|
btw,
|
||||||
|
|||||||
94
src/commands/artifacts/ArtifactsMenu.tsx
Normal file
94
src/commands/artifacts/ArtifactsMenu.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Box, Text, setClipboard, useInput } from '@anthropic/ink';
|
||||||
|
import type { ArtifactInfo } from './scanner.js';
|
||||||
|
import { openBrowser } from 'src/utils/browser.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
artifacts: ArtifactInfo[];
|
||||||
|
onExit: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ArtifactsMenu({ artifacts, onExit }: Props): React.ReactElement {
|
||||||
|
const [selected, setSelected] = React.useState(0);
|
||||||
|
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (input === 'q' || key.escape) {
|
||||||
|
onExit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (artifacts.length === 0) return;
|
||||||
|
if (key.upArrow) {
|
||||||
|
setSelected(s => (s - 1 + artifacts.length) % artifacts.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.downArrow) {
|
||||||
|
setSelected(s => (s + 1) % artifacts.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.return) {
|
||||||
|
const target = artifacts[selected];
|
||||||
|
if (target.url) {
|
||||||
|
void openBrowser(target.url);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input === 'c') {
|
||||||
|
const target = artifacts[selected];
|
||||||
|
if (target.url) {
|
||||||
|
void setClipboard(target.url).then(raw => {
|
||||||
|
if (raw) process.stdout.write(raw);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" paddingX={1} paddingY={0}>
|
||||||
|
<Box marginBottom={1}>
|
||||||
|
<Text bold>Artifacts ({artifacts.length})</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{artifacts.length === 0 ? (
|
||||||
|
<Text color="subtle">No artifacts uploaded this session. Run /use-artifacts to learn how.</Text>
|
||||||
|
) : (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{artifacts.map((a, idx) => (
|
||||||
|
<ArtifactRow key={a.toolUseId} artifact={a} isSelected={idx === selected} />
|
||||||
|
))}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color="subtle">{'↑/↓ select · Enter open · c copy URL · Esc exit'}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtifactRow({ artifact, isSelected }: { artifact: ArtifactInfo; isSelected: boolean }): React.ReactElement {
|
||||||
|
const marker = isSelected ? '›' : ' ';
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Box>
|
||||||
|
<Text color={isSelected ? 'suggestion' : undefined}>{marker} </Text>
|
||||||
|
<Text bold={isSelected} color={artifact.isError ? 'error' : undefined}>
|
||||||
|
{artifact.basename}
|
||||||
|
</Text>
|
||||||
|
{artifact.hash ? <Text color="subtle"> ({artifact.hash})</Text> : null}
|
||||||
|
</Box>
|
||||||
|
{artifact.url ? (
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
<Text color="background">{artifact.url}</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
<Text color="error">{artifact.rawContent}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{artifact.expiresAt ? (
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
<Text color="subtle">expires: {artifact.expiresAt}</Text>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/commands/artifacts/__tests__/scanner.test.ts
Normal file
158
src/commands/artifacts/__tests__/scanner.test.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { extractArtifacts } from '../scanner.js'
|
||||||
|
import type { Message } from 'src/types/message.js'
|
||||||
|
|
||||||
|
function assistantToolUse(id: string, input: Record<string, unknown>): Message {
|
||||||
|
return {
|
||||||
|
type: 'assistant',
|
||||||
|
uuid: crypto.randomUUID(),
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'tool_use' as const, id, name: 'artifact', input }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function userToolResult(id: string, content: string, isError = false): Message {
|
||||||
|
return {
|
||||||
|
type: 'user',
|
||||||
|
uuid: crypto.randomUUID(),
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_result' as const,
|
||||||
|
tool_use_id: id,
|
||||||
|
content,
|
||||||
|
is_error: isError,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('extractArtifacts', () => {
|
||||||
|
test('returns empty list when no artifact tool_use messages', () => {
|
||||||
|
expect(extractArtifacts([])).toEqual([])
|
||||||
|
expect(
|
||||||
|
extractArtifacts([
|
||||||
|
{
|
||||||
|
type: 'user',
|
||||||
|
uuid: crypto.randomUUID(),
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text' as const, text: 'hi' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pairs a successful tool_use with its tool_result and returns parsed fields', () => {
|
||||||
|
const messages: Message[] = [
|
||||||
|
assistantToolUse('tu1', { file_path: '/tmp/report.html', ttl: 7 }),
|
||||||
|
userToolResult(
|
||||||
|
'tu1',
|
||||||
|
'Artifact uploaded: https://x.test/7d/abc.html (id: abc, expires: 2026-06-27T10:00:00.000Z)',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = extractArtifacts(messages)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
filePath: '/tmp/report.html',
|
||||||
|
hash: 'abc',
|
||||||
|
url: 'https://x.test/7d/abc.html',
|
||||||
|
expiresAt: '2026-06-27T10:00:00.000Z',
|
||||||
|
basename: 'report.html',
|
||||||
|
isError: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('skips artifact tool_use without a matching tool_result', () => {
|
||||||
|
const messages: Message[] = [
|
||||||
|
assistantToolUse('tu1', { file_path: '/tmp/report.html', ttl: 7 }),
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(extractArtifacts(messages)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps error results with isError=true and no parsed fields', () => {
|
||||||
|
const messages: Message[] = [
|
||||||
|
assistantToolUse('tu1', { file_path: '/tmp/missing.html', ttl: 7 }),
|
||||||
|
userToolResult(
|
||||||
|
'tu1',
|
||||||
|
'File does not exist or is not readable: /tmp/missing.html',
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = extractArtifacts(messages)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
filePath: '/tmp/missing.html',
|
||||||
|
basename: 'missing.html',
|
||||||
|
isError: true,
|
||||||
|
})
|
||||||
|
expect(result[0].url).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parses url/id/expires from array-form tool_result content', () => {
|
||||||
|
const messages: Message[] = [
|
||||||
|
assistantToolUse('tu1', { file_path: '/tmp/report.html', ttl: 7 }),
|
||||||
|
{
|
||||||
|
type: 'user',
|
||||||
|
uuid: crypto.randomUUID(),
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_result' as const,
|
||||||
|
tool_use_id: 'tu1',
|
||||||
|
content: [
|
||||||
|
{ type: 'text' as const, text: 'Artifact uploaded: ' },
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: 'https://x.test/7d/def.html (id: def, expires: 2026-06-27T10:00:00.000Z)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = extractArtifacts(messages)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
filePath: '/tmp/report.html',
|
||||||
|
hash: 'def',
|
||||||
|
url: 'https://x.test/7d/def.html',
|
||||||
|
expiresAt: '2026-06-27T10:00:00.000Z',
|
||||||
|
basename: 'report.html',
|
||||||
|
isError: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('orders newest first (last in conversation appears at top)', () => {
|
||||||
|
const messages: Message[] = [
|
||||||
|
assistantToolUse('tu1', { file_path: '/tmp/a.html', ttl: 7 }),
|
||||||
|
userToolResult(
|
||||||
|
'tu1',
|
||||||
|
'Artifact uploaded: https://x.test/7d/a.html (id: a, expires: 2026-06-27T10:00:00.000Z)',
|
||||||
|
),
|
||||||
|
assistantToolUse('tu2', { file_path: '/tmp/b.html', ttl: 7 }),
|
||||||
|
userToolResult(
|
||||||
|
'tu2',
|
||||||
|
'Artifact uploaded: https://x.test/7d/b.html (id: b, expires: 2026-06-27T10:00:00.000Z)',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = extractArtifacts(messages)
|
||||||
|
|
||||||
|
expect(result.map(r => r.basename)).toEqual(['b.html', 'a.html'])
|
||||||
|
})
|
||||||
|
})
|
||||||
11
src/commands/artifacts/artifacts.tsx
Normal file
11
src/commands/artifacts/artifacts.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import type { LocalJSXCommandOnDone } from 'src/types/command.js';
|
||||||
|
import type { ToolUseContext } from 'src/Tool.js';
|
||||||
|
import { ArtifactsMenu } from './ArtifactsMenu.js';
|
||||||
|
import { extractArtifacts } from './scanner.js';
|
||||||
|
|
||||||
|
export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext): Promise<React.ReactNode> {
|
||||||
|
const messages = context.messages ?? [];
|
||||||
|
const artifacts = extractArtifacts(messages);
|
||||||
|
return <ArtifactsMenu artifacts={artifacts} onExit={onDone} />;
|
||||||
|
}
|
||||||
12
src/commands/artifacts/index.ts
Normal file
12
src/commands/artifacts/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Command } from '../../commands.js'
|
||||||
|
|
||||||
|
const artifacts = {
|
||||||
|
type: 'local-jsx',
|
||||||
|
name: 'artifacts',
|
||||||
|
description:
|
||||||
|
'List HTML artifacts uploaded to cloud-artifacts in this session',
|
||||||
|
isEnabled: () => true,
|
||||||
|
load: () => import('./artifacts.js'),
|
||||||
|
} satisfies Command
|
||||||
|
|
||||||
|
export default artifacts
|
||||||
97
src/commands/artifacts/scanner.ts
Normal file
97
src/commands/artifacts/scanner.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { basename } from 'path'
|
||||||
|
import type { Message } from 'src/types/message.js'
|
||||||
|
|
||||||
|
export type ArtifactInfo = {
|
||||||
|
toolUseId: string
|
||||||
|
filePath: string
|
||||||
|
basename: string
|
||||||
|
hash?: string
|
||||||
|
url?: string
|
||||||
|
expiresAt?: string
|
||||||
|
rawContent: string
|
||||||
|
isError: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const URL_REGEX = /https?:\/\/[^\s)"',]+\.html\b/
|
||||||
|
const ID_REGEX = /\bid:\s*([A-Za-z0-9_-]+)/
|
||||||
|
const EXPIRES_REGEX = /\bexpires:\s*([0-9T:.Z+-]+)/
|
||||||
|
|
||||||
|
export function extractArtifacts(messages: Message[]): ArtifactInfo[] {
|
||||||
|
const results: ArtifactInfo[] = []
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.type !== 'assistant') continue
|
||||||
|
const content = message.message?.content
|
||||||
|
if (!Array.isArray(content)) continue
|
||||||
|
|
||||||
|
for (const block of content) {
|
||||||
|
if (typeof block !== 'object' || block === null) continue
|
||||||
|
if (!('type' in block)) continue
|
||||||
|
const b = block as unknown as Record<string, unknown>
|
||||||
|
if (b.type !== 'tool_use') continue
|
||||||
|
if (b.name !== 'artifact') continue
|
||||||
|
|
||||||
|
const toolUseId = b.id as string
|
||||||
|
const input = b.input as { file_path?: string } | undefined
|
||||||
|
const filePath = input?.file_path ?? '<unknown>'
|
||||||
|
|
||||||
|
const resultBlock = findToolResult(messages, toolUseId)
|
||||||
|
if (!resultBlock) continue
|
||||||
|
|
||||||
|
const rawContent =
|
||||||
|
typeof resultBlock.content === 'string'
|
||||||
|
? resultBlock.content
|
||||||
|
: Array.isArray(resultBlock.content)
|
||||||
|
? resultBlock.content
|
||||||
|
.map(c =>
|
||||||
|
typeof c === 'string'
|
||||||
|
? c
|
||||||
|
: 'text' in c
|
||||||
|
? (c as { text: string }).text
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const isError = resultBlock.is_error === true
|
||||||
|
const urlMatch = rawContent.match(URL_REGEX)
|
||||||
|
const idMatch = rawContent.match(ID_REGEX)
|
||||||
|
const expiresMatch = rawContent.match(EXPIRES_REGEX)
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
toolUseId,
|
||||||
|
filePath,
|
||||||
|
basename: basename(filePath),
|
||||||
|
hash: idMatch?.[1],
|
||||||
|
url: urlMatch?.[0],
|
||||||
|
expiresAt: expiresMatch?.[1],
|
||||||
|
rawContent,
|
||||||
|
isError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newest first
|
||||||
|
return results.reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
function findToolResult(
|
||||||
|
messages: Message[],
|
||||||
|
toolUseId: string,
|
||||||
|
): { content: unknown; is_error?: boolean } | null {
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.type !== 'user') continue
|
||||||
|
const content = message.message?.content
|
||||||
|
if (!Array.isArray(content)) continue
|
||||||
|
|
||||||
|
for (const block of content) {
|
||||||
|
if (typeof block !== 'object' || block === null) continue
|
||||||
|
if (!('type' in block)) continue
|
||||||
|
const b = block as unknown as Record<string, unknown>
|
||||||
|
if (b.type !== 'tool_result') continue
|
||||||
|
if (b.tool_use_id !== toolUseId) continue
|
||||||
|
return { content: b.content, is_error: b.is_error as boolean | undefined }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
38
src/query.ts
38
src/query.ts
@@ -522,21 +522,35 @@ async function* queryLoop(
|
|||||||
|
|
||||||
let messagesForQuery = getMessagesAfterCompactBoundary(messages)
|
let messagesForQuery = getMessagesAfterCompactBoundary(messages)
|
||||||
|
|
||||||
// Release toolUseResult payloads from previous turns. By this point the
|
// Release toolUseResult payloads from previous turns — the next API call
|
||||||
// UI has already rendered those results and the next API call only needs
|
// only needs message.message.content (tool_result blocks), not the raw
|
||||||
// message.message.content (tool_result blocks), not the raw output object.
|
// output object. This prevents unbounded memory growth in long sessions
|
||||||
// This prevents unbounded memory growth in long sessions before compact
|
// before compact triggers (a single FileRead of a 400KB file would
|
||||||
// triggers — a single FileRead of a 400KB file would otherwise stay in
|
// otherwise stay in mutableMessages forever).
|
||||||
// mutableMessages forever.
|
//
|
||||||
for (const msg of messagesForQuery) {
|
// IMPORTANT: shallow-copy rather than mutate. messagesForQuery elements
|
||||||
|
// are references shared with mutableMessages (UI state); deleting
|
||||||
|
// toolUseResult in place strips it from the live message while React may
|
||||||
|
// still be rendering it. The next query can start within milliseconds of
|
||||||
|
// tool_result creation (model immediately calls the next tool), before
|
||||||
|
// the UI commit lands — UserToolSuccessMessage reads
|
||||||
|
// message.toolUseResult to delegate to tool.renderToolResultMessage, so a
|
||||||
|
// mutation race makes tool-result rows render blank. Map to a stripped
|
||||||
|
// copy so mutableMessages keeps the original for the UI; downstream API
|
||||||
|
// transformations (applyToolResultBudget, snip, microcompact) already
|
||||||
|
// build new arrays via .map(), so they compose cleanly with this copy.
|
||||||
|
messagesForQuery = messagesForQuery.map(msg => {
|
||||||
if (
|
if (
|
||||||
msg.type === 'user' &&
|
msg.type !== 'user' ||
|
||||||
'toolUseResult' in msg &&
|
!('toolUseResult' in msg) ||
|
||||||
msg.toolUseResult !== undefined
|
(msg as { toolUseResult?: unknown }).toolUseResult === undefined
|
||||||
) {
|
) {
|
||||||
delete (msg as Message & { toolUseResult?: unknown }).toolUseResult
|
return msg
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const copy: typeof msg = { ...msg }
|
||||||
|
delete (copy as Message & { toolUseResult?: unknown }).toolUseResult
|
||||||
|
return copy
|
||||||
|
})
|
||||||
|
|
||||||
let tracking = autoCompactTracking
|
let tracking = autoCompactTracking
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { registerKeybindingsSkill } from './keybindings.js'
|
|||||||
import { registerLoremIpsumSkill } from './loremIpsum.js'
|
import { registerLoremIpsumSkill } from './loremIpsum.js'
|
||||||
import { registerRememberSkill } from './remember.js'
|
import { registerRememberSkill } from './remember.js'
|
||||||
import { registerSimplifySkill } from './simplify.js'
|
import { registerSimplifySkill } from './simplify.js'
|
||||||
|
import { registerUseArtifactsSkill } from './useArtifacts.js'
|
||||||
import { registerSkillifySkill } from './skillify.js'
|
import { registerSkillifySkill } from './skillify.js'
|
||||||
import { registerStuckSkill } from './stuck.js'
|
import { registerStuckSkill } from './stuck.js'
|
||||||
import { registerUltracodeSkill } from './ultracode.js'
|
import { registerUltracodeSkill } from './ultracode.js'
|
||||||
@@ -34,6 +35,7 @@ export function initBundledSkills(): void {
|
|||||||
registerSkillifySkill()
|
registerSkillifySkill()
|
||||||
registerRememberSkill()
|
registerRememberSkill()
|
||||||
registerSimplifySkill()
|
registerSimplifySkill()
|
||||||
|
registerUseArtifactsSkill()
|
||||||
registerBatchSkill()
|
registerBatchSkill()
|
||||||
registerStuckSkill()
|
registerStuckSkill()
|
||||||
registerUltracodeSkill()
|
registerUltracodeSkill()
|
||||||
|
|||||||
101
src/skills/bundled/useArtifacts.ts
Normal file
101
src/skills/bundled/useArtifacts.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { registerBundledSkill } from '../bundledSkills.js'
|
||||||
|
|
||||||
|
const USE_ARTIFACTS_PROMPT = `# Using Artifacts
|
||||||
|
|
||||||
|
Artifacts are public HTML pages you upload to a hosting service. They have stable URLs that you can share with the user or open in a browser. Use them to surface work-in-progress, summaries, and reports.
|
||||||
|
|
||||||
|
## When to use artifacts
|
||||||
|
|
||||||
|
**Good artifact content:**
|
||||||
|
- Progress panels / kanbans (task list with status)
|
||||||
|
- Research reports and analysis (data + findings + recommendations)
|
||||||
|
- Design docs / decision records (with context and rationale)
|
||||||
|
- Data visualizations (tables, SVG charts, flow diagrams)
|
||||||
|
- Final deliverables (the "thing the user asked for" rendered as HTML)
|
||||||
|
|
||||||
|
**Do NOT use artifacts for:**
|
||||||
|
- Code snippets — use files directly
|
||||||
|
- One-line answers — keep them in chat
|
||||||
|
- Internal debug logs — keep them in chat
|
||||||
|
- Large data dumps — link to source files instead
|
||||||
|
|
||||||
|
## Cadence — when to upload
|
||||||
|
|
||||||
|
- **Task start**: if the task is complex (multi-step, research, deliverable), upload a skeleton artifact first as scaffolding (placeholder sections).
|
||||||
|
- **Milestones**: when you complete a phase (research done / implementation done / tests pass), update the artifact.
|
||||||
|
- **User asks**: upload immediately.
|
||||||
|
- **Task end**: ship the final artifact as the deliverable.
|
||||||
|
|
||||||
|
**Do NOT upload:**
|
||||||
|
- After every tool call (noise)
|
||||||
|
- Mid-step with no meaningful change (e.g. fixed a typo)
|
||||||
|
|
||||||
|
## How to invoke (deferred tool)
|
||||||
|
|
||||||
|
\`artifact\` is a deferred tool. The first call requires two steps; subsequent calls one step.
|
||||||
|
|
||||||
|
**First upload (creates a new artifact):**
|
||||||
|
\`\`\`
|
||||||
|
1. Use the Write tool to write HTML to a local file (location is your choice).
|
||||||
|
2. SearchExtraTools({ query: "select:artifact" }) // loads the tool schema
|
||||||
|
3. ExecuteExtraTool({ tool_name: "artifact", params: { file_path: "<absolute-path>.html" } })
|
||||||
|
4. Save the returned \`id\` from the tool result — this is the hash.
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
**Subsequent updates (overwrites in place, URL stays stable):**
|
||||||
|
\`\`\`
|
||||||
|
1. Update the local HTML file.
|
||||||
|
2. ExecuteExtraTool({ tool_name: "artifact", params: { file_path: "<absolute-path>.html", hash: "<id-from-first-call>" } })
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
The URL returned on every call is the same when you pass the same \`hash\`. The user can open it at any time to see the latest version.
|
||||||
|
|
||||||
|
## Minimal HTML skeleton
|
||||||
|
|
||||||
|
\`\`\`html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Artifact Title</title>
|
||||||
|
<style>
|
||||||
|
body { font: 14px/1.5 -apple-system, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
|
||||||
|
h1, h2 { color: #1a1a1a; }
|
||||||
|
table { border-collapse: collapse; width: 100%; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Artifact Title</h1>
|
||||||
|
<!-- content here -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
The hosting service serves the HTML verbatim (including any \`<script>\` you include), so you can use vanilla JS/SVG/CSS as needed. Do not embed secrets.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Artifacts expire (default 7 days; pass \`ttl: 30\` for 30-day retention).
|
||||||
|
- Anyone with the URL can view the artifact — treat the URL as the secret.
|
||||||
|
- The \`/artifacts\` slash command (user-invoked) shows all artifacts uploaded in the current session.
|
||||||
|
`
|
||||||
|
|
||||||
|
export function registerUseArtifactsSkill(): void {
|
||||||
|
registerBundledSkill({
|
||||||
|
name: 'use-artifacts',
|
||||||
|
description:
|
||||||
|
'Teach the agent when and how to use the artifact tool: what content belongs in artifacts, when to upload/update, and the SearchExtraTools + ExecuteExtraTool invocation flow for the deferred artifact tool.',
|
||||||
|
whenToUse:
|
||||||
|
'Use this skill at the start of any complex task that would benefit from a living progress document or a deliverable HTML report.',
|
||||||
|
userInvocable: true,
|
||||||
|
argumentHint: '[optional focus note]',
|
||||||
|
async getPromptForCommand(args) {
|
||||||
|
let prompt = USE_ARTIFACTS_PROMPT
|
||||||
|
if (args && args.trim().length > 0) {
|
||||||
|
prompt += `\n\n## Additional Focus\n\n${args.trim()}\n`
|
||||||
|
}
|
||||||
|
return [{ type: 'text', text: prompt }]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ import { TaskOutputTool } from '@claude-code-best/builtin-tools/tools/TaskOutput
|
|||||||
import { WebSearchTool } from '@claude-code-best/builtin-tools/tools/WebSearchTool/WebSearchTool.js'
|
import { WebSearchTool } from '@claude-code-best/builtin-tools/tools/WebSearchTool/WebSearchTool.js'
|
||||||
import { TodoWriteTool } from '@claude-code-best/builtin-tools/tools/TodoWriteTool/TodoWriteTool.js'
|
import { TodoWriteTool } from '@claude-code-best/builtin-tools/tools/TodoWriteTool/TodoWriteTool.js'
|
||||||
import { ExitPlanModeV2Tool } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'
|
import { ExitPlanModeV2Tool } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'
|
||||||
|
import { ArtifactTool } from '@claude-code-best/builtin-tools/tools/ArtifactTool/ArtifactTool.js'
|
||||||
import { TestingPermissionTool } from '@claude-code-best/builtin-tools/tools/testing/TestingPermissionTool.js'
|
import { TestingPermissionTool } from '@claude-code-best/builtin-tools/tools/testing/TestingPermissionTool.js'
|
||||||
import { GrepTool } from '@claude-code-best/builtin-tools/tools/GrepTool/GrepTool.js'
|
import { GrepTool } from '@claude-code-best/builtin-tools/tools/GrepTool/GrepTool.js'
|
||||||
import { TungstenTool } from '@claude-code-best/builtin-tools/tools/TungstenTool/TungstenTool.js'
|
import { TungstenTool } from '@claude-code-best/builtin-tools/tools/TungstenTool/TungstenTool.js'
|
||||||
@@ -228,6 +229,7 @@ export function getAllBaseTools(): Tools {
|
|||||||
FileEditTool,
|
FileEditTool,
|
||||||
FileWriteTool,
|
FileWriteTool,
|
||||||
NotebookEditTool,
|
NotebookEditTool,
|
||||||
|
ArtifactTool,
|
||||||
WebFetchTool,
|
WebFetchTool,
|
||||||
TodoWriteTool,
|
TodoWriteTool,
|
||||||
WebSearchTool,
|
WebSearchTool,
|
||||||
|
|||||||
Reference in New Issue
Block a user