mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-21 06:04:29 +00:00
Compare commits
16 Commits
feat/fff-s
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e0d5d2308 | ||
|
|
38e0dc9ccd | ||
|
|
40aeaa120d | ||
|
|
6a64177589 | ||
|
|
5dc47905a9 | ||
|
|
dc0044882c | ||
|
|
45ae7dc653 | ||
|
|
129fe1e350 | ||
|
|
214a6c6cf1 | ||
|
|
3f249aba6d | ||
|
|
5c6ec1caac | ||
|
|
24f9df5463 | ||
|
|
12b8e1c2be | ||
|
|
d70099b059 | ||
|
|
ce845a0b1b | ||
|
|
05d3e65f76 |
4
.github/VOUCHED.td
vendored
4
.github/VOUCHED.td
vendored
@@ -10,6 +10,7 @@
|
||||
adamdotdevin
|
||||
-agusbasari29 AI PR slop
|
||||
ariane-emory
|
||||
-danieljoshuanazareth
|
||||
edemaine
|
||||
-florianleibert
|
||||
fwang
|
||||
@@ -17,8 +18,9 @@ iamdavidhill
|
||||
jayair
|
||||
kitlangton
|
||||
kommander
|
||||
-opencode2026
|
||||
r44vc0rp
|
||||
rekram1-node
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
thdxr
|
||||
-OpenCode2026
|
||||
-danieljoshuanazareth
|
||||
|
||||
70
bun.lock
70
bun.lock
@@ -325,9 +325,6 @@
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@ff-labs/fff-node": "0.4.2",
|
||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
@@ -346,6 +343,7 @@
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/scheduled": "1.5.2",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "2.3.1",
|
||||
"bonjour-service": "1.3.0",
|
||||
@@ -358,6 +356,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "5.2.2",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -368,6 +367,7 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
"open": "10.1.2",
|
||||
"opencode-gitlab-auth": "2.0.0",
|
||||
"opentui-spinner": "0.0.6",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
@@ -1098,24 +1098,6 @@
|
||||
|
||||
"@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="],
|
||||
|
||||
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R9ieZvBaAmYNbdGT3gs2HUQ0Sm4I5tBrJwOepdCoeIZvJFI71hCY2DCFzeoXH2wbxMsPF70c1FSr8qERhcrbVw=="],
|
||||
|
||||
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-iJNgUdkS1sVMhWe6l60ZmG9BcSB87CdO65K4AuMbwHQZTHxje9Sapf+AWPGYem6H0endS7HF7ejH+yoZmCF0uw=="],
|
||||
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-vldJC/j/Kf0LVS599CPTeIaBAd+8J6FFJ1euWn4OoSu63P3CD+9ITrmPWkIGUrt+0myOXABAx0KgLBGADtIAKg=="],
|
||||
|
||||
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-RgL1Oq6QMZm+M4R14SYLtiRMUObA8k+EHIftaplKpLu4Cr0q5lCclRszS0o0Le0hmFrrIvMn6pFRE7LoEzKqAQ=="],
|
||||
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ysImURWrxLT7WFTn46NrXOg4ygbuIp4NkKbWzOAzLYoMOU5JRllUxb3huw3sZNbXn+/9tpq3OE9VmWuAi0YZ/w=="],
|
||||
|
||||
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Us4ysq/oCrcf+h5lOPzDbxFJ8WI8bSVbSVudYHYFpe54417oWtbokdbzgb5Yx7108dW7jCDtGkxq+Cnau2002A=="],
|
||||
|
||||
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-rkF8lNEUhaZmGESJdQGSnIReg5hMDbb7giFxTlEzgeFRkWZpjKkDajGVuJ+Rd2bI5AoxUNuTDUfvta5EkQ2S5g=="],
|
||||
|
||||
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-wtSZiI2/7Z61GdVlGxPtXcuQV4EyoHgVBLhJ5wXcGwEQLp/r8GUWzSpN7iDQaOKKEvHbT2XiEbcbdw+jhDR7qQ=="],
|
||||
|
||||
"@ff-labs/fff-node": ["@ff-labs/fff-node@0.4.2", "", { "dependencies": { "ffi-rs": "^1.0.0" }, "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.4.2", "@ff-labs/fff-bin-darwin-x64": "0.4.2", "@ff-labs/fff-bin-linux-arm64-gnu": "0.4.2", "@ff-labs/fff-bin-linux-arm64-musl": "0.4.2", "@ff-labs/fff-bin-linux-x64-gnu": "0.4.2", "@ff-labs/fff-bin-linux-x64-musl": "0.4.2", "@ff-labs/fff-bin-win32-arm64": "0.4.2", "@ff-labs/fff-bin-win32-x64": "0.4.2" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-R1jjyvWmLC6qLOxFwdZhhA4UrOZY6r5nuqsuMpdsrDOhMMktJsbhMDZzRqXIy+GXTQBqAF1oBhW6FN6ahTCPBA=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||
@@ -1128,10 +1110,6 @@
|
||||
|
||||
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
|
||||
|
||||
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
|
||||
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
|
||||
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
||||
@@ -2252,27 +2230,7 @@
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
|
||||
|
||||
"@yuuang/ffi-rs-android-arm64": ["@yuuang/ffi-rs-android-arm64@1.3.1", "", { "os": "android", "cpu": "arm64" }, "sha512-V4nmlXdOYZEa7GOxSExVG95SLp8FE0iTq2yKeN54UlfNMr3Sik+1Ff57LcCv7qYcn4TBqnBAt5rT3FAM6T6caQ=="],
|
||||
|
||||
"@yuuang/ffi-rs-darwin-arm64": ["@yuuang/ffi-rs-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YlnTMIyzfW3mAULC5ZA774nzQfFlYXM0rrfq/8ZzWt+IMbYk55a++jrI+6JeKV+1EqlDS3TFBEFtjdBNG94KzQ=="],
|
||||
|
||||
"@yuuang/ffi-rs-darwin-x64": ["@yuuang/ffi-rs-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-sI3LpQQ34SX4nyOHc5yxA7FSqs9qPEUMqW/y/wWo9cuyPpaHMFsi/BeOVYsnC0syp3FrY7gzn6RnD6PlXCktXg=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-arm-gnueabihf": ["@yuuang/ffi-rs-linux-arm-gnueabihf@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-1WkcGkJTlwh4ZA59htKI+RXhiL3oKiYwLv7PO8LUf6FuADK73s5GcXp67iakKu243uYu+qGYr4RHco4ySddYhQ=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-arm64-gnu": ["@yuuang/ffi-rs-linux-arm64-gnu@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-J2PwqviycZxaEVA0Bwv38LqGDGSB9A1DPN4iYginYJZSvTvKW8kh7Tis0HbZrX1YDKnY8hi3lt0N0tCTNPDH5Q=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-arm64-musl": ["@yuuang/ffi-rs-linux-arm64-musl@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Hn1W1hBPssTaqikU1Bqp1XUdDdOgbnYVIOtR++LVx66hhrtjf/xrIUQOhTm+NmOFDG16JUKXe1skfM4gpaqYwg=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-x64-gnu": ["@yuuang/ffi-rs-linux-x64-gnu@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-kW6e+oCYZPvpH2ppPsffA18e1aLowtmWTRjVlyHtY04g/nQDepQvDUkkcvInh9fW5jLna7PjHvktW1tVgYIj2A=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-x64-musl": ["@yuuang/ffi-rs-linux-x64-musl@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HTwblAzruUS16nQPrez3ozvEHm1Xxh8J8w7rZYrpmAcNl1hzyOT8z/hY70M9Rt9fOqQ4Ovgor9qVy/U3ZJo0ZA=="],
|
||||
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc": ["@yuuang/ffi-rs-win32-arm64-msvc@1.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-WeZkGl2BP1U4tRhEQH+FXLQS52N8obp74smK5AAGOfzPAT1pHkq6+dVkC1QCSIt7dHJs7SPtlnQw+5DkdZYlWA=="],
|
||||
|
||||
"@yuuang/ffi-rs-win32-ia32-msvc": ["@yuuang/ffi-rs-win32-ia32-msvc@1.3.1", "", { "os": "win32", "cpu": [ "x64", "ia32", ] }, "sha512-rNGgMeCH5mdeHiMiJgt7wWXovZ+FHEfXhU9p4zZBH4n8M1/QnEsRUwlapISPLpILSGpoYS6iBuq9/fUlZY8Mhg=="],
|
||||
|
||||
"@yuuang/ffi-rs-win32-x64-msvc": ["@yuuang/ffi-rs-win32-x64-msvc@1.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-dr2LcLD2CXo2a7BktlOpV68QhayqiI112KxIJC9tBgQO/Dkdg4CPsdqmvzzLhFo64iC5RLl2BT7M5lJImrfUWw=="],
|
||||
"@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="],
|
||||
|
||||
"abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="],
|
||||
|
||||
@@ -2972,8 +2930,6 @@
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"ffi-rs": ["ffi-rs@1.3.1", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.1", "@yuuang/ffi-rs-darwin-arm64": "1.3.1", "@yuuang/ffi-rs-darwin-x64": "1.3.1", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.1", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.1", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.1", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.1", "@yuuang/ffi-rs-linux-x64-musl": "1.3.1", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.1", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.1", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.1" } }, "sha512-ZyNXL9fnclnZV+waQmWB9JrfbIEyxQa1OWtMrHOrAgcC04PgP5hBMG5TdhVN8N4uT/eul8zCFMVnJUukAFFlXA=="],
|
||||
|
||||
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
|
||||
|
||||
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
||||
@@ -3072,6 +3028,8 @@
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@5.2.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-ejwnie62rimfVHbjYZ2tsnqwLjF9YLgXD3OQA458gHz8hUvw7vEnhuyuMv5PmWQtyS3ISAghiX7r5SBhUWeCTA=="],
|
||||
|
||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||
|
||||
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
@@ -3824,6 +3782,8 @@
|
||||
|
||||
"opencode": ["opencode@workspace:packages/opencode"],
|
||||
|
||||
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="],
|
||||
|
||||
"opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="],
|
||||
|
||||
"openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
|
||||
@@ -4286,7 +4246,7 @@
|
||||
|
||||
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
|
||||
|
||||
"socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="],
|
||||
"socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="],
|
||||
|
||||
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
|
||||
|
||||
@@ -5100,10 +5060,6 @@
|
||||
|
||||
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider/openai": ["openai@6.27.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
|
||||
|
||||
"@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
@@ -5500,6 +5456,10 @@
|
||||
|
||||
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"gitlab-ai-provider/openai": ["openai@6.32.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-j3k+BjydAf8yQlcOI7WUQMQTbbF5GEIMAE2iZYCOzwwB3S2pCheaWYp+XZRNAch4jWVc52PMDGRRjutao3lLCg=="],
|
||||
|
||||
"gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
@@ -5576,6 +5536,8 @@
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
|
||||
|
||||
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
|
||||
@@ -6326,6 +6288,8 @@
|
||||
|
||||
"node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
|
||||
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-Gv0pHYCinlj0SQXRQ/a9ozYPxECwdrC99ssTzpeOr1I=",
|
||||
"aarch64-linux": "sha256-WzVt5goOrxoGe26juzRf73PWPqwnB1URu2TYjxye/Aw=",
|
||||
"aarch64-darwin": "sha256-18Nn0TR1wK2gRUF/FFP4vFMY/td49XkfjOwFbD5iJNc=",
|
||||
"x86_64-darwin": "sha256-zk2yaulPzUUiCerCPJaCOCLhklXKMp9mSv7v0N8AMfA="
|
||||
"x86_64-linux": "sha256-P0RJfQF8APTYVGP6hLJRrOkRSl5nVDNxdcGcZECPPJE=",
|
||||
"aarch64-linux": "sha256-ZtMjTcd35X3JhJIdn3DilFsp7i/IZIcNaKZFnSzW/nk=",
|
||||
"aarch64-darwin": "sha256-Uw/okFDRxxKQMfEsj8MXuHyhpugxZGgIKtu89Getlz8=",
|
||||
"x86_64-darwin": "sha256-ZySIgT1HbWZWnaQ0W0eURKC43BTupRmmply92JDFPWA="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,13 @@ import {
|
||||
FreeUsageLimitError,
|
||||
SubscriptionUsageLimitError,
|
||||
} from "./error"
|
||||
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
|
||||
import {
|
||||
buildCostChunk,
|
||||
createBodyConverter,
|
||||
createStreamPartConverter,
|
||||
createResponseConverter,
|
||||
UsageInfo,
|
||||
} from "./provider/provider"
|
||||
import { anthropicHelper } from "./provider/anthropic"
|
||||
import { googleHelper } from "./provider/google"
|
||||
import { openaiHelper } from "./provider/openai"
|
||||
@@ -90,7 +96,7 @@ export async function handler(
|
||||
const projectId = input.request.headers.get("x-opencode-project") ?? ""
|
||||
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
|
||||
logger.metric({
|
||||
is_tream: isStream,
|
||||
is_stream: isStream,
|
||||
session: sessionId,
|
||||
request: requestId,
|
||||
client: ocClient,
|
||||
@@ -230,7 +236,7 @@ export async function handler(
|
||||
const body = JSON.stringify(
|
||||
responseConverter({
|
||||
...json,
|
||||
cost: calculateOccuredCost(billingSource, costInfo),
|
||||
cost: calculateOccurredCost(billingSource, costInfo),
|
||||
}),
|
||||
)
|
||||
logger.metric({ response_length: body.length })
|
||||
@@ -274,8 +280,8 @@ export async function handler(
|
||||
await trialLimiter?.track(usageInfo)
|
||||
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
||||
await reload(billingSource, authInfo, costInfo)
|
||||
const cost = calculateOccuredCost(billingSource, costInfo)
|
||||
c.enqueue(encoder.encode(usageParser.buidlCostChunk(cost)))
|
||||
const cost = calculateOccurredCost(billingSource, costInfo)
|
||||
c.enqueue(encoder.encode(buildCostChunk(opts.format, cost)))
|
||||
}
|
||||
c.close()
|
||||
return
|
||||
@@ -818,7 +824,7 @@ export async function handler(
|
||||
}
|
||||
}
|
||||
|
||||
function calculateOccuredCost(billingSource: BillingSource, costInfo: CostInfo) {
|
||||
function calculateOccurredCost(billingSource: BillingSource, costInfo: CostInfo) {
|
||||
return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0"
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,6 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
||||
}
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => ({
|
||||
|
||||
@@ -56,7 +56,6 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
|
||||
usage = json.usageMetadata
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
|
||||
@@ -54,7 +54,6 @@ export const oaCompatHelper: ProviderHelper = () => ({
|
||||
usage = json.usage
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ choices: [], cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
|
||||
@@ -44,7 +44,6 @@ export const openaiHelper: ProviderHelper = () => ({
|
||||
usage = json.response.usage
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
|
||||
@@ -43,7 +43,6 @@ export type ProviderHelper = (input: { reqModel: string; providerModel: string }
|
||||
createUsageParser: () => {
|
||||
parse: (chunk: string) => void
|
||||
retrieve: () => any
|
||||
buidlCostChunk: (cost: string) => string
|
||||
}
|
||||
normalizeUsage: (usage: any) => UsageInfo
|
||||
}
|
||||
@@ -162,6 +161,19 @@ export interface CommonChunk {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCostChunk(format: ZenData.Format, cost: string): string {
|
||||
switch (format) {
|
||||
case "anthropic":
|
||||
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
||||
case "openai":
|
||||
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
||||
case "oa-compat":
|
||||
return `data: ${JSON.stringify({ choices: [], cost })}\n\n`
|
||||
default:
|
||||
return `data: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
||||
}
|
||||
}
|
||||
|
||||
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
|
||||
return (body: any): any => {
|
||||
if (from === to) return body
|
||||
|
||||
@@ -90,9 +90,6 @@
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@ff-labs/fff-node": "0.4.2",
|
||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
@@ -111,6 +108,7 @@
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/scheduled": "1.5.2",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "2.3.1",
|
||||
"bonjour-service": "1.3.0",
|
||||
@@ -123,6 +121,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "5.2.2",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -133,6 +132,7 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
"open": "10.1.2",
|
||||
"opencode-gitlab-auth": "2.0.0",
|
||||
"opentui-spinner": "0.0.6",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
|
||||
@@ -199,6 +199,19 @@ for (const item of targets) {
|
||||
},
|
||||
})
|
||||
|
||||
// Smoke test: only run if binary is for current platform
|
||||
if (item.os === process.platform && item.arch === process.arch && !item.abi) {
|
||||
const binaryPath = `dist/${name}/bin/opencode`
|
||||
console.log(`Running smoke test: ${binaryPath} --version`)
|
||||
try {
|
||||
const versionOutput = await $`${binaryPath} --version`.text()
|
||||
console.log(`Smoke test passed: ${versionOutput.trim()}`)
|
||||
} catch (e) {
|
||||
console.error(`Smoke test failed for ${name}:`, e)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
await $`rm -rf ./dist/${name}/bin/tui`
|
||||
await Bun.file(`dist/${name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
|
||||
@@ -11,7 +11,6 @@ const seed = async () => {
|
||||
const { Instance } = await import("../src/project/instance")
|
||||
const { InstanceBootstrap } = await import("../src/project/bootstrap")
|
||||
const { Config } = await import("../src/config/config")
|
||||
const { disposeRuntime } = await import("../src/effect/runtime")
|
||||
const { Session } = await import("../src/session")
|
||||
const { MessageID, PartID } = await import("../src/session/schema")
|
||||
const { Project } = await import("../src/project/project")
|
||||
@@ -55,7 +54,6 @@ const seed = async () => {
|
||||
})
|
||||
} finally {
|
||||
await Instance.disposeAll().catch(() => {})
|
||||
await disposeRuntime().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,18 +4,18 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
|
||||
|
||||
## Choose scope
|
||||
|
||||
Use the shared runtime for process-wide services with one lifecycle for the whole app.
|
||||
Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
|
||||
|
||||
Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup.
|
||||
Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`.
|
||||
|
||||
- Shared runtime: config readers, stateless helpers, global clients
|
||||
- Instance-scoped: watchers, per-project caches, session state, project-bound background work
|
||||
- Global services (no per-directory state): Account, Auth, Installation, Truncate
|
||||
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
|
||||
|
||||
Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`.
|
||||
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
|
||||
|
||||
## Service shape
|
||||
|
||||
For a fully migrated module, use the public namespace directly:
|
||||
Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
|
||||
|
||||
```ts
|
||||
export namespace Foo {
|
||||
@@ -28,53 +28,52 @@ export namespace Foo {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
return Service.of({
|
||||
get: Effect.fn("Foo.get")(function* (id) {
|
||||
return yield* ...
|
||||
}),
|
||||
// For instance-scoped services:
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
|
||||
)
|
||||
|
||||
const get = Effect.fn("Foo.get")(function* (id: FooID) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
// ...
|
||||
})
|
||||
|
||||
return Service.of({ get })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
|
||||
// Optional: wire dependencies
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
|
||||
|
||||
// Per-service runtime (inside the namespace)
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
// Async facade functions
|
||||
export async function get(id: FooID) {
|
||||
return runPromise((svc) => svc.get(id))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace
|
||||
- Export `defaultLayer` only when wiring dependencies is useful
|
||||
- Use the direct namespace form once the module is fully migrated
|
||||
- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
|
||||
- `runPromise` goes inside the namespace (not exported unless tests need it)
|
||||
- Facade functions are plain `async function` — no `fn()` wrappers
|
||||
- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
|
||||
- No `Layer.fresh` — InstanceState handles per-directory isolation
|
||||
|
||||
## Temporary mixed-mode pattern
|
||||
## Schema → Zod interop
|
||||
|
||||
Prefer a single namespace whenever possible.
|
||||
|
||||
Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles.
|
||||
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`:
|
||||
|
||||
```ts
|
||||
export namespace FooEffect {
|
||||
export interface Interface {
|
||||
readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
|
||||
}
|
||||
import { zod } from "@/util/effect-zod"
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
|
||||
|
||||
export const layer = Layer.effect(...)
|
||||
}
|
||||
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
|
||||
```
|
||||
|
||||
Then keep the old boundary thin:
|
||||
|
||||
```ts
|
||||
export namespace Foo {
|
||||
export function get(id: FooID) {
|
||||
return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Remove the `Effect` suffix when the boundary split is gone.
|
||||
See `Auth.ZodInfo` for the canonical example.
|
||||
|
||||
## Scheduled Tasks
|
||||
|
||||
@@ -107,22 +106,23 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
|
||||
|
||||
## Migration checklist
|
||||
|
||||
Done now:
|
||||
Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
||||
|
||||
- [x] `AccountEffect` (mixed-mode)
|
||||
- [x] `AuthEffect` (mixed-mode)
|
||||
- [x] `TruncateEffect` (mixed-mode)
|
||||
- [x] `Question`
|
||||
- [x] `PermissionNext`
|
||||
- [x] `ProviderAuth`
|
||||
- [x] `FileWatcher`
|
||||
- [x] `FileTime`
|
||||
- [x] `Format`
|
||||
- [x] `Vcs`
|
||||
- [x] `Skill`
|
||||
- [x] `Discovery`
|
||||
- [x] `File`
|
||||
- [x] `Snapshot`
|
||||
- [x] `Account` — `account/index.ts`
|
||||
- [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
|
||||
- [x] `File` — `file/index.ts`
|
||||
- [x] `FileTime` — `file/time.ts`
|
||||
- [x] `FileWatcher` — `file/watcher.ts`
|
||||
- [x] `Format` — `format/index.ts`
|
||||
- [x] `Installation` — `installation/index.ts`
|
||||
- [x] `Permission` — `permission/index.ts`
|
||||
- [x] `ProviderAuth` — `provider/auth.ts`
|
||||
- [x] `Question` — `question/index.ts`
|
||||
- [x] `Skill` — `skill/index.ts`
|
||||
- [x] `Snapshot` — `snapshot/index.ts`
|
||||
- [x] `Truncate` — `tool/truncate.ts`
|
||||
- [x] `Vcs` — `project/vcs.ts`
|
||||
- [x] `Discovery` — `skill/discovery.ts`
|
||||
|
||||
Still open and likely worth migrating:
|
||||
|
||||
@@ -130,7 +130,6 @@ Still open and likely worth migrating:
|
||||
- [ ] `ToolRegistry`
|
||||
- [ ] `Pty`
|
||||
- [ ] `Worktree`
|
||||
- [ ] `Installation`
|
||||
- [ ] `Bus`
|
||||
- [ ] `Command`
|
||||
- [ ] `Config`
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { AccountRepo, type AccountRow } from "./repo"
|
||||
import {
|
||||
type AccountError,
|
||||
AccessToken,
|
||||
AccountID,
|
||||
DeviceCode,
|
||||
Info,
|
||||
RefreshToken,
|
||||
AccountServiceError,
|
||||
Login,
|
||||
Org,
|
||||
OrgID,
|
||||
PollDenied,
|
||||
PollError,
|
||||
PollExpired,
|
||||
PollPending,
|
||||
type PollResult,
|
||||
PollSlow,
|
||||
PollSuccess,
|
||||
UserCode,
|
||||
} from "./schema"
|
||||
|
||||
export {
|
||||
AccountID,
|
||||
type AccountError,
|
||||
AccountRepoError,
|
||||
AccountServiceError,
|
||||
AccessToken,
|
||||
RefreshToken,
|
||||
DeviceCode,
|
||||
UserCode,
|
||||
Info,
|
||||
Org,
|
||||
OrgID,
|
||||
Login,
|
||||
PollSuccess,
|
||||
PollPending,
|
||||
PollSlow,
|
||||
PollExpired,
|
||||
PollDenied,
|
||||
PollError,
|
||||
PollResult,
|
||||
} from "./schema"
|
||||
|
||||
export type AccountOrgs = {
|
||||
account: Info
|
||||
orgs: readonly Org[]
|
||||
}
|
||||
|
||||
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||
config: Schema.Record(Schema.String, Schema.Json),
|
||||
}) {}
|
||||
|
||||
const DurationFromSeconds = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Duration, {
|
||||
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
||||
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
||||
}),
|
||||
)
|
||||
|
||||
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
device_code: DeviceCode,
|
||||
user_code: UserCode,
|
||||
verification_uri_complete: Schema.String,
|
||||
expires_in: DurationFromSeconds,
|
||||
interval: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
token_type: Schema.Literal("Bearer"),
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||
error: Schema.String,
|
||||
error_description: Schema.String,
|
||||
}) {
|
||||
toPollResult(): PollResult {
|
||||
if (this.error === "authorization_pending") return new PollPending()
|
||||
if (this.error === "slow_down") return new PollSlow()
|
||||
if (this.error === "expired_token") return new PollExpired()
|
||||
if (this.error === "access_denied") return new PollDenied()
|
||||
return new PollError({ cause: this.error })
|
||||
}
|
||||
}
|
||||
|
||||
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||
|
||||
class User extends Schema.Class<User>("User")({
|
||||
id: AccountID,
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||
|
||||
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||
grant_type: Schema.String,
|
||||
device_code: DeviceCode,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||
grant_type: Schema.String,
|
||||
refresh_token: RefreshToken,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
const clientId = "opencode-cli"
|
||||
|
||||
const mapAccountServiceError =
|
||||
(message = "Account service operation failed") =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
|
||||
effect.pipe(
|
||||
Effect.mapError((cause) =>
|
||||
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
|
||||
),
|
||||
)
|
||||
|
||||
export namespace Account {
|
||||
export interface Interface {
|
||||
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
|
||||
readonly list: () => Effect.Effect<Info[], AccountError>
|
||||
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
|
||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
|
||||
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
|
||||
readonly config: (
|
||||
accountID: AccountID,
|
||||
orgID: OrgID,
|
||||
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
|
||||
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
|
||||
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
||||
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const repo = yield* AccountRepo
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const httpRead = withTransientReadRetry(http)
|
||||
const httpOk = HttpClient.filterStatusOk(http)
|
||||
const httpReadOk = HttpClient.filterStatusOk(httpRead)
|
||||
|
||||
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||
|
||||
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||
|
||||
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => httpOk.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => http.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
|
||||
new TokenRefreshRequest({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: row.refresh_token,
|
||||
client_id: clientId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
|
||||
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
|
||||
|
||||
yield* repo.persistToken({
|
||||
accountID: row.id,
|
||||
accessToken: parsed.access_token,
|
||||
refreshToken: parsed.refresh_token,
|
||||
expiry,
|
||||
})
|
||||
|
||||
return parsed.access_token
|
||||
})
|
||||
|
||||
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
||||
const maybeAccount = yield* repo.getRow(accountID)
|
||||
if (Option.isNone(maybeAccount)) return Option.none()
|
||||
|
||||
const account = maybeAccount.value
|
||||
const accessToken = yield* resolveToken(account)
|
||||
return Option.some({ account, accessToken })
|
||||
})
|
||||
|
||||
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||
const response = yield* executeReadOk(
|
||||
HttpClientRequest.get(`${url}/api/orgs`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
})
|
||||
|
||||
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||
const response = yield* executeReadOk(
|
||||
HttpClientRequest.get(`${url}/api/user`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
})
|
||||
|
||||
const token = Effect.fn("Account.token")((accountID: AccountID) =>
|
||||
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
||||
)
|
||||
|
||||
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
|
||||
const accounts = yield* repo.list()
|
||||
const [errors, results] = yield* Effect.partition(
|
||||
accounts,
|
||||
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
for (const error of errors) {
|
||||
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
|
||||
Effect.annotateLogs({ error: String(error) }),
|
||||
)
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return []
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
return yield* fetchOrgs(account.url, accessToken)
|
||||
})
|
||||
|
||||
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return Option.none()
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
const response = yield* executeRead(
|
||||
HttpClientRequest.get(`${account.url}/api/config`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
|
||||
),
|
||||
)
|
||||
|
||||
if (response.status === 404) return Option.none()
|
||||
|
||||
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
return Option.some(parsed.config)
|
||||
})
|
||||
|
||||
const login = Effect.fn("Account.login")(function* (server: string) {
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
return new Login({
|
||||
code: parsed.device_code,
|
||||
user: parsed.user_code,
|
||||
url: `${server}${parsed.verification_uri_complete}`,
|
||||
server,
|
||||
expiry: parsed.expires_in,
|
||||
interval: parsed.interval,
|
||||
})
|
||||
})
|
||||
|
||||
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
||||
const response = yield* executeEffect(
|
||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
||||
new DeviceTokenRequest({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
device_code: input.code,
|
||||
client_id: clientId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
|
||||
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
|
||||
const accessToken = parsed.access_token
|
||||
|
||||
const user = fetchUser(input.server, accessToken)
|
||||
const orgs = fetchOrgs(input.server, accessToken)
|
||||
|
||||
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
|
||||
|
||||
// TODO: When there are multiple orgs, let the user choose
|
||||
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
|
||||
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const expiry = now + Duration.toMillis(parsed.expires_in)
|
||||
const refreshToken = parsed.refresh_token
|
||||
|
||||
yield* repo.persistAccount({
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
url: input.server,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiry,
|
||||
orgID: firstOrgID,
|
||||
})
|
||||
|
||||
return new PollSuccess({ email: account.email })
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
active: repo.active,
|
||||
list: repo.list,
|
||||
orgsByAccount,
|
||||
remove: repo.remove,
|
||||
use: repo.use,
|
||||
orgs,
|
||||
config,
|
||||
token,
|
||||
login,
|
||||
poll,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||
}
|
||||
@@ -1,34 +1,397 @@
|
||||
import { Effect, Option } from "effect"
|
||||
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { AccountRepo, type AccountRow } from "./repo"
|
||||
import {
|
||||
type AccountError,
|
||||
AccessToken,
|
||||
AccountID,
|
||||
DeviceCode,
|
||||
Info,
|
||||
RefreshToken,
|
||||
AccountServiceError,
|
||||
Login,
|
||||
Org,
|
||||
OrgID,
|
||||
PollDenied,
|
||||
PollError,
|
||||
PollExpired,
|
||||
PollPending,
|
||||
type PollResult,
|
||||
PollSlow,
|
||||
PollSuccess,
|
||||
UserCode,
|
||||
} from "./schema"
|
||||
|
||||
export { AccessToken, AccountID, OrgID } from "./effect"
|
||||
export {
|
||||
AccountID,
|
||||
type AccountError,
|
||||
AccountRepoError,
|
||||
AccountServiceError,
|
||||
AccessToken,
|
||||
RefreshToken,
|
||||
DeviceCode,
|
||||
UserCode,
|
||||
Info,
|
||||
Org,
|
||||
OrgID,
|
||||
Login,
|
||||
PollSuccess,
|
||||
PollPending,
|
||||
PollSlow,
|
||||
PollExpired,
|
||||
PollDenied,
|
||||
PollError,
|
||||
PollResult,
|
||||
} from "./schema"
|
||||
|
||||
import { runtime } from "@/effect/runtime"
|
||||
|
||||
function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runSync(S.Service.use(f))
|
||||
export type AccountOrgs = {
|
||||
account: Info
|
||||
orgs: readonly Org[]
|
||||
}
|
||||
|
||||
function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runPromise(S.Service.use(f))
|
||||
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||
config: Schema.Record(Schema.String, Schema.Json),
|
||||
}) {}
|
||||
|
||||
const DurationFromSeconds = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Duration, {
|
||||
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
||||
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
||||
}),
|
||||
)
|
||||
|
||||
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
device_code: DeviceCode,
|
||||
user_code: UserCode,
|
||||
verification_uri_complete: Schema.String,
|
||||
expires_in: DurationFromSeconds,
|
||||
interval: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
token_type: Schema.Literal("Bearer"),
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||
error: Schema.String,
|
||||
error_description: Schema.String,
|
||||
}) {
|
||||
toPollResult(): PollResult {
|
||||
if (this.error === "authorization_pending") return new PollPending()
|
||||
if (this.error === "slow_down") return new PollSlow()
|
||||
if (this.error === "expired_token") return new PollExpired()
|
||||
if (this.error === "access_denied") return new PollDenied()
|
||||
return new PollError({ cause: this.error })
|
||||
}
|
||||
}
|
||||
|
||||
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||
|
||||
class User extends Schema.Class<User>("User")({
|
||||
id: AccountID,
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||
|
||||
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||
grant_type: Schema.String,
|
||||
device_code: DeviceCode,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||
grant_type: Schema.String,
|
||||
refresh_token: RefreshToken,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
const clientId = "opencode-cli"
|
||||
|
||||
const mapAccountServiceError =
|
||||
(message = "Account service operation failed") =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
|
||||
effect.pipe(
|
||||
Effect.mapError((cause) =>
|
||||
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
|
||||
),
|
||||
)
|
||||
|
||||
export namespace Account {
|
||||
export const Info = Model
|
||||
export type Info = Model
|
||||
export interface Interface {
|
||||
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
|
||||
readonly list: () => Effect.Effect<Info[], AccountError>
|
||||
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
|
||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
|
||||
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
|
||||
readonly config: (
|
||||
accountID: AccountID,
|
||||
orgID: OrgID,
|
||||
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
|
||||
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
|
||||
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
||||
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||
}
|
||||
|
||||
export function active(): Info | undefined {
|
||||
return Option.getOrUndefined(runSync((service) => service.active()))
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const repo = yield* AccountRepo
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const httpRead = withTransientReadRetry(http)
|
||||
const httpOk = HttpClient.filterStatusOk(http)
|
||||
const httpReadOk = HttpClient.filterStatusOk(httpRead)
|
||||
|
||||
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||
|
||||
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||
|
||||
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => httpOk.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => http.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
|
||||
new TokenRefreshRequest({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: row.refresh_token,
|
||||
client_id: clientId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
|
||||
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
|
||||
|
||||
yield* repo.persistToken({
|
||||
accountID: row.id,
|
||||
accessToken: parsed.access_token,
|
||||
refreshToken: parsed.refresh_token,
|
||||
expiry,
|
||||
})
|
||||
|
||||
return parsed.access_token
|
||||
})
|
||||
|
||||
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
||||
const maybeAccount = yield* repo.getRow(accountID)
|
||||
if (Option.isNone(maybeAccount)) return Option.none()
|
||||
|
||||
const account = maybeAccount.value
|
||||
const accessToken = yield* resolveToken(account)
|
||||
return Option.some({ account, accessToken })
|
||||
})
|
||||
|
||||
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||
const response = yield* executeReadOk(
|
||||
HttpClientRequest.get(`${url}/api/orgs`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
})
|
||||
|
||||
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||
const response = yield* executeReadOk(
|
||||
HttpClientRequest.get(`${url}/api/user`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
})
|
||||
|
||||
const token = Effect.fn("Account.token")((accountID: AccountID) =>
|
||||
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
||||
)
|
||||
|
||||
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
|
||||
const accounts = yield* repo.list()
|
||||
const [errors, results] = yield* Effect.partition(
|
||||
accounts,
|
||||
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
for (const error of errors) {
|
||||
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
|
||||
Effect.annotateLogs({ error: String(error) }),
|
||||
)
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return []
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
return yield* fetchOrgs(account.url, accessToken)
|
||||
})
|
||||
|
||||
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return Option.none()
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
const response = yield* executeRead(
|
||||
HttpClientRequest.get(`${account.url}/api/config`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
|
||||
),
|
||||
)
|
||||
|
||||
if (response.status === 404) return Option.none()
|
||||
|
||||
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
return Option.some(parsed.config)
|
||||
})
|
||||
|
||||
const login = Effect.fn("Account.login")(function* (server: string) {
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
return new Login({
|
||||
code: parsed.device_code,
|
||||
user: parsed.user_code,
|
||||
url: `${server}${parsed.verification_uri_complete}`,
|
||||
server,
|
||||
expiry: parsed.expires_in,
|
||||
interval: parsed.interval,
|
||||
})
|
||||
})
|
||||
|
||||
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
||||
const response = yield* executeEffect(
|
||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
||||
new DeviceTokenRequest({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
device_code: input.code,
|
||||
client_id: clientId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
|
||||
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
|
||||
const accessToken = parsed.access_token
|
||||
|
||||
const user = fetchUser(input.server, accessToken)
|
||||
const orgs = fetchOrgs(input.server, accessToken)
|
||||
|
||||
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
|
||||
|
||||
// TODO: When there are multiple orgs, let the user choose
|
||||
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
|
||||
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const expiry = now + Duration.toMillis(parsed.expires_in)
|
||||
const refreshToken = parsed.refresh_token
|
||||
|
||||
yield* repo.persistAccount({
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
url: input.server,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiry,
|
||||
orgID: firstOrgID,
|
||||
})
|
||||
|
||||
return new PollSuccess({ email: account.email })
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
active: repo.active,
|
||||
list: repo.list,
|
||||
orgsByAccount,
|
||||
remove: repo.remove,
|
||||
use: repo.use,
|
||||
orgs,
|
||||
config,
|
||||
token,
|
||||
login,
|
||||
poll,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||
|
||||
export const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
export async function active(): Promise<Info | undefined> {
|
||||
return Option.getOrUndefined(await runPromise((service) => service.active()))
|
||||
}
|
||||
|
||||
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
|
||||
const config = await runPromise((service) => service.config(accountID, orgID))
|
||||
return Option.getOrUndefined(config)
|
||||
const cfg = await runPromise((service) => service.config(accountID, orgID))
|
||||
return Option.getOrUndefined(cfg)
|
||||
}
|
||||
|
||||
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
|
||||
const token = await runPromise((service) => service.token(accountID))
|
||||
return Option.getOrUndefined(token)
|
||||
const t = await runPromise((service) => service.token(accountID))
|
||||
return Option.getOrUndefined(t)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,8 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
|
||||
.onConflictDoUpdate({
|
||||
target: AccountTable.id,
|
||||
set: {
|
||||
email: input.email,
|
||||
url: input.url,
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: input.expiry,
|
||||
|
||||
@@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
||||
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
||||
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
||||
import PROMPT_TITLE from "./prompt/title.txt"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
import { mergeDeep, pipe, sortBy, values } from "remeda"
|
||||
import { Global } from "@/global"
|
||||
import path from "path"
|
||||
@@ -32,7 +32,7 @@ export namespace Agent {
|
||||
topP: z.number().optional(),
|
||||
temperature: z.number().optional(),
|
||||
color: z.string().optional(),
|
||||
permission: PermissionNext.Ruleset,
|
||||
permission: Permission.Ruleset,
|
||||
model: z
|
||||
.object({
|
||||
modelID: ModelID.zod,
|
||||
@@ -54,7 +54,7 @@ export namespace Agent {
|
||||
|
||||
const skillDirs = await Skill.dirs()
|
||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||
const defaults = PermissionNext.fromConfig({
|
||||
const defaults = Permission.fromConfig({
|
||||
"*": "allow",
|
||||
doom_loop: "ask",
|
||||
external_directory: {
|
||||
@@ -72,16 +72,16 @@ export namespace Agent {
|
||||
"*.env.example": "allow",
|
||||
},
|
||||
})
|
||||
const user = PermissionNext.fromConfig(cfg.permission ?? {})
|
||||
const user = Permission.fromConfig(cfg.permission ?? {})
|
||||
|
||||
const result: Record<string, Info> = {
|
||||
build: {
|
||||
name: "build",
|
||||
description: "The default agent. Executes tools based on configured permissions.",
|
||||
options: {},
|
||||
permission: PermissionNext.merge(
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
Permission.fromConfig({
|
||||
question: "allow",
|
||||
plan_enter: "allow",
|
||||
}),
|
||||
@@ -94,9 +94,9 @@ export namespace Agent {
|
||||
name: "plan",
|
||||
description: "Plan mode. Disallows all edit tools.",
|
||||
options: {},
|
||||
permission: PermissionNext.merge(
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
Permission.fromConfig({
|
||||
question: "allow",
|
||||
plan_exit: "allow",
|
||||
external_directory: {
|
||||
@@ -116,9 +116,9 @@ export namespace Agent {
|
||||
general: {
|
||||
name: "general",
|
||||
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
|
||||
permission: PermissionNext.merge(
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
Permission.fromConfig({
|
||||
todoread: "deny",
|
||||
todowrite: "deny",
|
||||
}),
|
||||
@@ -130,9 +130,9 @@ export namespace Agent {
|
||||
},
|
||||
explore: {
|
||||
name: "explore",
|
||||
permission: PermissionNext.merge(
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
grep: "allow",
|
||||
glob: "allow",
|
||||
@@ -161,9 +161,9 @@ export namespace Agent {
|
||||
native: true,
|
||||
hidden: true,
|
||||
prompt: PROMPT_COMPACTION,
|
||||
permission: PermissionNext.merge(
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
}),
|
||||
user,
|
||||
@@ -177,9 +177,9 @@ export namespace Agent {
|
||||
native: true,
|
||||
hidden: true,
|
||||
temperature: 0.5,
|
||||
permission: PermissionNext.merge(
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
}),
|
||||
user,
|
||||
@@ -192,9 +192,9 @@ export namespace Agent {
|
||||
options: {},
|
||||
native: true,
|
||||
hidden: true,
|
||||
permission: PermissionNext.merge(
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
}),
|
||||
user,
|
||||
@@ -213,7 +213,7 @@ export namespace Agent {
|
||||
item = result[key] = {
|
||||
name: key,
|
||||
mode: "all",
|
||||
permission: PermissionNext.merge(defaults, user),
|
||||
permission: Permission.merge(defaults, user),
|
||||
options: {},
|
||||
native: false,
|
||||
}
|
||||
@@ -229,7 +229,7 @@ export namespace Agent {
|
||||
item.name = value.name ?? item.name
|
||||
item.steps = value.steps ?? item.steps
|
||||
item.options = mergeDeep(item.options, value.options ?? {})
|
||||
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
|
||||
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
||||
}
|
||||
|
||||
// Ensure Truncate.GLOB is allowed unless explicitly configured
|
||||
@@ -242,9 +242,9 @@ export namespace Agent {
|
||||
})
|
||||
if (explicit) continue
|
||||
|
||||
result[name].permission = PermissionNext.merge(
|
||||
result[name].permission = Permission.merge(
|
||||
result[name].permission,
|
||||
PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
|
||||
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import path from "path"
|
||||
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||
|
||||
export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
||||
type: Schema.Literal("oauth"),
|
||||
refresh: Schema.String,
|
||||
access: Schema.String,
|
||||
expires: Schema.Number,
|
||||
accountId: Schema.optional(Schema.String),
|
||||
enterpriseUrl: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class Api extends Schema.Class<Api>("ApiAuth")({
|
||||
type: Schema.Literal("api"),
|
||||
key: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
||||
type: Schema.Literal("wellknown"),
|
||||
key: Schema.String,
|
||||
token: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const Info = Schema.Union([Oauth, Api, WellKnown])
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
const file = path.join(Global.Path.data, "auth.json")
|
||||
|
||||
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
|
||||
|
||||
export namespace Auth {
|
||||
export interface Interface {
|
||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
||||
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
|
||||
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const decode = Schema.decodeUnknownOption(Info)
|
||||
|
||||
const all = Effect.fn("Auth.all")(() =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
||||
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
||||
},
|
||||
catch: fail("Failed to read auth data"),
|
||||
}),
|
||||
)
|
||||
|
||||
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
||||
return (yield* all())[providerID]
|
||||
})
|
||||
|
||||
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
if (norm !== key) delete data[key]
|
||||
delete data[norm + "/"]
|
||||
yield* Effect.tryPromise({
|
||||
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
|
||||
catch: fail("Failed to write auth data"),
|
||||
})
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
delete data[key]
|
||||
delete data[norm]
|
||||
yield* Effect.tryPromise({
|
||||
try: () => Filesystem.writeJson(file, data, 0o600),
|
||||
catch: fail("Failed to write auth data"),
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ get, all, set, remove })
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,43 +1,101 @@
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import * as S from "./effect"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export { OAUTH_DUMMY_KEY } from "./effect"
|
||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||
|
||||
function runPromise<A>(f: (service: S.Auth.Interface) => Effect.Effect<A, S.AuthError>) {
|
||||
return runtime.runPromise(S.Auth.Service.use(f))
|
||||
}
|
||||
const file = path.join(Global.Path.data, "auth.json")
|
||||
|
||||
const fail = (message: string) => (cause: unknown) => new Auth.AuthError({ message, cause })
|
||||
|
||||
export namespace Auth {
|
||||
export const Oauth = z
|
||||
.object({
|
||||
type: z.literal("oauth"),
|
||||
refresh: z.string(),
|
||||
access: z.string(),
|
||||
expires: z.number(),
|
||||
accountId: z.string().optional(),
|
||||
enterpriseUrl: z.string().optional(),
|
||||
})
|
||||
.meta({ ref: "OAuth" })
|
||||
export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
||||
type: Schema.Literal("oauth"),
|
||||
refresh: Schema.String,
|
||||
access: Schema.String,
|
||||
expires: Schema.Number,
|
||||
accountId: Schema.optional(Schema.String),
|
||||
enterpriseUrl: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export const Api = z
|
||||
.object({
|
||||
type: z.literal("api"),
|
||||
key: z.string(),
|
||||
})
|
||||
.meta({ ref: "ApiAuth" })
|
||||
export class Api extends Schema.Class<Api>("ApiAuth")({
|
||||
type: Schema.Literal("api"),
|
||||
key: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const WellKnown = z
|
||||
.object({
|
||||
type: z.literal("wellknown"),
|
||||
key: z.string(),
|
||||
token: z.string(),
|
||||
})
|
||||
.meta({ ref: "WellKnownAuth" })
|
||||
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
||||
type: Schema.Literal("wellknown"),
|
||||
key: Schema.String,
|
||||
token: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
|
||||
export type Info = z.infer<typeof Info>
|
||||
const _Info = Schema.Union([Oauth, Api, WellKnown])
|
||||
export const Info = Object.assign(_Info, { zod: zod(_Info) })
|
||||
export type Info = Schema.Schema.Type<typeof _Info>
|
||||
|
||||
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
||||
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
|
||||
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const decode = Schema.decodeUnknownOption(Info)
|
||||
|
||||
const all = Effect.fn("Auth.all")(() =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
||||
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
||||
},
|
||||
catch: fail("Failed to read auth data"),
|
||||
}),
|
||||
)
|
||||
|
||||
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
||||
return (yield* all())[providerID]
|
||||
})
|
||||
|
||||
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
if (norm !== key) delete data[key]
|
||||
delete data[norm + "/"]
|
||||
yield* Effect.tryPromise({
|
||||
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
|
||||
catch: fail("Failed to write auth data"),
|
||||
})
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
delete data[key]
|
||||
delete data[norm]
|
||||
yield* Effect.tryPromise({
|
||||
try: () => Filesystem.writeJson(file, data, 0o600),
|
||||
catch: fail("Failed to write auth data"),
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ get, all, set, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
|
||||
export async function get(providerID: string) {
|
||||
return runPromise((service) => service.get(providerID))
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { Duration, Effect, Match, Option } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect"
|
||||
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
|
||||
import { type AccountError } from "@/account/schema"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import open from "open"
|
||||
@@ -160,7 +159,7 @@ export const LoginCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
await runtime.runPromise(loginEffect(args.url))
|
||||
await Account.runPromise((_svc) => loginEffect(args.url))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -174,7 +173,7 @@ export const LogoutCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
await runtime.runPromise(logoutEffect(args.email))
|
||||
await Account.runPromise((_svc) => logoutEffect(args.email))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -183,7 +182,7 @@ export const SwitchCommand = cmd({
|
||||
describe: false,
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await runtime.runPromise(switchEffect())
|
||||
await Account.runPromise((_svc) => switchEffect())
|
||||
},
|
||||
})
|
||||
|
||||
@@ -192,7 +191,7 @@ export const OrgsCommand = cmd({
|
||||
describe: false,
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await runtime.runPromise(orgsEffect())
|
||||
await Account.runPromise((_svc) => orgsEffect())
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
|
||||
import { MessageID, PartID } from "../../../session/schema"
|
||||
import { ToolRegistry } from "../../../tool/registry"
|
||||
import { Instance } from "../../../project/instance"
|
||||
import { PermissionNext } from "../../../permission"
|
||||
import { Permission } from "../../../permission"
|
||||
import { iife } from "../../../util/iife"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
@@ -75,7 +75,7 @@ async function getAvailableTools(agent: Agent.Info) {
|
||||
}
|
||||
|
||||
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
||||
const disabled = PermissionNext.disabled(
|
||||
const disabled = Permission.disabled(
|
||||
availableTools.map((tool) => tool.id),
|
||||
agent.permission,
|
||||
)
|
||||
@@ -145,7 +145,7 @@ async function createToolContext(agent: Agent.Info) {
|
||||
}
|
||||
await Session.updateMessage(message)
|
||||
|
||||
const ruleset = PermissionNext.merge(agent.permission, session.permission ?? [])
|
||||
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
|
||||
|
||||
return {
|
||||
sessionID: session.id,
|
||||
@@ -155,11 +155,11 @@ async function createToolContext(agent: Agent.Info) {
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
|
||||
async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
|
||||
for (const pattern of req.patterns) {
|
||||
const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
|
||||
const rule = Permission.evaluate(req.permission, pattern, ruleset)
|
||||
if (rule.action === "deny") {
|
||||
throw new PermissionNext.DeniedError({ ruleset })
|
||||
throw new Permission.DeniedError({ ruleset })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EOL } from "os"
|
||||
import { File } from "../../../file"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { Fff } from "@/file/fff"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
|
||||
const FileSearchCommand = cmd({
|
||||
command: "search <query>",
|
||||
@@ -77,7 +77,7 @@ const FileTreeCommand = cmd({
|
||||
default: process.cwd(),
|
||||
}),
|
||||
async handler(args) {
|
||||
const files = await Fff.tree({ cwd: args.dir, limit: 200 })
|
||||
const files = await Ripgrep.tree({ cwd: args.dir, limit: 200 })
|
||||
console.log(JSON.stringify(files, null, 2))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cmd } from "../cmd"
|
||||
import { ConfigCommand } from "./config"
|
||||
import { FileCommand } from "./file"
|
||||
import { LSPCommand } from "./lsp"
|
||||
import { SearchCommand } from "./search"
|
||||
import { RipgrepCommand } from "./ripgrep"
|
||||
import { ScrapCommand } from "./scrap"
|
||||
import { SkillCommand } from "./skill"
|
||||
import { SnapshotCommand } from "./snapshot"
|
||||
@@ -17,7 +17,7 @@ export const DebugCommand = cmd({
|
||||
yargs
|
||||
.command(ConfigCommand)
|
||||
.command(LSPCommand)
|
||||
.command(SearchCommand)
|
||||
.command(RipgrepCommand)
|
||||
.command(FileCommand)
|
||||
.command(ScrapCommand)
|
||||
.command(SkillCommand)
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
import { EOL } from "os"
|
||||
import { Fff } from "../../../file/fff"
|
||||
import { Ripgrep } from "../../../file/ripgrep"
|
||||
import { Instance } from "../../../project/instance"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { Glob } from "@/util/glob"
|
||||
|
||||
export const SearchCommand = cmd({
|
||||
command: "search",
|
||||
describe: "fff search debugging utilities",
|
||||
builder: (yargs) => yargs.command(TreeCommand).command(FilesCommand).command(ContentCommand).demandCommand(),
|
||||
export const RipgrepCommand = cmd({
|
||||
command: "rg",
|
||||
describe: "ripgrep debugging utilities",
|
||||
builder: (yargs) => yargs.command(TreeCommand).command(FilesCommand).command(SearchCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const TreeCommand = cmd({
|
||||
command: "tree",
|
||||
describe: "show file tree using fff",
|
||||
describe: "show file tree using ripgrep",
|
||||
builder: (yargs) =>
|
||||
yargs.option("limit", {
|
||||
type: "number",
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
process.stdout.write((await Fff.tree({ cwd: Instance.directory, limit: args.limit })) + EOL)
|
||||
process.stdout.write((await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit })) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const FilesCommand = cmd({
|
||||
command: "files",
|
||||
describe: "list files using fff",
|
||||
describe: "list files using ripgrep",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option("query", {
|
||||
@@ -45,24 +44,22 @@ const FilesCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const limit = args.limit ?? 100
|
||||
const files = (await Glob.scan("**/*", {
|
||||
const files: string[] = []
|
||||
for await (const file of Ripgrep.files({
|
||||
cwd: Instance.directory,
|
||||
include: "file",
|
||||
dot: true,
|
||||
}))
|
||||
.map((x) => x.replaceAll("\\", "/"))
|
||||
.filter((x) => Fff.allowed({ rel: x, hidden: true, glob: args.glob ? [args.glob] : undefined }))
|
||||
.filter((x) => !args.query || x.includes(args.query))
|
||||
.slice(0, limit)
|
||||
glob: args.glob ? [args.glob] : undefined,
|
||||
})) {
|
||||
files.push(file)
|
||||
if (args.limit && files.length >= args.limit) break
|
||||
}
|
||||
process.stdout.write(files.join(EOL) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const ContentCommand = cmd({
|
||||
command: "content <pattern>",
|
||||
describe: "search file contents using fff",
|
||||
const SearchCommand = cmd({
|
||||
command: "search <pattern>",
|
||||
describe: "search file contents using ripgrep",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("pattern", {
|
||||
@@ -79,12 +76,12 @@ const ContentCommand = cmd({
|
||||
description: "Limit number of results",
|
||||
}),
|
||||
async handler(args) {
|
||||
const rows = await Fff.search({
|
||||
const results = await Ripgrep.search({
|
||||
cwd: process.cwd(),
|
||||
pattern: args.pattern,
|
||||
glob: args.glob as string[] | undefined,
|
||||
limit: args.limit,
|
||||
})
|
||||
process.stdout.write(JSON.stringify(rows, null, 2) + EOL)
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
},
|
||||
})
|
||||
@@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
|
||||
import { Server } from "../../server/server"
|
||||
import { Provider } from "../../provider/provider"
|
||||
import { Agent } from "../../agent/agent"
|
||||
import { PermissionNext } from "../../permission"
|
||||
import { Permission } from "../../permission"
|
||||
import { Tool } from "../../tool/tool"
|
||||
import { GlobTool } from "../../tool/glob"
|
||||
import { GrepTool } from "../../tool/grep"
|
||||
@@ -354,7 +354,7 @@ export const RunCommand = cmd({
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const rules: PermissionNext.Ruleset = [
|
||||
const rules: Permission.Ruleset = [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
|
||||
@@ -1667,6 +1667,7 @@ function InlineTool(props: {
|
||||
|
||||
const denied = createMemo(
|
||||
() =>
|
||||
error()?.includes("QuestionRejectedError") ||
|
||||
error()?.includes("rejected permission") ||
|
||||
error()?.includes("specified a rule") ||
|
||||
error()?.includes("user dismissed"),
|
||||
|
||||
@@ -177,7 +177,7 @@ export namespace Config {
|
||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||
}
|
||||
|
||||
const active = Account.active()
|
||||
const active = await Account.active()
|
||||
if (active?.active_org_id) {
|
||||
try {
|
||||
const [config, token] = await Promise.all([
|
||||
|
||||
47
packages/opencode/src/effect/instance-state.ts
Normal file
47
packages/opencode/src/effect/instance-state.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Effect, ScopedCache, Scope } from "effect"
|
||||
import { Instance, type Shape } from "@/project/instance"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
|
||||
const TypeId = "~opencode/InstanceState"
|
||||
|
||||
export interface InstanceState<A, E = never, R = never> {
|
||||
readonly [TypeId]: typeof TypeId
|
||||
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
|
||||
}
|
||||
|
||||
export namespace InstanceState {
|
||||
export const make = <A, E = never, R = never>(
|
||||
init: (ctx: Shape) => Effect.Effect<A, E, R | Scope.Scope>,
|
||||
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ScopedCache.make<string, A, E, R>({
|
||||
capacity: Number.POSITIVE_INFINITY,
|
||||
lookup: () => init(Instance.current),
|
||||
})
|
||||
|
||||
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(off))
|
||||
|
||||
return {
|
||||
[TypeId]: TypeId,
|
||||
cache,
|
||||
}
|
||||
})
|
||||
|
||||
export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||
Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory))
|
||||
|
||||
export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) =>
|
||||
Effect.map(get(self), select)
|
||||
|
||||
export const useEffect = <A, E, R, B, E2, R2>(
|
||||
self: InstanceState<A, E, R>,
|
||||
select: (value: A) => Effect.Effect<B, E2, R2>,
|
||||
) => Effect.flatMap(get(self), select)
|
||||
|
||||
export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||
Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory))
|
||||
|
||||
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||
Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory))
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
||||
import { File } from "@/file"
|
||||
import { FileTime } from "@/file/time"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Format } from "@/format"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
import { Question } from "@/question"
|
||||
import { Skill } from "@/skill/skill"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { InstanceContext } from "./instance-context"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
|
||||
export { InstanceContext } from "./instance-context"
|
||||
|
||||
export type InstanceServices =
|
||||
| Question.Service
|
||||
| PermissionNext.Service
|
||||
| ProviderAuth.Service
|
||||
| FileWatcher.Service
|
||||
| Vcs.Service
|
||||
| FileTime.Service
|
||||
| Format.Service
|
||||
| File.Service
|
||||
| Skill.Service
|
||||
| Snapshot.Service
|
||||
|
||||
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
|
||||
// the full instance context (directory, worktree, project). We read from the
|
||||
// legacy Instance ALS here, which is safe because lookup is only triggered via
|
||||
// runPromiseInstance -> Instances.get, which always runs inside Instance.provide.
|
||||
// This should go away once the old Instance type is removed and lookup can load
|
||||
// the full context directly.
|
||||
function lookup(_key: string) {
|
||||
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
|
||||
return Layer.mergeAll(
|
||||
Layer.fresh(Question.layer),
|
||||
Layer.fresh(PermissionNext.layer),
|
||||
Layer.fresh(ProviderAuth.defaultLayer),
|
||||
Layer.fresh(FileWatcher.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(Vcs.layer),
|
||||
Layer.fresh(FileTime.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(Format.layer),
|
||||
Layer.fresh(File.layer),
|
||||
Layer.fresh(Skill.defaultLayer),
|
||||
Layer.fresh(Snapshot.defaultLayer),
|
||||
).pipe(Layer.provide(ctx))
|
||||
}
|
||||
|
||||
export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<string, InstanceServices>>()(
|
||||
"opencode/Instances",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
Instances,
|
||||
Effect.gen(function* () {
|
||||
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
|
||||
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unregister))
|
||||
return Instances.of(layerMap)
|
||||
}),
|
||||
)
|
||||
|
||||
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
|
||||
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
|
||||
}
|
||||
}
|
||||
13
packages/opencode/src/effect/run-service.ts
Normal file
13
packages/opencode/src/effect/run-service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import * as ServiceMap from "effect/ServiceMap"
|
||||
|
||||
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||
|
||||
export function makeRunPromise<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
||||
|
||||
return <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => {
|
||||
rt ??= ManagedRuntime.make(layer, { memoMap })
|
||||
return rt.runPromise(service.use(fn), options)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { Account } from "@/account/effect"
|
||||
import { Auth } from "@/auth/effect"
|
||||
import { Instances } from "@/effect/instances"
|
||||
import type { InstanceServices } from "@/effect/instances"
|
||||
import { Installation } from "@/installation"
|
||||
import { Truncate } from "@/tool/truncate-effect"
|
||||
import { Instance } from "@/project/instance"
|
||||
|
||||
export const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(
|
||||
Account.defaultLayer, //
|
||||
Installation.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
Instances.layer,
|
||||
).pipe(Layer.provideMerge(Auth.layer)),
|
||||
)
|
||||
|
||||
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
|
||||
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
||||
}
|
||||
|
||||
export function disposeRuntime() {
|
||||
return runtime.dispose()
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import {
|
||||
FileFinder,
|
||||
type FileItem,
|
||||
type GrepCursor,
|
||||
type GrepMatch,
|
||||
type GrepMode,
|
||||
type SearchResult,
|
||||
} from "@ff-labs/fff-node"
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Glob } from "../util/glob"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace Fff {
|
||||
const log = Log.create({ service: "file.fff" })
|
||||
|
||||
export const Match = z.object({
|
||||
path: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
lines: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
line_number: z.number(),
|
||||
absolute_offset: z.number(),
|
||||
submatches: z.array(
|
||||
z.object({
|
||||
match: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const state = Instance.state(
|
||||
async () => ({
|
||||
map: new Map<string, FileFinder>(),
|
||||
pending: new Map<string, Promise<FileFinder>>(),
|
||||
}),
|
||||
async (state) => {
|
||||
for (const pick of state.map.values()) pick.destroy()
|
||||
},
|
||||
)
|
||||
|
||||
const root = path.join(Global.Path.cache, "fff")
|
||||
|
||||
function key(dir: string) {
|
||||
return Buffer.from(dir).toString("base64url")
|
||||
}
|
||||
|
||||
async function db(dir: string) {
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
const id = key(dir)
|
||||
return {
|
||||
frecency: path.join(root, `${id}.frecency.mdb`),
|
||||
history: path.join(root, `${id}.history.mdb`),
|
||||
}
|
||||
}
|
||||
|
||||
function refresh(pick: FileFinder) {
|
||||
const git = pick.refreshGitStatus()
|
||||
if (!git.ok) {
|
||||
log.warn("git refresh failed", { error: git.error })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export async function picker(cwd: string) {
|
||||
const dir = Filesystem.resolve(cwd)
|
||||
const memo = await state()
|
||||
const cached = memo.map.get(dir)
|
||||
if (cached) return cached
|
||||
|
||||
const wait = memo.pending.get(dir)
|
||||
if (wait) return wait
|
||||
|
||||
const next = (async () => {
|
||||
const files = await db(dir)
|
||||
const made = FileFinder.create({
|
||||
basePath: dir,
|
||||
frecencyDbPath: files.frecency,
|
||||
historyDbPath: files.history,
|
||||
aiMode: true,
|
||||
})
|
||||
if (!made.ok) throw new Error(made.error)
|
||||
|
||||
const pick = made.value
|
||||
const done = await pick.waitForScan(5000)
|
||||
if (!done.ok) {
|
||||
pick.destroy()
|
||||
throw new Error(done.error)
|
||||
}
|
||||
|
||||
memo.map.set(dir, pick)
|
||||
refresh(pick)
|
||||
return pick
|
||||
})()
|
||||
|
||||
memo.pending.set(dir, next)
|
||||
try {
|
||||
return await next
|
||||
} finally {
|
||||
if (memo.pending.get(dir) === next) memo.pending.delete(dir)
|
||||
}
|
||||
}
|
||||
|
||||
export async function files(input: { cwd: string; query: string; page?: number; size?: number; current?: string }) {
|
||||
const pick = await picker(input.cwd)
|
||||
const out = pick.fileSearch(input.query, {
|
||||
pageIndex: input.page ?? 0,
|
||||
pageSize: input.size ?? 100,
|
||||
currentFile: input.current,
|
||||
})
|
||||
if (!out.ok) throw new Error(out.error)
|
||||
return out.value
|
||||
}
|
||||
|
||||
export async function grep(input: {
|
||||
cwd: string
|
||||
query: string
|
||||
mode?: GrepMode
|
||||
max?: number
|
||||
before?: number
|
||||
after?: number
|
||||
budget?: number
|
||||
cursor?: GrepCursor | null
|
||||
}) {
|
||||
const pick = await picker(input.cwd)
|
||||
const out = pick.grep(input.query, {
|
||||
mode: input.mode,
|
||||
maxMatchesPerFile: input.max,
|
||||
beforeContext: input.before,
|
||||
afterContext: input.after,
|
||||
timeBudgetMs: input.budget,
|
||||
cursor: input.cursor,
|
||||
})
|
||||
if (!out.ok) throw new Error(out.error)
|
||||
return out.value
|
||||
}
|
||||
|
||||
function norm(text: string) {
|
||||
return text.replaceAll("\\", "/")
|
||||
}
|
||||
|
||||
function hidden(rel: string) {
|
||||
return norm(rel)
|
||||
.split("/")
|
||||
.some((part) => part.startsWith("."))
|
||||
}
|
||||
|
||||
function accept(rel: string, file: string, glob?: string[], show?: boolean) {
|
||||
if (show === false && hidden(rel)) return false
|
||||
if (!glob?.length) return true
|
||||
const allow = glob.filter((x) => !x.startsWith("!"))
|
||||
const deny = glob.filter((x) => x.startsWith("!")).map((x) => x.slice(1))
|
||||
if (allow.length > 0 && !allow.some((x) => Glob.match(x, rel) || Glob.match(x, file))) return false
|
||||
if (deny.some((x) => Glob.match(x, rel) || Glob.match(x, file))) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function allowed(input: { rel: string; file?: string; glob?: string[]; hidden?: boolean }) {
|
||||
return accept(input.rel, input.file ?? input.rel.split("/").at(-1) ?? input.rel, input.glob, input.hidden !== false)
|
||||
}
|
||||
|
||||
export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
|
||||
input.signal?.throwIfAborted()
|
||||
const files = (await Glob.scan("**/*", {
|
||||
cwd: input.cwd,
|
||||
include: "file",
|
||||
dot: true,
|
||||
}))
|
||||
.map((row) => norm(row))
|
||||
.filter((row) => allowed({ rel: row, hidden: true }))
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
input.signal?.throwIfAborted()
|
||||
interface Node {
|
||||
name: string
|
||||
children: Map<string, Node>
|
||||
}
|
||||
|
||||
function dir(node: Node, name: string) {
|
||||
const old = node.children.get(name)
|
||||
if (old) return old
|
||||
const next = { name, children: new Map<string, Node>() }
|
||||
node.children.set(name, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const root = { name: "", children: new Map<string, Node>() }
|
||||
for (const file of files) {
|
||||
if (file.includes(".opencode")) continue
|
||||
const parts = file.split("/")
|
||||
if (parts.length < 2) continue
|
||||
let node = root
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
node = dir(node, part)
|
||||
}
|
||||
}
|
||||
|
||||
function count(node: Node): number {
|
||||
return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0)
|
||||
}
|
||||
|
||||
const total = count(root)
|
||||
const limit = input.limit ?? total
|
||||
const lines: string[] = []
|
||||
const queue = Array.from(root.children.values())
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.map((node) => ({ node, path: node.name }))
|
||||
|
||||
let used = 0
|
||||
for (let i = 0; i < queue.length && used < limit; i++) {
|
||||
input.signal?.throwIfAborted()
|
||||
const row = queue[i]
|
||||
lines.push(row.path)
|
||||
used++
|
||||
queue.push(
|
||||
...Array.from(row.node.children.values())
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.map((node) => ({ node, path: `${row.path}/${node.name}` })),
|
||||
)
|
||||
}
|
||||
if (total > used) lines.push(`[${total - used} truncated]`)
|
||||
input.signal?.throwIfAborted()
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export async function search(input: {
|
||||
cwd: string
|
||||
pattern: string
|
||||
glob?: string[]
|
||||
limit?: number
|
||||
follow?: boolean
|
||||
}) {
|
||||
const out = await grep({
|
||||
cwd: input.cwd,
|
||||
query: input.pattern,
|
||||
mode: "regex",
|
||||
max: input.limit,
|
||||
})
|
||||
const rows = out.items
|
||||
.filter((row) => accept(norm(row.relativePath), row.fileName, input.glob, true))
|
||||
.slice(0, input.limit)
|
||||
.map((row) => ({
|
||||
path: { text: row.relativePath },
|
||||
lines: { text: row.lineContent },
|
||||
line_number: row.lineNumber,
|
||||
absolute_offset: row.byteOffset,
|
||||
submatches: row.matchRanges
|
||||
.map(([start, end]) => {
|
||||
const text = row.lineContent.slice(start, end)
|
||||
if (!text) return undefined
|
||||
return {
|
||||
match: { text },
|
||||
start,
|
||||
end,
|
||||
}
|
||||
})
|
||||
.filter((row) => row !== undefined),
|
||||
}))
|
||||
return Match.array().parse(rows)
|
||||
}
|
||||
|
||||
export type Search = SearchResult
|
||||
export type File = FileItem
|
||||
export type Hit = GrepMatch
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { git } from "@/util/git"
|
||||
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
@@ -12,9 +12,9 @@ import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Glob } from "../util/glob"
|
||||
import { Log } from "../util/log"
|
||||
import { Protected } from "./protected"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
|
||||
export namespace File {
|
||||
export const Info = z
|
||||
@@ -83,26 +83,6 @@ export namespace File {
|
||||
),
|
||||
}
|
||||
|
||||
export function init() {
|
||||
return runPromiseInstance(Service.use((svc) => svc.init()))
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
return runPromiseInstance(Service.use((svc) => svc.status()))
|
||||
}
|
||||
|
||||
export async function read(file: string): Promise<Content> {
|
||||
return runPromiseInstance(Service.use((svc) => svc.read(file)))
|
||||
}
|
||||
|
||||
export async function list(dir?: string) {
|
||||
return runPromiseInstance(Service.use((svc) => svc.list(dir)))
|
||||
}
|
||||
|
||||
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
|
||||
return runPromiseInstance(Service.use((svc) => svc.search(input)))
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "file" })
|
||||
|
||||
const binary = new Set([
|
||||
@@ -199,12 +179,6 @@ export namespace File {
|
||||
"efi",
|
||||
"rom",
|
||||
"com",
|
||||
"cmd",
|
||||
"ps1",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
])
|
||||
|
||||
const image = new Set([
|
||||
@@ -323,7 +297,7 @@ export namespace File {
|
||||
|
||||
function shouldEncode(mimeType: string) {
|
||||
const type = mimeType.toLowerCase()
|
||||
log.info("shouldEncode", { type })
|
||||
log.debug("shouldEncode", { type })
|
||||
if (!type) return false
|
||||
if (type.startsWith("text/")) return false
|
||||
if (type.includes("charset=")) return false
|
||||
@@ -347,6 +321,11 @@ export namespace File {
|
||||
return [...visible, ...hiddenItems]
|
||||
}
|
||||
|
||||
interface State {
|
||||
cache: Entry
|
||||
fiber: Fiber.Fiber<void> | undefined
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<File.Info[]>
|
||||
@@ -365,12 +344,18 @@ export namespace File {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
let cache: Entry = { files: [], dirs: [] }
|
||||
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("File.state")(() =>
|
||||
Effect.succeed({
|
||||
cache: { files: [], dirs: [] } as Entry,
|
||||
fiber: undefined as Fiber.Fiber<void> | undefined,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const scan = Effect.fn("File.scan")(function* () {
|
||||
if (instance.directory === path.parse(instance.directory).root) return
|
||||
if (Instance.directory === path.parse(Instance.directory).root) return
|
||||
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
|
||||
const next: Entry = { files: [], dirs: [] }
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
@@ -381,7 +366,7 @@ export namespace File {
|
||||
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
||||
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
||||
const top = await fs.promises
|
||||
.readdir(instance.directory, { withFileTypes: true })
|
||||
.readdir(Instance.directory, { withFileTypes: true })
|
||||
.catch(() => [] as fs.Dirent[])
|
||||
|
||||
for (const entry of top) {
|
||||
@@ -389,7 +374,7 @@ export namespace File {
|
||||
if (shouldIgnoreName(entry.name)) continue
|
||||
dirs.add(entry.name + "/")
|
||||
|
||||
const base = path.join(instance.directory, entry.name)
|
||||
const base = path.join(Instance.directory, entry.name)
|
||||
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
|
||||
for (const child of children) {
|
||||
if (!child.isDirectory()) continue
|
||||
@@ -401,11 +386,7 @@ export namespace File {
|
||||
next.dirs = Array.from(dirs).toSorted()
|
||||
} else {
|
||||
const seen = new Set<string>()
|
||||
for (const file of (await Glob.scan("**/*", {
|
||||
cwd: instance.directory,
|
||||
include: "file",
|
||||
dot: true,
|
||||
})).toSorted((a, b) => a.localeCompare(b))) {
|
||||
for await (const file of Ripgrep.files({ cwd: Instance.directory })) {
|
||||
next.files.push(file)
|
||||
let current = file
|
||||
while (true) {
|
||||
@@ -421,31 +402,38 @@ export namespace File {
|
||||
}
|
||||
})
|
||||
|
||||
cache = next
|
||||
const s = yield* InstanceState.get(state)
|
||||
s.cache = next
|
||||
})
|
||||
|
||||
const getFiles = () => cache
|
||||
|
||||
const scope = yield* Scope.Scope
|
||||
let fiber: Fiber.Fiber<void> | undefined
|
||||
|
||||
const init = Effect.fn("File.init")(function* () {
|
||||
if (!fiber) {
|
||||
fiber = yield* scan().pipe(
|
||||
const ensure = Effect.fn("File.ensure")(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
if (!s.fiber)
|
||||
s.fiber = yield* scan().pipe(
|
||||
Effect.catchCause(() => Effect.void),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
s.fiber = undefined
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
}
|
||||
yield* Fiber.join(fiber)
|
||||
yield* Fiber.join(s.fiber)
|
||||
})
|
||||
|
||||
const init = Effect.fn("File.init")(function* () {
|
||||
yield* ensure()
|
||||
})
|
||||
|
||||
const status = Effect.fn("File.status")(function* () {
|
||||
if (instance.project.vcs !== "git") return []
|
||||
if (Instance.project.vcs !== "git") return []
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
const diffOutput = (
|
||||
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||
cwd: instance.directory,
|
||||
cwd: Instance.directory,
|
||||
})
|
||||
).text()
|
||||
|
||||
@@ -475,7 +463,7 @@ export namespace File {
|
||||
"--exclude-standard",
|
||||
],
|
||||
{
|
||||
cwd: instance.directory,
|
||||
cwd: Instance.directory,
|
||||
},
|
||||
)
|
||||
).text()
|
||||
@@ -483,7 +471,7 @@ export namespace File {
|
||||
if (untrackedOutput.trim()) {
|
||||
for (const file of untrackedOutput.trim().split("\n")) {
|
||||
try {
|
||||
const content = await Filesystem.readText(path.join(instance.directory, file))
|
||||
const content = await Filesystem.readText(path.join(Instance.directory, file))
|
||||
changed.push({
|
||||
path: file,
|
||||
added: content.split("\n").length,
|
||||
@@ -509,7 +497,7 @@ export namespace File {
|
||||
"HEAD",
|
||||
],
|
||||
{
|
||||
cwd: instance.directory,
|
||||
cwd: Instance.directory,
|
||||
},
|
||||
)
|
||||
).text()
|
||||
@@ -526,10 +514,10 @@ export namespace File {
|
||||
}
|
||||
|
||||
return changed.map((item) => {
|
||||
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
|
||||
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
|
||||
return {
|
||||
...item,
|
||||
path: path.relative(instance.directory, full),
|
||||
path: path.relative(Instance.directory, full),
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -538,7 +526,7 @@ export namespace File {
|
||||
const read = Effect.fn("File.read")(function* (file: string) {
|
||||
return yield* Effect.promise(async (): Promise<File.Content> => {
|
||||
using _ = log.time("read", { file })
|
||||
const full = path.join(instance.directory, file)
|
||||
const full = path.join(Instance.directory, file)
|
||||
|
||||
if (!Instance.containsPath(full)) {
|
||||
throw new Error("Access denied: path escapes project directory")
|
||||
@@ -586,19 +574,19 @@ export namespace File {
|
||||
|
||||
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
||||
|
||||
if (instance.project.vcs === "git") {
|
||||
if (Instance.project.vcs === "git") {
|
||||
let diff = (
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
||||
).text()
|
||||
if (!diff.trim()) {
|
||||
diff = (
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||
cwd: instance.directory,
|
||||
cwd: Instance.directory,
|
||||
})
|
||||
).text()
|
||||
}
|
||||
if (diff.trim()) {
|
||||
const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
|
||||
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||
context: Infinity,
|
||||
ignoreWhitespace: true,
|
||||
@@ -620,20 +608,20 @@ export namespace File {
|
||||
return yield* Effect.promise(async () => {
|
||||
const exclude = [".git", ".DS_Store"]
|
||||
let ignored = (_: string) => false
|
||||
if (instance.project.vcs === "git") {
|
||||
if (Instance.project.vcs === "git") {
|
||||
const ig = ignore()
|
||||
const gitignore = path.join(instance.project.worktree, ".gitignore")
|
||||
const gitignore = path.join(Instance.project.worktree, ".gitignore")
|
||||
if (await Filesystem.exists(gitignore)) {
|
||||
ig.add(await Filesystem.readText(gitignore))
|
||||
}
|
||||
const ignoreFile = path.join(instance.project.worktree, ".ignore")
|
||||
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
|
||||
if (await Filesystem.exists(ignoreFile)) {
|
||||
ig.add(await Filesystem.readText(ignoreFile))
|
||||
}
|
||||
ignored = ig.ignores.bind(ig)
|
||||
}
|
||||
|
||||
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
|
||||
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
|
||||
if (!Instance.containsPath(resolved)) {
|
||||
throw new Error("Access denied: path escapes project directory")
|
||||
}
|
||||
@@ -642,7 +630,7 @@ export namespace File {
|
||||
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
|
||||
if (exclude.includes(entry.name)) continue
|
||||
const absolute = path.join(resolved, entry.name)
|
||||
const file = path.relative(instance.directory, absolute)
|
||||
const file = path.relative(Instance.directory, absolute)
|
||||
const type = entry.isDirectory() ? "directory" : "file"
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
@@ -666,13 +654,16 @@ export namespace File {
|
||||
dirs?: boolean
|
||||
type?: "file" | "directory"
|
||||
}) {
|
||||
yield* ensure()
|
||||
const { cache } = yield* InstanceState.get(state)
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
const query = input.query.trim()
|
||||
const limit = input.limit ?? 100
|
||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||
log.info("search", { query, kind })
|
||||
|
||||
const result = getFiles()
|
||||
const result = cache
|
||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||
|
||||
if (!query) {
|
||||
@@ -696,4 +687,26 @@ export namespace File {
|
||||
return Service.of({ init, status, read, list, search })
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
return runPromise((svc) => svc.status())
|
||||
}
|
||||
|
||||
export async function read(file: string): Promise<Content> {
|
||||
return runPromise((svc) => svc.read(file))
|
||||
}
|
||||
|
||||
export async function list(dir?: string) {
|
||||
return runPromise((svc) => svc.list(dir))
|
||||
}
|
||||
|
||||
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
|
||||
return runPromise((svc) => svc.search(input))
|
||||
}
|
||||
}
|
||||
|
||||
376
packages/opencode/src/file/ripgrep.ts
Normal file
376
packages/opencode/src/file/ripgrep.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
// Ripgrep utility functions
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import z from "zod"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { lazy } from "../util/lazy"
|
||||
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { text } from "node:stream/consumers"
|
||||
|
||||
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
|
||||
import { Log } from "@/util/log"
|
||||
|
||||
export namespace Ripgrep {
|
||||
const log = Log.create({ service: "ripgrep" })
|
||||
const Stats = z.object({
|
||||
elapsed: z.object({
|
||||
secs: z.number(),
|
||||
nanos: z.number(),
|
||||
human: z.string(),
|
||||
}),
|
||||
searches: z.number(),
|
||||
searches_with_match: z.number(),
|
||||
bytes_searched: z.number(),
|
||||
bytes_printed: z.number(),
|
||||
matched_lines: z.number(),
|
||||
matches: z.number(),
|
||||
})
|
||||
|
||||
const Begin = z.object({
|
||||
type: z.literal("begin"),
|
||||
data: z.object({
|
||||
path: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export const Match = z.object({
|
||||
type: z.literal("match"),
|
||||
data: z.object({
|
||||
path: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
lines: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
line_number: z.number(),
|
||||
absolute_offset: z.number(),
|
||||
submatches: z.array(
|
||||
z.object({
|
||||
match: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
const End = z.object({
|
||||
type: z.literal("end"),
|
||||
data: z.object({
|
||||
path: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
binary_offset: z.number().nullable(),
|
||||
stats: Stats,
|
||||
}),
|
||||
})
|
||||
|
||||
const Summary = z.object({
|
||||
type: z.literal("summary"),
|
||||
data: z.object({
|
||||
elapsed_total: z.object({
|
||||
human: z.string(),
|
||||
nanos: z.number(),
|
||||
secs: z.number(),
|
||||
}),
|
||||
stats: Stats,
|
||||
}),
|
||||
})
|
||||
|
||||
const Result = z.union([Begin, Match, End, Summary])
|
||||
|
||||
export type Result = z.infer<typeof Result>
|
||||
export type Match = z.infer<typeof Match>
|
||||
export type Begin = z.infer<typeof Begin>
|
||||
export type End = z.infer<typeof End>
|
||||
export type Summary = z.infer<typeof Summary>
|
||||
const PLATFORM = {
|
||||
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
|
||||
"arm64-linux": {
|
||||
platform: "aarch64-unknown-linux-gnu",
|
||||
extension: "tar.gz",
|
||||
},
|
||||
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
|
||||
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
|
||||
"arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
|
||||
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
|
||||
} as const
|
||||
|
||||
export const ExtractionFailedError = NamedError.create(
|
||||
"RipgrepExtractionFailedError",
|
||||
z.object({
|
||||
filepath: z.string(),
|
||||
stderr: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const UnsupportedPlatformError = NamedError.create(
|
||||
"RipgrepUnsupportedPlatformError",
|
||||
z.object({
|
||||
platform: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const DownloadFailedError = NamedError.create(
|
||||
"RipgrepDownloadFailedError",
|
||||
z.object({
|
||||
url: z.string(),
|
||||
status: z.number(),
|
||||
}),
|
||||
)
|
||||
|
||||
const state = lazy(async () => {
|
||||
const system = which("rg")
|
||||
if (system) {
|
||||
const stat = await fs.stat(system).catch(() => undefined)
|
||||
if (stat?.isFile()) return { filepath: system }
|
||||
log.warn("bun.which returned invalid rg path", { filepath: system })
|
||||
}
|
||||
const filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : ""))
|
||||
|
||||
if (!(await Filesystem.exists(filepath))) {
|
||||
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
|
||||
const config = PLATFORM[platformKey]
|
||||
if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
|
||||
|
||||
const version = "14.1.1"
|
||||
const filename = `ripgrep-${version}-${config.platform}.${config.extension}`
|
||||
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const archivePath = path.join(Global.Path.bin, filename)
|
||||
await Filesystem.write(archivePath, Buffer.from(arrayBuffer))
|
||||
if (config.extension === "tar.gz") {
|
||||
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
|
||||
|
||||
if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
|
||||
if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
|
||||
|
||||
const proc = Process.spawn(args, {
|
||||
cwd: Global.Path.bin,
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
})
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
const stderr = proc.stderr ? await text(proc.stderr) : ""
|
||||
throw new ExtractionFailedError({
|
||||
filepath,
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (config.extension === "zip") {
|
||||
const zipFileReader = new ZipReader(new BlobReader(new Blob([arrayBuffer])))
|
||||
const entries = await zipFileReader.getEntries()
|
||||
let rgEntry: any
|
||||
for (const entry of entries) {
|
||||
if (entry.filename.endsWith("rg.exe")) {
|
||||
rgEntry = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!rgEntry) {
|
||||
throw new ExtractionFailedError({
|
||||
filepath: archivePath,
|
||||
stderr: "rg.exe not found in zip archive",
|
||||
})
|
||||
}
|
||||
|
||||
const rgBlob = await rgEntry.getData(new BlobWriter())
|
||||
if (!rgBlob) {
|
||||
throw new ExtractionFailedError({
|
||||
filepath: archivePath,
|
||||
stderr: "Failed to extract rg.exe from zip archive",
|
||||
})
|
||||
}
|
||||
await Filesystem.write(filepath, Buffer.from(await rgBlob.arrayBuffer()))
|
||||
await zipFileReader.close()
|
||||
}
|
||||
await fs.unlink(archivePath)
|
||||
if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
|
||||
}
|
||||
|
||||
return {
|
||||
filepath,
|
||||
}
|
||||
})
|
||||
|
||||
export async function filepath() {
|
||||
const { filepath } = await state()
|
||||
return filepath
|
||||
}
|
||||
|
||||
export async function* files(input: {
|
||||
cwd: string
|
||||
glob?: string[]
|
||||
hidden?: boolean
|
||||
follow?: boolean
|
||||
maxDepth?: number
|
||||
signal?: AbortSignal
|
||||
}) {
|
||||
input.signal?.throwIfAborted()
|
||||
|
||||
const args = [await filepath(), "--files", "--glob=!.git/*"]
|
||||
if (input.follow) args.push("--follow")
|
||||
if (input.hidden !== false) args.push("--hidden")
|
||||
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
|
||||
if (input.glob) {
|
||||
for (const g of input.glob) {
|
||||
args.push(`--glob=${g}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Guard against invalid cwd to provide a consistent ENOENT error.
|
||||
if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) {
|
||||
throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
|
||||
code: "ENOENT",
|
||||
errno: -2,
|
||||
path: input.cwd,
|
||||
})
|
||||
}
|
||||
|
||||
const proc = Process.spawn(args, {
|
||||
cwd: input.cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
abort: input.signal,
|
||||
})
|
||||
|
||||
if (!proc.stdout) {
|
||||
throw new Error("Process output not available")
|
||||
}
|
||||
|
||||
let buffer = ""
|
||||
const stream = proc.stdout as AsyncIterable<Buffer | string>
|
||||
for await (const chunk of stream) {
|
||||
input.signal?.throwIfAborted()
|
||||
|
||||
buffer += typeof chunk === "string" ? chunk : chunk.toString()
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
const lines = buffer.split(/\r?\n/)
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (line) yield line
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer) yield buffer
|
||||
await proc.exited
|
||||
|
||||
input.signal?.throwIfAborted()
|
||||
}
|
||||
|
||||
export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
|
||||
log.info("tree", input)
|
||||
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))
|
||||
interface Node {
|
||||
name: string
|
||||
children: Map<string, Node>
|
||||
}
|
||||
|
||||
function dir(node: Node, name: string) {
|
||||
const existing = node.children.get(name)
|
||||
if (existing) return existing
|
||||
const next = { name, children: new Map() }
|
||||
node.children.set(name, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const root: Node = { name: "", children: new Map() }
|
||||
for (const file of files) {
|
||||
if (file.includes(".opencode")) continue
|
||||
const parts = file.split(path.sep)
|
||||
if (parts.length < 2) continue
|
||||
let node = root
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
node = dir(node, part)
|
||||
}
|
||||
}
|
||||
|
||||
function count(node: Node): number {
|
||||
let total = 0
|
||||
for (const child of node.children.values()) {
|
||||
total += 1 + count(child)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
const total = count(root)
|
||||
const limit = input.limit ?? total
|
||||
const lines: string[] = []
|
||||
const queue: { node: Node; path: string }[] = []
|
||||
for (const child of Array.from(root.children.values()).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
queue.push({ node: child, path: child.name })
|
||||
}
|
||||
|
||||
let used = 0
|
||||
for (let i = 0; i < queue.length && used < limit; i++) {
|
||||
const { node, path } = queue[i]
|
||||
lines.push(path)
|
||||
used++
|
||||
for (const child of Array.from(node.children.values()).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
queue.push({ node: child, path: `${path}/${child.name}` })
|
||||
}
|
||||
}
|
||||
|
||||
if (total > used) lines.push(`[${total - used} truncated]`)
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export async function search(input: {
|
||||
cwd: string
|
||||
pattern: string
|
||||
glob?: string[]
|
||||
limit?: number
|
||||
follow?: boolean
|
||||
}) {
|
||||
const args = [`${await filepath()}`, "--json", "--hidden", "--glob=!.git/*"]
|
||||
if (input.follow) args.push("--follow")
|
||||
|
||||
if (input.glob) {
|
||||
for (const g of input.glob) {
|
||||
args.push(`--glob=${g}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (input.limit) {
|
||||
args.push(`--max-count=${input.limit}`)
|
||||
}
|
||||
|
||||
args.push("--")
|
||||
args.push(input.pattern)
|
||||
|
||||
const result = await Process.text(args, {
|
||||
cwd: input.cwd,
|
||||
nothrow: true,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
const lines = result.text.trim().split(/\r?\n/).filter(Boolean)
|
||||
// Parse JSON lines from ripgrep output
|
||||
|
||||
return lines
|
||||
.map((line) => JSON.parse(line))
|
||||
.map((parsed) => Result.parse(parsed))
|
||||
.filter((r) => r.type === "match")
|
||||
.map((r) => r.data)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
@@ -35,6 +36,11 @@ export namespace FileTime {
|
||||
return next
|
||||
}
|
||||
|
||||
interface State {
|
||||
reads: Map<SessionID, Map<string, Stamp>>
|
||||
locks: Map<string, Semaphore.Semaphore>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
||||
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
||||
@@ -48,30 +54,40 @@ export namespace FileTime {
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||
const reads = new Map<SessionID, Map<string, Stamp>>()
|
||||
const locks = new Map<string, Semaphore.Semaphore>()
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("FileTime.state")(() =>
|
||||
Effect.succeed({
|
||||
reads: new Map<SessionID, Map<string, Stamp>>(),
|
||||
locks: new Map<string, Semaphore.Semaphore>(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const getLock = (filepath: string) => {
|
||||
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
|
||||
const locks = (yield* InstanceState.get(state)).locks
|
||||
const lock = locks.get(filepath)
|
||||
if (lock) return lock
|
||||
|
||||
const next = Semaphore.makeUnsafe(1)
|
||||
locks.set(filepath, next)
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
|
||||
const reads = (yield* InstanceState.get(state)).reads
|
||||
log.info("read", { sessionID, file })
|
||||
session(reads, sessionID).set(file, yield* stamp(file))
|
||||
})
|
||||
|
||||
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
|
||||
const reads = (yield* InstanceState.get(state)).reads
|
||||
return reads.get(sessionID)?.get(file)?.read
|
||||
})
|
||||
|
||||
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
|
||||
if (disableCheck) return
|
||||
|
||||
const reads = (yield* InstanceState.get(state)).reads
|
||||
const time = reads.get(sessionID)?.get(filepath)
|
||||
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||
|
||||
@@ -85,26 +101,28 @@ export namespace FileTime {
|
||||
})
|
||||
|
||||
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
||||
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
|
||||
return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1))
|
||||
})
|
||||
|
||||
return Service.of({ read, get, assert, withLock })
|
||||
}),
|
||||
)
|
||||
).pipe(Layer.orDie)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
|
||||
export function read(sessionID: SessionID, file: string) {
|
||||
return runPromiseInstance(Service.use((s) => s.read(sessionID, file)))
|
||||
return runPromise((s) => s.read(sessionID, file))
|
||||
}
|
||||
|
||||
export function get(sessionID: SessionID, file: string) {
|
||||
return runPromiseInstance(Service.use((s) => s.get(sessionID, file)))
|
||||
return runPromise((s) => s.get(sessionID, file))
|
||||
}
|
||||
|
||||
export async function assert(sessionID: SessionID, filepath: string) {
|
||||
return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath)))
|
||||
return runPromise((s) => s.assert(sessionID, filepath))
|
||||
}
|
||||
|
||||
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
||||
return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn)))
|
||||
return runPromise((s) => s.withLock(filepath, fn))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Effect, Layer, ServiceMap } from "effect"
|
||||
import { Cause, Effect, Layer, Scope, ServiceMap } from "effect"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
@@ -7,7 +7,8 @@ import path from "path"
|
||||
import z from "zod"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { git } from "@/util/git"
|
||||
@@ -60,82 +61,107 @@ export namespace FileWatcher {
|
||||
|
||||
export const hasNativeBinding = () => !!watcher()
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileWatcher") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("FileWatcher.state")(
|
||||
function* () {
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
|
||||
|
||||
log.info("init", { directory: instance.directory })
|
||||
log.info("init", { directory: Instance.directory })
|
||||
|
||||
const backend = getBackend()
|
||||
if (!backend) {
|
||||
log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
|
||||
return Service.of({})
|
||||
}
|
||||
const backend = getBackend()
|
||||
if (!backend) {
|
||||
log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
|
||||
return
|
||||
}
|
||||
|
||||
const w = watcher()
|
||||
if (!w) return Service.of({})
|
||||
const w = watcher()
|
||||
if (!w) return
|
||||
|
||||
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
|
||||
log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
|
||||
|
||||
const subs: ParcelWatcher.AsyncSubscription[] = []
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))))
|
||||
const subs: ParcelWatcher.AsyncSubscription[] = []
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
|
||||
)
|
||||
|
||||
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
||||
if (err) return
|
||||
for (const evt of evts) {
|
||||
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
||||
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
||||
}
|
||||
})
|
||||
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
||||
if (err) return
|
||||
for (const evt of evts) {
|
||||
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
||||
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
||||
}
|
||||
})
|
||||
|
||||
const subscribe = (dir: string, ignore: string[]) => {
|
||||
const pending = w.subscribe(dir, cb, { ignore, backend })
|
||||
return Effect.gen(function* () {
|
||||
const sub = yield* Effect.promise(() => pending)
|
||||
subs.push(sub)
|
||||
}).pipe(
|
||||
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
||||
const subscribe = (dir: string, ignore: string[]) => {
|
||||
const pending = w.subscribe(dir, cb, { ignore, backend })
|
||||
return Effect.gen(function* () {
|
||||
const sub = yield* Effect.promise(() => pending)
|
||||
subs.push(sub)
|
||||
}).pipe(
|
||||
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
||||
pending.then((s) => s.unsubscribe()).catch(() => {})
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||
yield* subscribe(Instance.directory, [
|
||||
...FileIgnore.PATTERNS,
|
||||
...cfgIgnores,
|
||||
...protecteds(Instance.directory),
|
||||
])
|
||||
}
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
const result = yield* Effect.promise(() =>
|
||||
git(["rev-parse", "--git-dir"], {
|
||||
cwd: Instance.project.worktree,
|
||||
}),
|
||||
)
|
||||
const vcsDir =
|
||||
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
||||
(entry) => entry !== "HEAD",
|
||||
)
|
||||
yield* subscribe(vcsDir, ignore)
|
||||
}
|
||||
}
|
||||
},
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
||||
pending.then((s) => s.unsubscribe()).catch(() => {})
|
||||
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||
yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(instance.directory)])
|
||||
}
|
||||
|
||||
if (instance.project.vcs === "git") {
|
||||
const result = yield* Effect.promise(() =>
|
||||
git(["rev-parse", "--git-dir"], {
|
||||
cwd: instance.project.worktree,
|
||||
}),
|
||||
)
|
||||
const vcsDir = result.exitCode === 0 ? path.resolve(instance.project.worktree, result.text().trim()) : undefined
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
||||
(entry) => entry !== "HEAD",
|
||||
)
|
||||
yield* subscribe(vcsDir, ignore)
|
||||
}
|
||||
}
|
||||
|
||||
return Service.of({})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
||||
return Effect.succeed(Service.of({}))
|
||||
}),
|
||||
),
|
||||
return Service.of({
|
||||
init: Effect.fn("FileWatcher.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ export namespace Flag {
|
||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||
export const OPENCODE_DB = process.env["OPENCODE_DB"]
|
||||
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
|
||||
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")
|
||||
export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import path from "path"
|
||||
import { mergeDeep } from "remeda"
|
||||
import z from "zod"
|
||||
@@ -27,6 +27,7 @@ export namespace Format {
|
||||
export type Status = z.infer<typeof Status>
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Status[]>
|
||||
}
|
||||
|
||||
@@ -35,106 +36,124 @@ export namespace Format {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Format.state")(function* (_ctx) {
|
||||
const enabled: Record<string, boolean> = {}
|
||||
const formatters: Record<string, Formatter.Info> = {}
|
||||
|
||||
const enabled: Record<string, boolean> = {}
|
||||
const formatters: Record<string, Formatter.Info> = {}
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
|
||||
if (cfg.formatter !== false) {
|
||||
for (const item of Object.values(Formatter)) {
|
||||
formatters[item.name] = item
|
||||
}
|
||||
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
||||
if (item.disabled) {
|
||||
delete formatters[name]
|
||||
continue
|
||||
}
|
||||
const info = mergeDeep(formatters[name] ?? {}, {
|
||||
command: [],
|
||||
extensions: [],
|
||||
...item,
|
||||
})
|
||||
|
||||
if (info.command.length === 0) continue
|
||||
|
||||
formatters[name] = {
|
||||
...info,
|
||||
name,
|
||||
enabled: async () => true,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.info("all formatters are disabled")
|
||||
}
|
||||
|
||||
async function isEnabled(item: Formatter.Info) {
|
||||
let status = enabled[item.name]
|
||||
if (status === undefined) {
|
||||
status = await item.enabled()
|
||||
enabled[item.name] = status
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
async function getFormatter(ext: string) {
|
||||
const result = []
|
||||
for (const item of Object.values(formatters)) {
|
||||
log.info("checking", { name: item.name, ext })
|
||||
if (!item.extensions.includes(ext)) continue
|
||||
if (!(await isEnabled(item))) continue
|
||||
log.info("enabled", { name: item.name, ext })
|
||||
result.push(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribe(
|
||||
File.Event.Edited,
|
||||
Instance.bind(async (payload) => {
|
||||
const file = payload.properties.file
|
||||
log.info("formatting", { file })
|
||||
const ext = path.extname(file)
|
||||
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
try {
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", file)),
|
||||
{
|
||||
cwd: instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file,
|
||||
})
|
||||
}
|
||||
if (cfg.formatter !== false) {
|
||||
for (const item of Object.values(Formatter)) {
|
||||
formatters[item.name] = item
|
||||
}
|
||||
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
||||
if (item.disabled) {
|
||||
delete formatters[name]
|
||||
continue
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
(unsubscribe) => Effect.sync(unsubscribe),
|
||||
const info = mergeDeep(formatters[name] ?? {}, {
|
||||
command: [],
|
||||
extensions: [],
|
||||
...item,
|
||||
})
|
||||
|
||||
if (info.command.length === 0) continue
|
||||
|
||||
formatters[name] = {
|
||||
...info,
|
||||
name,
|
||||
enabled: async () => true,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.info("all formatters are disabled")
|
||||
}
|
||||
|
||||
async function isEnabled(item: Formatter.Info) {
|
||||
let status = enabled[item.name]
|
||||
if (status === undefined) {
|
||||
status = await item.enabled()
|
||||
enabled[item.name] = status
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
async function getFormatter(ext: string) {
|
||||
const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext))
|
||||
const checks = await Promise.all(
|
||||
matching.map(async (item) => {
|
||||
log.info("checking", { name: item.name, ext })
|
||||
const on = await isEnabled(item)
|
||||
if (on) {
|
||||
log.info("enabled", { name: item.name, ext })
|
||||
}
|
||||
return {
|
||||
item,
|
||||
enabled: on,
|
||||
}
|
||||
}),
|
||||
)
|
||||
return checks.filter((x) => x.enabled).map((x) => x.item)
|
||||
}
|
||||
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribe(
|
||||
File.Event.Edited,
|
||||
Instance.bind(async (payload) => {
|
||||
const file = payload.properties.file
|
||||
log.info("formatting", { file })
|
||||
const ext = path.extname(file)
|
||||
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
try {
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", file)),
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file,
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
(unsubscribe) => Effect.sync(unsubscribe),
|
||||
)
|
||||
log.info("init")
|
||||
|
||||
return {
|
||||
formatters,
|
||||
isEnabled,
|
||||
}
|
||||
}),
|
||||
)
|
||||
log.info("init")
|
||||
|
||||
const init = Effect.fn("Format.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
})
|
||||
|
||||
const status = Effect.fn("Format.status")(function* () {
|
||||
const { formatters, isEnabled } = yield* InstanceState.get(state)
|
||||
const result: Status[] = []
|
||||
for (const formatter of Object.values(formatters)) {
|
||||
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
||||
@@ -147,11 +166,17 @@ export namespace Format {
|
||||
return result
|
||||
})
|
||||
|
||||
return Service.of({ status })
|
||||
return Service.of({ init, status })
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
|
||||
export async function init() {
|
||||
return runPromise((s) => s.init())
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
return runPromiseInstance(Service.use((s) => s.status()))
|
||||
return runPromise((s) => s.status())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
@@ -293,7 +294,7 @@ export namespace Installation {
|
||||
result = yield* run(["scoop", "install", `opencode@${target}`])
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unknown method: ${m}`)
|
||||
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
|
||||
}
|
||||
if (!result || result.code !== 0) {
|
||||
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
|
||||
@@ -329,27 +330,21 @@ export namespace Installation {
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
// Legacy adapters — dynamic import avoids circular dependency since
|
||||
// foundational modules (db.ts, provider/models.ts) import Installation
|
||||
// at load time, and runtime transitively loads those same modules.
|
||||
async function runPromise<A>(f: (service: Interface) => Effect.Effect<A, any>) {
|
||||
const { runtime } = await import("@/effect/runtime")
|
||||
return runtime.runPromise(Service.use(f))
|
||||
}
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
export function info(): Promise<Info> {
|
||||
export async function info(): Promise<Info> {
|
||||
return runPromise((svc) => svc.info())
|
||||
}
|
||||
|
||||
export function method(): Promise<Method> {
|
||||
export async function method(): Promise<Method> {
|
||||
return runPromise((svc) => svc.method())
|
||||
}
|
||||
|
||||
export function latest(installMethod?: Method): Promise<string> {
|
||||
export async function latest(installMethod?: Method): Promise<string> {
|
||||
return runPromise((svc) => svc.latest(installMethod))
|
||||
}
|
||||
|
||||
export function upgrade(m: Method, target: string): Promise<void> {
|
||||
export async function upgrade(m: Method, target: string): Promise<void> {
|
||||
return runPromise((svc) => svc.upgrade(m, target))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { PermissionTable } from "@/session/session.sql"
|
||||
import { Database, eq } from "@/storage/db"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Log } from "@/util/log"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
@@ -16,7 +16,7 @@ import z from "zod"
|
||||
import { evaluate as evalRule } from "./evaluate"
|
||||
import { PermissionID } from "./schema"
|
||||
|
||||
export namespace PermissionNext {
|
||||
export namespace Permission {
|
||||
const log = Log.create({ service: "permission" })
|
||||
|
||||
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
||||
@@ -125,24 +125,46 @@ export namespace PermissionNext {
|
||||
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||
}
|
||||
|
||||
interface State {
|
||||
pending: Map<PermissionID, PendingEntry>
|
||||
approved: Ruleset
|
||||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
|
||||
return evalRule(permission, pattern, ...rulesets)
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Permission") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { project } = yield* InstanceContext
|
||||
const row = Database.use((db) =>
|
||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Permission.state")(function* (ctx) {
|
||||
const row = Database.use((db) =>
|
||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
|
||||
)
|
||||
const state = {
|
||||
pending: new Map<PermissionID, PendingEntry>(),
|
||||
approved: row?.data ?? [],
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
for (const item of state.pending.values()) {
|
||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||
}
|
||||
state.pending.clear()
|
||||
}),
|
||||
)
|
||||
|
||||
return state
|
||||
}),
|
||||
)
|
||||
const pending = new Map<PermissionID, PendingEntry>()
|
||||
const approved: Ruleset = row?.data ?? []
|
||||
|
||||
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
|
||||
const { approved, pending } = yield* InstanceState.get(state)
|
||||
const { ruleset, ...request } = input
|
||||
let needsAsk = false
|
||||
|
||||
@@ -179,6 +201,7 @@ export namespace PermissionNext {
|
||||
})
|
||||
|
||||
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
||||
const { approved, pending } = yield* InstanceState.get(state)
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) return
|
||||
|
||||
@@ -236,6 +259,7 @@ export namespace PermissionNext {
|
||||
})
|
||||
|
||||
const list = Effect.fn("Permission.list")(function* () {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
return Array.from(pending.values(), (item) => item.info)
|
||||
})
|
||||
|
||||
@@ -269,14 +293,6 @@ export namespace PermissionNext {
|
||||
return rulesets.flat()
|
||||
}
|
||||
|
||||
export const ask = fn(AskInput, async (input) => runPromiseInstance(Service.use((svc) => svc.ask(input))))
|
||||
|
||||
export const reply = fn(ReplyInput, async (input) => runPromiseInstance(Service.use((svc) => svc.reply(input))))
|
||||
|
||||
export async function list() {
|
||||
return runPromiseInstance(Service.use((svc) => svc.list()))
|
||||
}
|
||||
|
||||
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
||||
|
||||
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||
@@ -289,4 +305,18 @@ export namespace PermissionNext {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const runPromise = makeRunPromise(Service, layer)
|
||||
|
||||
export async function ask(input: z.infer<typeof AskInput>) {
|
||||
return runPromise((s) => s.ask(input))
|
||||
}
|
||||
|
||||
export async function reply(input: z.infer<typeof ReplyInput>) {
|
||||
return runPromise((s) => s.reply(input))
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
return runPromise((s) => s.list())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CodexAuthPlugin } from "./codex"
|
||||
import { Session } from "../session"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { CopilotAuthPlugin } from "./copilot"
|
||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
|
||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||
|
||||
export namespace Plugin {
|
||||
const log = Log.create({ service: "plugin" })
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Plugin } from "../plugin"
|
||||
import { Format } from "../format"
|
||||
import { LSP } from "../lsp"
|
||||
import { File } from "../file"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { Project } from "./project"
|
||||
import { Vcs } from "./vcs"
|
||||
import { Bus } from "../bus"
|
||||
import { Command } from "../command"
|
||||
import { Instance } from "./instance"
|
||||
@@ -12,8 +16,12 @@ export async function InstanceBootstrap() {
|
||||
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
||||
await Plugin.init()
|
||||
ShareNext.init()
|
||||
Format.init()
|
||||
await LSP.init()
|
||||
File.init()
|
||||
FileWatcher.init()
|
||||
Vcs.init()
|
||||
Snapshot.init()
|
||||
|
||||
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
||||
if (payload.properties.name === Command.Default.INIT) {
|
||||
|
||||
@@ -7,13 +7,13 @@ import { Context } from "../util/context"
|
||||
import { Project } from "./project"
|
||||
import { State } from "./state"
|
||||
|
||||
interface Context {
|
||||
export interface Shape {
|
||||
directory: string
|
||||
worktree: string
|
||||
project: Project.Info
|
||||
}
|
||||
const context = Context.create<Context>("instance")
|
||||
const cache = new Map<string, Promise<Context>>()
|
||||
const context = Context.create<Shape>("instance")
|
||||
const cache = new Map<string, Promise<Shape>>()
|
||||
|
||||
const disposal = {
|
||||
all: undefined as Promise<void> | undefined,
|
||||
@@ -52,7 +52,7 @@ function boot(input: { directory: string; init?: () => Promise<any>; project?: P
|
||||
})
|
||||
}
|
||||
|
||||
function track(directory: string, next: Promise<Context>) {
|
||||
function track(directory: string, next: Promise<Shape>) {
|
||||
const task = next.catch((error) => {
|
||||
if (cache.get(directory) === task) cache.delete(directory)
|
||||
throw error
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Log } from "@/util/log"
|
||||
import { git } from "@/util/git"
|
||||
@@ -30,54 +31,81 @@ export namespace Vcs {
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly branch: () => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
interface State {
|
||||
current: string | undefined
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
let currentBranch: string | undefined
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Vcs.state")((ctx) =>
|
||||
Effect.gen(function* () {
|
||||
if (ctx.project.vcs !== "git") {
|
||||
return { current: undefined }
|
||||
}
|
||||
|
||||
if (instance.project.vcs === "git") {
|
||||
const getCurrentBranch = async () => {
|
||||
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd: instance.project.worktree,
|
||||
})
|
||||
if (result.exitCode !== 0) return undefined
|
||||
const text = result.text().trim()
|
||||
return text || undefined
|
||||
}
|
||||
const getCurrentBranch = async () => {
|
||||
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd: ctx.worktree,
|
||||
})
|
||||
if (result.exitCode !== 0) return undefined
|
||||
const text = result.text().trim()
|
||||
return text || undefined
|
||||
}
|
||||
|
||||
currentBranch = yield* Effect.promise(() => getCurrentBranch())
|
||||
log.info("initialized", { branch: currentBranch })
|
||||
const value = {
|
||||
current: yield* Effect.promise(() => getCurrentBranch()),
|
||||
}
|
||||
log.info("initialized", { branch: value.current })
|
||||
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribe(
|
||||
FileWatcher.Event.Updated,
|
||||
Instance.bind(async (evt) => {
|
||||
if (!evt.properties.file.endsWith("HEAD")) return
|
||||
const next = await getCurrentBranch()
|
||||
if (next !== currentBranch) {
|
||||
log.info("branch changed", { from: currentBranch, to: next })
|
||||
currentBranch = next
|
||||
Bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
(unsubscribe) => Effect.sync(unsubscribe),
|
||||
)
|
||||
}
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribe(
|
||||
FileWatcher.Event.Updated,
|
||||
Instance.bind(async (evt) => {
|
||||
if (!evt.properties.file.endsWith("HEAD")) return
|
||||
const next = await getCurrentBranch()
|
||||
if (next !== value.current) {
|
||||
log.info("branch changed", { from: value.current, to: next })
|
||||
value.current = next
|
||||
Bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
(unsubscribe) => Effect.sync(unsubscribe),
|
||||
)
|
||||
|
||||
return value
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
init: Effect.fn("Vcs.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
}),
|
||||
branch: Effect.fn("Vcs.branch")(function* () {
|
||||
return currentBranch
|
||||
return yield* InstanceState.use(state, (x) => x.current)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
}
|
||||
|
||||
export function branch() {
|
||||
return runPromise((svc) => svc.branch())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { AuthOuathResult } from "@opencode-ai/plugin"
|
||||
import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import * as Auth from "@/auth/effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Auth } from "@/auth"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { Plugin } from "../plugin"
|
||||
import { ProviderID } from "./schema"
|
||||
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect"
|
||||
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
export namespace ProviderAuth {
|
||||
@@ -91,6 +92,8 @@ export namespace ProviderAuth {
|
||||
| InstanceType<typeof OauthCallbackFailed>
|
||||
| InstanceType<typeof ValidationFailed>
|
||||
|
||||
type Hook = NonNullable<Hooks["auth"]>
|
||||
|
||||
export interface Interface {
|
||||
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
|
||||
readonly authorize: (input: {
|
||||
@@ -101,26 +104,37 @@ export namespace ProviderAuth {
|
||||
readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
interface State {
|
||||
hooks: Record<ProviderID, Hook>
|
||||
pending: Map<ProviderID, AuthOuathResult>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Auth.Service
|
||||
const hooks = yield* Effect.promise(async () => {
|
||||
const mod = await import("../plugin")
|
||||
const plugins = await mod.Plugin.list()
|
||||
return Record.fromEntries(
|
||||
Arr.filterMap(plugins, (x) =>
|
||||
x.auth?.provider !== undefined
|
||||
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
|
||||
: Result.failVoid,
|
||||
),
|
||||
)
|
||||
})
|
||||
const pending = new Map<ProviderID, AuthOuathResult>()
|
||||
const auth = yield* Auth.Service
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("ProviderAuth.state")(() =>
|
||||
Effect.promise(async () => {
|
||||
const plugins = await Plugin.list()
|
||||
return {
|
||||
hooks: Record.fromEntries(
|
||||
Arr.filterMap(plugins, (x) =>
|
||||
x.auth?.provider !== undefined
|
||||
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
|
||||
: Result.failVoid,
|
||||
),
|
||||
),
|
||||
pending: new Map<ProviderID, AuthOuathResult>(),
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const methods = Effect.fn("ProviderAuth.methods")(function* () {
|
||||
const hooks = (yield* InstanceState.get(state)).hooks
|
||||
return Record.map(hooks, (item) =>
|
||||
item.methods.map(
|
||||
(method): Method => ({
|
||||
@@ -154,6 +168,7 @@ export namespace ProviderAuth {
|
||||
method: number
|
||||
inputs?: Record<string, string>
|
||||
}) {
|
||||
const { hooks, pending } = yield* InstanceState.get(state)
|
||||
const method = hooks[input.providerID].methods[input.method]
|
||||
if (method.type !== "oauth") return
|
||||
|
||||
@@ -180,6 +195,7 @@ export namespace ProviderAuth {
|
||||
method: number
|
||||
code?: string
|
||||
}) {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
const match = pending.get(input.providerID)
|
||||
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
|
||||
if (match.method === "code" && !input.code) {
|
||||
@@ -213,27 +229,23 @@ export namespace ProviderAuth {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.Auth.layer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
export async function methods() {
|
||||
return runPromiseInstance(Service.use((svc) => svc.methods()))
|
||||
return runPromise((svc) => svc.methods())
|
||||
}
|
||||
|
||||
export const authorize = fn(
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
method: z.number(),
|
||||
inputs: z.record(z.string(), z.string()).optional(),
|
||||
}),
|
||||
async (input): Promise<Authorization | undefined> => runPromiseInstance(Service.use((svc) => svc.authorize(input))),
|
||||
)
|
||||
export async function authorize(input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
inputs?: Record<string, string>
|
||||
}): Promise<Authorization | undefined> {
|
||||
return runPromise((svc) => svc.authorize(input))
|
||||
}
|
||||
|
||||
export const callback = fn(
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
method: z.number(),
|
||||
code: z.string().optional(),
|
||||
}),
|
||||
async (input) => runPromiseInstance(Service.use((svc) => svc.callback(input))),
|
||||
)
|
||||
export async function callback(input: { providerID: ProviderID; method: number; code?: string }) {
|
||||
return runPromise((svc) => svc.callback(input))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,12 @@ import { createGateway } from "@ai-sdk/gateway"
|
||||
import { createTogetherAI } from "@ai-sdk/togetherai"
|
||||
import { createPerplexity } from "@ai-sdk/perplexity"
|
||||
import { createVercel } from "@ai-sdk/vercel"
|
||||
import { createGitLab, VERSION as GITLAB_PROVIDER_VERSION } from "@gitlab/gitlab-ai-provider"
|
||||
import {
|
||||
createGitLab,
|
||||
VERSION as GITLAB_PROVIDER_VERSION,
|
||||
isWorkflowModel,
|
||||
discoverWorkflowModels,
|
||||
} from "gitlab-ai-provider"
|
||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||
import { GoogleAuth } from "google-auth-library"
|
||||
import { ProviderTransform } from "./transform"
|
||||
@@ -124,18 +129,20 @@ export namespace Provider {
|
||||
"@ai-sdk/togetherai": createTogetherAI,
|
||||
"@ai-sdk/perplexity": createPerplexity,
|
||||
"@ai-sdk/vercel": createVercel,
|
||||
"@gitlab/gitlab-ai-provider": createGitLab,
|
||||
"gitlab-ai-provider": createGitLab,
|
||||
// @ts-ignore (TODO: kill this code so we dont have to maintain it)
|
||||
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
|
||||
}
|
||||
|
||||
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
|
||||
type CustomVarsLoader = (options: Record<string, any>) => Record<string, string>
|
||||
type CustomDiscoverModels = () => Promise<Record<string, Model>>
|
||||
type CustomLoader = (provider: Info) => Promise<{
|
||||
autoload: boolean
|
||||
getModel?: CustomModelLoader
|
||||
vars?: CustomVarsLoader
|
||||
options?: Record<string, any>
|
||||
discoverModels?: CustomDiscoverModels
|
||||
}>
|
||||
|
||||
function useLanguageModel(sdk: any) {
|
||||
@@ -533,28 +540,105 @@ export namespace Provider {
|
||||
...(providerConfig?.options?.aiGatewayHeaders || {}),
|
||||
}
|
||||
|
||||
const featureFlags = {
|
||||
duo_agent_platform_agentic_chat: true,
|
||||
duo_agent_platform: true,
|
||||
...(providerConfig?.options?.featureFlags || {}),
|
||||
}
|
||||
|
||||
return {
|
||||
autoload: !!apiKey,
|
||||
options: {
|
||||
instanceUrl,
|
||||
apiKey,
|
||||
aiGatewayHeaders,
|
||||
featureFlags: {
|
||||
duo_agent_platform_agentic_chat: true,
|
||||
duo_agent_platform: true,
|
||||
...(providerConfig?.options?.featureFlags || {}),
|
||||
},
|
||||
featureFlags,
|
||||
},
|
||||
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
|
||||
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string, options?: Record<string, any>) {
|
||||
if (modelID.startsWith("duo-workflow-")) {
|
||||
const workflowRef = options?.workflowRef as string | undefined
|
||||
// Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef
|
||||
const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
|
||||
const model = sdk.workflowChat(sdkModelID, {
|
||||
featureFlags,
|
||||
})
|
||||
if (workflowRef) {
|
||||
model.selectedModelRef = workflowRef
|
||||
}
|
||||
return model
|
||||
}
|
||||
return sdk.agenticChat(modelID, {
|
||||
aiGatewayHeaders,
|
||||
featureFlags: {
|
||||
duo_agent_platform_agentic_chat: true,
|
||||
duo_agent_platform: true,
|
||||
...(providerConfig?.options?.featureFlags || {}),
|
||||
},
|
||||
featureFlags,
|
||||
})
|
||||
},
|
||||
async discoverModels(): Promise<Record<string, Model>> {
|
||||
if (!apiKey) {
|
||||
log.info("gitlab model discovery skipped: no apiKey")
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const token = apiKey
|
||||
const getHeaders = (): Record<string, string> =>
|
||||
auth?.type === "api" ? { "PRIVATE-TOKEN": token } : { Authorization: `Bearer ${token}` }
|
||||
|
||||
log.info("gitlab model discovery starting", { instanceUrl })
|
||||
const result = await discoverWorkflowModels(
|
||||
{ instanceUrl, getHeaders },
|
||||
{ workingDirectory: Instance.directory },
|
||||
)
|
||||
|
||||
if (!result.models.length) {
|
||||
log.info("gitlab model discovery skipped: no models found", {
|
||||
project: result.project ? { id: result.project.id, path: result.project.pathWithNamespace } : null,
|
||||
})
|
||||
return {}
|
||||
}
|
||||
|
||||
const models: Record<string, Model> = {}
|
||||
for (const m of result.models) {
|
||||
if (!input.models[m.id]) {
|
||||
models[m.id] = {
|
||||
id: ModelID.make(m.id),
|
||||
providerID: ProviderID.make("gitlab"),
|
||||
name: `Agent Platform (${m.name})`,
|
||||
family: "",
|
||||
api: {
|
||||
id: m.id,
|
||||
url: instanceUrl,
|
||||
npm: "gitlab-ai-provider",
|
||||
},
|
||||
status: "active",
|
||||
headers: {},
|
||||
options: { workflowRef: m.ref },
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
limit: { context: m.context, output: m.output },
|
||||
capabilities: {
|
||||
temperature: false,
|
||||
reasoning: true,
|
||||
attachment: true,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: true, video: false, pdf: true },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
release_date: "",
|
||||
variants: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("gitlab model discovery complete", {
|
||||
count: Object.keys(models).length,
|
||||
models: Object.keys(models),
|
||||
})
|
||||
return models
|
||||
} catch (e) {
|
||||
log.warn("gitlab model discovery failed", { error: e })
|
||||
return {}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"cloudflare-workers-ai": async (input) => {
|
||||
@@ -853,6 +937,9 @@ export namespace Provider {
|
||||
const varsLoaders: {
|
||||
[providerID: string]: CustomVarsLoader
|
||||
} = {}
|
||||
const discoveryLoaders: {
|
||||
[providerID: string]: CustomDiscoverModels
|
||||
} = {}
|
||||
const sdk = new Map<string, SDK>()
|
||||
|
||||
log.info("init")
|
||||
@@ -1009,6 +1096,7 @@ export namespace Provider {
|
||||
if (result && (result.autoload || providers[providerID])) {
|
||||
if (result.getModel) modelLoaders[providerID] = result.getModel
|
||||
if (result.vars) varsLoaders[providerID] = result.vars
|
||||
if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels
|
||||
const opts = result.options ?? {}
|
||||
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
|
||||
mergeProvider(providerID, patch)
|
||||
@@ -1070,6 +1158,18 @@ export namespace Provider {
|
||||
log.info("found", { providerID })
|
||||
}
|
||||
|
||||
const gitlab = ProviderID.make("gitlab")
|
||||
if (discoveryLoaders[gitlab] && providers[gitlab]) {
|
||||
await (async () => {
|
||||
const discovered = await discoveryLoaders[gitlab]()
|
||||
for (const [modelID, model] of Object.entries(discovered)) {
|
||||
if (!providers[gitlab].models[modelID]) {
|
||||
providers[gitlab].models[modelID] = model
|
||||
}
|
||||
}
|
||||
})().catch((e) => log.warn("state discovery error", { id: "gitlab", error: e }))
|
||||
}
|
||||
|
||||
return {
|
||||
models: languages,
|
||||
providers,
|
||||
@@ -1250,7 +1350,7 @@ export namespace Provider {
|
||||
|
||||
try {
|
||||
const language = s.modelLoaders[model.providerID]
|
||||
? await s.modelLoaders[model.providerID](sdk, model.api.id, provider.options)
|
||||
? await s.modelLoaders[model.providerID](sdk, model.api.id, { ...provider.options, ...model.options })
|
||||
: sdk.languageModel(model.api.id)
|
||||
s.models.set(key, language)
|
||||
return language
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Log } from "@/util/log"
|
||||
import z from "zod"
|
||||
import { QuestionID } from "./schema"
|
||||
|
||||
const log = Log.create({ service: "question" })
|
||||
|
||||
export namespace Question {
|
||||
const log = Log.create({ service: "question" })
|
||||
|
||||
// Schemas
|
||||
|
||||
export const Option = z
|
||||
@@ -86,6 +87,10 @@ export namespace Question {
|
||||
deferred: Deferred.Deferred<Answer[], RejectedError>
|
||||
}
|
||||
|
||||
interface State {
|
||||
pending: Map<QuestionID, PendingEntry>
|
||||
}
|
||||
|
||||
// Service
|
||||
|
||||
export interface Interface {
|
||||
@@ -104,13 +109,31 @@ export namespace Question {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const pending = new Map<QuestionID, PendingEntry>()
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Question.state")(function* () {
|
||||
const state = {
|
||||
pending: new Map<QuestionID, PendingEntry>(),
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
for (const item of state.pending.values()) {
|
||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||
}
|
||||
state.pending.clear()
|
||||
}),
|
||||
)
|
||||
|
||||
return state
|
||||
}),
|
||||
)
|
||||
|
||||
const ask = Effect.fn("Question.ask")(function* (input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}) {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
const id = QuestionID.ascending()
|
||||
log.info("asking", { id, questions: input.questions.length })
|
||||
|
||||
@@ -133,6 +156,7 @@ export namespace Question {
|
||||
})
|
||||
|
||||
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) {
|
||||
log.warn("reply for unknown request", { requestID: input.requestID })
|
||||
@@ -149,6 +173,7 @@ export namespace Question {
|
||||
})
|
||||
|
||||
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
const existing = pending.get(requestID)
|
||||
if (!existing) {
|
||||
log.warn("reject for unknown request", { requestID })
|
||||
@@ -164,6 +189,7 @@ export namespace Question {
|
||||
})
|
||||
|
||||
const list = Effect.fn("Question.list")(function* () {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
return Array.from(pending.values(), (x) => x.info)
|
||||
})
|
||||
|
||||
@@ -171,23 +197,25 @@ export namespace Question {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
|
||||
export async function ask(input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}): Promise<Answer[]> {
|
||||
return runPromiseInstance(Service.use((svc) => svc.ask(input)))
|
||||
return runPromise((s) => s.ask(input))
|
||||
}
|
||||
|
||||
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
|
||||
return runPromiseInstance(Service.use((svc) => svc.reply(input)))
|
||||
export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
|
||||
return runPromise((s) => s.reply(input))
|
||||
}
|
||||
|
||||
export async function reject(requestID: QuestionID): Promise<void> {
|
||||
return runPromiseInstance(Service.use((svc) => svc.reject(requestID)))
|
||||
export async function reject(requestID: QuestionID) {
|
||||
return runPromise((s) => s.reject(requestID))
|
||||
}
|
||||
|
||||
export async function list(): Promise<Request[]> {
|
||||
return runPromiseInstance(Service.use((svc) => svc.list()))
|
||||
export async function list() {
|
||||
return runPromise((s) => s.list())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { File } from "../../file"
|
||||
import { Fff } from "../../file/fff"
|
||||
import { Ripgrep } from "../../file/ripgrep"
|
||||
import { LSP } from "../../lsp"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { lazy } from "../../util/lazy"
|
||||
@@ -13,14 +13,14 @@ export const FileRoutes = lazy(() =>
|
||||
"/find",
|
||||
describeRoute({
|
||||
summary: "Find text",
|
||||
description: "Search for text patterns across files in the project.",
|
||||
description: "Search for text patterns across files in the project using ripgrep.",
|
||||
operationId: "find.text",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Matches",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Fff.Match.array()),
|
||||
schema: resolver(Ripgrep.Match.shape.data.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -34,7 +34,7 @@ export const FileRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const pattern = c.req.valid("query").pattern
|
||||
const result = await Fff.search({
|
||||
const result = await Ripgrep.search({
|
||||
cwd: Instance.directory,
|
||||
pattern,
|
||||
limit: 10,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
@@ -32,11 +32,11 @@ export const PermissionRoutes = lazy(() =>
|
||||
requestID: PermissionID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })),
|
||||
validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: params.requestID,
|
||||
reply: json.reply,
|
||||
message: json.message,
|
||||
@@ -55,14 +55,14 @@ export const PermissionRoutes = lazy(() =>
|
||||
description: "List of pending permissions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(PermissionNext.Request.array()),
|
||||
schema: resolver(Permission.Request.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const permissions = await PermissionNext.list()
|
||||
const permissions = await Permission.list()
|
||||
return c.json(permissions)
|
||||
},
|
||||
),
|
||||
|
||||
@@ -9,6 +9,9 @@ import { ProviderID } from "../../provider/schema"
|
||||
import { mapValues } from "remeda"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { Log } from "../../util/log"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export const ProviderRoutes = lazy(() =>
|
||||
new Hono()
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Todo } from "../../session/todo"
|
||||
import { Agent } from "../../agent/agent"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Log } from "../../util/log"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { errors } from "../error"
|
||||
@@ -1010,10 +1010,10 @@ export const SessionRoutes = lazy(() =>
|
||||
permissionID: PermissionID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", z.object({ response: PermissionNext.Reply })),
|
||||
validator("json", z.object({ response: Permission.Reply })),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
PermissionNext.reply({
|
||||
Permission.reply({
|
||||
requestID: params.permissionID,
|
||||
reply: c.req.valid("json").response,
|
||||
})
|
||||
|
||||
@@ -12,9 +12,8 @@ import { Format } from "../format"
|
||||
import { TuiRoutes } from "./routes/tui"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Vcs } from "../project/vcs"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Skill } from "../skill/skill"
|
||||
import { Skill } from "../skill"
|
||||
import { Auth } from "../auth"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Command } from "../command"
|
||||
@@ -152,7 +151,7 @@ export namespace Server {
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", Auth.Info),
|
||||
validator("json", Auth.Info.zod),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const info = c.req.valid("json")
|
||||
@@ -331,7 +330,7 @@ export namespace Server {
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
|
||||
const branch = await Vcs.branch()
|
||||
return c.json({
|
||||
branch,
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema"
|
||||
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
import { Global } from "@/global"
|
||||
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
|
||||
import { iife } from "@/util/iife"
|
||||
@@ -148,7 +148,7 @@ export namespace Session {
|
||||
compacting: z.number().optional(),
|
||||
archived: z.number().optional(),
|
||||
}),
|
||||
permission: PermissionNext.Ruleset.optional(),
|
||||
permission: Permission.Ruleset.optional(),
|
||||
revert: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
@@ -300,7 +300,7 @@ export namespace Session {
|
||||
parentID?: SessionID
|
||||
workspaceID?: WorkspaceID
|
||||
directory: string
|
||||
permission?: PermissionNext.Ruleset
|
||||
permission?: Permission.Ruleset
|
||||
}) {
|
||||
const result: Info = {
|
||||
id: SessionID.descending(input.id),
|
||||
@@ -423,7 +423,7 @@ export namespace Session {
|
||||
export const setPermission = fn(
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
permission: PermissionNext.Ruleset,
|
||||
permission: Permission.Ruleset,
|
||||
}),
|
||||
async (input) => {
|
||||
return Database.use((db) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
jsonSchema,
|
||||
} from "ai"
|
||||
import { mergeDeep, pipe } from "remeda"
|
||||
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { Config } from "@/config/config"
|
||||
import { Instance } from "@/project/instance"
|
||||
@@ -20,7 +21,7 @@ import type { MessageV2 } from "./message-v2"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
import { Auth } from "@/auth"
|
||||
|
||||
export namespace LLM {
|
||||
@@ -32,7 +33,7 @@ export namespace LLM {
|
||||
sessionID: string
|
||||
model: Provider.Model
|
||||
agent: Agent.Info
|
||||
permission?: PermissionNext.Ruleset
|
||||
permission?: Permission.Ruleset
|
||||
system: string[]
|
||||
abort: AbortSignal
|
||||
messages: ModelMessage[]
|
||||
@@ -184,6 +185,34 @@ export namespace LLM {
|
||||
})
|
||||
}
|
||||
|
||||
// Wire up toolExecutor for DWS workflow models so that tool calls
|
||||
// from the workflow service are executed via opencode's tool system
|
||||
// and results sent back over the WebSocket.
|
||||
if (language instanceof GitLabWorkflowLanguageModel) {
|
||||
const workflowModel = language
|
||||
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
|
||||
const t = tools[toolName]
|
||||
if (!t || !t.execute) {
|
||||
return { result: "", error: `Unknown tool: ${toolName}` }
|
||||
}
|
||||
try {
|
||||
const result = await t.execute!(JSON.parse(argsJson), {
|
||||
toolCallId: _requestID,
|
||||
messages: input.messages,
|
||||
abortSignal: input.abort,
|
||||
})
|
||||
const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
|
||||
return {
|
||||
result: output,
|
||||
metadata: typeof result === "object" ? result?.metadata : undefined,
|
||||
title: typeof result === "object" ? result?.title : undefined,
|
||||
}
|
||||
} catch (e: any) {
|
||||
return { result: "", error: e.message ?? String(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return streamText({
|
||||
onError(error) {
|
||||
l.error("stream error", {
|
||||
@@ -257,9 +286,9 @@ export namespace LLM {
|
||||
}
|
||||
|
||||
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
|
||||
const disabled = PermissionNext.disabled(
|
||||
const disabled = Permission.disabled(
|
||||
Object.keys(input.tools),
|
||||
PermissionNext.merge(input.agent.permission, input.permission ?? []),
|
||||
Permission.merge(input.agent.permission, input.permission ?? []),
|
||||
)
|
||||
for (const tool of Object.keys(input.tools)) {
|
||||
if (input.user.tools?.[tool] === false || disabled.has(tool)) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { Provider } from "@/provider/provider"
|
||||
import { LLM } from "./llm"
|
||||
import { Config } from "@/config/config"
|
||||
import { SessionCompaction } from "./compaction"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
import { Question } from "@/question"
|
||||
import { PartID } from "./schema"
|
||||
import type { SessionID, MessageID } from "./schema"
|
||||
@@ -163,7 +163,7 @@ export namespace SessionProcessor {
|
||||
)
|
||||
) {
|
||||
const agent = await Agent.get(input.assistantMessage.agent)
|
||||
await PermissionNext.ask({
|
||||
await Permission.ask({
|
||||
permission: "doom_loop",
|
||||
patterns: [value.toolName],
|
||||
sessionID: input.assistantMessage.sessionID,
|
||||
@@ -219,7 +219,7 @@ export namespace SessionProcessor {
|
||||
})
|
||||
|
||||
if (
|
||||
value.error instanceof PermissionNext.RejectedError ||
|
||||
value.error instanceof Permission.RejectedError ||
|
||||
value.error instanceof Question.RejectedError
|
||||
) {
|
||||
blocked = shouldBreak
|
||||
|
||||
@@ -41,7 +41,7 @@ import { fn } from "@/util/fn"
|
||||
import { SessionProcessor } from "./processor"
|
||||
import { TaskTool } from "@/tool/task"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
import { SessionStatus } from "./status"
|
||||
import { LLM } from "./llm"
|
||||
import { iife } from "@/util/iife"
|
||||
@@ -168,7 +168,7 @@ export namespace SessionPrompt {
|
||||
|
||||
// this is backwards compatibility for allowing `tools` to be specified when
|
||||
// prompting
|
||||
const permissions: PermissionNext.Ruleset = []
|
||||
const permissions: Permission.Ruleset = []
|
||||
for (const [tool, enabled] of Object.entries(input.tools ?? {})) {
|
||||
permissions.push({
|
||||
permission: tool,
|
||||
@@ -437,10 +437,10 @@ export namespace SessionPrompt {
|
||||
} satisfies MessageV2.ToolPart)) as MessageV2.ToolPart
|
||||
},
|
||||
async ask(req) {
|
||||
await PermissionNext.ask({
|
||||
await Permission.ask({
|
||||
...req,
|
||||
sessionID: sessionID,
|
||||
ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []),
|
||||
ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -781,11 +781,11 @@ export namespace SessionPrompt {
|
||||
}
|
||||
},
|
||||
async ask(req) {
|
||||
await PermissionNext.ask({
|
||||
await Permission.ask({
|
||||
...req,
|
||||
sessionID: input.session.id,
|
||||
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
|
||||
ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []),
|
||||
ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -1271,7 +1271,7 @@ export namespace SessionPrompt {
|
||||
|
||||
if (part.type === "agent") {
|
||||
// Check if this agent would be denied by task permission
|
||||
const perm = PermissionNext.evaluate("task", part.name, agent.permission)
|
||||
const perm = Permission.evaluate("task", part.name, agent.permission)
|
||||
const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlit
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
import type { Snapshot } from "../snapshot"
|
||||
import type { PermissionNext } from "../permission"
|
||||
import type { Permission } from "../permission"
|
||||
import type { ProjectID } from "../project/schema"
|
||||
import type { SessionID, MessageID, PartID } from "./schema"
|
||||
import type { WorkspaceID } from "../control-plane/schema"
|
||||
@@ -31,7 +31,7 @@ export const SessionTable = sqliteTable(
|
||||
summary_files: integer(),
|
||||
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
|
||||
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
|
||||
permission: text({ mode: "json" }).$type<PermissionNext.Ruleset>(),
|
||||
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
|
||||
...Timestamps,
|
||||
time_compacting: integer(),
|
||||
time_archived: integer(),
|
||||
@@ -99,5 +99,5 @@ export const PermissionTable = sqliteTable("permission", {
|
||||
.primaryKey()
|
||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
||||
...Timestamps,
|
||||
data: text({ mode: "json" }).notNull().$type<PermissionNext.Ruleset>(),
|
||||
data: text({ mode: "json" }).notNull().$type<Permission.Ruleset>(),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fff } from "../file/fff"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
|
||||
import { Instance } from "../project/instance"
|
||||
|
||||
@@ -11,7 +11,7 @@ import PROMPT_CODEX from "./prompt/codex.txt"
|
||||
import PROMPT_TRINITY from "./prompt/trinity.txt"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
import { Skill } from "@/skill"
|
||||
|
||||
export namespace SystemPrompt {
|
||||
@@ -41,7 +41,7 @@ export namespace SystemPrompt {
|
||||
`<directories>`,
|
||||
` ${
|
||||
project.vcs === "git" && false
|
||||
? await Fff.tree({
|
||||
? await Ripgrep.tree({
|
||||
cwd: Instance.directory,
|
||||
limit: 50,
|
||||
})
|
||||
@@ -53,7 +53,7 @@ export namespace SystemPrompt {
|
||||
}
|
||||
|
||||
export async function skills(agent: Agent.Info) {
|
||||
if (PermissionNext.disabled(["skill"], agent.permission).has("skill")) return
|
||||
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
|
||||
|
||||
const list = await Skill.available(agent)
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export namespace ShareNext {
|
||||
}> {
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
const active = Account.active()
|
||||
const active = await Account.active()
|
||||
if (!active?.active_org_id) {
|
||||
const baseUrl = await Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
|
||||
return { headers, api: legacyApi, baseUrl }
|
||||
|
||||
@@ -1 +1,262 @@
|
||||
export * from "./skill"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import z from "zod"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Global } from "@/global"
|
||||
import { Permission } from "@/permission"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Config } from "../config/config"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
import { Glob } from "../util/glob"
|
||||
import { Log } from "../util/log"
|
||||
import { Discovery } from "./discovery"
|
||||
|
||||
export namespace Skill {
|
||||
const log = Log.create({ service: "skill" })
|
||||
const EXTERNAL_DIRS = [".claude", ".agents"]
|
||||
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
||||
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
||||
const SKILL_PATTERN = "**/SKILL.md"
|
||||
|
||||
export const Info = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
location: z.string(),
|
||||
content: z.string(),
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const InvalidError = NamedError.create(
|
||||
"SkillInvalidError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
message: z.string().optional(),
|
||||
issues: z.custom<z.core.$ZodIssue[]>().optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const NameMismatchError = NamedError.create(
|
||||
"SkillNameMismatchError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
expected: z.string(),
|
||||
actual: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
type State = {
|
||||
skills: Record<string, Info>
|
||||
dirs: Set<string>
|
||||
task?: Promise<void>
|
||||
}
|
||||
|
||||
type Cache = State & {
|
||||
ensure: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly all: () => Effect.Effect<Info[]>
|
||||
readonly dirs: () => Effect.Effect<string[]>
|
||||
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
const add = async (state: State, match: string) => {
|
||||
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse skill ${match}`
|
||||
const { Session } = await import("@/session")
|
||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load skill", { skill: match, err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!md) return
|
||||
|
||||
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||
if (!parsed.success) return
|
||||
|
||||
if (state.skills[parsed.data.name]) {
|
||||
log.warn("duplicate skill name", {
|
||||
name: parsed.data.name,
|
||||
existing: state.skills[parsed.data.name].location,
|
||||
duplicate: match,
|
||||
})
|
||||
}
|
||||
|
||||
state.dirs.add(path.dirname(match))
|
||||
state.skills[parsed.data.name] = {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
location: match,
|
||||
content: md.content,
|
||||
}
|
||||
}
|
||||
|
||||
const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
|
||||
return Glob.scan(pattern, {
|
||||
cwd: root,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
symlink: true,
|
||||
dot: opts?.dot,
|
||||
})
|
||||
.then((matches) => Promise.all(matches.map((match) => add(state, match))))
|
||||
.catch((error) => {
|
||||
if (!opts?.scope) throw error
|
||||
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Migrate to Effect
|
||||
const create = (discovery: Discovery.Interface, directory: string, worktree: string): Cache => {
|
||||
const state: State = {
|
||||
skills: {},
|
||||
dirs: new Set<string>(),
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||
for (const dir of EXTERNAL_DIRS) {
|
||||
const root = path.join(Global.Path.home, dir)
|
||||
if (!(await Filesystem.isDir(root))) continue
|
||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||
}
|
||||
|
||||
for await (const root of Filesystem.up({
|
||||
targets: EXTERNAL_DIRS,
|
||||
start: directory,
|
||||
stop: worktree,
|
||||
})) {
|
||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of await Config.directories()) {
|
||||
await scan(state, dir, OPENCODE_SKILL_PATTERN)
|
||||
}
|
||||
|
||||
const cfg = await Config.get()
|
||||
for (const item of cfg.skills?.paths ?? []) {
|
||||
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
|
||||
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
|
||||
if (!(await Filesystem.isDir(dir))) {
|
||||
log.warn("skill path not found", { path: dir })
|
||||
continue
|
||||
}
|
||||
|
||||
await scan(state, dir, SKILL_PATTERN)
|
||||
}
|
||||
|
||||
for (const url of cfg.skills?.urls ?? []) {
|
||||
for (const dir of await Effect.runPromise(discovery.pull(url))) {
|
||||
state.dirs.add(dir)
|
||||
await scan(state, dir, SKILL_PATTERN)
|
||||
}
|
||||
}
|
||||
|
||||
log.info("init", { count: Object.keys(state.skills).length })
|
||||
}
|
||||
|
||||
const ensure = () => {
|
||||
if (state.task) return state.task
|
||||
state.task = load().catch((err) => {
|
||||
state.task = undefined
|
||||
throw err
|
||||
})
|
||||
return state.task
|
||||
}
|
||||
|
||||
return { ...state, ensure }
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, Discovery.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const discovery = yield* Discovery.Service
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Skill.state")((ctx) => Effect.sync(() => create(discovery, ctx.directory, ctx.worktree))),
|
||||
)
|
||||
|
||||
const ensure = Effect.fn("Skill.ensure")(function* () {
|
||||
const cache = yield* InstanceState.get(state)
|
||||
yield* Effect.promise(() => cache.ensure())
|
||||
return cache
|
||||
})
|
||||
|
||||
const get = Effect.fn("Skill.get")(function* (name: string) {
|
||||
const cache = yield* ensure()
|
||||
return cache.skills[name]
|
||||
})
|
||||
|
||||
const all = Effect.fn("Skill.all")(function* () {
|
||||
const cache = yield* ensure()
|
||||
return Object.values(cache.skills)
|
||||
})
|
||||
|
||||
const dirs = Effect.fn("Skill.dirs")(function* () {
|
||||
const cache = yield* ensure()
|
||||
return Array.from(cache.dirs)
|
||||
})
|
||||
|
||||
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
||||
const cache = yield* ensure()
|
||||
const list = Object.values(cache.skills).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
if (!agent) return list
|
||||
return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
||||
})
|
||||
|
||||
return Service.of({ get, all, dirs, available })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(Layer.provide(Discovery.defaultLayer))
|
||||
|
||||
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
||||
if (list.length === 0) return "No skills are currently available."
|
||||
|
||||
if (opts.verbose) {
|
||||
return [
|
||||
"<available_skills>",
|
||||
...list.flatMap((skill) => [
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
` <location>${pathToFileURL(skill.location).href}</location>`,
|
||||
" </skill>",
|
||||
]),
|
||||
"</available_skills>",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
|
||||
}
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
export async function get(name: string) {
|
||||
return runPromise((skill) => skill.get(name))
|
||||
}
|
||||
|
||||
export async function all() {
|
||||
return runPromise((skill) => skill.all())
|
||||
}
|
||||
|
||||
export async function dirs() {
|
||||
return runPromise((skill) => skill.dirs())
|
||||
}
|
||||
|
||||
export async function available(agent?: Agent.Info) {
|
||||
return runPromise((skill) => skill.available(agent))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import z from "zod"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Global } from "@/global"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Config } from "../config/config"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
import { Glob } from "../util/glob"
|
||||
import { Log } from "../util/log"
|
||||
import { Discovery } from "./discovery"
|
||||
|
||||
export namespace Skill {
|
||||
const log = Log.create({ service: "skill" })
|
||||
const EXTERNAL_DIRS = [".claude", ".agents"]
|
||||
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
||||
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
||||
const SKILL_PATTERN = "**/SKILL.md"
|
||||
|
||||
export const Info = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
location: z.string(),
|
||||
content: z.string(),
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const InvalidError = NamedError.create(
|
||||
"SkillInvalidError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
message: z.string().optional(),
|
||||
issues: z.custom<z.core.$ZodIssue[]>().optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const NameMismatchError = NamedError.create(
|
||||
"SkillNameMismatchError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
expected: z.string(),
|
||||
actual: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
type State = {
|
||||
skills: Record<string, Info>
|
||||
dirs: Set<string>
|
||||
task?: Promise<void>
|
||||
}
|
||||
|
||||
type Cache = State & {
|
||||
ensure: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly all: () => Effect.Effect<Info[]>
|
||||
readonly dirs: () => Effect.Effect<string[]>
|
||||
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
const add = async (state: State, match: string) => {
|
||||
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse skill ${match}`
|
||||
const { Session } = await import("@/session")
|
||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load skill", { skill: match, err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!md) return
|
||||
|
||||
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||
if (!parsed.success) return
|
||||
|
||||
if (state.skills[parsed.data.name]) {
|
||||
log.warn("duplicate skill name", {
|
||||
name: parsed.data.name,
|
||||
existing: state.skills[parsed.data.name].location,
|
||||
duplicate: match,
|
||||
})
|
||||
}
|
||||
|
||||
state.dirs.add(path.dirname(match))
|
||||
state.skills[parsed.data.name] = {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
location: match,
|
||||
content: md.content,
|
||||
}
|
||||
}
|
||||
|
||||
const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
|
||||
return Glob.scan(pattern, {
|
||||
cwd: root,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
symlink: true,
|
||||
dot: opts?.dot,
|
||||
})
|
||||
.then((matches) => Promise.all(matches.map((match) => add(state, match))))
|
||||
.catch((error) => {
|
||||
if (!opts?.scope) throw error
|
||||
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Migrate to Effect
|
||||
const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
|
||||
const state: State = {
|
||||
skills: {},
|
||||
dirs: new Set<string>(),
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||
for (const dir of EXTERNAL_DIRS) {
|
||||
const root = path.join(Global.Path.home, dir)
|
||||
if (!(await Filesystem.isDir(root))) continue
|
||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||
}
|
||||
|
||||
for await (const root of Filesystem.up({
|
||||
targets: EXTERNAL_DIRS,
|
||||
start: instance.directory,
|
||||
stop: instance.project.worktree,
|
||||
})) {
|
||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of await Config.directories()) {
|
||||
await scan(state, dir, OPENCODE_SKILL_PATTERN)
|
||||
}
|
||||
|
||||
const cfg = await Config.get()
|
||||
for (const item of cfg.skills?.paths ?? []) {
|
||||
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
|
||||
const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
|
||||
if (!(await Filesystem.isDir(dir))) {
|
||||
log.warn("skill path not found", { path: dir })
|
||||
continue
|
||||
}
|
||||
|
||||
await scan(state, dir, SKILL_PATTERN)
|
||||
}
|
||||
|
||||
for (const url of cfg.skills?.urls ?? []) {
|
||||
for (const dir of await Effect.runPromise(discovery.pull(url))) {
|
||||
state.dirs.add(dir)
|
||||
await scan(state, dir, SKILL_PATTERN)
|
||||
}
|
||||
}
|
||||
|
||||
log.info("init", { count: Object.keys(state.skills).length })
|
||||
}
|
||||
|
||||
const ensure = () => {
|
||||
if (state.task) return state.task
|
||||
state.task = load().catch((err) => {
|
||||
state.task = undefined
|
||||
throw err
|
||||
})
|
||||
return state.task
|
||||
}
|
||||
|
||||
return { ...state, ensure }
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
const discovery = yield* Discovery.Service
|
||||
const state = create(instance, discovery)
|
||||
|
||||
const get = Effect.fn("Skill.get")(function* (name: string) {
|
||||
yield* Effect.promise(() => state.ensure())
|
||||
return state.skills[name]
|
||||
})
|
||||
|
||||
const all = Effect.fn("Skill.all")(function* () {
|
||||
yield* Effect.promise(() => state.ensure())
|
||||
return Object.values(state.skills)
|
||||
})
|
||||
|
||||
const dirs = Effect.fn("Skill.dirs")(function* () {
|
||||
yield* Effect.promise(() => state.ensure())
|
||||
return Array.from(state.dirs)
|
||||
})
|
||||
|
||||
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
||||
yield* Effect.promise(() => state.ensure())
|
||||
const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
if (!agent) return list
|
||||
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
||||
})
|
||||
|
||||
return Service.of({ get, all, dirs, available })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
|
||||
Layer.provide(Discovery.defaultLayer),
|
||||
)
|
||||
|
||||
export async function get(name: string) {
|
||||
return runPromiseInstance(Service.use((skill) => skill.get(name)))
|
||||
}
|
||||
|
||||
export async function all() {
|
||||
return runPromiseInstance(Service.use((skill) => skill.all()))
|
||||
}
|
||||
|
||||
export async function dirs() {
|
||||
return runPromiseInstance(Service.use((skill) => skill.dirs()))
|
||||
}
|
||||
|
||||
export async function available(agent?: Agent.Info) {
|
||||
return runPromiseInstance(Service.use((skill) => skill.available(agent)))
|
||||
}
|
||||
|
||||
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
||||
if (list.length === 0) return "No skills are currently available."
|
||||
|
||||
if (opts.verbose) {
|
||||
return [
|
||||
"<available_skills>",
|
||||
...list.flatMap((skill) => [
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
` <location>${pathToFileURL(skill.location).href}</location>`,
|
||||
" </skill>",
|
||||
]),
|
||||
"</available_skills>",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "ef
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Config } from "../config/config"
|
||||
import { Global } from "../global"
|
||||
@@ -31,34 +31,6 @@ export namespace Snapshot {
|
||||
})
|
||||
export type FileDiff = z.infer<typeof FileDiff>
|
||||
|
||||
export async function cleanup() {
|
||||
return runPromiseInstance(Service.use((svc) => svc.cleanup()))
|
||||
}
|
||||
|
||||
export async function track() {
|
||||
return runPromiseInstance(Service.use((svc) => svc.track()))
|
||||
}
|
||||
|
||||
export async function patch(hash: string) {
|
||||
return runPromiseInstance(Service.use((svc) => svc.patch(hash)))
|
||||
}
|
||||
|
||||
export async function restore(snapshot: string) {
|
||||
return runPromiseInstance(Service.use((svc) => svc.restore(snapshot)))
|
||||
}
|
||||
|
||||
export async function revert(patches: Patch[]) {
|
||||
return runPromiseInstance(Service.use((svc) => svc.revert(patches)))
|
||||
}
|
||||
|
||||
export async function diff(hash: string) {
|
||||
return runPromiseInstance(Service.use((svc) => svc.diff(hash)))
|
||||
}
|
||||
|
||||
export async function diffFull(from: string, to: string) {
|
||||
return runPromiseInstance(Service.use((svc) => svc.diffFull(from, to)))
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "snapshot" })
|
||||
const prune = "7.days"
|
||||
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
|
||||
@@ -71,7 +43,10 @@ export namespace Snapshot {
|
||||
readonly stderr: string
|
||||
}
|
||||
|
||||
type State = Omit<Interface, "init">
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
readonly track: () => Effect.Effect<string | undefined>
|
||||
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
|
||||
@@ -83,262 +58,300 @@ export namespace Snapshot {
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
|
||||
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* InstanceContext
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const directory = ctx.directory
|
||||
const worktree = ctx.worktree
|
||||
const project = ctx.project
|
||||
const gitdir = path.join(Global.Path.data, "snapshot", project.id)
|
||||
|
||||
const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
||||
const proc = ChildProcess.make("git", cmd, {
|
||||
cwd: opts?.cwd,
|
||||
env: opts?.env,
|
||||
extendEnv: true,
|
||||
})
|
||||
const handle = yield* spawner.spawn(proc)
|
||||
const [text, stderr] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const code = yield* handle.exitCode
|
||||
return { code, text, stderr } satisfies GitResult
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch((err) =>
|
||||
Effect.succeed({
|
||||
code: ChildProcessSpawner.ExitCode(1),
|
||||
text: "",
|
||||
stderr: String(err),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// Snapshot-specific error handling on top of AppFileSystem
|
||||
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
||||
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
||||
|
||||
const enabled = Effect.fnUntraced(function* () {
|
||||
if (project.vcs !== "git") return false
|
||||
return (yield* Effect.promise(() => Config.get())).snapshot !== false
|
||||
})
|
||||
|
||||
const excludes = Effect.fnUntraced(function* () {
|
||||
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||
cwd: worktree,
|
||||
})
|
||||
const file = result.text.trim()
|
||||
if (!file) return
|
||||
if (!(yield* exists(file))) return
|
||||
return file
|
||||
})
|
||||
|
||||
const sync = Effect.fnUntraced(function* () {
|
||||
const file = yield* excludes()
|
||||
const target = path.join(gitdir, "info", "exclude")
|
||||
yield* fs.ensureDir(path.join(gitdir, "info")).pipe(Effect.orDie)
|
||||
if (!file) {
|
||||
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
|
||||
return
|
||||
}
|
||||
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const add = Effect.fnUntraced(function* () {
|
||||
yield* sync()
|
||||
yield* git([...cfg, ...args(["add", "."])], { cwd: directory })
|
||||
})
|
||||
|
||||
const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
if (!(yield* exists(gitdir))) return
|
||||
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("cleanup", { prune })
|
||||
})
|
||||
|
||||
const track = Effect.fn("Snapshot.track")(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
const existed = yield* exists(gitdir)
|
||||
yield* fs.ensureDir(gitdir).pipe(Effect.orDie)
|
||||
if (!existed) {
|
||||
yield* git(["init"], {
|
||||
env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
|
||||
})
|
||||
yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"])
|
||||
yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"])
|
||||
yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"])
|
||||
yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"])
|
||||
log.info("initialized")
|
||||
}
|
||||
yield* add()
|
||||
const result = yield* git(args(["write-tree"]), { cwd: directory })
|
||||
const hash = result.text.trim()
|
||||
log.info("tracking", { hash, cwd: directory, git: gitdir })
|
||||
return hash
|
||||
})
|
||||
|
||||
const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
|
||||
yield* add()
|
||||
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], {
|
||||
cwd: directory,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
return {
|
||||
hash,
|
||||
files: result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => path.join(worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
})
|
||||
|
||||
const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
|
||||
if (result.code === 0) {
|
||||
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
})
|
||||
|
||||
const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
|
||||
const seen = new Set<string>()
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (seen.has(file)) continue
|
||||
seen.add(file)
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree })
|
||||
if (result.code !== 0) {
|
||||
const rel = path.relative(worktree, file)
|
||||
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], { cwd: worktree })
|
||||
if (tree.code === 0 && tree.text.trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file })
|
||||
yield* remove(file)
|
||||
}
|
||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner> =
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Snapshot.state")(function* (ctx) {
|
||||
const state = {
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id),
|
||||
vcs: ctx.project.vcs,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
|
||||
yield* add()
|
||||
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
|
||||
cwd: worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
return result.text.trim()
|
||||
})
|
||||
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
|
||||
|
||||
const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
|
||||
const result: Snapshot.FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
const git = Effect.fnUntraced(
|
||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
||||
const proc = ChildProcess.make("git", cmd, {
|
||||
cwd: opts?.cwd,
|
||||
env: opts?.env,
|
||||
extendEnv: true,
|
||||
})
|
||||
const handle = yield* spawner.spawn(proc)
|
||||
const [text, stderr] = yield* Effect.all(
|
||||
[
|
||||
Stream.mkString(Stream.decodeText(handle.stdout)),
|
||||
Stream.mkString(Stream.decodeText(handle.stderr)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const code = yield* handle.exitCode
|
||||
return { code, text, stderr } satisfies GitResult
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch((err) =>
|
||||
Effect.succeed({
|
||||
code: ChildProcessSpawner.ExitCode(1),
|
||||
text: "",
|
||||
stderr: String(err),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const statuses = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
|
||||
{ cwd: directory },
|
||||
)
|
||||
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
||||
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
||||
|
||||
for (const line of statuses.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [code, file] = line.split("\t")
|
||||
if (!code || !file) continue
|
||||
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
||||
}
|
||||
const enabled = Effect.fnUntraced(function* () {
|
||||
if (state.vcs !== "git") return false
|
||||
return (yield* Effect.promise(() => Config.get())).snapshot !== false
|
||||
})
|
||||
|
||||
const numstat = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||
{
|
||||
cwd: directory,
|
||||
},
|
||||
)
|
||||
const excludes = Effect.fnUntraced(function* () {
|
||||
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
const file = result.text.trim()
|
||||
if (!file) return
|
||||
if (!(yield* exists(file))) return
|
||||
return file
|
||||
})
|
||||
|
||||
for (const line of numstat.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [adds, dels, file] = line.split("\t")
|
||||
if (!file) continue
|
||||
const binary = adds === "-" && dels === "-"
|
||||
const [before, after] = binary
|
||||
? ["", ""]
|
||||
: yield* Effect.all(
|
||||
[
|
||||
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
const sync = Effect.fnUntraced(function* () {
|
||||
const file = yield* excludes()
|
||||
const target = path.join(state.gitdir, "info", "exclude")
|
||||
yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
|
||||
if (!file) {
|
||||
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
|
||||
return
|
||||
}
|
||||
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const add = Effect.fnUntraced(function* () {
|
||||
yield* sync()
|
||||
yield* git([...cfg, ...args(["add", "."])], { cwd: state.directory })
|
||||
})
|
||||
|
||||
const cleanup = Effect.fnUntraced(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
if (!(yield* exists(state.gitdir))) return
|
||||
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("cleanup", { prune })
|
||||
})
|
||||
|
||||
const track = Effect.fnUntraced(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
const existed = yield* exists(state.gitdir)
|
||||
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
|
||||
if (!existed) {
|
||||
yield* git(["init"], {
|
||||
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
|
||||
})
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
|
||||
log.info("initialized")
|
||||
}
|
||||
yield* add()
|
||||
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
|
||||
const hash = result.text.trim()
|
||||
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
|
||||
return hash
|
||||
})
|
||||
|
||||
const patch = Effect.fnUntraced(function* (hash: string) {
|
||||
yield* add()
|
||||
const result = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
const additions = binary ? 0 : parseInt(adds)
|
||||
const deletions = binary ? 0 : parseInt(dels)
|
||||
result.push({
|
||||
file,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
status: status.get(file) ?? "modified",
|
||||
})
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
return {
|
||||
hash,
|
||||
files: result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
const restore = Effect.fnUntraced(function* (snapshot: string) {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
|
||||
if (result.code === 0) {
|
||||
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: state.worktree })
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
})
|
||||
|
||||
yield* cleanup().pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.delay(Duration.minutes(1)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
|
||||
const seen = new Set<string>()
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (seen.has(file)) continue
|
||||
seen.add(file)
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
const rel = path.relative(state.worktree, file)
|
||||
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (tree.code === 0 && tree.text.trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file })
|
||||
yield* remove(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
|
||||
}),
|
||||
)
|
||||
const diff = Effect.fnUntraced(function* (hash: string) {
|
||||
yield* add()
|
||||
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
return result.text.trim()
|
||||
})
|
||||
|
||||
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
|
||||
const result: Snapshot.FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
const statuses = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
|
||||
{ cwd: state.directory },
|
||||
)
|
||||
|
||||
for (const line of statuses.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [code, file] = line.split("\t")
|
||||
if (!code || !file) continue
|
||||
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
||||
}
|
||||
|
||||
const numstat = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
|
||||
for (const line of numstat.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [adds, dels, file] = line.split("\t")
|
||||
if (!file) continue
|
||||
const binary = adds === "-" && dels === "-"
|
||||
const [before, after] = binary
|
||||
? ["", ""]
|
||||
: yield* Effect.all(
|
||||
[
|
||||
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const additions = binary ? 0 : parseInt(adds)
|
||||
const deletions = binary ? 0 : parseInt(dels)
|
||||
result.push({
|
||||
file,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
status: status.get(file) ?? "modified",
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
yield* cleanup().pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.delay(Duration.minutes(1)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return { cleanup, track, patch, restore, revert, diff, diffFull }
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
init: Effect.fn("Snapshot.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
}),
|
||||
cleanup: Effect.fn("Snapshot.cleanup")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.cleanup())
|
||||
}),
|
||||
track: Effect.fn("Snapshot.track")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.track())
|
||||
}),
|
||||
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
|
||||
}),
|
||||
restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
|
||||
}),
|
||||
revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
|
||||
}),
|
||||
diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
|
||||
}),
|
||||
diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(NodeChildProcessSpawner.layer),
|
||||
@@ -346,4 +359,38 @@ export namespace Snapshot {
|
||||
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
export async function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
}
|
||||
|
||||
export async function cleanup() {
|
||||
return runPromise((svc) => svc.cleanup())
|
||||
}
|
||||
|
||||
export async function track() {
|
||||
return runPromise((svc) => svc.track())
|
||||
}
|
||||
|
||||
export async function patch(hash: string) {
|
||||
return runPromise((svc) => svc.patch(hash))
|
||||
}
|
||||
|
||||
export async function restore(snapshot: string) {
|
||||
return runPromise((svc) => svc.restore(snapshot))
|
||||
}
|
||||
|
||||
export async function revert(patches: Patch[]) {
|
||||
return runPromise((svc) => svc.revert(patches))
|
||||
}
|
||||
|
||||
export async function diff(hash: string) {
|
||||
return runPromise((svc) => svc.diff(hash))
|
||||
}
|
||||
|
||||
export async function diffFull(from: string, to: string) {
|
||||
return runPromise((svc) => svc.diffFull(from, to))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ const log = Log.create({ service: "db" })
|
||||
|
||||
export namespace Database {
|
||||
export const Path = iife(() => {
|
||||
if (Flag.OPENCODE_DB) {
|
||||
if (path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
|
||||
return path.join(Global.Path.data, Flag.OPENCODE_DB)
|
||||
}
|
||||
const channel = Installation.CHANNEL
|
||||
if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
|
||||
return path.join(Global.Path.data, "opencode.db")
|
||||
|
||||
@@ -1,95 +1,11 @@
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import { Tool } from "./tool"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
import { Fff } from "../file/fff"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Instance } from "../project/instance"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { Glob } from "../util/glob"
|
||||
|
||||
type Row = {
|
||||
path: string
|
||||
rel: string
|
||||
}
|
||||
|
||||
function include(pattern: string) {
|
||||
const val = pattern.trim().replaceAll("\\", "/")
|
||||
if (!val) return "*"
|
||||
const flat = val.replaceAll("**/", "").replaceAll("/**", "/")
|
||||
const idx = flat.lastIndexOf("/")
|
||||
if (idx < 0) return flat
|
||||
const dir = flat.slice(0, idx + 1)
|
||||
const glob = flat.slice(idx + 1)
|
||||
if (!glob) return dir
|
||||
return `${dir} ${glob}`
|
||||
}
|
||||
|
||||
function words(text: string) {
|
||||
return text.trim().split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
function norm(text: string) {
|
||||
return text.replaceAll("\\", "/")
|
||||
}
|
||||
|
||||
function hidden(rel: string) {
|
||||
return norm(rel).split("/").includes(".git")
|
||||
}
|
||||
|
||||
function broad(pattern: string) {
|
||||
const val = norm(pattern.trim())
|
||||
if (!val) return true
|
||||
if (["*", "**", "**/*", "./**", "./**/*"].includes(val)) return true
|
||||
return /^(\*\*\/)?\*$/.test(val)
|
||||
}
|
||||
|
||||
function allowed(pattern: string, rel: string) {
|
||||
if (Glob.match(pattern, rel)) return true
|
||||
const file = rel.split("/").at(-1) ?? rel
|
||||
return Glob.match(pattern, file)
|
||||
}
|
||||
|
||||
function pick(items: { path: string; relativePath: string }[]) {
|
||||
return items
|
||||
.map((item) => ({
|
||||
path: item.path,
|
||||
rel: norm(item.relativePath),
|
||||
}))
|
||||
.filter((item) => !hidden(item.rel))
|
||||
}
|
||||
|
||||
function top(rows: Row[]) {
|
||||
const out = new Map<string, number>()
|
||||
for (const row of rows) {
|
||||
const parts = row.rel.split("/")
|
||||
const key = parts.length < 2 ? "." : parts.slice(0, Math.min(2, parts.length - 1)).join("/") + "/"
|
||||
out.set(key, (out.get(key) ?? 0) + 1)
|
||||
}
|
||||
return Array.from(out.entries())
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, 12)
|
||||
}
|
||||
|
||||
async function scan(pattern: string, dir: string) {
|
||||
const direct = await Glob.scan(pattern, {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
})
|
||||
const out = direct.length > 0 ? direct : await Glob.scan(`**/${pattern}`, {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
})
|
||||
return out
|
||||
.map((file) => ({
|
||||
path: file,
|
||||
rel: norm(path.relative(dir, file)),
|
||||
}))
|
||||
.filter((item) => !hidden(item.rel))
|
||||
}
|
||||
|
||||
export const GlobTool = Tool.define("glob", {
|
||||
description: DESCRIPTION,
|
||||
@@ -113,60 +29,35 @@ export const GlobTool = Tool.define("glob", {
|
||||
},
|
||||
})
|
||||
|
||||
let dir = params.path ?? Instance.directory
|
||||
dir = path.isAbsolute(dir) ? dir : path.resolve(Instance.directory, dir)
|
||||
await assertExternalDirectory(ctx, dir, { kind: "directory" })
|
||||
let search = params.path ?? Instance.directory
|
||||
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
|
||||
await assertExternalDirectory(ctx, search, { kind: "directory" })
|
||||
|
||||
const limit = 100
|
||||
const wide = broad(params.pattern)
|
||||
const size = wide ? 400 : limit + 1
|
||||
|
||||
const first = await Fff.files({
|
||||
cwd: dir,
|
||||
query: include(params.pattern),
|
||||
size,
|
||||
current: path.join(dir, ".opencode"),
|
||||
})
|
||||
|
||||
let fallback = false
|
||||
let rows = pick(first.items).filter((row) => allowed(params.pattern, row.rel))
|
||||
if (!rows.length) {
|
||||
const list = words(params.pattern)
|
||||
if (list.length >= 3) {
|
||||
const short = list.slice(0, 2).join(" ")
|
||||
const next = await Fff.files({
|
||||
cwd: dir,
|
||||
query: include(short),
|
||||
size,
|
||||
current: path.join(dir, ".opencode"),
|
||||
})
|
||||
rows = pick(next.items).filter((row) => allowed(params.pattern, row.rel))
|
||||
const files = []
|
||||
let truncated = false
|
||||
for await (const file of Ripgrep.files({
|
||||
cwd: search,
|
||||
glob: [params.pattern],
|
||||
signal: ctx.abort,
|
||||
})) {
|
||||
if (files.length >= limit) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
const full = path.resolve(search, file)
|
||||
const stats = Filesystem.stat(full)?.mtime.getTime() ?? 0
|
||||
files.push({
|
||||
path: full,
|
||||
mtime: stats,
|
||||
})
|
||||
}
|
||||
if (!rows.length) {
|
||||
fallback = true
|
||||
rows = (await scan(params.pattern, dir)).filter((row) => allowed(params.pattern, row.rel))
|
||||
}
|
||||
|
||||
const truncated = rows.length > limit
|
||||
const files = rows.slice(0, limit).map((row) => row.path)
|
||||
files.sort((a, b) => b.mtime - a.mtime)
|
||||
|
||||
const output = []
|
||||
if (files.length === 0) output.push("No files found")
|
||||
if (files.length > 0) {
|
||||
output.push(...files)
|
||||
if (wide && truncated) {
|
||||
const dirs = top(rows)
|
||||
if (dirs.length > 0) {
|
||||
output.push("")
|
||||
output.push("Top directories in this result set:")
|
||||
output.push(...dirs.map(([dir, count]) => `${dir} (${count})`))
|
||||
}
|
||||
}
|
||||
if (fallback) {
|
||||
output.push("")
|
||||
output.push("(Used filesystem glob fallback for this pattern.)")
|
||||
}
|
||||
output.push(...files.map((f) => f.path))
|
||||
if (truncated) {
|
||||
output.push("")
|
||||
output.push(
|
||||
@@ -176,7 +67,7 @@ export const GlobTool = Tool.define("glob", {
|
||||
}
|
||||
|
||||
return {
|
||||
title: path.relative(Instance.worktree, dir),
|
||||
title: path.relative(Instance.worktree, search),
|
||||
metadata: {
|
||||
count: files.length,
|
||||
truncated,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- Fast file pattern matching tool that uses fuzzy-first indexing and frecency ranking
|
||||
- Fast file pattern matching tool that works with any codebase size
|
||||
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
|
||||
- Returns matching file paths prioritized by recent and relevant files
|
||||
- Returns matching file paths sorted by modification time
|
||||
- Use this tool when you need to find files by name patterns
|
||||
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
|
||||
- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.
|
||||
|
||||
@@ -1,136 +1,16 @@
|
||||
import z from "zod"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Tool } from "./tool"
|
||||
import { Fff } from "../file/fff"
|
||||
import type { GrepMode } from "@ff-labs/fff-node"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
import DESCRIPTION from "./grep.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import path from "path"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { Glob } from "../util/glob"
|
||||
|
||||
const MAX_LINE = 180
|
||||
const MAX_MATCH = 100
|
||||
const MAX_DEF_FIRST = 8
|
||||
const MAX_DEF_NEXT = 5
|
||||
|
||||
function isRegex(pattern: string) {
|
||||
return /[.*+?^${}()|[\]\\]/.test(pattern)
|
||||
}
|
||||
|
||||
function isConstraint(text: string) {
|
||||
return text.startsWith("!") || text.startsWith("*") || text.endsWith("/")
|
||||
}
|
||||
|
||||
function clean(text: string) {
|
||||
return text.replaceAll(":", "").replaceAll("-", "").replaceAll("_", "").toLowerCase().trim()
|
||||
}
|
||||
|
||||
function include(text?: string) {
|
||||
if (!text) return undefined
|
||||
const val = text.trim().replaceAll("\\", "/")
|
||||
if (!val) return undefined
|
||||
const flat = val.replaceAll("**/", "").replaceAll("/**", "/")
|
||||
const idx = flat.lastIndexOf("/")
|
||||
if (idx < 0) return flat
|
||||
const dir = flat.slice(0, idx + 1)
|
||||
const glob = flat.slice(idx + 1)
|
||||
if (!glob) return dir
|
||||
return `${dir} ${glob}`
|
||||
}
|
||||
|
||||
function query(pattern: string, inc?: string) {
|
||||
if (!inc) return pattern
|
||||
return `${inc} ${pattern}`.trim()
|
||||
}
|
||||
|
||||
function norm(text: string) {
|
||||
return text.replaceAll("\\", "/")
|
||||
}
|
||||
|
||||
function allowed(hit: Fff.Hit, inc?: string) {
|
||||
if (!inc) return true
|
||||
const rel = norm(hit.relativePath)
|
||||
if (Glob.match(inc, rel)) return true
|
||||
return Glob.match(inc, norm(hit.fileName))
|
||||
}
|
||||
|
||||
function def(line: string) {
|
||||
const text = line.trim()
|
||||
if (!text) return false
|
||||
return /^(export\s+)?(default\s+)?(async\s+)?(function|class|interface|type|enum|const|let|var)\b/.test(text)
|
||||
}
|
||||
|
||||
function imp(line: string) {
|
||||
return /^(import\b|export\s+\{.*\}\s+from\b|use\b|#include\b|require\()/.test(line.trim())
|
||||
}
|
||||
|
||||
function line(text: string, ranges: [number, number][]) {
|
||||
const trim = text.trim()
|
||||
if (trim.length <= MAX_LINE) return trim
|
||||
const first = ranges[0]
|
||||
if (!first) return trim.slice(0, MAX_LINE - 3) + "..."
|
||||
const start = Math.max(0, first[0] - Math.floor(MAX_LINE / 3))
|
||||
const end = Math.min(trim.length, start + MAX_LINE)
|
||||
const body = trim.slice(start, end)
|
||||
const pre = start > 0 ? "..." : ""
|
||||
const post = end < trim.length ? "..." : ""
|
||||
return pre + body + post
|
||||
}
|
||||
|
||||
function group(rows: Item[]) {
|
||||
const out = new Map<string, Item[]>()
|
||||
for (const row of rows) {
|
||||
const list = out.get(row.hit.path)
|
||||
if (list) {
|
||||
list.push(row)
|
||||
continue
|
||||
}
|
||||
out.set(row.hit.path, [row])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type Item = {
|
||||
hit: Fff.Hit
|
||||
def: boolean
|
||||
imp: boolean
|
||||
idx: number
|
||||
}
|
||||
|
||||
async function run(input: {
|
||||
cwd: string
|
||||
pattern: string
|
||||
inc?: string
|
||||
mode: GrepMode
|
||||
max: number
|
||||
before: number
|
||||
after: number
|
||||
}) {
|
||||
const first = await Fff.grep({
|
||||
cwd: input.cwd,
|
||||
query: query(input.pattern, include(input.inc)),
|
||||
mode: input.mode,
|
||||
max: input.max,
|
||||
before: input.before,
|
||||
after: input.after,
|
||||
})
|
||||
const head = first.items.filter((hit) => allowed(hit, input.inc))
|
||||
if (head.length) return { out: first, hits: head }
|
||||
if (!input.inc) return { out: first, hits: head }
|
||||
const raw = await Fff.grep({
|
||||
cwd: input.cwd,
|
||||
query: input.pattern,
|
||||
mode: input.mode,
|
||||
max: input.max,
|
||||
before: input.before,
|
||||
after: input.after,
|
||||
})
|
||||
return {
|
||||
out: raw,
|
||||
hits: raw.items.filter((hit) => allowed(hit, input.inc)),
|
||||
}
|
||||
}
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
|
||||
export const GrepTool = Tool.define("grep", {
|
||||
description: DESCRIPTION,
|
||||
@@ -155,86 +35,35 @@ export const GrepTool = Tool.define("grep", {
|
||||
},
|
||||
})
|
||||
|
||||
let dir = params.path ?? Instance.directory
|
||||
dir = path.isAbsolute(dir) ? dir : path.resolve(Instance.directory, dir)
|
||||
await assertExternalDirectory(ctx, dir, { kind: "directory" })
|
||||
let searchPath = params.path ?? Instance.directory
|
||||
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
|
||||
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
|
||||
|
||||
const mode = isRegex(params.pattern) ? "regex" : "plain"
|
||||
const exact = await run({
|
||||
cwd: dir,
|
||||
pattern: params.pattern,
|
||||
inc: params.include,
|
||||
mode,
|
||||
max: 10,
|
||||
before: 0,
|
||||
after: 4,
|
||||
const rgPath = await Ripgrep.filepath()
|
||||
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
|
||||
if (params.include) {
|
||||
args.push("--glob", params.include)
|
||||
}
|
||||
args.push(searchPath)
|
||||
|
||||
const proc = Process.spawn([rgPath, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
abort: ctx.abort,
|
||||
})
|
||||
|
||||
let phase = "exact"
|
||||
let note = ""
|
||||
let warn = exact.out.regexFallbackError
|
||||
let hits = exact.hits
|
||||
|
||||
if (!hits.length) {
|
||||
const words = params.pattern.trim().split(/\s+/).filter(Boolean)
|
||||
if (words.length >= 2 && !isConstraint(words[0])) {
|
||||
const next = words.slice(1).join(" ")
|
||||
const step = await run({
|
||||
cwd: dir,
|
||||
pattern: next,
|
||||
inc: params.include,
|
||||
mode: isRegex(next) ? "regex" : "plain",
|
||||
max: 10,
|
||||
before: 0,
|
||||
after: 4,
|
||||
})
|
||||
warn = warn ?? step.out.regexFallbackError
|
||||
if (step.hits.length > 0 && step.hits.length <= 10) {
|
||||
phase = "broad"
|
||||
note = `0 exact matches. Broadened query \`${next}\`:`
|
||||
hits = step.hits
|
||||
}
|
||||
}
|
||||
if (!proc.stdout || !proc.stderr) {
|
||||
throw new Error("Process output not available")
|
||||
}
|
||||
|
||||
if (!hits.length) {
|
||||
const fuzzy = clean(params.pattern)
|
||||
if (fuzzy) {
|
||||
const step = await run({
|
||||
cwd: dir,
|
||||
pattern: fuzzy,
|
||||
inc: params.include,
|
||||
mode: "fuzzy",
|
||||
max: 3,
|
||||
before: 0,
|
||||
after: 2,
|
||||
})
|
||||
if (step.hits.length) {
|
||||
phase = "fuzzy"
|
||||
note = `0 exact matches. ${step.hits.length} approximate:`
|
||||
hits = step.hits
|
||||
}
|
||||
}
|
||||
}
|
||||
const output = await text(proc.stdout)
|
||||
const errorOutput = await text(proc.stderr)
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (!hits.length && params.pattern.includes("/")) {
|
||||
const files = await Fff.files({
|
||||
cwd: dir,
|
||||
query: params.pattern,
|
||||
size: 1,
|
||||
})
|
||||
const row = files.items[0]
|
||||
const score = files.scores[0]
|
||||
if (row && score && score.baseScore > params.pattern.length * 10) {
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: { matches: 0, truncated: false },
|
||||
output: `0 content matches. But there is a relevant file path:\n${row.path}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hits.length) {
|
||||
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
|
||||
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
|
||||
// Only fail if exit code is 2 AND no output was produced
|
||||
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: { matches: 0, truncated: false },
|
||||
@@ -242,74 +71,86 @@ export const GrepTool = Tool.define("grep", {
|
||||
}
|
||||
}
|
||||
|
||||
const rows = hits.map((hit, idx) => ({
|
||||
hit,
|
||||
idx,
|
||||
def: def(hit.lineContent),
|
||||
imp: imp(hit.lineContent),
|
||||
}))
|
||||
const hasDef = rows.some((row) => row.def)
|
||||
const show = hasDef ? rows.filter((row) => !row.imp || row.def) : rows
|
||||
show.sort((a, b) => {
|
||||
const ak = a.def ? 0 : a.imp ? 2 : 1
|
||||
const bk = b.def ? 0 : b.imp ? 2 : 1
|
||||
if (ak !== bk) return ak - bk
|
||||
return a.idx - b.idx
|
||||
})
|
||||
|
||||
const total = show.length
|
||||
const trim = show.slice(0, MAX_MATCH)
|
||||
const over = total > MAX_MATCH
|
||||
const files = new Set(trim.map((row) => row.hit.path)).size
|
||||
const budget = files <= 3 ? 5000 : files <= 8 ? 3500 : 2500
|
||||
const read = (trim.find((row) => row.def) ?? trim[0]).hit.path
|
||||
|
||||
const out: string[] = []
|
||||
if (phase === "exact") out.push(`Found ${total} matches${over ? ` (showing first ${MAX_MATCH})` : ""}`)
|
||||
if (phase !== "exact") out.push(note)
|
||||
out.push(`Read ${read}`)
|
||||
if (warn) out.push(`! regex failed: ${warn}`)
|
||||
|
||||
const by = group(trim)
|
||||
let used = out.join("\n").length
|
||||
let cut = false
|
||||
let firstDef = true
|
||||
let shown = 0
|
||||
for (const [file, list] of by.entries()) {
|
||||
const chunk = ["", `${file}:`]
|
||||
let add = 0
|
||||
for (const row of list) {
|
||||
add++
|
||||
chunk.push(` Line ${row.hit.lineNumber}: ${line(row.hit.lineContent, row.hit.matchRanges)}`)
|
||||
if (!row.def) continue
|
||||
const max = firstDef ? MAX_DEF_FIRST : MAX_DEF_NEXT
|
||||
firstDef = false
|
||||
for (const extra of (row.hit.contextAfter ?? []).slice(0, max)) {
|
||||
chunk.push(` ${line(extra, [])}`)
|
||||
}
|
||||
}
|
||||
const text = chunk.join("\n")
|
||||
if (used + text.length > budget && shown > 0) {
|
||||
cut = true
|
||||
break
|
||||
}
|
||||
out.push(...chunk)
|
||||
used += text.length
|
||||
shown += add
|
||||
if (exitCode !== 0 && exitCode !== 2) {
|
||||
throw new Error(`ripgrep failed: ${errorOutput}`)
|
||||
}
|
||||
|
||||
if (over || cut) {
|
||||
out.push("")
|
||||
out.push(`(Results truncated: showing first ${shown} results. Consider using a more specific path or pattern.)`)
|
||||
const hasErrors = exitCode === 2
|
||||
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
const lines = output.trim().split(/\r?\n/)
|
||||
const matches = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line) continue
|
||||
|
||||
const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
|
||||
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
|
||||
|
||||
const lineNum = parseInt(lineNumStr, 10)
|
||||
const lineText = lineTextParts.join("|")
|
||||
|
||||
const stats = Filesystem.stat(filePath)
|
||||
if (!stats) continue
|
||||
|
||||
matches.push({
|
||||
path: filePath,
|
||||
modTime: stats.mtime.getTime(),
|
||||
lineNum,
|
||||
lineText,
|
||||
})
|
||||
}
|
||||
|
||||
matches.sort((a, b) => b.modTime - a.modTime)
|
||||
|
||||
const limit = 100
|
||||
const truncated = matches.length > limit
|
||||
const finalMatches = truncated ? matches.slice(0, limit) : matches
|
||||
|
||||
if (finalMatches.length === 0) {
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: { matches: 0, truncated: false },
|
||||
output: "No files found",
|
||||
}
|
||||
}
|
||||
|
||||
const totalMatches = matches.length
|
||||
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
|
||||
|
||||
let currentFile = ""
|
||||
for (const match of finalMatches) {
|
||||
if (currentFile !== match.path) {
|
||||
if (currentFile !== "") {
|
||||
outputLines.push("")
|
||||
}
|
||||
currentFile = match.path
|
||||
outputLines.push(`${match.path}:`)
|
||||
}
|
||||
const truncatedLineText =
|
||||
match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText
|
||||
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
outputLines.push("")
|
||||
outputLines.push(
|
||||
`(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
|
||||
)
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
outputLines.push("")
|
||||
outputLines.push("(Some paths were inaccessible and skipped)")
|
||||
}
|
||||
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: {
|
||||
matches: total,
|
||||
truncated: over || cut,
|
||||
matches: totalMatches,
|
||||
truncated,
|
||||
},
|
||||
output: out.join("\n"),
|
||||
output: outputLines.join("\n"),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
- Fast content search tool that uses fuzzy-first indexing and frecency ranking
|
||||
- Searches file contents with plain text, regex, and typo-tolerant fuzzy fallback
|
||||
- Supports regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
|
||||
- Fast content search tool that works with any codebase size
|
||||
- Searches file contents using regular expressions
|
||||
- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
|
||||
- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
|
||||
- Returns file paths and line numbers, prioritizing likely definitions and high-signal results
|
||||
- Includes smart retries (query broadening and path suggestions) when exact matches fail
|
||||
- Returns file paths and line numbers with at least one match sorted by modification time
|
||||
- Use this tool when you need to find files containing specific patterns
|
||||
- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.
|
||||
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
|
||||
|
||||
@@ -3,9 +3,8 @@ import { Tool } from "./tool"
|
||||
import * as path from "path"
|
||||
import DESCRIPTION from "./ls.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Fff } from "../file/fff"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { Glob } from "../util/glob"
|
||||
|
||||
export const IGNORE_PATTERNS = [
|
||||
"node_modules/",
|
||||
@@ -56,18 +55,11 @@ export const ListTool = Tool.define("list", {
|
||||
})
|
||||
|
||||
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
|
||||
const rows = (await Glob.scan("**/*", {
|
||||
cwd: searchPath,
|
||||
include: "file",
|
||||
dot: true,
|
||||
}))
|
||||
.map((row) => row.replaceAll("\\", "/"))
|
||||
.filter((row) => {
|
||||
ctx.abort.throwIfAborted()
|
||||
return Fff.allowed({ rel: row, glob: ignoreGlobs, hidden: true })
|
||||
})
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
const files = rows.slice(0, LIMIT)
|
||||
const files = []
|
||||
for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs, signal: ctx.abort })) {
|
||||
files.push(file)
|
||||
if (files.length >= LIMIT) break
|
||||
}
|
||||
|
||||
// Build directory structure
|
||||
const dirs = new Set<string>()
|
||||
@@ -121,7 +113,7 @@ export const ListTool = Tool.define("list", {
|
||||
title: path.relative(Instance.worktree, searchPath),
|
||||
metadata: {
|
||||
count: files.length,
|
||||
truncated: rows.length > LIMIT,
|
||||
truncated: files.length >= LIMIT,
|
||||
},
|
||||
output,
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ import { pathToFileURL } from "url"
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import { Skill } from "../skill"
|
||||
import { Fff } from "../file/fff"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Glob } from "../util/glob"
|
||||
|
||||
export const SkillTool = Tool.define("skill", async (ctx) => {
|
||||
const list = await Skill.available(ctx?.agent)
|
||||
@@ -61,17 +60,22 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
|
||||
|
||||
const limit = 10
|
||||
const files = await iife(async () => {
|
||||
ctx.abort.throwIfAborted()
|
||||
return (await Glob.scan("**/*", {
|
||||
const arr = []
|
||||
for await (const file of Ripgrep.files({
|
||||
cwd: dir,
|
||||
include: "file",
|
||||
dot: true,
|
||||
}))
|
||||
.map((file) => file.replaceAll("\\", "/"))
|
||||
.filter((file) => Fff.allowed({ rel: file, hidden: true, glob: ["!node_modules/*", "!.git/*"] }))
|
||||
.filter((file) => !file.includes("SKILL.md"))
|
||||
.slice(0, limit)
|
||||
.map((file) => path.resolve(dir, file))
|
||||
follow: false,
|
||||
hidden: true,
|
||||
signal: ctx.abort,
|
||||
})) {
|
||||
if (file.includes("SKILL.md")) {
|
||||
continue
|
||||
}
|
||||
arr.push(path.resolve(dir, file))
|
||||
if (arr.length >= limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}).then((f) => f.map((file) => `<file>${file}</file>`).join("\n"))
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SessionPrompt } from "../session/prompt"
|
||||
import { iife } from "@/util/iife"
|
||||
import { defer } from "@/util/defer"
|
||||
import { Config } from "../config/config"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
|
||||
const parameters = z.object({
|
||||
description: z.string().describe("A short (3-5 words) description of the task"),
|
||||
@@ -31,7 +31,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
|
||||
// Filter agents by permissions if agent provided
|
||||
const caller = ctx?.agent
|
||||
const accessibleAgents = caller
|
||||
? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny")
|
||||
? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
|
||||
: agents
|
||||
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import z from "zod"
|
||||
import type { MessageV2 } from "../session/message-v2"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import type { PermissionNext } from "../permission"
|
||||
import type { Permission } from "../permission"
|
||||
import type { SessionID, MessageID } from "../session/schema"
|
||||
import { Truncate } from "./truncate"
|
||||
|
||||
@@ -23,7 +23,7 @@ export namespace Tool {
|
||||
extra?: { [key: string]: any }
|
||||
messages: MessageV2.WithParts[]
|
||||
metadata(input: { title?: string; metadata?: M }): void
|
||||
ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void>
|
||||
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
|
||||
}
|
||||
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
|
||||
id: string
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
|
||||
import path from "path"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { evaluate } from "@/permission/evaluate"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Log } from "../util/log"
|
||||
import { ToolID } from "./schema"
|
||||
import { TRUNCATION_DIR } from "./truncation-dir"
|
||||
|
||||
export namespace Truncate {
|
||||
const log = Log.create({ service: "truncation" })
|
||||
const RETENTION = Duration.days(7)
|
||||
|
||||
export const MAX_LINES = 2000
|
||||
export const MAX_BYTES = 50 * 1024
|
||||
export const DIR = TRUNCATION_DIR
|
||||
export const GLOB = path.join(TRUNCATION_DIR, "*")
|
||||
|
||||
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
|
||||
|
||||
export interface Options {
|
||||
maxLines?: number
|
||||
maxBytes?: number
|
||||
direction?: "head" | "tail"
|
||||
}
|
||||
|
||||
function hasTaskTool(agent?: Agent.Info) {
|
||||
if (!agent?.permission) return false
|
||||
return evaluate("task", "*", agent.permission).action !== "deny"
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
/**
|
||||
* Returns output unchanged when it fits within the limits, otherwise writes the full text
|
||||
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
|
||||
*/
|
||||
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
|
||||
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
|
||||
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
|
||||
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
|
||||
Effect.catch(() => Effect.succeed([])),
|
||||
)
|
||||
for (const entry of entries) {
|
||||
if (Identifier.timestamp(entry) >= cutoff) continue
|
||||
yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
})
|
||||
|
||||
const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
|
||||
const maxLines = options.maxLines ?? MAX_LINES
|
||||
const maxBytes = options.maxBytes ?? MAX_BYTES
|
||||
const direction = options.direction ?? "head"
|
||||
const lines = text.split("\n")
|
||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
||||
|
||||
if (lines.length <= maxLines && totalBytes <= maxBytes) {
|
||||
return { content: text, truncated: false } as const
|
||||
}
|
||||
|
||||
const out: string[] = []
|
||||
let i = 0
|
||||
let bytes = 0
|
||||
let hitBytes = false
|
||||
|
||||
if (direction === "head") {
|
||||
for (i = 0; i < lines.length && i < maxLines; i++) {
|
||||
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
|
||||
if (bytes + size > maxBytes) {
|
||||
hitBytes = true
|
||||
break
|
||||
}
|
||||
out.push(lines[i])
|
||||
bytes += size
|
||||
}
|
||||
} else {
|
||||
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
|
||||
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
|
||||
if (bytes + size > maxBytes) {
|
||||
hitBytes = true
|
||||
break
|
||||
}
|
||||
out.unshift(lines[i])
|
||||
bytes += size
|
||||
}
|
||||
}
|
||||
|
||||
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
|
||||
const unit = hitBytes ? "bytes" : "lines"
|
||||
const preview = out.join("\n")
|
||||
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
|
||||
|
||||
yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
|
||||
|
||||
const hint = hasTaskTool(agent)
|
||||
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
|
||||
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
|
||||
|
||||
return {
|
||||
content:
|
||||
direction === "head"
|
||||
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
|
||||
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
|
||||
truncated: true,
|
||||
outputPath: file,
|
||||
} as const
|
||||
})
|
||||
|
||||
yield* cleanup().pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.delay(Duration.minutes(1)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Service.of({ cleanup, output })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
|
||||
}
|
||||
@@ -1,18 +1,144 @@
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
|
||||
import path from "path"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import { Truncate as S } from "./truncate-effect"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { evaluate } from "@/permission/evaluate"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Log } from "../util/log"
|
||||
import { ToolID } from "./schema"
|
||||
import { TRUNCATION_DIR } from "./truncation-dir"
|
||||
|
||||
export namespace Truncate {
|
||||
export const MAX_LINES = S.MAX_LINES
|
||||
export const MAX_BYTES = S.MAX_BYTES
|
||||
export const DIR = S.DIR
|
||||
export const GLOB = S.GLOB
|
||||
const log = Log.create({ service: "truncation" })
|
||||
const RETENTION = Duration.days(7)
|
||||
|
||||
export type Result = S.Result
|
||||
export const MAX_LINES = 2000
|
||||
export const MAX_BYTES = 50 * 1024
|
||||
export const DIR = TRUNCATION_DIR
|
||||
export const GLOB = path.join(TRUNCATION_DIR, "*")
|
||||
|
||||
export type Options = S.Options
|
||||
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
|
||||
|
||||
export interface Options {
|
||||
maxLines?: number
|
||||
maxBytes?: number
|
||||
direction?: "head" | "tail"
|
||||
}
|
||||
|
||||
function hasTaskTool(agent?: Agent.Info) {
|
||||
if (!agent?.permission) return false
|
||||
return evaluate("task", "*", agent.permission).action !== "deny"
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
/**
|
||||
* Returns output unchanged when it fits within the limits, otherwise writes the full text
|
||||
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
|
||||
*/
|
||||
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
|
||||
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
|
||||
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
|
||||
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
|
||||
Effect.catch(() => Effect.succeed([])),
|
||||
)
|
||||
for (const entry of entries) {
|
||||
if (Identifier.timestamp(entry) >= cutoff) continue
|
||||
yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
})
|
||||
|
||||
const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
|
||||
const maxLines = options.maxLines ?? MAX_LINES
|
||||
const maxBytes = options.maxBytes ?? MAX_BYTES
|
||||
const direction = options.direction ?? "head"
|
||||
const lines = text.split("\n")
|
||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
||||
|
||||
if (lines.length <= maxLines && totalBytes <= maxBytes) {
|
||||
return { content: text, truncated: false } as const
|
||||
}
|
||||
|
||||
const out: string[] = []
|
||||
let i = 0
|
||||
let bytes = 0
|
||||
let hitBytes = false
|
||||
|
||||
if (direction === "head") {
|
||||
for (i = 0; i < lines.length && i < maxLines; i++) {
|
||||
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
|
||||
if (bytes + size > maxBytes) {
|
||||
hitBytes = true
|
||||
break
|
||||
}
|
||||
out.push(lines[i])
|
||||
bytes += size
|
||||
}
|
||||
} else {
|
||||
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
|
||||
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
|
||||
if (bytes + size > maxBytes) {
|
||||
hitBytes = true
|
||||
break
|
||||
}
|
||||
out.unshift(lines[i])
|
||||
bytes += size
|
||||
}
|
||||
}
|
||||
|
||||
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
|
||||
const unit = hitBytes ? "bytes" : "lines"
|
||||
const preview = out.join("\n")
|
||||
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
|
||||
|
||||
yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
|
||||
|
||||
const hint = hasTaskTool(agent)
|
||||
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
|
||||
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
|
||||
|
||||
return {
|
||||
content:
|
||||
direction === "head"
|
||||
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
|
||||
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
|
||||
truncated: true,
|
||||
outputPath: file,
|
||||
} as const
|
||||
})
|
||||
|
||||
yield* cleanup().pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.delay(Duration.minutes(1)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Service.of({ cleanup, output })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
|
||||
return runtime.runPromise(S.Service.use((s) => s.output(text, options, agent)))
|
||||
return runPromise((s) => s.output(text, options, agent))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { AccountRepo } from "../../src/account/repo"
|
||||
import { Account } from "../../src/account/effect"
|
||||
import { Account } from "../../src/account"
|
||||
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
|
||||
import { Database } from "../../src/storage/db"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { afterEach, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { PermissionNext } from "../../src/permission"
|
||||
import { Permission } from "../../src/permission"
|
||||
|
||||
// Helper to evaluate permission for a tool with wildcard pattern
|
||||
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
|
||||
function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined {
|
||||
if (!agent) return undefined
|
||||
return PermissionNext.evaluate(permission, "*", agent.permission).action
|
||||
return Permission.evaluate(permission, "*", agent.permission).action
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("returns default native agents when no config", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
@@ -54,7 +58,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => {
|
||||
// Wildcard is denied
|
||||
expect(evalPerm(plan, "edit")).toBe("deny")
|
||||
// But specific path is allowed
|
||||
expect(PermissionNext.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
|
||||
expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -83,8 +87,8 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy
|
||||
fn: async () => {
|
||||
const explore = await Agent.get("explore")
|
||||
expect(explore).toBeDefined()
|
||||
expect(PermissionNext.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
|
||||
expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -216,7 +220,7 @@ test("agent permission config merges with defaults", async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(build).toBeDefined()
|
||||
// Specific pattern is denied
|
||||
expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
|
||||
expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
|
||||
// Edit still allowed
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
},
|
||||
@@ -501,9 +505,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
|
||||
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||
expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -525,9 +529,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
|
||||
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||
expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -548,8 +552,8 @@ test("explicit Truncate.GLOB deny is respected", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
|
||||
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -582,7 +586,7 @@ description: Permission skill.
|
||||
const build = await Agent.get("build")
|
||||
const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill")
|
||||
const target = path.join(skillDir, "reference", "notes.md")
|
||||
expect(PermissionNext.evaluate("external_directory", target, build!.permission).action).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow")
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -251,7 +251,7 @@ test("resolves env templates in account config with account token", async () =>
|
||||
const originalToken = Account.token
|
||||
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
|
||||
|
||||
Account.active = mock(() => ({
|
||||
Account.active = mock(async () => ({
|
||||
id: AccountID.make("account-1"),
|
||||
email: "user@example.com",
|
||||
url: "https://control.example.com",
|
||||
|
||||
384
packages/opencode/test/effect/instance-state.test.ts
Normal file
384
packages/opencode/test/effect/instance-state.test.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect"
|
||||
import { InstanceState } from "../../src/effect/instance-state"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
async function access<A, E>(state: InstanceState<A, E>, dir: string) {
|
||||
return Instance.provide({
|
||||
directory: dir,
|
||||
fn: () => Effect.runPromise(InstanceState.get(state)),
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("InstanceState caches values per directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
|
||||
|
||||
const a = yield* Effect.promise(() => access(state, tmp.path))
|
||||
const b = yield* Effect.promise(() => access(state, tmp.path))
|
||||
|
||||
expect(a).toBe(b)
|
||||
expect(n).toBe(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState isolates directories", async () => {
|
||||
await using one = await tmpdir()
|
||||
await using two = await tmpdir()
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
|
||||
|
||||
const a = yield* Effect.promise(() => access(state, one.path))
|
||||
const b = yield* Effect.promise(() => access(state, two.path))
|
||||
const c = yield* Effect.promise(() => access(state, one.path))
|
||||
|
||||
expect(a).toBe(c)
|
||||
expect(a).not.toBe(b)
|
||||
expect(n).toBe(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState invalidates on reload", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const seen: string[] = []
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make(() =>
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => ({ n: ++n })),
|
||||
(value) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(String(value.n))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const a = yield* Effect.promise(() => access(state, tmp.path))
|
||||
yield* Effect.promise(() => Instance.reload({ directory: tmp.path }))
|
||||
const b = yield* Effect.promise(() => access(state, tmp.path))
|
||||
|
||||
expect(a).not.toBe(b)
|
||||
expect(seen).toEqual(["1"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState invalidates on disposeAll", async () => {
|
||||
await using one = await tmpdir()
|
||||
await using two = await tmpdir()
|
||||
const seen: string[] = []
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make((ctx) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => ({ dir: ctx.directory })),
|
||||
(value) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(value.dir)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => access(state, one.path))
|
||||
yield* Effect.promise(() => access(state, two.path))
|
||||
yield* Effect.promise(() => Instance.disposeAll())
|
||||
|
||||
expect(seen.sort()).toEqual([one.path, two.path].sort())
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState.get reads the current directory lazily", async () => {
|
||||
await using one = await tmpdir()
|
||||
await using two = await tmpdir()
|
||||
|
||||
interface Api {
|
||||
readonly get: () => Effect.Effect<string>
|
||||
}
|
||||
|
||||
class Test extends ServiceMap.Service<Test, Api>()("@test/InstanceStateLazy") {
|
||||
static readonly layer = Layer.effect(
|
||||
Test,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
|
||||
const get = InstanceState.get(state)
|
||||
|
||||
return Test.of({
|
||||
get: Effect.fn("Test.get")(function* () {
|
||||
return yield* get
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const rt = ManagedRuntime.make(Test.layer)
|
||||
|
||||
try {
|
||||
const a = await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||
})
|
||||
const b = await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||
})
|
||||
|
||||
expect(a).toBe(one.path)
|
||||
expect(b).toBe(two.path)
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("InstanceState preserves directory across async boundaries", async () => {
|
||||
await using one = await tmpdir({ git: true })
|
||||
await using two = await tmpdir({ git: true })
|
||||
await using three = await tmpdir({ git: true })
|
||||
|
||||
interface Api {
|
||||
readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }>
|
||||
}
|
||||
|
||||
class Test extends ServiceMap.Service<Test, Api>()("@test/InstanceStateAsync") {
|
||||
static readonly layer = Layer.effect(
|
||||
Test,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make((ctx) =>
|
||||
Effect.sync(() => ({
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
project: ctx.project.id,
|
||||
})),
|
||||
)
|
||||
|
||||
return Test.of({
|
||||
get: Effect.fn("Test.get")(function* () {
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
yield* Effect.sleep(Duration.millis(1))
|
||||
for (let i = 0; i < 100; i++) {
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
for (let i = 0; i < 100; i++) {
|
||||
yield* Effect.promise(() => Promise.resolve())
|
||||
}
|
||||
yield* Effect.sleep(Duration.millis(2))
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* InstanceState.get(state)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const rt = ManagedRuntime.make(Test.layer)
|
||||
|
||||
try {
|
||||
const [a, b, c] = await Promise.all([
|
||||
Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||
}),
|
||||
Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||
}),
|
||||
Instance.provide({
|
||||
directory: three.path,
|
||||
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||
}),
|
||||
])
|
||||
|
||||
expect(a).toEqual({ directory: one.path, worktree: one.path, project: a.project })
|
||||
expect(b).toEqual({ directory: two.path, worktree: two.path, project: b.project })
|
||||
expect(c).toEqual({ directory: three.path, worktree: three.path, project: c.project })
|
||||
expect(a.project).not.toBe(b.project)
|
||||
expect(a.project).not.toBe(c.project)
|
||||
expect(b.project).not.toBe(c.project)
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("InstanceState survives high-contention concurrent access", async () => {
|
||||
const N = 20
|
||||
const dirs = await Promise.all(Array.from({ length: N }, () => tmpdir()))
|
||||
|
||||
interface Api {
|
||||
readonly get: () => Effect.Effect<string>
|
||||
}
|
||||
|
||||
class Test extends ServiceMap.Service<Test, Api>()("@test/HighContention") {
|
||||
static readonly layer = Layer.effect(
|
||||
Test,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
|
||||
|
||||
return Test.of({
|
||||
get: Effect.fn("Test.get")(function* () {
|
||||
// Interleave many async hops to maximize chance of ALS corruption
|
||||
for (let i = 0; i < 10; i++) {
|
||||
yield* Effect.promise(() => Bun.sleep(Math.random() * 3))
|
||||
yield* Effect.yieldNow
|
||||
yield* Effect.promise(() => Promise.resolve())
|
||||
}
|
||||
return yield* InstanceState.get(state)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const rt = ManagedRuntime.make(Test.layer)
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
dirs.map((d) =>
|
||||
Instance.provide({
|
||||
directory: d.path,
|
||||
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
expect(results[i]).toBe(dirs[i].path)
|
||||
}
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
for (const d of dirs) await d[Symbol.asyncDispose]()
|
||||
}
|
||||
})
|
||||
|
||||
test("InstanceState correct after interleaved init and dispose", async () => {
|
||||
await using one = await tmpdir()
|
||||
await using two = await tmpdir()
|
||||
|
||||
interface Api {
|
||||
readonly get: () => Effect.Effect<string>
|
||||
}
|
||||
|
||||
class Test extends ServiceMap.Service<Test, Api>()("@test/InterleavedDispose") {
|
||||
static readonly layer = Layer.effect(
|
||||
Test,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make((ctx) =>
|
||||
Effect.promise(async () => {
|
||||
await Bun.sleep(5) // slow init
|
||||
return ctx.directory
|
||||
}),
|
||||
)
|
||||
|
||||
return Test.of({
|
||||
get: Effect.fn("Test.get")(function* () {
|
||||
return yield* InstanceState.get(state)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const rt = ManagedRuntime.make(Test.layer)
|
||||
|
||||
try {
|
||||
// Init both directories
|
||||
const a = await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||
})
|
||||
expect(a).toBe(one.path)
|
||||
|
||||
// Dispose one directory, access the other concurrently
|
||||
const [, b] = await Promise.all([
|
||||
Instance.reload({ directory: one.path }),
|
||||
Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||
}),
|
||||
])
|
||||
expect(b).toBe(two.path)
|
||||
|
||||
// Re-access disposed directory - should get fresh state
|
||||
const c = await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||
})
|
||||
expect(c).toBe(one.path)
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("InstanceState mutation in one directory does not leak to another", async () => {
|
||||
await using one = await tmpdir()
|
||||
await using two = await tmpdir()
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 })))
|
||||
|
||||
// Mutate state in directory one
|
||||
const s1 = yield* Effect.promise(() => access(state, one.path))
|
||||
s1.count = 42
|
||||
|
||||
// Access directory two — should be independent
|
||||
const s2 = yield* Effect.promise(() => access(state, two.path))
|
||||
expect(s2.count).toBe(0)
|
||||
|
||||
// Confirm directory one still has the mutation
|
||||
const s1again = yield* Effect.promise(() => access(state, one.path))
|
||||
expect(s1again.count).toBe(42)
|
||||
expect(s1again).toBe(s1) // same reference
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState dedupes concurrent lookups", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make(() =>
|
||||
Effect.promise(async () => {
|
||||
n += 1
|
||||
await Bun.sleep(10)
|
||||
return { n }
|
||||
}),
|
||||
)
|
||||
|
||||
const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)]))
|
||||
expect(a).toBe(b)
|
||||
expect(n).toBe(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
46
packages/opencode/test/effect/run-service.test.ts
Normal file
46
packages/opencode/test/effect/run-service.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { makeRunPromise } from "../../src/effect/run-service"
|
||||
|
||||
class Shared extends ServiceMap.Service<Shared, { readonly id: number }>()("@test/Shared") {}
|
||||
|
||||
test("makeRunPromise shares dependent layers through the shared memo map", async () => {
|
||||
let n = 0
|
||||
|
||||
const shared = Layer.effect(
|
||||
Shared,
|
||||
Effect.sync(() => {
|
||||
n += 1
|
||||
return Shared.of({ id: n })
|
||||
}),
|
||||
)
|
||||
|
||||
class One extends ServiceMap.Service<One, { readonly get: () => Effect.Effect<number> }>()("@test/One") {}
|
||||
const one = Layer.effect(
|
||||
One,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Shared
|
||||
return One.of({
|
||||
get: Effect.fn("One.get")(() => Effect.succeed(svc.id)),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(shared))
|
||||
|
||||
class Two extends ServiceMap.Service<Two, { readonly get: () => Effect.Effect<number> }>()("@test/Two") {}
|
||||
const two = Layer.effect(
|
||||
Two,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Shared
|
||||
return Two.of({
|
||||
get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(shared))
|
||||
|
||||
const runOne = makeRunPromise(One, one)
|
||||
const runTwo = makeRunPromise(Two, two)
|
||||
|
||||
expect(await runOne((svc) => svc.get())).toBe(1)
|
||||
expect(await runTwo((svc) => svc.get())).toBe(1)
|
||||
expect(n).toBe(1)
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Fff } from "../../src/file/fff"
|
||||
|
||||
describe("file.fff", () => {
|
||||
test("allowed respects hidden filter", async () => {
|
||||
expect(Fff.allowed({ rel: "visible.txt", hidden: true })).toBe(true)
|
||||
expect(Fff.allowed({ rel: ".opencode/thing.json", hidden: true })).toBe(true)
|
||||
expect(Fff.allowed({ rel: ".opencode/thing.json", hidden: false })).toBe(false)
|
||||
})
|
||||
|
||||
test("search returns empty when nothing matches", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "match.ts"), "const value = 'other'\n")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const hits = await Fff.search({
|
||||
cwd: tmp.path,
|
||||
pattern: "needle",
|
||||
})
|
||||
expect(hits).toEqual([])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("tree builds and truncates", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await fs.mkdir(path.join(dir, "a", "b"), { recursive: true })
|
||||
await Bun.write(path.join(dir, "a", "b", "c.ts"), "export const x = 1\n")
|
||||
await Bun.write(path.join(dir, "a", "d.ts"), "export const y = 1\n")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const tree = await Fff.tree({ cwd: tmp.path, limit: 1 })
|
||||
expect(tree).toContain("a")
|
||||
expect(tree).toContain("truncated")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { afterEach, describe, test, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
@@ -7,6 +7,10 @@ import { Instance } from "../../src/project/instance"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
describe("file/index Filesystem patterns", () => {
|
||||
describe("File.read() - text content", () => {
|
||||
test("reads text file via Filesystem.readText()", async () => {
|
||||
@@ -689,6 +693,18 @@ describe("file/index Filesystem patterns", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("search works before explicit init", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.search({ query: "main", type: "file" })
|
||||
expect(result.some((f) => f.includes("main"))).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("empty query returns dirs sorted with hidden last", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
@@ -785,6 +801,23 @@ describe("file/index Filesystem patterns", () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("search refreshes after init when files change", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
expect(await File.search({ query: "fresh", type: "file" })).toEqual([])
|
||||
|
||||
await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8")
|
||||
|
||||
const result = await File.search({ query: "fresh", type: "file" })
|
||||
expect(result).toContain("fresh.ts")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.read() - diff/patch", () => {
|
||||
@@ -849,4 +882,65 @@ describe("file/index Filesystem patterns", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("InstanceState isolation", () => {
|
||||
test("two directories get independent file caches", async () => {
|
||||
await using one = await tmpdir({ git: true })
|
||||
await using two = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8")
|
||||
await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
const results = await File.search({ query: "a.ts", type: "file" })
|
||||
expect(results).toContain("a.ts")
|
||||
const results2 = await File.search({ query: "b.ts", type: "file" })
|
||||
expect(results2).not.toContain("b.ts")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
const results = await File.search({ query: "b.ts", type: "file" })
|
||||
expect(results).toContain("b.ts")
|
||||
const results2 = await File.search({ query: "a.ts", type: "file" })
|
||||
expect(results2).not.toContain("a.ts")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("disposal gives fresh state on next access", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
const results = await File.search({ query: "before", type: "file" })
|
||||
expect(results).toContain("before.ts")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.disposeAll()
|
||||
|
||||
await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8")
|
||||
await fs.rm(path.join(tmp.path, "before.ts"))
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
const results = await File.search({ query: "after", type: "file" })
|
||||
expect(results).toContain("after.ts")
|
||||
const stale = await File.search({ query: "before", type: "file" })
|
||||
expect(stale).not.toContain("before.ts")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
54
packages/opencode/test/file/ripgrep.test.ts
Normal file
54
packages/opencode/test/file/ripgrep.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
|
||||
describe("file.ripgrep", () => {
|
||||
test("defaults to include hidden", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "visible.txt"), "hello")
|
||||
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
|
||||
await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
|
||||
},
|
||||
})
|
||||
|
||||
const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path }))
|
||||
const hasVisible = files.includes("visible.txt")
|
||||
const hasHidden = files.includes(path.join(".opencode", "thing.json"))
|
||||
expect(hasVisible).toBe(true)
|
||||
expect(hasHidden).toBe(true)
|
||||
})
|
||||
|
||||
test("hidden false excludes hidden", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "visible.txt"), "hello")
|
||||
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
|
||||
await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
|
||||
},
|
||||
})
|
||||
|
||||
const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path, hidden: false }))
|
||||
const hasVisible = files.includes("visible.txt")
|
||||
const hasHidden = files.includes(path.join(".opencode", "thing.json"))
|
||||
expect(hasVisible).toBe(true)
|
||||
expect(hasHidden).toBe(false)
|
||||
})
|
||||
|
||||
test("search returns empty when nothing matches", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "match.ts"), "const value = 'other'\n")
|
||||
},
|
||||
})
|
||||
|
||||
const hits = await Ripgrep.search({
|
||||
cwd: tmp.path,
|
||||
pattern: "needle",
|
||||
})
|
||||
|
||||
expect(hits).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,9 @@ import { SessionID } from "../../src/session/schema"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(() => Instance.disposeAll())
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
async function touch(file: string, time: number) {
|
||||
const date = new Date(time)
|
||||
@@ -84,6 +86,28 @@ describe("file/time", () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("isolates reads by directory", async () => {
|
||||
await using one = await tmpdir()
|
||||
await using two = await tmpdir()
|
||||
await using shared = await tmpdir()
|
||||
const filepath = path.join(shared.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(sessionID, filepath)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: async () => {
|
||||
expect(await FileTime.get(sessionID, filepath)).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("assert()", () => {
|
||||
|
||||
@@ -25,7 +25,7 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
||||
directory,
|
||||
FileWatcher.layer,
|
||||
async (rt) => {
|
||||
await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
|
||||
await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
|
||||
await Effect.runPromise(ready(directory))
|
||||
await Effect.runPromise(body)
|
||||
},
|
||||
@@ -136,7 +136,9 @@ function ready(directory: string) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describeWatcher("FileWatcher", () => {
|
||||
afterEach(() => Instance.disposeAll())
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("publishes root create, update, and delete events", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
@@ -34,7 +34,7 @@ export function withServices<S>(
|
||||
project: Instance.project,
|
||||
}),
|
||||
)
|
||||
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any
|
||||
let resolved: Layer.Layer<S> = layer.pipe(Layer.provide(ctx)) as any
|
||||
if (options?.provide) {
|
||||
for (const l of options.provide) {
|
||||
resolved = resolved.pipe(Layer.provide(l)) as any
|
||||
|
||||
@@ -2,11 +2,16 @@ import { Effect } from "effect"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { withServices } from "../fixture/instance"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { File } from "../../src/file"
|
||||
import { Format } from "../../src/format"
|
||||
import * as Formatter from "../../src/format/formatter"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
|
||||
describe("Format", () => {
|
||||
afterEach(() => Instance.disposeAll())
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("status() returns built-in formatters when no config overrides", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
@@ -62,4 +67,106 @@ describe("Format", () => {
|
||||
await rt.runPromise(Format.Service.use(() => Effect.void))
|
||||
})
|
||||
})
|
||||
|
||||
test("status() initializes formatter state per directory", async () => {
|
||||
await using off = await tmpdir({
|
||||
config: { formatter: false },
|
||||
})
|
||||
await using on = await tmpdir()
|
||||
|
||||
const a = await Instance.provide({
|
||||
directory: off.path,
|
||||
fn: () => Format.status(),
|
||||
})
|
||||
const b = await Instance.provide({
|
||||
directory: on.path,
|
||||
fn: () => Format.status(),
|
||||
})
|
||||
|
||||
expect(a).toEqual([])
|
||||
expect(b.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("runs enabled checks for matching formatters in parallel", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
const file = `${tmp.path}/test.parallel`
|
||||
await Bun.write(file, "x")
|
||||
|
||||
const one = {
|
||||
extensions: Formatter.gofmt.extensions,
|
||||
enabled: Formatter.gofmt.enabled,
|
||||
command: Formatter.gofmt.command,
|
||||
}
|
||||
const two = {
|
||||
extensions: Formatter.mix.extensions,
|
||||
enabled: Formatter.mix.enabled,
|
||||
command: Formatter.mix.command,
|
||||
}
|
||||
|
||||
let active = 0
|
||||
let max = 0
|
||||
|
||||
Formatter.gofmt.extensions = [".parallel"]
|
||||
Formatter.mix.extensions = [".parallel"]
|
||||
Formatter.gofmt.command = ["sh", "-c", "true"]
|
||||
Formatter.mix.command = ["sh", "-c", "true"]
|
||||
Formatter.gofmt.enabled = async () => {
|
||||
active++
|
||||
max = Math.max(max, active)
|
||||
await Bun.sleep(20)
|
||||
active--
|
||||
return true
|
||||
}
|
||||
Formatter.mix.enabled = async () => {
|
||||
active++
|
||||
max = Math.max(max, active)
|
||||
await Bun.sleep(20)
|
||||
active--
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
await withServices(tmp.path, Format.layer, async (rt) => {
|
||||
await rt.runPromise(Format.Service.use((s) => s.init()))
|
||||
await Bus.publish(File.Event.Edited, { file })
|
||||
})
|
||||
} finally {
|
||||
Formatter.gofmt.extensions = one.extensions
|
||||
Formatter.gofmt.enabled = one.enabled
|
||||
Formatter.gofmt.command = one.command
|
||||
Formatter.mix.extensions = two.extensions
|
||||
Formatter.mix.enabled = two.enabled
|
||||
Formatter.mix.command = two.command
|
||||
}
|
||||
|
||||
expect(max).toBe(2)
|
||||
})
|
||||
|
||||
test("runs matching formatters sequentially for the same file", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
formatter: {
|
||||
first: {
|
||||
command: ["sh", "-c", 'sleep 0.05; v=$(cat "$1"); printf \'%sA\' "$v" > "$1"', "sh", "$FILE"],
|
||||
extensions: [".seq"],
|
||||
},
|
||||
second: {
|
||||
command: ["sh", "-c", 'v=$(cat "$1"); printf \'%sB\' "$v" > "$1"', "sh", "$FILE"],
|
||||
extensions: [".seq"],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const file = `${tmp.path}/test.seq`
|
||||
await Bun.write(file, "x")
|
||||
|
||||
await withServices(tmp.path, Format.layer, async (rt) => {
|
||||
await rt.runPromise(Format.Service.use((s) => s.init()))
|
||||
await Bus.publish(File.Event.Edited, { file })
|
||||
})
|
||||
|
||||
expect(await Bun.file(file).text()).toBe("xAB")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { PermissionNext } from "../src/permission"
|
||||
import { afterEach, describe, test, expect } from "bun:test"
|
||||
import { Permission } from "../src/permission"
|
||||
import { Config } from "../src/config/config"
|
||||
import { Instance } from "../src/project/instance"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
describe("PermissionNext.evaluate for permission.task", () => {
|
||||
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
describe("Permission.evaluate for permission.task", () => {
|
||||
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
|
||||
Object.entries(rules).map(([pattern, action]) => ({
|
||||
permission: "task",
|
||||
pattern,
|
||||
@@ -13,42 +17,42 @@ describe("PermissionNext.evaluate for permission.task", () => {
|
||||
}))
|
||||
|
||||
test("returns ask when no match (default)", () => {
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", []).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "code-reviewer", []).action).toBe("ask")
|
||||
})
|
||||
|
||||
test("returns deny for explicit deny", () => {
|
||||
const ruleset = createRuleset({ "code-reviewer": "deny" })
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
})
|
||||
|
||||
test("returns allow for explicit allow", () => {
|
||||
const ruleset = createRuleset({ "code-reviewer": "allow" })
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
|
||||
})
|
||||
|
||||
test("returns ask for explicit ask", () => {
|
||||
const ruleset = createRuleset({ "code-reviewer": "ask" })
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
|
||||
})
|
||||
|
||||
test("matches wildcard patterns with deny", () => {
|
||||
const ruleset = createRuleset({ "orchestrator-*": "deny" })
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
|
||||
})
|
||||
|
||||
test("matches wildcard patterns with allow", () => {
|
||||
const ruleset = createRuleset({ "orchestrator-*": "allow" })
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
|
||||
})
|
||||
|
||||
test("matches wildcard patterns with ask", () => {
|
||||
const ruleset = createRuleset({ "orchestrator-*": "ask" })
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask")
|
||||
const globalRuleset = createRuleset({ "*": "ask" })
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
|
||||
})
|
||||
|
||||
test("later rules take precedence (last match wins)", () => {
|
||||
@@ -56,22 +60,22 @@ describe("PermissionNext.evaluate for permission.task", () => {
|
||||
"orchestrator-*": "deny",
|
||||
"orchestrator-fast": "allow",
|
||||
})
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
|
||||
})
|
||||
|
||||
test("matches global wildcard", () => {
|
||||
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask")
|
||||
})
|
||||
})
|
||||
|
||||
describe("PermissionNext.disabled for task tool", () => {
|
||||
describe("Permission.disabled for task tool", () => {
|
||||
// Note: The `disabled` function checks if a TOOL should be completely removed from the tool list.
|
||||
// It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`.
|
||||
// It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`.
|
||||
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
|
||||
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
|
||||
Object.entries(rules).map(([pattern, action]) => ({
|
||||
permission: "task",
|
||||
pattern,
|
||||
@@ -85,7 +89,7 @@ describe("PermissionNext.disabled for task tool", () => {
|
||||
"orchestrator-*": "allow",
|
||||
"*": "deny",
|
||||
})
|
||||
const disabled = PermissionNext.disabled(["task", "bash", "read"], ruleset)
|
||||
const disabled = Permission.disabled(["task", "bash", "read"], ruleset)
|
||||
// The task tool IS disabled because there's a pattern: "*" with action: "deny"
|
||||
expect(disabled.has("task")).toBe(true)
|
||||
})
|
||||
@@ -95,14 +99,14 @@ describe("PermissionNext.disabled for task tool", () => {
|
||||
"orchestrator-*": "ask",
|
||||
"*": "deny",
|
||||
})
|
||||
const disabled = PermissionNext.disabled(["task"], ruleset)
|
||||
const disabled = Permission.disabled(["task"], ruleset)
|
||||
// The task tool IS disabled because there's a pattern: "*" with action: "deny"
|
||||
expect(disabled.has("task")).toBe(true)
|
||||
})
|
||||
|
||||
test("task tool is disabled when global deny pattern exists", () => {
|
||||
const ruleset = createRuleset({ "*": "deny" })
|
||||
const disabled = PermissionNext.disabled(["task"], ruleset)
|
||||
const disabled = Permission.disabled(["task"], ruleset)
|
||||
expect(disabled.has("task")).toBe(true)
|
||||
})
|
||||
|
||||
@@ -113,13 +117,13 @@ describe("PermissionNext.disabled for task tool", () => {
|
||||
"orchestrator-*": "deny",
|
||||
general: "deny",
|
||||
})
|
||||
const disabled = PermissionNext.disabled(["task"], ruleset)
|
||||
const disabled = Permission.disabled(["task"], ruleset)
|
||||
// The task tool is NOT disabled because no rule has pattern: "*" with action: "deny"
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
})
|
||||
|
||||
test("task tool is enabled when no task rules exist (default ask)", () => {
|
||||
const disabled = PermissionNext.disabled(["task"], [])
|
||||
const disabled = Permission.disabled(["task"], [])
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
})
|
||||
|
||||
@@ -129,7 +133,7 @@ describe("PermissionNext.disabled for task tool", () => {
|
||||
"*": "deny",
|
||||
"orchestrator-coder": "allow",
|
||||
})
|
||||
const disabled = PermissionNext.disabled(["task"], ruleset)
|
||||
const disabled = Permission.disabled(["task"], ruleset)
|
||||
// The disabled() function uses findLast and checks if the last matching rule
|
||||
// has pattern: "*" and action: "deny". In this case, the last rule matching
|
||||
// "task" permission has pattern "orchestrator-coder", not "*", so not disabled
|
||||
@@ -155,11 +159,11 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
// general and orchestrator-fast should be allowed, code-reviewer denied
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -180,11 +184,11 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
// general and code-reviewer should be ask, orchestrator-* denied
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -205,11 +209,11 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
// Unspecified agents default to "ask"
|
||||
expect(PermissionNext.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -232,18 +236,18 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
|
||||
// Verify task permissions
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
|
||||
// Verify other tool permissions
|
||||
expect(PermissionNext.evaluate("bash", "*", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("edit", "*", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("bash", "*", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("edit", "*", ruleset).action).toBe("ask")
|
||||
|
||||
// Verify disabled tools
|
||||
const disabled = PermissionNext.disabled(["bash", "edit", "task"], ruleset)
|
||||
const disabled = Permission.disabled(["bash", "edit", "task"], ruleset)
|
||||
expect(disabled.has("bash")).toBe(false)
|
||||
expect(disabled.has("edit")).toBe(false)
|
||||
// task is NOT disabled because disabled() uses findLast, and the last rule
|
||||
@@ -270,16 +274,16 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
|
||||
// Last matching rule wins - "*" deny is last, so all agents are denied
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("task", "unknown", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "unknown", ruleset).action).toBe("deny")
|
||||
|
||||
// Since "*": "deny" is the last rule, disabled() finds it with findLast
|
||||
// and sees pattern: "*" with action: "deny", so task is disabled
|
||||
const disabled = PermissionNext.disabled(["task"], ruleset)
|
||||
const disabled = Permission.disabled(["task"], ruleset)
|
||||
expect(disabled.has("task")).toBe(true)
|
||||
},
|
||||
})
|
||||
@@ -301,17 +305,17 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
|
||||
// Evaluate uses findLast - "general" allow comes after "*" deny
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
// Other agents still denied by the earlier "*" deny
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
|
||||
// disabled() uses findLast and checks if the last rule has pattern: "*" with action: "deny"
|
||||
// In this case, the last rule is {pattern: "general", action: "allow"}, not pattern: "*"
|
||||
// So the task tool is NOT disabled (even though most subagents are denied)
|
||||
const disabled = PermissionNext.disabled(["task"], ruleset)
|
||||
const disabled = Permission.disabled(["task"], ruleset)
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { afterEach, test, expect } from "bun:test"
|
||||
import os from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { runtime } from "../../src/effect/runtime"
|
||||
import { Instances } from "../../src/effect/instances"
|
||||
import { PermissionNext } from "../../src/permission"
|
||||
import { PermissionNext as S } from "../../src/permission"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { PermissionID } from "../../src/permission/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
@@ -16,8 +12,8 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
async function rejectAll(message?: string) {
|
||||
for (const req of await PermissionNext.list()) {
|
||||
await PermissionNext.reply({
|
||||
for (const req of await Permission.list()) {
|
||||
await Permission.reply({
|
||||
requestID: req.id,
|
||||
reply: "reject",
|
||||
message,
|
||||
@@ -27,22 +23,22 @@ async function rejectAll(message?: string) {
|
||||
|
||||
async function waitForPending(count: number) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const list = await PermissionNext.list()
|
||||
const list = await Permission.list()
|
||||
if (list.length === count) return list
|
||||
await Bun.sleep(0)
|
||||
}
|
||||
return PermissionNext.list()
|
||||
return Permission.list()
|
||||
}
|
||||
|
||||
// fromConfig tests
|
||||
|
||||
test("fromConfig - string value becomes wildcard rule", () => {
|
||||
const result = PermissionNext.fromConfig({ bash: "allow" })
|
||||
const result = Permission.fromConfig({ bash: "allow" })
|
||||
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
|
||||
})
|
||||
|
||||
test("fromConfig - object value converts to rules array", () => {
|
||||
const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } })
|
||||
const result = Permission.fromConfig({ bash: { "*": "allow", rm: "deny" } })
|
||||
expect(result).toEqual([
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "rm", action: "deny" },
|
||||
@@ -50,7 +46,7 @@ test("fromConfig - object value converts to rules array", () => {
|
||||
})
|
||||
|
||||
test("fromConfig - mixed string and object values", () => {
|
||||
const result = PermissionNext.fromConfig({
|
||||
const result = Permission.fromConfig({
|
||||
bash: { "*": "allow", rm: "deny" },
|
||||
edit: "allow",
|
||||
webfetch: "ask",
|
||||
@@ -64,51 +60,51 @@ test("fromConfig - mixed string and object values", () => {
|
||||
})
|
||||
|
||||
test("fromConfig - empty object", () => {
|
||||
const result = PermissionNext.fromConfig({})
|
||||
const result = Permission.fromConfig({})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("fromConfig - expands tilde to home directory", () => {
|
||||
const result = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } })
|
||||
const result = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } })
|
||||
expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
|
||||
})
|
||||
|
||||
test("fromConfig - expands $HOME to home directory", () => {
|
||||
const result = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
|
||||
const result = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
|
||||
expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
|
||||
})
|
||||
|
||||
test("fromConfig - expands $HOME without trailing slash", () => {
|
||||
const result = PermissionNext.fromConfig({ external_directory: { $HOME: "allow" } })
|
||||
const result = Permission.fromConfig({ external_directory: { $HOME: "allow" } })
|
||||
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
|
||||
})
|
||||
|
||||
test("fromConfig - does not expand tilde in middle of path", () => {
|
||||
const result = PermissionNext.fromConfig({ external_directory: { "/some/~/path": "allow" } })
|
||||
const result = Permission.fromConfig({ external_directory: { "/some/~/path": "allow" } })
|
||||
expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }])
|
||||
})
|
||||
|
||||
test("fromConfig - expands exact tilde to home directory", () => {
|
||||
const result = PermissionNext.fromConfig({ external_directory: { "~": "allow" } })
|
||||
const result = Permission.fromConfig({ external_directory: { "~": "allow" } })
|
||||
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
|
||||
})
|
||||
|
||||
test("evaluate - matches expanded tilde pattern", () => {
|
||||
const ruleset = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } })
|
||||
const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
|
||||
const ruleset = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } })
|
||||
const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
|
||||
expect(result.action).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - matches expanded $HOME pattern", () => {
|
||||
const ruleset = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
|
||||
const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
|
||||
const ruleset = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
|
||||
const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
|
||||
expect(result.action).toBe("allow")
|
||||
})
|
||||
|
||||
// merge tests
|
||||
|
||||
test("merge - simple concatenation", () => {
|
||||
const result = PermissionNext.merge(
|
||||
const result = Permission.merge(
|
||||
[{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
[{ permission: "bash", pattern: "*", action: "deny" }],
|
||||
)
|
||||
@@ -119,7 +115,7 @@ test("merge - simple concatenation", () => {
|
||||
})
|
||||
|
||||
test("merge - adds new permission", () => {
|
||||
const result = PermissionNext.merge(
|
||||
const result = Permission.merge(
|
||||
[{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
[{ permission: "edit", pattern: "*", action: "deny" }],
|
||||
)
|
||||
@@ -130,7 +126,7 @@ test("merge - adds new permission", () => {
|
||||
})
|
||||
|
||||
test("merge - concatenates rules for same permission", () => {
|
||||
const result = PermissionNext.merge(
|
||||
const result = Permission.merge(
|
||||
[{ permission: "bash", pattern: "foo", action: "ask" }],
|
||||
[{ permission: "bash", pattern: "*", action: "deny" }],
|
||||
)
|
||||
@@ -141,7 +137,7 @@ test("merge - concatenates rules for same permission", () => {
|
||||
})
|
||||
|
||||
test("merge - multiple rulesets", () => {
|
||||
const result = PermissionNext.merge(
|
||||
const result = Permission.merge(
|
||||
[{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
[{ permission: "bash", pattern: "rm", action: "ask" }],
|
||||
[{ permission: "edit", pattern: "*", action: "allow" }],
|
||||
@@ -154,12 +150,12 @@ test("merge - multiple rulesets", () => {
|
||||
})
|
||||
|
||||
test("merge - empty ruleset does nothing", () => {
|
||||
const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
|
||||
const result = Permission.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
|
||||
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
|
||||
})
|
||||
|
||||
test("merge - preserves rule order", () => {
|
||||
const result = PermissionNext.merge(
|
||||
const result = Permission.merge(
|
||||
[
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
{ permission: "edit", pattern: "src/secret/*", action: "deny" },
|
||||
@@ -175,40 +171,40 @@ test("merge - preserves rule order", () => {
|
||||
|
||||
test("merge - config permission overrides default ask", () => {
|
||||
// Simulates: defaults have "*": "ask", config sets bash: "allow"
|
||||
const defaults: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
|
||||
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
|
||||
const merged = PermissionNext.merge(defaults, config)
|
||||
const defaults: Permission.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
|
||||
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
|
||||
const merged = Permission.merge(defaults, config)
|
||||
|
||||
// Config's bash allow should override default ask
|
||||
expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("allow")
|
||||
expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow")
|
||||
// Other permissions should still be ask (from defaults)
|
||||
expect(PermissionNext.evaluate("edit", "foo.ts", merged).action).toBe("ask")
|
||||
expect(Permission.evaluate("edit", "foo.ts", merged).action).toBe("ask")
|
||||
})
|
||||
|
||||
test("merge - config ask overrides default allow", () => {
|
||||
// Simulates: defaults have bash: "allow", config sets bash: "ask"
|
||||
const defaults: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
|
||||
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
|
||||
const merged = PermissionNext.merge(defaults, config)
|
||||
const defaults: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
|
||||
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
|
||||
const merged = Permission.merge(defaults, config)
|
||||
|
||||
// Config's ask should override default allow
|
||||
expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("ask")
|
||||
expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask")
|
||||
})
|
||||
|
||||
// evaluate tests
|
||||
|
||||
test("evaluate - exact pattern match", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
|
||||
const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
|
||||
expect(result.action).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - wildcard pattern match", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
|
||||
const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
|
||||
expect(result.action).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - last matching rule wins", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [
|
||||
const result = Permission.evaluate("bash", "rm", [
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "rm", action: "deny" },
|
||||
])
|
||||
@@ -216,7 +212,7 @@ test("evaluate - last matching rule wins", () => {
|
||||
})
|
||||
|
||||
test("evaluate - last matching rule wins (wildcard after specific)", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [
|
||||
const result = Permission.evaluate("bash", "rm", [
|
||||
{ permission: "bash", pattern: "rm", action: "deny" },
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
])
|
||||
@@ -224,14 +220,12 @@ test("evaluate - last matching rule wins (wildcard after specific)", () => {
|
||||
})
|
||||
|
||||
test("evaluate - glob pattern match", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/foo.ts", [
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
])
|
||||
const result = Permission.evaluate("edit", "src/foo.ts", [{ permission: "edit", pattern: "src/*", action: "allow" }])
|
||||
expect(result.action).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - last matching glob wins", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
|
||||
const result = Permission.evaluate("edit", "src/components/Button.tsx", [
|
||||
{ permission: "edit", pattern: "src/*", action: "deny" },
|
||||
{ permission: "edit", pattern: "src/components/*", action: "allow" },
|
||||
])
|
||||
@@ -240,7 +234,7 @@ test("evaluate - last matching glob wins", () => {
|
||||
|
||||
test("evaluate - order matters for specificity", () => {
|
||||
// If more specific rule comes first, later wildcard overrides it
|
||||
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
|
||||
const result = Permission.evaluate("edit", "src/components/Button.tsx", [
|
||||
{ permission: "edit", pattern: "src/components/*", action: "allow" },
|
||||
{ permission: "edit", pattern: "src/*", action: "deny" },
|
||||
])
|
||||
@@ -248,31 +242,29 @@ test("evaluate - order matters for specificity", () => {
|
||||
})
|
||||
|
||||
test("evaluate - unknown permission returns ask", () => {
|
||||
const result = PermissionNext.evaluate("unknown_tool", "anything", [
|
||||
const result = Permission.evaluate("unknown_tool", "anything", [
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
])
|
||||
expect(result.action).toBe("ask")
|
||||
})
|
||||
|
||||
test("evaluate - empty ruleset returns ask", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [])
|
||||
const result = Permission.evaluate("bash", "rm", [])
|
||||
expect(result.action).toBe("ask")
|
||||
})
|
||||
|
||||
test("evaluate - no matching pattern returns ask", () => {
|
||||
const result = PermissionNext.evaluate("edit", "etc/passwd", [
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
])
|
||||
const result = Permission.evaluate("edit", "etc/passwd", [{ permission: "edit", pattern: "src/*", action: "allow" }])
|
||||
expect(result.action).toBe("ask")
|
||||
})
|
||||
|
||||
test("evaluate - empty rules array returns ask", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [])
|
||||
const result = Permission.evaluate("bash", "rm", [])
|
||||
expect(result.action).toBe("ask")
|
||||
})
|
||||
|
||||
test("evaluate - multiple matching patterns, last wins", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/secret.ts", [
|
||||
const result = Permission.evaluate("edit", "src/secret.ts", [
|
||||
{ permission: "edit", pattern: "*", action: "ask" },
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
{ permission: "edit", pattern: "src/secret.ts", action: "deny" },
|
||||
@@ -281,7 +273,7 @@ test("evaluate - multiple matching patterns, last wins", () => {
|
||||
})
|
||||
|
||||
test("evaluate - non-matching patterns are skipped", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/foo.ts", [
|
||||
const result = Permission.evaluate("edit", "src/foo.ts", [
|
||||
{ permission: "edit", pattern: "*", action: "ask" },
|
||||
{ permission: "edit", pattern: "test/*", action: "deny" },
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
@@ -290,7 +282,7 @@ test("evaluate - non-matching patterns are skipped", () => {
|
||||
})
|
||||
|
||||
test("evaluate - exact match at end wins over earlier wildcard", () => {
|
||||
const result = PermissionNext.evaluate("bash", "/bin/rm", [
|
||||
const result = Permission.evaluate("bash", "/bin/rm", [
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "/bin/rm", action: "deny" },
|
||||
])
|
||||
@@ -298,7 +290,7 @@ test("evaluate - exact match at end wins over earlier wildcard", () => {
|
||||
})
|
||||
|
||||
test("evaluate - wildcard at end overrides earlier exact match", () => {
|
||||
const result = PermissionNext.evaluate("bash", "/bin/rm", [
|
||||
const result = Permission.evaluate("bash", "/bin/rm", [
|
||||
{ permission: "bash", pattern: "/bin/rm", action: "deny" },
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
])
|
||||
@@ -308,24 +300,24 @@ test("evaluate - wildcard at end overrides earlier exact match", () => {
|
||||
// wildcard permission tests
|
||||
|
||||
test("evaluate - wildcard permission matches any permission", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
|
||||
const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
|
||||
expect(result.action).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - wildcard permission with specific pattern", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
|
||||
const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
|
||||
expect(result.action).toBe("deny")
|
||||
})
|
||||
|
||||
test("evaluate - glob permission pattern", () => {
|
||||
const result = PermissionNext.evaluate("mcp_server_tool", "anything", [
|
||||
const result = Permission.evaluate("mcp_server_tool", "anything", [
|
||||
{ permission: "mcp_*", pattern: "*", action: "allow" },
|
||||
])
|
||||
expect(result.action).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluate - specific permission and wildcard permission combined", () => {
|
||||
const result = PermissionNext.evaluate("bash", "rm", [
|
||||
const result = Permission.evaluate("bash", "rm", [
|
||||
{ permission: "*", pattern: "*", action: "deny" },
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
])
|
||||
@@ -333,7 +325,7 @@ test("evaluate - specific permission and wildcard permission combined", () => {
|
||||
})
|
||||
|
||||
test("evaluate - wildcard permission does not match when specific exists", () => {
|
||||
const result = PermissionNext.evaluate("edit", "src/foo.ts", [
|
||||
const result = Permission.evaluate("edit", "src/foo.ts", [
|
||||
{ permission: "*", pattern: "*", action: "deny" },
|
||||
{ permission: "edit", pattern: "src/*", action: "allow" },
|
||||
])
|
||||
@@ -341,7 +333,7 @@ test("evaluate - wildcard permission does not match when specific exists", () =>
|
||||
})
|
||||
|
||||
test("evaluate - multiple matching permission patterns combine rules", () => {
|
||||
const result = PermissionNext.evaluate("mcp_dangerous", "anything", [
|
||||
const result = Permission.evaluate("mcp_dangerous", "anything", [
|
||||
{ permission: "*", pattern: "*", action: "ask" },
|
||||
{ permission: "mcp_*", pattern: "*", action: "allow" },
|
||||
{ permission: "mcp_dangerous", pattern: "*", action: "deny" },
|
||||
@@ -350,7 +342,7 @@ test("evaluate - multiple matching permission patterns combine rules", () => {
|
||||
})
|
||||
|
||||
test("evaluate - wildcard permission fallback for unknown tool", () => {
|
||||
const result = PermissionNext.evaluate("unknown_tool", "anything", [
|
||||
const result = Permission.evaluate("unknown_tool", "anything", [
|
||||
{ permission: "*", pattern: "*", action: "ask" },
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
])
|
||||
@@ -359,7 +351,7 @@ test("evaluate - wildcard permission fallback for unknown tool", () => {
|
||||
|
||||
test("evaluate - permission patterns sorted by length regardless of object order", () => {
|
||||
// specific permission listed before wildcard, but specific should still win
|
||||
const result = PermissionNext.evaluate("bash", "rm", [
|
||||
const result = Permission.evaluate("bash", "rm", [
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "*", pattern: "*", action: "deny" },
|
||||
])
|
||||
@@ -368,22 +360,22 @@ test("evaluate - permission patterns sorted by length regardless of object order
|
||||
})
|
||||
|
||||
test("evaluate - merges multiple rulesets", () => {
|
||||
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
|
||||
const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
|
||||
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
|
||||
const approved: Permission.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
|
||||
// approved comes after config, so rm should be denied
|
||||
const result = PermissionNext.evaluate("bash", "rm", config, approved)
|
||||
const result = Permission.evaluate("bash", "rm", config, approved)
|
||||
expect(result.action).toBe("deny")
|
||||
})
|
||||
|
||||
// disabled tests
|
||||
|
||||
test("disabled - returns empty set when all tools allowed", () => {
|
||||
const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
|
||||
const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
test("disabled - disables tool when denied", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
const result = Permission.disabled(
|
||||
["bash", "edit", "read"],
|
||||
[
|
||||
{ permission: "*", pattern: "*", action: "allow" },
|
||||
@@ -396,7 +388,7 @@ test("disabled - disables tool when denied", () => {
|
||||
})
|
||||
|
||||
test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
const result = Permission.disabled(
|
||||
["edit", "write", "apply_patch", "multiedit", "bash"],
|
||||
[
|
||||
{ permission: "*", pattern: "*", action: "allow" },
|
||||
@@ -411,7 +403,7 @@ test("disabled - disables edit/write/apply_patch/multiedit when edit denied", ()
|
||||
})
|
||||
|
||||
test("disabled - does not disable when partially denied", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
const result = Permission.disabled(
|
||||
["bash"],
|
||||
[
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
@@ -422,14 +414,14 @@ test("disabled - does not disable when partially denied", () => {
|
||||
})
|
||||
|
||||
test("disabled - does not disable when action is ask", () => {
|
||||
const result = PermissionNext.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
|
||||
const result = Permission.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
test("disabled - does not disable when specific allow after wildcard deny", () => {
|
||||
// Tool is NOT disabled because a specific allow after wildcard deny means
|
||||
// there's at least some usage allowed
|
||||
const result = PermissionNext.disabled(
|
||||
const result = Permission.disabled(
|
||||
["bash"],
|
||||
[
|
||||
{ permission: "bash", pattern: "*", action: "deny" },
|
||||
@@ -440,7 +432,7 @@ test("disabled - does not disable when specific allow after wildcard deny", () =
|
||||
})
|
||||
|
||||
test("disabled - does not disable when wildcard allow after deny", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
const result = Permission.disabled(
|
||||
["bash"],
|
||||
[
|
||||
{ permission: "bash", pattern: "rm *", action: "deny" },
|
||||
@@ -451,7 +443,7 @@ test("disabled - does not disable when wildcard allow after deny", () => {
|
||||
})
|
||||
|
||||
test("disabled - disables multiple tools", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
const result = Permission.disabled(
|
||||
["bash", "edit", "webfetch"],
|
||||
[
|
||||
{ permission: "bash", pattern: "*", action: "deny" },
|
||||
@@ -465,14 +457,14 @@ test("disabled - disables multiple tools", () => {
|
||||
})
|
||||
|
||||
test("disabled - wildcard permission denies all tools", () => {
|
||||
const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
|
||||
const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
|
||||
expect(result.has("bash")).toBe(true)
|
||||
expect(result.has("edit")).toBe(true)
|
||||
expect(result.has("read")).toBe(true)
|
||||
})
|
||||
|
||||
test("disabled - specific allow overrides wildcard deny", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
const result = Permission.disabled(
|
||||
["bash", "edit", "read"],
|
||||
[
|
||||
{ permission: "*", pattern: "*", action: "deny" },
|
||||
@@ -491,7 +483,7 @@ test("ask - resolves immediately when action is allow", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await PermissionNext.ask({
|
||||
const result = await Permission.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
@@ -510,7 +502,7 @@ test("ask - throws RejectedError when action is deny", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(
|
||||
PermissionNext.ask({
|
||||
Permission.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["rm -rf /"],
|
||||
@@ -518,7 +510,7 @@ test("ask - throws RejectedError when action is deny", async () => {
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PermissionNext.DeniedError)
|
||||
).rejects.toBeInstanceOf(Permission.DeniedError)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -528,7 +520,7 @@ test("ask - returns pending promise when action is ask", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const promise = PermissionNext.ask({
|
||||
const promise = Permission.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
@@ -550,7 +542,7 @@ test("ask - adds request to pending list", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ask = PermissionNext.ask({
|
||||
const ask = Permission.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
@@ -563,7 +555,7 @@ test("ask - adds request to pending list", async () => {
|
||||
ruleset: [],
|
||||
})
|
||||
|
||||
const list = await PermissionNext.list()
|
||||
const list = await Permission.list()
|
||||
expect(list).toHaveLength(1)
|
||||
expect(list[0]).toMatchObject({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
@@ -588,12 +580,12 @@ test("ask - publishes asked event", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
let seen: PermissionNext.Request | undefined
|
||||
const unsub = Bus.subscribe(PermissionNext.Event.Asked, (event) => {
|
||||
let seen: Permission.Request | undefined
|
||||
const unsub = Bus.subscribe(Permission.Event.Asked, (event) => {
|
||||
seen = event.properties
|
||||
})
|
||||
|
||||
const ask = PermissionNext.ask({
|
||||
const ask = Permission.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
@@ -606,7 +598,7 @@ test("ask - publishes asked event", async () => {
|
||||
ruleset: [],
|
||||
})
|
||||
|
||||
expect(await PermissionNext.list()).toHaveLength(1)
|
||||
expect(await Permission.list()).toHaveLength(1)
|
||||
expect(seen).toBeDefined()
|
||||
expect(seen).toMatchObject({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
@@ -628,7 +620,7 @@ test("reply - once resolves the pending ask", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const askPromise = PermissionNext.ask({
|
||||
const askPromise = Permission.ask({
|
||||
id: PermissionID.make("per_test1"),
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
@@ -640,7 +632,7 @@ test("reply - once resolves the pending ask", async () => {
|
||||
|
||||
await waitForPending(1)
|
||||
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: PermissionID.make("per_test1"),
|
||||
reply: "once",
|
||||
})
|
||||
@@ -655,7 +647,7 @@ test("reply - reject throws RejectedError", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const askPromise = PermissionNext.ask({
|
||||
const askPromise = Permission.ask({
|
||||
id: PermissionID.make("per_test2"),
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
@@ -667,12 +659,12 @@ test("reply - reject throws RejectedError", async () => {
|
||||
|
||||
await waitForPending(1)
|
||||
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: PermissionID.make("per_test2"),
|
||||
reply: "reject",
|
||||
})
|
||||
|
||||
await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError)
|
||||
await expect(askPromise).rejects.toBeInstanceOf(Permission.RejectedError)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -682,7 +674,7 @@ test("reply - reject with message throws CorrectedError", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ask = PermissionNext.ask({
|
||||
const ask = Permission.ask({
|
||||
id: PermissionID.make("per_test2b"),
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
@@ -694,14 +686,14 @@ test("reply - reject with message throws CorrectedError", async () => {
|
||||
|
||||
await waitForPending(1)
|
||||
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: PermissionID.make("per_test2b"),
|
||||
reply: "reject",
|
||||
message: "Use a safer command",
|
||||
})
|
||||
|
||||
const err = await ask.catch((err) => err)
|
||||
expect(err).toBeInstanceOf(PermissionNext.CorrectedError)
|
||||
expect(err).toBeInstanceOf(Permission.CorrectedError)
|
||||
expect(err.message).toContain("Use a safer command")
|
||||
},
|
||||
})
|
||||
@@ -712,7 +704,7 @@ test("reply - always persists approval and resolves", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const askPromise = PermissionNext.ask({
|
||||
const askPromise = Permission.ask({
|
||||
id: PermissionID.make("per_test3"),
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
@@ -724,7 +716,7 @@ test("reply - always persists approval and resolves", async () => {
|
||||
|
||||
await waitForPending(1)
|
||||
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: PermissionID.make("per_test3"),
|
||||
reply: "always",
|
||||
})
|
||||
@@ -737,7 +729,7 @@ test("reply - always persists approval and resolves", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Stored approval should allow without asking
|
||||
const result = await PermissionNext.ask({
|
||||
const result = await Permission.ask({
|
||||
sessionID: SessionID.make("session_test2"),
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
@@ -755,7 +747,7 @@ test("reply - reject cancels all pending for same session", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const askPromise1 = PermissionNext.ask({
|
||||
const askPromise1 = Permission.ask({
|
||||
id: PermissionID.make("per_test4a"),
|
||||
sessionID: SessionID.make("session_same"),
|
||||
permission: "bash",
|
||||
@@ -765,7 +757,7 @@ test("reply - reject cancels all pending for same session", async () => {
|
||||
ruleset: [],
|
||||
})
|
||||
|
||||
const askPromise2 = PermissionNext.ask({
|
||||
const askPromise2 = Permission.ask({
|
||||
id: PermissionID.make("per_test4b"),
|
||||
sessionID: SessionID.make("session_same"),
|
||||
permission: "edit",
|
||||
@@ -782,14 +774,14 @@ test("reply - reject cancels all pending for same session", async () => {
|
||||
const result2 = askPromise2.catch((e) => e)
|
||||
|
||||
// Reject the first one
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: PermissionID.make("per_test4a"),
|
||||
reply: "reject",
|
||||
})
|
||||
|
||||
// Both should be rejected
|
||||
expect(await result1).toBeInstanceOf(PermissionNext.RejectedError)
|
||||
expect(await result2).toBeInstanceOf(PermissionNext.RejectedError)
|
||||
expect(await result1).toBeInstanceOf(Permission.RejectedError)
|
||||
expect(await result2).toBeInstanceOf(Permission.RejectedError)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -799,7 +791,7 @@ test("reply - always resolves matching pending requests in same session", async
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const a = PermissionNext.ask({
|
||||
const a = Permission.ask({
|
||||
id: PermissionID.make("per_test5a"),
|
||||
sessionID: SessionID.make("session_same"),
|
||||
permission: "bash",
|
||||
@@ -809,7 +801,7 @@ test("reply - always resolves matching pending requests in same session", async
|
||||
ruleset: [],
|
||||
})
|
||||
|
||||
const b = PermissionNext.ask({
|
||||
const b = Permission.ask({
|
||||
id: PermissionID.make("per_test5b"),
|
||||
sessionID: SessionID.make("session_same"),
|
||||
permission: "bash",
|
||||
@@ -821,14 +813,14 @@ test("reply - always resolves matching pending requests in same session", async
|
||||
|
||||
await waitForPending(2)
|
||||
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: PermissionID.make("per_test5a"),
|
||||
reply: "always",
|
||||
})
|
||||
|
||||
await expect(a).resolves.toBeUndefined()
|
||||
await expect(b).resolves.toBeUndefined()
|
||||
expect(await PermissionNext.list()).toHaveLength(0)
|
||||
expect(await Permission.list()).toHaveLength(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -838,7 +830,7 @@ test("reply - always keeps other session pending", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const a = PermissionNext.ask({
|
||||
const a = Permission.ask({
|
||||
id: PermissionID.make("per_test6a"),
|
||||
sessionID: SessionID.make("session_a"),
|
||||
permission: "bash",
|
||||
@@ -848,7 +840,7 @@ test("reply - always keeps other session pending", async () => {
|
||||
ruleset: [],
|
||||
})
|
||||
|
||||
const b = PermissionNext.ask({
|
||||
const b = Permission.ask({
|
||||
id: PermissionID.make("per_test6b"),
|
||||
sessionID: SessionID.make("session_b"),
|
||||
permission: "bash",
|
||||
@@ -860,13 +852,13 @@ test("reply - always keeps other session pending", async () => {
|
||||
|
||||
await waitForPending(2)
|
||||
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: PermissionID.make("per_test6a"),
|
||||
reply: "always",
|
||||
})
|
||||
|
||||
await expect(a).resolves.toBeUndefined()
|
||||
expect((await PermissionNext.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")])
|
||||
expect((await Permission.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")])
|
||||
|
||||
await rejectAll()
|
||||
await b.catch(() => {})
|
||||
@@ -879,7 +871,7 @@ test("reply - publishes replied event", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ask = PermissionNext.ask({
|
||||
const ask = Permission.ask({
|
||||
id: PermissionID.make("per_test7"),
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
@@ -895,14 +887,14 @@ test("reply - publishes replied event", async () => {
|
||||
| {
|
||||
sessionID: SessionID
|
||||
requestID: PermissionID
|
||||
reply: PermissionNext.Reply
|
||||
reply: Permission.Reply
|
||||
}
|
||||
| undefined
|
||||
const unsub = Bus.subscribe(PermissionNext.Event.Replied, (event) => {
|
||||
const unsub = Bus.subscribe(Permission.Event.Replied, (event) => {
|
||||
seen = event.properties
|
||||
})
|
||||
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: PermissionID.make("per_test7"),
|
||||
reply: "once",
|
||||
})
|
||||
@@ -918,16 +910,141 @@ test("reply - publishes replied event", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("permission requests stay isolated by directory", async () => {
|
||||
await using one = await tmpdir({ git: true })
|
||||
await using two = await tmpdir({ git: true })
|
||||
|
||||
const a = Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () =>
|
||||
Permission.ask({
|
||||
id: PermissionID.make("per_dir_a"),
|
||||
sessionID: SessionID.make("session_dir_a"),
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [],
|
||||
}),
|
||||
})
|
||||
|
||||
const b = Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () =>
|
||||
Permission.ask({
|
||||
id: PermissionID.make("per_dir_b"),
|
||||
sessionID: SessionID.make("session_dir_b"),
|
||||
permission: "bash",
|
||||
patterns: ["pwd"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [],
|
||||
}),
|
||||
})
|
||||
|
||||
const onePending = await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => waitForPending(1),
|
||||
})
|
||||
const twoPending = await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () => waitForPending(1),
|
||||
})
|
||||
|
||||
expect(onePending).toHaveLength(1)
|
||||
expect(twoPending).toHaveLength(1)
|
||||
expect(onePending[0].id).toBe(PermissionID.make("per_dir_a"))
|
||||
expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b"))
|
||||
|
||||
await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => Permission.reply({ requestID: onePending[0].id, reply: "reject" }),
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () => Permission.reply({ requestID: twoPending[0].id, reply: "reject" }),
|
||||
})
|
||||
|
||||
await a.catch(() => {})
|
||||
await b.catch(() => {})
|
||||
})
|
||||
|
||||
test("pending permission rejects on instance dispose", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const ask = Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
Permission.ask({
|
||||
id: PermissionID.make("per_dispose"),
|
||||
sessionID: SessionID.make("session_dispose"),
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [],
|
||||
}),
|
||||
})
|
||||
const result = ask.then(
|
||||
() => "resolved" as const,
|
||||
(err) => err,
|
||||
)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const pending = await waitForPending(1)
|
||||
expect(pending).toHaveLength(1)
|
||||
await Instance.dispose()
|
||||
},
|
||||
})
|
||||
|
||||
expect(await result).toBeInstanceOf(Permission.RejectedError)
|
||||
})
|
||||
|
||||
test("pending permission rejects on instance reload", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const ask = Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
Permission.ask({
|
||||
id: PermissionID.make("per_reload"),
|
||||
sessionID: SessionID.make("session_reload"),
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [],
|
||||
}),
|
||||
})
|
||||
const result = ask.then(
|
||||
() => "resolved" as const,
|
||||
(err) => err,
|
||||
)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const pending = await waitForPending(1)
|
||||
expect(pending).toHaveLength(1)
|
||||
await Instance.reload({ directory: tmp.path })
|
||||
},
|
||||
})
|
||||
|
||||
expect(await result).toBeInstanceOf(Permission.RejectedError)
|
||||
})
|
||||
|
||||
test("reply - does nothing for unknown requestID", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await PermissionNext.reply({
|
||||
await Permission.reply({
|
||||
requestID: PermissionID.make("per_unknown"),
|
||||
reply: "once",
|
||||
})
|
||||
expect(await PermissionNext.list()).toHaveLength(0)
|
||||
expect(await Permission.list()).toHaveLength(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -938,7 +1055,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(
|
||||
PermissionNext.ask({
|
||||
Permission.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["echo hello", "rm -rf /"],
|
||||
@@ -949,7 +1066,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
|
||||
{ permission: "bash", pattern: "rm *", action: "deny" },
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PermissionNext.DeniedError)
|
||||
).rejects.toBeInstanceOf(Permission.DeniedError)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -959,7 +1076,7 @@ test("ask - allows all patterns when all match allow rules", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await PermissionNext.ask({
|
||||
const result = await Permission.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["echo hello", "ls -la", "pwd"],
|
||||
@@ -977,7 +1094,7 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const err = await PermissionNext.ask({
|
||||
const err = await Permission.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["echo hello", "rm -rf /"],
|
||||
@@ -992,8 +1109,8 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
|
||||
(err) => err,
|
||||
)
|
||||
|
||||
expect(err).toBeInstanceOf(PermissionNext.DeniedError)
|
||||
expect(await PermissionNext.list()).toHaveLength(0)
|
||||
expect(err).toBeInstanceOf(Permission.DeniedError)
|
||||
expect(await Permission.list()).toHaveLength(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -1004,8 +1121,8 @@ test("ask - abort should clear pending request", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ctl = new AbortController()
|
||||
const ask = runtime.runPromise(
|
||||
S.Service.use((svc) =>
|
||||
const ask = Permission.runPromise(
|
||||
(svc) =>
|
||||
svc.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
@@ -1014,7 +1131,6 @@ test("ask - abort should clear pending request", async () => {
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
|
||||
}),
|
||||
).pipe(Effect.provide(Instances.get(Instance.directory))),
|
||||
{ signal: ctl.signal },
|
||||
)
|
||||
|
||||
@@ -1023,7 +1139,7 @@ test("ask - abort should clear pending request", async () => {
|
||||
await ask.catch(() => {})
|
||||
|
||||
try {
|
||||
expect(await PermissionNext.list()).toHaveLength(0)
|
||||
expect(await Permission.list()).toHaveLength(0)
|
||||
} finally {
|
||||
await rejectAll()
|
||||
}
|
||||
|
||||
@@ -31,15 +31,26 @@ describe("plugin.auth-override", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await using plain = await tmpdir()
|
||||
|
||||
const methods = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const methods = await ProviderAuth.methods()
|
||||
const copilot = methods[ProviderID.make("github-copilot")]
|
||||
expect(copilot).toBeDefined()
|
||||
expect(copilot.length).toBe(1)
|
||||
expect(copilot[0].label).toBe("Test Override Auth")
|
||||
return ProviderAuth.methods()
|
||||
},
|
||||
})
|
||||
|
||||
const plainMethods = await Instance.provide({
|
||||
directory: plain.path,
|
||||
fn: async () => {
|
||||
return ProviderAuth.methods()
|
||||
},
|
||||
})
|
||||
|
||||
const copilot = methods[ProviderID.make("github-copilot")]
|
||||
expect(copilot).toBeDefined()
|
||||
expect(copilot.length).toBe(1)
|
||||
expect(copilot[0].label).toBe("Test Override Auth")
|
||||
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
|
||||
}, 30000) // Increased timeout for plugin installation
|
||||
})
|
||||
|
||||
@@ -25,8 +25,8 @@ function withVcs(
|
||||
directory,
|
||||
Layer.merge(FileWatcher.layer, Vcs.layer),
|
||||
async (rt) => {
|
||||
await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
|
||||
await rt.runPromise(Vcs.Service.use(() => Effect.void))
|
||||
await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
|
||||
await rt.runPromise(Vcs.Service.use((s) => s.init()))
|
||||
await Bun.sleep(500)
|
||||
await body(rt)
|
||||
},
|
||||
@@ -67,7 +67,9 @@ function nextBranchUpdate(directory: string, timeout = 10_000) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describeVcs("Vcs", () => {
|
||||
afterEach(() => Instance.disposeAll())
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("branch() returns current branch name", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { test, expect, describe } from "bun:test"
|
||||
import path from "path"
|
||||
|
||||
import { ProviderID } from "../../src/provider/schema"
|
||||
import { ProviderID, ModelID } from "../../src/provider/schema"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { Env } from "../../src/env"
|
||||
import { Global } from "../../src/global"
|
||||
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
|
||||
|
||||
test("GitLab Duo: loads provider with API key from environment", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
@@ -287,3 +288,121 @@ test("GitLab Duo: has multiple agentic chat models available", async () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe("GitLab Duo: workflow model routing", () => {
|
||||
test("duo-workflow-* model routes through workflowChat", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("GITLAB_TOKEN", "test-token")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const gitlab = providers[ProviderID.gitlab]
|
||||
expect(gitlab).toBeDefined()
|
||||
gitlab.models["duo-workflow-sonnet-4-6"] = {
|
||||
id: ModelID.make("duo-workflow-sonnet-4-6"),
|
||||
providerID: ProviderID.make("gitlab"),
|
||||
name: "Agent Platform (Claude Sonnet 4.6)",
|
||||
family: "",
|
||||
api: { id: "duo-workflow-sonnet-4-6", url: "https://gitlab.com", npm: "gitlab-ai-provider" },
|
||||
status: "active",
|
||||
headers: {},
|
||||
options: { workflowRef: "claude_sonnet_4_6" },
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
limit: { context: 200000, output: 64000 },
|
||||
capabilities: {
|
||||
temperature: false,
|
||||
reasoning: true,
|
||||
attachment: true,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: true, video: false, pdf: true },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
release_date: "",
|
||||
variants: {},
|
||||
}
|
||||
const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-workflow-sonnet-4-6"))
|
||||
expect(model).toBeDefined()
|
||||
expect(model.options?.workflowRef).toBe("claude_sonnet_4_6")
|
||||
const language = await Provider.getLanguage(model)
|
||||
expect(language).toBeDefined()
|
||||
expect(language).toBeInstanceOf(GitLabWorkflowLanguageModel)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("duo-chat-* model routes through agenticChat (not workflow)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("GITLAB_TOKEN", "test-token")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
|
||||
expect(model).toBeDefined()
|
||||
const language = await Provider.getLanguage(model)
|
||||
expect(language).toBeDefined()
|
||||
expect(language).not.toBeInstanceOf(GitLabWorkflowLanguageModel)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("model.options merged with provider.options in getLanguage", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("GITLAB_TOKEN", "test-token")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const gitlab = providers[ProviderID.gitlab]
|
||||
expect(gitlab.options?.featureFlags).toBeDefined()
|
||||
const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
|
||||
expect(model).toBeDefined()
|
||||
expect(model.options).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("GitLab Duo: static models", () => {
|
||||
test("static duo-chat models always present regardless of discovery", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("GITLAB_TOKEN", "test-token")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const models = Object.keys(providers[ProviderID.gitlab].models)
|
||||
expect(models).toContain("duo-chat-haiku-4-5")
|
||||
expect(models).toContain("duo-chat-sonnet-4-5")
|
||||
expect(models).toContain("duo-chat-opus-4-5")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -320,3 +320,134 @@ test("list - returns empty when no pending", async () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("questions stay isolated by directory", async () => {
|
||||
await using one = await tmpdir({ git: true })
|
||||
await using two = await tmpdir({ git: true })
|
||||
|
||||
const p1 = Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () =>
|
||||
Question.ask({
|
||||
sessionID: SessionID.make("ses_one"),
|
||||
questions: [
|
||||
{
|
||||
question: "Question 1?",
|
||||
header: "Q1",
|
||||
options: [{ label: "A", description: "A" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
const p2 = Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () =>
|
||||
Question.ask({
|
||||
sessionID: SessionID.make("ses_two"),
|
||||
questions: [
|
||||
{
|
||||
question: "Question 2?",
|
||||
header: "Q2",
|
||||
options: [{ label: "B", description: "B" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
const onePending = await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => Question.list(),
|
||||
})
|
||||
const twoPending = await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () => Question.list(),
|
||||
})
|
||||
|
||||
expect(onePending.length).toBe(1)
|
||||
expect(twoPending.length).toBe(1)
|
||||
expect(onePending[0].sessionID).toBe(SessionID.make("ses_one"))
|
||||
expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two"))
|
||||
|
||||
await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => Question.reject(onePending[0].id),
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () => Question.reject(twoPending[0].id),
|
||||
})
|
||||
|
||||
await p1.catch(() => {})
|
||||
await p2.catch(() => {})
|
||||
})
|
||||
|
||||
test("pending question rejects on instance dispose", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const ask = Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
return Question.ask({
|
||||
sessionID: SessionID.make("ses_dispose"),
|
||||
questions: [
|
||||
{
|
||||
question: "Dispose me?",
|
||||
header: "Dispose",
|
||||
options: [{ label: "Yes", description: "Yes" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
const result = ask.then(
|
||||
() => "resolved" as const,
|
||||
(err) => err,
|
||||
)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const pending = await Question.list()
|
||||
expect(pending).toHaveLength(1)
|
||||
await Instance.dispose()
|
||||
},
|
||||
})
|
||||
|
||||
expect(await result).toBeInstanceOf(Question.RejectedError)
|
||||
})
|
||||
|
||||
test("pending question rejects on instance reload", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const ask = Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
return Question.ask({
|
||||
sessionID: SessionID.make("ses_reload"),
|
||||
questions: [
|
||||
{
|
||||
question: "Reload me?",
|
||||
header: "Reload",
|
||||
options: [{ label: "Yes", description: "Yes" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
const result = ask.then(
|
||||
() => "resolved" as const,
|
||||
(err) => err,
|
||||
)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const pending = await Question.list()
|
||||
expect(pending).toHaveLength(1)
|
||||
await Instance.reload({ directory: tmp.path })
|
||||
},
|
||||
})
|
||||
|
||||
expect(await result).toBeInstanceOf(Question.RejectedError)
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ test("ShareNext.request uses legacy share API without active org account", async
|
||||
const originalActive = Account.active
|
||||
const originalConfigGet = Config.get
|
||||
|
||||
Account.active = mock(() => undefined)
|
||||
Account.active = mock(async () => undefined)
|
||||
Config.get = mock(async () => ({ enterprise: { url: "https://legacy-share.example.com" } }))
|
||||
|
||||
try {
|
||||
@@ -29,7 +29,7 @@ test("ShareNext.request uses org share API with auth headers when account is act
|
||||
const originalActive = Account.active
|
||||
const originalToken = Account.token
|
||||
|
||||
Account.active = mock(() => ({
|
||||
Account.active = mock(async () => ({
|
||||
id: AccountID.make("account-1"),
|
||||
email: "user@example.com",
|
||||
url: "https://control.example.com",
|
||||
@@ -59,7 +59,7 @@ test("ShareNext.request fails when org account has no token", async () => {
|
||||
const originalActive = Account.active
|
||||
const originalToken = Account.token
|
||||
|
||||
Account.active = mock(() => ({
|
||||
Account.active = mock(async () => ({
|
||||
id: AccountID.make("account-1"),
|
||||
email: "user@example.com",
|
||||
url: "https://control.example.com",
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { afterEach, test, expect } from "bun:test"
|
||||
import { Skill } from "../../src/skill"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
async function createGlobalSkill(homeDir: string) {
|
||||
const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill")
|
||||
await fs.mkdir(skillDir, { recursive: true })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { afterEach, test, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
@@ -12,6 +12,10 @@ import { tmpdir } from "../fixture/fixture"
|
||||
// This helper does the same for expected values so assertions match cross-platform.
|
||||
const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/")
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
async function bootstrap() {
|
||||
return tmpdir({
|
||||
git: true,
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Global } from "../../src/global"
|
||||
import { Installation } from "../../src/installation"
|
||||
import { Database } from "../../src/storage/db"
|
||||
|
||||
describe("Database.Path", () => {
|
||||
test("returns database path for the current channel", () => {
|
||||
const file = path.basename(Database.Path)
|
||||
const expected = ["latest", "beta"].includes(Installation.CHANNEL)
|
||||
? "opencode.db"
|
||||
: `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`
|
||||
expect(file).toBe(expected)
|
||||
const db = process.env["OPENCODE_DB"]
|
||||
const expected = db
|
||||
? path.isAbsolute(db)
|
||||
? db
|
||||
: path.join(Global.Path.data, db)
|
||||
: ["latest", "beta"].includes(Installation.CHANNEL)
|
||||
? path.join(Global.Path.data, "opencode.db")
|
||||
: path.join(Global.Path.data, `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
|
||||
expect(Database.Path).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BashTool } from "../../src/tool/bash"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import type { PermissionNext } from "../../src/permission"
|
||||
import type { Permission } from "../../src/permission"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
@@ -49,10 +49,10 @@ describe("tool.bash permissions", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
@@ -76,10 +76,10 @@ describe("tool.bash permissions", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
@@ -104,10 +104,10 @@ describe("tool.bash permissions", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
@@ -130,10 +130,10 @@ describe("tool.bash permissions", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
@@ -163,10 +163,10 @@ describe("tool.bash permissions", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
@@ -193,10 +193,10 @@ describe("tool.bash permissions", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
@@ -223,10 +223,10 @@ describe("tool.bash permissions", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
@@ -250,10 +250,10 @@ describe("tool.bash permissions", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
@@ -276,10 +276,10 @@ describe("tool.bash permissions", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
@@ -297,10 +297,10 @@ describe("tool.bash permissions", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { afterEach, describe, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { EditTool } from "../../src/tool/edit"
|
||||
@@ -18,6 +18,10 @@ const ctx = {
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
async function touch(file: string, time: number) {
|
||||
const date = new Date(time)
|
||||
await fs.utimes(file, date, date)
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path"
|
||||
import type { Tool } from "../../src/tool/tool"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { assertExternalDirectory } from "../../src/tool/external-directory"
|
||||
import type { PermissionNext } from "../../src/permission"
|
||||
import type { Permission } from "../../src/permission"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
const baseCtx: Omit<Tool.Context, "ask"> = {
|
||||
@@ -18,7 +18,7 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
|
||||
|
||||
describe("tool.assertExternalDirectory", () => {
|
||||
test("no-ops for empty target", async () => {
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
@@ -37,7 +37,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("no-ops for paths inside Instance.directory", async () => {
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
@@ -56,7 +56,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("asks with a single canonical glob", async () => {
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
@@ -82,7 +82,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("uses target directory when kind=directory", async () => {
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
@@ -108,7 +108,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("skips prompting when bypass=true", async () => {
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
|
||||
@@ -32933,7 +32933,7 @@
|
||||
"gitlab": {
|
||||
"id": "gitlab",
|
||||
"env": ["GITLAB_TOKEN"],
|
||||
"npm": "@gitlab/gitlab-ai-provider",
|
||||
"npm": "gitlab-ai-provider",
|
||||
"name": "GitLab Duo",
|
||||
"doc": "https://docs.gitlab.com/user/duo_agent_platform/",
|
||||
"models": {
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { GlobTool } from "../../src/tool/glob"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make(""),
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
describe("tool.glob", () => {
|
||||
test("finds files by glob pattern", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "src", "foo.ts"), "export const foo = 1\n")
|
||||
await Bun.write(path.join(dir, "src", "bar.ts"), "export const bar = 1\n")
|
||||
await Bun.write(path.join(dir, "src", "baz.js"), "export const baz = 1\n")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const glob = await GlobTool.init()
|
||||
const result = await glob.execute(
|
||||
{
|
||||
pattern: "*.ts",
|
||||
path: tmp.path,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.metadata.count).toBe(2)
|
||||
expect(result.output).toContain(path.join(tmp.path, "src", "foo.ts"))
|
||||
expect(result.output).toContain(path.join(tmp.path, "src", "bar.ts"))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns no files found for unmatched patterns", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "src", "foo.ts"), "export const foo = 1\n")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const glob = await GlobTool.init()
|
||||
const result = await glob.execute(
|
||||
{
|
||||
pattern: "*.py",
|
||||
path: tmp.path,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.metadata.count).toBe(0)
|
||||
expect(result.output).toBe("No files found")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back for brace glob patterns", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "src", "foo.ts"), "export const foo = 1\n")
|
||||
await Bun.write(path.join(dir, "src", "bar.js"), "export const bar = 1\n")
|
||||
await Bun.write(path.join(dir, "src", "baz.py"), "print('baz')\n")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const glob = await GlobTool.init()
|
||||
const result = await glob.execute(
|
||||
{
|
||||
pattern: "*.{ts,js}",
|
||||
path: tmp.path,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.metadata.count).toBe(2)
|
||||
expect(result.output).toContain(path.join(tmp.path, "src", "foo.ts"))
|
||||
expect(result.output).toContain(path.join(tmp.path, "src", "bar.js"))
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user