mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -04:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ff4da5639 | |||
| 24f9df5463 | |||
| 12b8e1c2be | |||
| d70099b059 | |||
| ce845a0b1b | |||
| 05d3e65f76 | |||
| 51618e9cef | |||
| e78944e9a4 | |||
| bfdc38e421 | |||
| 83023e4f0f | |||
| d0a57305ef | |||
| 27a70ad70f | |||
| b6e94fd2a8 | |||
| 0bbf26a1ce | |||
| 83cdb4de64 | |||
| 4989632245 | |||
| 3d5c041129 | |||
| 3af557512b | |||
| b53a95fd81 | |||
| 8e11a46fe0 | |||
| 8ab4d84057 | |||
| 4066247988 | |||
| b9de3ad370 |
@@ -325,8 +325,6 @@
|
|||||||
"@aws-sdk/credential-providers": "3.993.0",
|
"@aws-sdk/credential-providers": "3.993.0",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
|
||||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
|
||||||
"@hono/standard-validator": "0.1.5",
|
"@hono/standard-validator": "0.1.5",
|
||||||
"@hono/zod-validator": "catalog:",
|
"@hono/zod-validator": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "1.25.2",
|
"@modelcontextprotocol/sdk": "1.25.2",
|
||||||
@@ -358,6 +356,7 @@
|
|||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
|
"gitlab-ai-provider": "5.2.2",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
@@ -368,6 +367,7 @@
|
|||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
"minimatch": "10.0.3",
|
"minimatch": "10.0.3",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
|
"opencode-gitlab-auth": "2.0.0",
|
||||||
"opentui-spinner": "0.0.6",
|
"opentui-spinner": "0.0.6",
|
||||||
"partial-json": "0.1.7",
|
"partial-json": "0.1.7",
|
||||||
"remeda": "catalog:",
|
"remeda": "catalog:",
|
||||||
@@ -586,6 +586,7 @@
|
|||||||
],
|
],
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
||||||
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
|
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
},
|
},
|
||||||
@@ -1109,10 +1110,6 @@
|
|||||||
|
|
||||||
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
||||||
@@ -3031,6 +3028,8 @@
|
|||||||
|
|
||||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
"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": ["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=="],
|
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
@@ -3783,6 +3782,8 @@
|
|||||||
|
|
||||||
"opencode": ["opencode@workspace:packages/opencode"],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
@@ -4245,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-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=="],
|
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
|
||||||
|
|
||||||
@@ -5059,10 +5060,6 @@
|
|||||||
|
|
||||||
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
"@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/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=="],
|
"@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||||
@@ -5459,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=="],
|
"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=="],
|
"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=="],
|
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
@@ -5535,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/@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/@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=="],
|
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
|
||||||
@@ -6285,6 +6288,8 @@
|
|||||||
|
|
||||||
"node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
"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/@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=="],
|
"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=="],
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-xq0W2Ym0AzANLXnLyAL+IUwrFm0MKXwkJVdENowoPyY=",
|
"x86_64-linux": "sha256-P0RJfQF8APTYVGP6hLJRrOkRSl5nVDNxdcGcZECPPJE=",
|
||||||
"aarch64-linux": "sha256-RtpiGZXk+BboD9MjBetq5sInIbH/OPkLVNSFgN/0ehY=",
|
"aarch64-linux": "sha256-ZtMjTcd35X3JhJIdn3DilFsp7i/IZIcNaKZFnSzW/nk=",
|
||||||
"aarch64-darwin": "sha256-cX6y262OzqRicH4m0/u1DCsMkpJfzCUOOBFQqtQLvls=",
|
"aarch64-darwin": "sha256-Uw/okFDRxxKQMfEsj8MXuHyhpugxZGgIKtu89Getlz8=",
|
||||||
"x86_64-darwin": "sha256-K4UmRKiEfKkvVeKUB85XjHJ1jf0ZUnjL0dWvx9TD4pI="
|
"x86_64-darwin": "sha256-ZySIgT1HbWZWnaQ0W0eURKC43BTupRmmply92JDFPWA="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -113,6 +113,7 @@
|
|||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
||||||
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch"
|
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
|
||||||
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,6 +169,70 @@ async function overflow(page: Parameters<typeof test>[0]["page"], file: string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openReviewFile(page: Parameters<typeof test>[0]["page"], file: string) {
|
||||||
|
const row = page.locator(`[data-file="${file}"]`).first()
|
||||||
|
await expect(row).toBeVisible()
|
||||||
|
await row.hover()
|
||||||
|
|
||||||
|
const open = row.getByRole("button", { name: /^Open file$/i }).first()
|
||||||
|
await expect(open).toBeVisible()
|
||||||
|
await open.click()
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: file }).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
|
await expect(viewer).toBeVisible()
|
||||||
|
return viewer
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileComment(page: Parameters<typeof test>[0]["page"], note: string) {
|
||||||
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
|
await expect(viewer).toBeVisible()
|
||||||
|
|
||||||
|
const line = viewer.locator('diffs-container [data-line="2"]').first()
|
||||||
|
await expect(line).toBeVisible()
|
||||||
|
await line.hover()
|
||||||
|
|
||||||
|
const add = viewer.getByRole("button", { name: /^Comment$/ }).first()
|
||||||
|
await expect(add).toBeVisible()
|
||||||
|
await add.click()
|
||||||
|
|
||||||
|
const area = viewer.locator('[data-slot="line-comment-textarea"]').first()
|
||||||
|
await expect(area).toBeVisible()
|
||||||
|
await area.fill(note)
|
||||||
|
|
||||||
|
const submit = viewer.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
|
||||||
|
await expect(submit).toBeEnabled()
|
||||||
|
await submit.click()
|
||||||
|
|
||||||
|
await expect(viewer.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
|
||||||
|
await expect(viewer.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
|
||||||
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
|
const view = page.locator('[role="tabpanel"] .scroll-view__viewport').first()
|
||||||
|
const pop = viewer.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
|
||||||
|
const tools = viewer.locator('[data-slot="line-comment-tools"]').first()
|
||||||
|
|
||||||
|
const [width, viewBox, popBox, toolsBox] = await Promise.all([
|
||||||
|
view.evaluate((el) => el.scrollWidth - el.clientWidth),
|
||||||
|
view.boundingBox(),
|
||||||
|
pop.boundingBox(),
|
||||||
|
tools.boundingBox(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!viewBox || !popBox || !toolsBox) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
|
||||||
|
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
|
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
|
||||||
test.setTimeout(180_000)
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
@@ -218,6 +282,56 @@ test("review applies inline comment clicks without horizontal overflow", async (
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
|
||||||
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
|
const tag = `review-file-comment-${Date.now()}`
|
||||||
|
const file = `review-file-comment-${tag}.txt`
|
||||||
|
const note = `comment ${tag}`
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 })
|
||||||
|
|
||||||
|
await withProject(async (project) => {
|
||||||
|
const sdk = createSdk(project.directory)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
|
||||||
|
await patch(sdk, session.id, seed([{ file, mark: tag }]))
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
|
return diff.length
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(1)
|
||||||
|
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await show(page)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
|
await expand(page)
|
||||||
|
await waitMark(page, file, tag)
|
||||||
|
await openReviewFile(page, file)
|
||||||
|
await fileComment(page, note)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
|
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
|
||||||
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
|
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
|
||||||
test.setTimeout(180_000)
|
test.setTimeout(180_000)
|
||||||
|
|||||||
@@ -1383,11 +1383,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
multiple
|
||||||
accept={ACCEPTED_FILE_TYPES.join(",")}
|
accept={ACCEPTED_FILE_TYPES.join(",")}
|
||||||
class="hidden"
|
class="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.currentTarget.files?.[0]
|
const list = e.currentTarget.files
|
||||||
if (file) void addAttachment(file)
|
if (list) {
|
||||||
|
for (const file of Array.from(list)) {
|
||||||
|
void addAttachment(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
e.currentTarget.value = ""
|
e.currentTarget.value = ""
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker"
|
||||||
|
|
||||||
|
export { ACCEPTED_FILE_TYPES }
|
||||||
|
|
||||||
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
|
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
|
||||||
const IMAGE_EXTS = new Map([
|
const IMAGE_EXTS = new Map([
|
||||||
@@ -18,61 +20,6 @@ const TEXT_MIMES = new Set([
|
|||||||
"application/yaml",
|
"application/yaml",
|
||||||
])
|
])
|
||||||
|
|
||||||
export const ACCEPTED_FILE_TYPES = [
|
|
||||||
...ACCEPTED_IMAGE_TYPES,
|
|
||||||
"application/pdf",
|
|
||||||
"text/*",
|
|
||||||
"application/json",
|
|
||||||
"application/ld+json",
|
|
||||||
"application/toml",
|
|
||||||
"application/x-toml",
|
|
||||||
"application/x-yaml",
|
|
||||||
"application/xml",
|
|
||||||
"application/yaml",
|
|
||||||
".c",
|
|
||||||
".cc",
|
|
||||||
".cjs",
|
|
||||||
".conf",
|
|
||||||
".cpp",
|
|
||||||
".css",
|
|
||||||
".csv",
|
|
||||||
".cts",
|
|
||||||
".env",
|
|
||||||
".go",
|
|
||||||
".gql",
|
|
||||||
".graphql",
|
|
||||||
".h",
|
|
||||||
".hh",
|
|
||||||
".hpp",
|
|
||||||
".htm",
|
|
||||||
".html",
|
|
||||||
".ini",
|
|
||||||
".java",
|
|
||||||
".js",
|
|
||||||
".json",
|
|
||||||
".jsx",
|
|
||||||
".log",
|
|
||||||
".md",
|
|
||||||
".mdx",
|
|
||||||
".mjs",
|
|
||||||
".mts",
|
|
||||||
".py",
|
|
||||||
".rb",
|
|
||||||
".rs",
|
|
||||||
".sass",
|
|
||||||
".scss",
|
|
||||||
".sh",
|
|
||||||
".sql",
|
|
||||||
".toml",
|
|
||||||
".ts",
|
|
||||||
".tsx",
|
|
||||||
".txt",
|
|
||||||
".xml",
|
|
||||||
".yaml",
|
|
||||||
".yml",
|
|
||||||
".zsh",
|
|
||||||
]
|
|
||||||
|
|
||||||
const SAMPLE = 4096
|
const SAMPLE = 4096
|
||||||
|
|
||||||
function kind(type: string) {
|
function kind(type: string) {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||||
|
|
||||||
|
export const ACCEPTED_FILE_TYPES = [
|
||||||
|
...ACCEPTED_IMAGE_TYPES,
|
||||||
|
"application/pdf",
|
||||||
|
"text/*",
|
||||||
|
"application/json",
|
||||||
|
"application/ld+json",
|
||||||
|
"application/toml",
|
||||||
|
"application/x-toml",
|
||||||
|
"application/x-yaml",
|
||||||
|
"application/xml",
|
||||||
|
"application/yaml",
|
||||||
|
".c",
|
||||||
|
".cc",
|
||||||
|
".cjs",
|
||||||
|
".conf",
|
||||||
|
".cpp",
|
||||||
|
".css",
|
||||||
|
".csv",
|
||||||
|
".cts",
|
||||||
|
".env",
|
||||||
|
".go",
|
||||||
|
".gql",
|
||||||
|
".graphql",
|
||||||
|
".h",
|
||||||
|
".hh",
|
||||||
|
".hpp",
|
||||||
|
".htm",
|
||||||
|
".html",
|
||||||
|
".ini",
|
||||||
|
".java",
|
||||||
|
".js",
|
||||||
|
".json",
|
||||||
|
".jsx",
|
||||||
|
".log",
|
||||||
|
".md",
|
||||||
|
".mdx",
|
||||||
|
".mjs",
|
||||||
|
".mts",
|
||||||
|
".py",
|
||||||
|
".rb",
|
||||||
|
".rs",
|
||||||
|
".sass",
|
||||||
|
".scss",
|
||||||
|
".sh",
|
||||||
|
".sql",
|
||||||
|
".toml",
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".txt",
|
||||||
|
".xml",
|
||||||
|
".yaml",
|
||||||
|
".yml",
|
||||||
|
".zsh",
|
||||||
|
]
|
||||||
|
|
||||||
|
const MIME_EXT = new Map([
|
||||||
|
["image/png", "png"],
|
||||||
|
["image/jpeg", "jpg"],
|
||||||
|
["image/gif", "gif"],
|
||||||
|
["image/webp", "webp"],
|
||||||
|
["application/pdf", "pdf"],
|
||||||
|
["application/json", "json"],
|
||||||
|
["application/ld+json", "jsonld"],
|
||||||
|
["application/toml", "toml"],
|
||||||
|
["application/x-toml", "toml"],
|
||||||
|
["application/x-yaml", "yaml"],
|
||||||
|
["application/xml", "xml"],
|
||||||
|
["application/yaml", "yaml"],
|
||||||
|
])
|
||||||
|
|
||||||
|
const TEXT_EXT = ["txt", "text", "md", "markdown", "log", "csv"]
|
||||||
|
|
||||||
|
export const ACCEPTED_FILE_EXTENSIONS = Array.from(
|
||||||
|
new Set(
|
||||||
|
ACCEPTED_FILE_TYPES.flatMap((item) => {
|
||||||
|
if (item.startsWith(".")) return [item.slice(1)]
|
||||||
|
if (item === "text/*") return TEXT_EXT
|
||||||
|
const out = MIME_EXT.get(item)
|
||||||
|
return out ? [out] : []
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).sort()
|
||||||
|
|
||||||
|
export function filePickerFilters(ext?: string[]) {
|
||||||
|
if (!ext || ext.length === 0) return undefined
|
||||||
|
return [{ name: "Files", extensions: ext }]
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { ServerConnection } from "./server"
|
|||||||
|
|
||||||
type PickerPaths = string | string[] | null
|
type PickerPaths = string | string[] | null
|
||||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||||
type OpenFilePickerOptions = { title?: string; multiple?: boolean }
|
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
|
||||||
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
||||||
type UpdateInfo = { updateAvailable: boolean; version?: string }
|
type UpdateInfo = { updateAvailable: boolean; version?: string }
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { AppBaseProviders, AppInterface } from "./app"
|
export { AppBaseProviders, AppInterface } from "./app"
|
||||||
|
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
||||||
export { useCommand } from "./context/command"
|
export { useCommand } from "./context/command"
|
||||||
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
|
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
|
||||||
export { ServerConnection } from "./context/server"
|
export { ServerConnection } from "./context/server"
|
||||||
|
|||||||
@@ -217,17 +217,6 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
onDelete={controls.remove}
|
onDelete={controls.remove}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
onDraftPopoverFocusOut: (e: FocusEvent) => {
|
|
||||||
const current = e.currentTarget as HTMLDivElement
|
|
||||||
const target = e.relatedTarget
|
|
||||||
if (target instanceof Node && current.contains(target)) return
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!document.activeElement || !current.contains(document.activeElement)) {
|
|
||||||
setNote("commenting", null)
|
|
||||||
}
|
|
||||||
}, 0)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -426,7 +415,6 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
commentsUi.onLineSelectionEnd(range)
|
commentsUi.onLineSelectionEnd(range)
|
||||||
}}
|
}}
|
||||||
search={search}
|
search={search}
|
||||||
overflow="scroll"
|
|
||||||
class="select-text"
|
class="select-text"
|
||||||
media={{
|
media={{
|
||||||
mode: "auto",
|
mode: "auto",
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme,
|
|||||||
import { getStore } from "./store"
|
import { getStore } from "./store"
|
||||||
import { setTitlebar } from "./windows"
|
import { setTitlebar } from "./windows"
|
||||||
|
|
||||||
|
const pickerFilters = (ext?: string[]) => {
|
||||||
|
if (!ext || ext.length === 0) return undefined
|
||||||
|
return [{ name: "Files", extensions: ext }]
|
||||||
|
}
|
||||||
|
|
||||||
type Deps = {
|
type Deps = {
|
||||||
killSidecar: () => void
|
killSidecar: () => void
|
||||||
installCli: () => Promise<string>
|
installCli: () => Promise<string>
|
||||||
@@ -94,11 +99,15 @@ export function registerIpcHandlers(deps: Deps) {
|
|||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
"open-file-picker",
|
"open-file-picker",
|
||||||
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
|
async (
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
opts?: { multiple?: boolean; title?: string; defaultPath?: string; accept?: string[]; extensions?: string[] },
|
||||||
|
) => {
|
||||||
const result = await dialog.showOpenDialog({
|
const result = await dialog.showOpenDialog({
|
||||||
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
|
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
|
||||||
title: opts?.title ?? "Choose a file",
|
title: opts?.title ?? "Choose a file",
|
||||||
defaultPath: opts?.defaultPath,
|
defaultPath: opts?.defaultPath,
|
||||||
|
filters: pickerFilters(opts?.extensions),
|
||||||
})
|
})
|
||||||
if (result.canceled) return null
|
if (result.canceled) return null
|
||||||
return opts?.multiple ? result.filePaths : result.filePaths[0]
|
return opts?.multiple ? result.filePaths : result.filePaths[0]
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export type ElectronAPI = {
|
|||||||
multiple?: boolean
|
multiple?: boolean
|
||||||
title?: string
|
title?: string
|
||||||
defaultPath?: string
|
defaultPath?: string
|
||||||
|
accept?: string[]
|
||||||
|
extensions?: string[]
|
||||||
}) => Promise<string | string[] | null>
|
}) => Promise<string | string[] | null>
|
||||||
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
|
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
|
||||||
openLink: (url: string) => void
|
openLink: (url: string) => void
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// @refresh reload
|
// @refresh reload
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ACCEPTED_FILE_EXTENSIONS,
|
||||||
|
ACCEPTED_FILE_TYPES,
|
||||||
AppBaseProviders,
|
AppBaseProviders,
|
||||||
AppInterface,
|
AppInterface,
|
||||||
handleNotificationClick,
|
handleNotificationClick,
|
||||||
@@ -111,6 +113,8 @@ const createPlatform = (): Platform => {
|
|||||||
const result = await window.api.openFilePicker({
|
const result = await window.api.openFilePicker({
|
||||||
multiple: opts?.multiple ?? false,
|
multiple: opts?.multiple ?? false,
|
||||||
title: opts?.title ?? t("desktop.dialog.chooseFile"),
|
title: opts?.title ?? t("desktop.dialog.chooseFile"),
|
||||||
|
accept: opts?.accept ?? ACCEPTED_FILE_TYPES,
|
||||||
|
extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
|
||||||
})
|
})
|
||||||
return handleWslPicker(result)
|
return handleWslPicker(result)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// @refresh reload
|
// @refresh reload
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ACCEPTED_FILE_EXTENSIONS,
|
||||||
|
filePickerFilters,
|
||||||
AppBaseProviders,
|
AppBaseProviders,
|
||||||
AppInterface,
|
AppInterface,
|
||||||
handleNotificationClick,
|
handleNotificationClick,
|
||||||
@@ -98,6 +100,7 @@ const createPlatform = (): Platform => {
|
|||||||
directory: false,
|
directory: false,
|
||||||
multiple: opts?.multiple ?? false,
|
multiple: opts?.multiple ?? false,
|
||||||
title: opts?.title ?? t("desktop.dialog.chooseFile"),
|
title: opts?.title ?? t("desktop.dialog.chooseFile"),
|
||||||
|
filters: filePickerFilters(opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS),
|
||||||
})
|
})
|
||||||
return handleWslPicker(result)
|
return handleWslPicker(result)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -89,8 +89,9 @@
|
|||||||
"@ai-sdk/xai": "2.0.51",
|
"@ai-sdk/xai": "2.0.51",
|
||||||
"@aws-sdk/credential-providers": "3.993.0",
|
"@aws-sdk/credential-providers": "3.993.0",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
"gitlab-ai-provider": "5.2.2",
|
||||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
"opencode-gitlab-auth": "2.0.0",
|
||||||
|
"@effect/platform-node": "catalog:",
|
||||||
"@hono/standard-validator": "0.1.5",
|
"@hono/standard-validator": "0.1.5",
|
||||||
"@hono/zod-validator": "catalog:",
|
"@hono/zod-validator": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "1.25.2",
|
"@modelcontextprotocol/sdk": "1.25.2",
|
||||||
@@ -104,7 +105,6 @@
|
|||||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||||
"@opentui/core": "0.1.87",
|
"@opentui/core": "0.1.87",
|
||||||
"@opentui/solid": "0.1.87",
|
"@opentui/solid": "0.1.87",
|
||||||
"@effect/platform-node": "catalog:",
|
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ Still open and likely worth migrating:
|
|||||||
- [ ] `Worktree`
|
- [ ] `Worktree`
|
||||||
- [ ] `Installation`
|
- [ ] `Installation`
|
||||||
- [ ] `Bus`
|
- [ ] `Bus`
|
||||||
- [ ] `Command`
|
- [x] `Command`
|
||||||
- [ ] `Config`
|
- [ ] `Config`
|
||||||
- [ ] `Session`
|
- [ ] `Session`
|
||||||
- [ ] `SessionProcessor`
|
- [ ] `SessionProcessor`
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { AccountRepo, type AccountRow } from "./repo"
|
|||||||
import {
|
import {
|
||||||
type AccountError,
|
type AccountError,
|
||||||
AccessToken,
|
AccessToken,
|
||||||
Account,
|
|
||||||
AccountID,
|
AccountID,
|
||||||
DeviceCode,
|
DeviceCode,
|
||||||
|
Info,
|
||||||
RefreshToken,
|
RefreshToken,
|
||||||
AccountServiceError,
|
AccountServiceError,
|
||||||
Login,
|
Login,
|
||||||
@@ -24,10 +24,30 @@ import {
|
|||||||
UserCode,
|
UserCode,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
|
|
||||||
export * 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 = {
|
export type AccountOrgs = {
|
||||||
account: Account
|
account: Info
|
||||||
orgs: readonly Org[]
|
orgs: readonly Org[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,10 +128,10 @@ const mapAccountServiceError =
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
export namespace AccountEffect {
|
export namespace Account {
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
|
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
|
||||||
readonly list: () => Effect.Effect<Account[], AccountError>
|
readonly list: () => Effect.Effect<Info[], AccountError>
|
||||||
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
||||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
|
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
|
||||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
|
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
|
||||||
|
|||||||
@@ -1,31 +1,24 @@
|
|||||||
import { Effect, Option } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
|
|
||||||
import {
|
import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
|
||||||
Account as AccountSchema,
|
|
||||||
type AccountError,
|
|
||||||
type AccessToken,
|
|
||||||
AccountID,
|
|
||||||
AccountEffect,
|
|
||||||
OrgID,
|
|
||||||
} from "./effect"
|
|
||||||
|
|
||||||
export { AccessToken, AccountID, OrgID } from "./effect"
|
export { AccessToken, AccountID, OrgID } from "./effect"
|
||||||
|
|
||||||
import { runtime } from "@/effect/runtime"
|
import { runtime } from "@/effect/runtime"
|
||||||
|
|
||||||
function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
|
function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
|
||||||
return runtime.runSync(AccountEffect.Service.use(f))
|
return runtime.runSync(S.Service.use(f))
|
||||||
}
|
}
|
||||||
|
|
||||||
function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
|
function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
|
||||||
return runtime.runPromise(AccountEffect.Service.use(f))
|
return runtime.runPromise(S.Service.use(f))
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Account {
|
export namespace Account {
|
||||||
export const Account = AccountSchema
|
export const Info = Model
|
||||||
export type Account = AccountSchema
|
export type Info = Model
|
||||||
|
|
||||||
export function active(): Account | undefined {
|
export function active(): Info | undefined {
|
||||||
return Option.getOrUndefined(runSync((service) => service.active()))
|
return Option.getOrUndefined(runSync((service) => service.active()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
|
|||||||
|
|
||||||
import { Database } from "@/storage/db"
|
import { Database } from "@/storage/db"
|
||||||
import { AccountStateTable, AccountTable } from "./account.sql"
|
import { AccountStateTable, AccountTable } from "./account.sql"
|
||||||
import { AccessToken, Account, AccountID, AccountRepoError, OrgID, RefreshToken } from "./schema"
|
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
|
||||||
|
|
||||||
export type AccountRow = (typeof AccountTable)["$inferSelect"]
|
export type AccountRow = (typeof AccountTable)["$inferSelect"]
|
||||||
|
|
||||||
@@ -13,8 +13,8 @@ const ACCOUNT_STATE_ID = 1
|
|||||||
|
|
||||||
export namespace AccountRepo {
|
export namespace AccountRepo {
|
||||||
export interface Service {
|
export interface Service {
|
||||||
readonly active: () => Effect.Effect<Option.Option<Account>, AccountRepoError>
|
readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
|
||||||
readonly list: () => Effect.Effect<Account[], AccountRepoError>
|
readonly list: () => Effect.Effect<Info[], AccountRepoError>
|
||||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
|
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
|
||||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
|
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
|
||||||
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
|
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
|
||||||
@@ -40,7 +40,7 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
|
|||||||
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
|
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
|
||||||
AccountRepo,
|
AccountRepo,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const decode = Schema.decodeUnknownSync(Account)
|
const decode = Schema.decodeUnknownSync(Info)
|
||||||
|
|
||||||
const query = <A>(f: (db: DbClient) => A) =>
|
const query = <A>(f: (db: DbClient) => A) =>
|
||||||
Effect.try({
|
Effect.try({
|
||||||
@@ -136,6 +136,8 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
|
|||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: AccountTable.id,
|
target: AccountTable.id,
|
||||||
set: {
|
set: {
|
||||||
|
email: input.email,
|
||||||
|
url: input.url,
|
||||||
access_token: input.accessToken,
|
access_token: input.accessToken,
|
||||||
refresh_token: input.refreshToken,
|
refresh_token: input.refreshToken,
|
||||||
token_expiry: input.expiry,
|
token_expiry: input.expiry,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const UserCode = Schema.String.pipe(
|
|||||||
)
|
)
|
||||||
export type UserCode = Schema.Schema.Type<typeof UserCode>
|
export type UserCode = Schema.Schema.Type<typeof UserCode>
|
||||||
|
|
||||||
export class Account extends Schema.Class<Account>("Account")({
|
export class Info extends Schema.Class<Info>("Account")({
|
||||||
id: AccountID,
|
id: AccountID,
|
||||||
email: Schema.String,
|
email: Schema.String,
|
||||||
url: Schema.String,
|
url: Schema.String,
|
||||||
|
|||||||
@@ -322,11 +322,11 @@ export namespace Agent {
|
|||||||
}),
|
}),
|
||||||
} satisfies Parameters<typeof generateObject>[0]
|
} satisfies Parameters<typeof generateObject>[0]
|
||||||
|
|
||||||
|
// TODO: clean this up so provider specific logic doesnt bleed over
|
||||||
if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
|
if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
|
||||||
const result = streamObject({
|
const result = streamObject({
|
||||||
...params,
|
...params,
|
||||||
providerOptions: ProviderTransform.providerOptions(model, {
|
providerOptions: ProviderTransform.providerOptions(model, {
|
||||||
instructions: SystemPrompt.instructions(),
|
|
||||||
store: false,
|
store: false,
|
||||||
}),
|
}),
|
||||||
onError: () => {},
|
onError: () => {},
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const file = path.join(Global.Path.data, "auth.json")
|
|||||||
|
|
||||||
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
|
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
|
||||||
|
|
||||||
export namespace AuthEffect {
|
export namespace Auth {
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
||||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import * as S from "./effect"
|
|||||||
|
|
||||||
export { OAUTH_DUMMY_KEY } from "./effect"
|
export { OAUTH_DUMMY_KEY } from "./effect"
|
||||||
|
|
||||||
function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthError>) {
|
function runPromise<A>(f: (service: S.Auth.Interface) => Effect.Effect<A, S.AuthError>) {
|
||||||
return runtime.runPromise(S.AuthEffect.Service.use(f))
|
return runtime.runPromise(S.Auth.Service.use(f))
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Auth {
|
export namespace Auth {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import type { ZodType } from "zod"
|
import type { ZodObject, ZodRawShape } from "zod"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
export namespace BusEvent {
|
export namespace BusEvent {
|
||||||
@@ -9,7 +9,7 @@ export namespace BusEvent {
|
|||||||
|
|
||||||
const registry = new Map<string, Definition>()
|
const registry = new Map<string, Definition>()
|
||||||
|
|
||||||
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
|
export function define<Type extends string, Properties extends ZodObject<ZodRawShape>>(type: Type, properties: Properties) {
|
||||||
const result = {
|
const result = {
|
||||||
type,
|
type,
|
||||||
properties,
|
properties,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const GlobalBus = new EventEmitter<{
|
|||||||
event: [
|
event: [
|
||||||
{
|
{
|
||||||
directory?: string
|
directory?: string
|
||||||
payload: any
|
payload: { type: string; properties: Record<string, unknown> }
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { cmd } from "./cmd"
|
|||||||
import { Duration, Effect, Match, Option } from "effect"
|
import { Duration, Effect, Match, Option } from "effect"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { runtime } from "@/effect/runtime"
|
import { runtime } from "@/effect/runtime"
|
||||||
import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect"
|
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect"
|
||||||
import { type AccountError } from "@/account/schema"
|
import { type AccountError } from "@/account/schema"
|
||||||
import * as Prompt from "../effect/prompt"
|
import * as Prompt from "../effect/prompt"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
@@ -17,7 +17,7 @@ const isActiveOrgChoice = (
|
|||||||
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
|
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
|
||||||
|
|
||||||
const loginEffect = Effect.fn("login")(function* (url: string) {
|
const loginEffect = Effect.fn("login")(function* (url: string) {
|
||||||
const service = yield* AccountEffect.Service
|
const service = yield* Account.Service
|
||||||
|
|
||||||
yield* Prompt.intro("Log in")
|
yield* Prompt.intro("Log in")
|
||||||
const login = yield* service.login(url)
|
const login = yield* service.login(url)
|
||||||
@@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
|
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
|
||||||
const service = yield* AccountEffect.Service
|
const service = yield* Account.Service
|
||||||
const accounts = yield* service.list()
|
const accounts = yield* service.list()
|
||||||
if (accounts.length === 0) return yield* println("Not logged in")
|
if (accounts.length === 0) return yield* println("Not logged in")
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ interface OrgChoice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const switchEffect = Effect.fn("switch")(function* () {
|
const switchEffect = Effect.fn("switch")(function* () {
|
||||||
const service = yield* AccountEffect.Service
|
const service = yield* Account.Service
|
||||||
|
|
||||||
const groups = yield* service.orgsByAccount()
|
const groups = yield* service.orgsByAccount()
|
||||||
if (groups.length === 0) return yield* println("Not logged in")
|
if (groups.length === 0) return yield* println("Not logged in")
|
||||||
@@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const orgsEffect = Effect.fn("orgs")(function* () {
|
const orgsEffect = Effect.fn("orgs")(function* () {
|
||||||
const service = yield* AccountEffect.Service
|
const service = yield* Account.Service
|
||||||
|
|
||||||
const groups = yield* service.orgsByAccount()
|
const groups = yield* service.orgsByAccount()
|
||||||
if (groups.length === 0) return yield* println("No accounts found")
|
if (groups.length === 0) return yield* println("No accounts found")
|
||||||
|
|||||||
@@ -58,10 +58,10 @@ export const UpgradeCommand = {
|
|||||||
spinner.stop("Upgrade failed", 1)
|
spinner.stop("Upgrade failed", 1)
|
||||||
if (err instanceof Installation.UpgradeFailedError) {
|
if (err instanceof Installation.UpgradeFailedError) {
|
||||||
// necessary because choco only allows install/upgrade in elevated terminals
|
// necessary because choco only allows install/upgrade in elevated terminals
|
||||||
if (method === "choco" && err.data.stderr.includes("not running from an elevated command shell")) {
|
if (method === "choco" && err.stderr.includes("not running from an elevated command shell")) {
|
||||||
prompts.log.error("Please run the terminal as Administrator and try again")
|
prompts.log.error("Please run the terminal as Administrator and try again")
|
||||||
} else {
|
} else {
|
||||||
prompts.log.error(err.data.stderr)
|
prompts.log.error(err.stderr)
|
||||||
}
|
}
|
||||||
} else if (err instanceof Error) prompts.log.error(err.message)
|
} else if (err instanceof Error) prompts.log.error(err.message)
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { SessionID, MessageID } from "@/session/schema"
|
import { SessionID, MessageID } from "@/session/schema"
|
||||||
|
import { Effect, Fiber, Layer, ServiceMap } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { Identifier } from "../id/id"
|
|
||||||
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
||||||
import PROMPT_REVIEW from "./template/review.txt"
|
import PROMPT_REVIEW from "./template/review.txt"
|
||||||
import { MCP } from "../mcp"
|
import { MCP } from "../mcp"
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
export namespace Command {
|
export namespace Command {
|
||||||
|
const log = Log.create({ service: "command" })
|
||||||
export const Event = {
|
export const Event = {
|
||||||
Executed: BusEvent.define(
|
Executed: BusEvent.define(
|
||||||
"command.executed",
|
"command.executed",
|
||||||
@@ -57,95 +60,126 @@ export namespace Command {
|
|||||||
REVIEW: "review",
|
REVIEW: "review",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const state = Instance.state(async () => {
|
export interface Interface {
|
||||||
const cfg = await Config.get()
|
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||||
|
readonly list: () => Effect.Effect<Info[]>
|
||||||
|
}
|
||||||
|
|
||||||
const result: Record<string, Info> = {
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Command") {}
|
||||||
[Default.INIT]: {
|
|
||||||
name: Default.INIT,
|
|
||||||
description: "create/update AGENTS.md",
|
|
||||||
source: "command",
|
|
||||||
get template() {
|
|
||||||
return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
|
|
||||||
},
|
|
||||||
hints: hints(PROMPT_INITIALIZE),
|
|
||||||
},
|
|
||||||
[Default.REVIEW]: {
|
|
||||||
name: Default.REVIEW,
|
|
||||||
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
|
||||||
source: "command",
|
|
||||||
get template() {
|
|
||||||
return PROMPT_REVIEW.replace("${path}", Instance.worktree)
|
|
||||||
},
|
|
||||||
subtask: true,
|
|
||||||
hints: hints(PROMPT_REVIEW),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, command] of Object.entries(cfg.command ?? {})) {
|
export const layer = Layer.effect(
|
||||||
result[name] = {
|
Service,
|
||||||
name,
|
Effect.gen(function* () {
|
||||||
agent: command.agent,
|
const instance = yield* InstanceContext
|
||||||
model: command.model,
|
|
||||||
description: command.description,
|
|
||||||
source: "command",
|
|
||||||
get template() {
|
|
||||||
return command.template
|
|
||||||
},
|
|
||||||
subtask: command.subtask,
|
|
||||||
hints: hints(command.template),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
|
|
||||||
result[name] = {
|
|
||||||
name,
|
|
||||||
source: "mcp",
|
|
||||||
description: prompt.description,
|
|
||||||
get template() {
|
|
||||||
// since a getter can't be async we need to manually return a promise here
|
|
||||||
return new Promise<string>(async (resolve, reject) => {
|
|
||||||
const template = await MCP.getPrompt(
|
|
||||||
prompt.client,
|
|
||||||
prompt.name,
|
|
||||||
prompt.arguments
|
|
||||||
? // substitute each argument with $1, $2, etc.
|
|
||||||
Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`]))
|
|
||||||
: {},
|
|
||||||
).catch(reject)
|
|
||||||
resolve(
|
|
||||||
template?.messages
|
|
||||||
.map((message) => (message.content.type === "text" ? message.content.text : ""))
|
|
||||||
.join("\n") || "",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add skills as invokable commands
|
const commands: Record<string, Info> = {}
|
||||||
for (const skill of await Skill.all()) {
|
|
||||||
// Skip if a command with this name already exists
|
|
||||||
if (result[skill.name]) continue
|
|
||||||
result[skill.name] = {
|
|
||||||
name: skill.name,
|
|
||||||
description: skill.description,
|
|
||||||
source: "skill",
|
|
||||||
get template() {
|
|
||||||
return skill.content
|
|
||||||
},
|
|
||||||
hints: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
const load = Effect.fn("Command.load")(function* () {
|
||||||
})
|
yield* Effect.promise(async () => {
|
||||||
|
const cfg = await Config.get()
|
||||||
|
|
||||||
|
commands[Default.INIT] = {
|
||||||
|
name: Default.INIT,
|
||||||
|
description: "create/update AGENTS.md",
|
||||||
|
source: "command",
|
||||||
|
get template() {
|
||||||
|
return PROMPT_INITIALIZE.replace("${path}", instance.worktree)
|
||||||
|
},
|
||||||
|
hints: hints(PROMPT_INITIALIZE),
|
||||||
|
}
|
||||||
|
commands[Default.REVIEW] = {
|
||||||
|
name: Default.REVIEW,
|
||||||
|
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||||
|
source: "command",
|
||||||
|
get template() {
|
||||||
|
return PROMPT_REVIEW.replace("${path}", instance.worktree)
|
||||||
|
},
|
||||||
|
subtask: true,
|
||||||
|
hints: hints(PROMPT_REVIEW),
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, command] of Object.entries(cfg.command ?? {})) {
|
||||||
|
commands[name] = {
|
||||||
|
name,
|
||||||
|
agent: command.agent,
|
||||||
|
model: command.model,
|
||||||
|
description: command.description,
|
||||||
|
source: "command",
|
||||||
|
get template() {
|
||||||
|
return command.template
|
||||||
|
},
|
||||||
|
subtask: command.subtask,
|
||||||
|
hints: hints(command.template),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
|
||||||
|
commands[name] = {
|
||||||
|
name,
|
||||||
|
source: "mcp",
|
||||||
|
description: prompt.description,
|
||||||
|
get template() {
|
||||||
|
// since a getter can't be async we need to manually return a promise here
|
||||||
|
return new Promise<string>(async (resolve, reject) => {
|
||||||
|
const template = await MCP.getPrompt(
|
||||||
|
prompt.client,
|
||||||
|
prompt.name,
|
||||||
|
prompt.arguments
|
||||||
|
? // substitute each argument with $1, $2, etc.
|
||||||
|
Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`]))
|
||||||
|
: {},
|
||||||
|
).catch(reject)
|
||||||
|
resolve(
|
||||||
|
template?.messages
|
||||||
|
.map((message) => (message.content.type === "text" ? message.content.text : ""))
|
||||||
|
.join("\n") || "",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add skills as invokable commands
|
||||||
|
for (const skill of await Skill.all()) {
|
||||||
|
// Skip if a command with this name already exists
|
||||||
|
if (commands[skill.name]) continue
|
||||||
|
commands[skill.name] = {
|
||||||
|
name: skill.name,
|
||||||
|
description: skill.description,
|
||||||
|
source: "skill",
|
||||||
|
get template() {
|
||||||
|
return skill.content
|
||||||
|
},
|
||||||
|
hints: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadFiber = yield* load().pipe(
|
||||||
|
Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause }))),
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
|
||||||
|
const get = Effect.fn("Command.get")(function* (name: string) {
|
||||||
|
yield* Fiber.join(loadFiber)
|
||||||
|
return commands[name]
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("Command.list")(function* () {
|
||||||
|
yield* Fiber.join(loadFiber)
|
||||||
|
return Object.values(commands)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ get, list })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export async function get(name: string) {
|
export async function get(name: string) {
|
||||||
return state().then((x) => x[name])
|
return runPromiseInstance(Service.use((svc) => svc.get(name)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list() {
|
export async function list() {
|
||||||
return state().then((x) => Object.values(x))
|
return runPromiseInstance(Service.use((svc) => svc.list()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export namespace Workspace {
|
|||||||
await parseSSE(res.body, stop, (event) => {
|
await parseSSE(res.body, stop, (event) => {
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: space.id,
|
directory: space.id,
|
||||||
payload: event,
|
payload: event as { type: string; properties: Record<string, unknown> },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
// Wait 250ms and retry if SSE connection fails
|
// Wait 250ms and retry if SSE connection fails
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
||||||
import { File } from "@/file"
|
import { Command } from "@/command"
|
||||||
import { FileTime } from "@/file/time"
|
import { File } from "@/file/service"
|
||||||
|
import { FileTime } from "@/file/time-service"
|
||||||
import { FileWatcher } from "@/file/watcher"
|
import { FileWatcher } from "@/file/watcher"
|
||||||
import { Format } from "@/format"
|
import { Format } from "@/format/service"
|
||||||
import { PermissionNext } from "@/permission"
|
import { Permission } from "@/permission/service"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { Vcs } from "@/project/vcs"
|
import { Vcs } from "@/project/vcs"
|
||||||
import { ProviderAuth } from "@/provider/auth"
|
import { ProviderAuth } from "@/provider/auth-service"
|
||||||
import { Question } from "@/question"
|
import { Question } from "@/question/service"
|
||||||
import { Skill } from "@/skill/skill"
|
import { Skill } from "@/skill/service"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot/service"
|
||||||
import { InstanceContext } from "./instance-context"
|
import { InstanceContext } from "./instance-context"
|
||||||
import { registerDisposer } from "./instance-registry"
|
import { registerDisposer } from "./instance-registry"
|
||||||
|
|
||||||
export { InstanceContext } from "./instance-context"
|
export { InstanceContext } from "./instance-context"
|
||||||
|
|
||||||
export type InstanceServices =
|
export type InstanceServices =
|
||||||
|
| Command.Service
|
||||||
| Question.Service
|
| Question.Service
|
||||||
| PermissionNext.Service
|
| Permission.Service
|
||||||
| ProviderAuth.Service
|
| ProviderAuth.Service
|
||||||
| FileWatcher.Service
|
| FileWatcher.Service
|
||||||
| Vcs.Service
|
| Vcs.Service
|
||||||
@@ -36,16 +38,17 @@ export type InstanceServices =
|
|||||||
function lookup(_key: string) {
|
function lookup(_key: string) {
|
||||||
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
|
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
|
||||||
return Layer.mergeAll(
|
return Layer.mergeAll(
|
||||||
Layer.fresh(Question.layer),
|
Command.layer,
|
||||||
Layer.fresh(PermissionNext.layer),
|
Question.layer,
|
||||||
Layer.fresh(ProviderAuth.defaultLayer),
|
Permission.layer,
|
||||||
Layer.fresh(FileWatcher.layer).pipe(Layer.orDie),
|
ProviderAuth.defaultLayer,
|
||||||
Layer.fresh(Vcs.layer),
|
FileWatcher.layer,
|
||||||
Layer.fresh(FileTime.layer).pipe(Layer.orDie),
|
Vcs.layer,
|
||||||
Layer.fresh(Format.layer),
|
FileTime.layer,
|
||||||
Layer.fresh(File.layer),
|
Format.layer,
|
||||||
Layer.fresh(Skill.defaultLayer),
|
File.layer,
|
||||||
Layer.fresh(Snapshot.defaultLayer),
|
Skill.defaultLayer,
|
||||||
|
Snapshot.defaultLayer,
|
||||||
).pipe(Layer.provide(ctx))
|
).pipe(Layer.provide(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
import { AccountEffect } from "@/account/effect"
|
import { Account } from "@/account/effect"
|
||||||
import { AuthEffect } from "@/auth/effect"
|
import { Auth } from "@/auth/effect"
|
||||||
import { Instances } from "@/effect/instances"
|
import { Instances } from "@/effect/instances"
|
||||||
import type { InstanceServices } from "@/effect/instances"
|
import type { InstanceServices } from "@/effect/instances"
|
||||||
import { TruncateEffect } from "@/tool/truncate-effect"
|
import { Installation } from "@/installation"
|
||||||
|
import { Truncate } from "@/tool/truncate-effect"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
|
|
||||||
export const runtime = ManagedRuntime.make(
|
export const runtime = ManagedRuntime.make(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
AccountEffect.defaultLayer, //
|
Account.defaultLayer, //
|
||||||
TruncateEffect.defaultLayer,
|
Installation.defaultLayer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
Instances.layer,
|
Instances.layer,
|
||||||
).pipe(Layer.provideMerge(AuthEffect.layer)),
|
).pipe(Layer.provideMerge(Auth.layer)),
|
||||||
)
|
)
|
||||||
|
|
||||||
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
|
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
|
||||||
|
|||||||
@@ -1,695 +1,40 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { git } from "@/util/git"
|
import { File as S } from "./service"
|
||||||
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
|
||||||
import { formatPatch, structuredPatch } from "diff"
|
|
||||||
import fs from "fs"
|
|
||||||
import fuzzysort from "fuzzysort"
|
|
||||||
import ignore from "ignore"
|
|
||||||
import path from "path"
|
|
||||||
import z from "zod"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
import { Protected } from "./protected"
|
|
||||||
import { Ripgrep } from "./ripgrep"
|
|
||||||
|
|
||||||
export namespace File {
|
export namespace File {
|
||||||
export const Info = z
|
export const Info = S.Info
|
||||||
.object({
|
export type Info = S.Info
|
||||||
path: z.string(),
|
|
||||||
added: z.number().int(),
|
|
||||||
removed: z.number().int(),
|
|
||||||
status: z.enum(["added", "deleted", "modified"]),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "File",
|
|
||||||
})
|
|
||||||
|
|
||||||
export type Info = z.infer<typeof Info>
|
export const Node = S.Node
|
||||||
|
export type Node = S.Node
|
||||||
|
|
||||||
export const Node = z
|
export const Content = S.Content
|
||||||
.object({
|
export type Content = S.Content
|
||||||
name: z.string(),
|
|
||||||
path: z.string(),
|
|
||||||
absolute: z.string(),
|
|
||||||
type: z.enum(["file", "directory"]),
|
|
||||||
ignored: z.boolean(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "FileNode",
|
|
||||||
})
|
|
||||||
export type Node = z.infer<typeof Node>
|
|
||||||
|
|
||||||
export const Content = z
|
export const Event = S.Event
|
||||||
.object({
|
|
||||||
type: z.enum(["text", "binary"]),
|
|
||||||
content: z.string(),
|
|
||||||
diff: z.string().optional(),
|
|
||||||
patch: z
|
|
||||||
.object({
|
|
||||||
oldFileName: z.string(),
|
|
||||||
newFileName: z.string(),
|
|
||||||
oldHeader: z.string().optional(),
|
|
||||||
newHeader: z.string().optional(),
|
|
||||||
hunks: z.array(
|
|
||||||
z.object({
|
|
||||||
oldStart: z.number(),
|
|
||||||
oldLines: z.number(),
|
|
||||||
newStart: z.number(),
|
|
||||||
newLines: z.number(),
|
|
||||||
lines: z.array(z.string()),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
index: z.string().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
encoding: z.literal("base64").optional(),
|
|
||||||
mimeType: z.string().optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "FileContent",
|
|
||||||
})
|
|
||||||
export type Content = z.infer<typeof Content>
|
|
||||||
|
|
||||||
export const Event = {
|
export type Interface = S.Interface
|
||||||
Edited: BusEvent.define(
|
|
||||||
"file.edited",
|
export const Service = S.Service
|
||||||
z.object({
|
export const layer = S.layer
|
||||||
file: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export function init() {
|
export function init() {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.init()))
|
return runPromiseInstance(S.Service.use((svc) => svc.init()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function status() {
|
export async function status() {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.status()))
|
return runPromiseInstance(S.Service.use((svc) => svc.status()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function read(file: string): Promise<Content> {
|
export async function read(file: string): Promise<Content> {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.read(file)))
|
return runPromiseInstance(S.Service.use((svc) => svc.read(file)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list(dir?: string) {
|
export async function list(dir?: string) {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.list(dir)))
|
return runPromiseInstance(S.Service.use((svc) => svc.list(dir)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
|
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.search(input)))
|
return runPromiseInstance(S.Service.use((svc) => svc.search(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const log = Log.create({ service: "file" })
|
|
||||||
|
|
||||||
const binary = new Set([
|
|
||||||
"exe",
|
|
||||||
"dll",
|
|
||||||
"pdb",
|
|
||||||
"bin",
|
|
||||||
"so",
|
|
||||||
"dylib",
|
|
||||||
"o",
|
|
||||||
"a",
|
|
||||||
"lib",
|
|
||||||
"wav",
|
|
||||||
"mp3",
|
|
||||||
"ogg",
|
|
||||||
"oga",
|
|
||||||
"ogv",
|
|
||||||
"ogx",
|
|
||||||
"flac",
|
|
||||||
"aac",
|
|
||||||
"wma",
|
|
||||||
"m4a",
|
|
||||||
"weba",
|
|
||||||
"mp4",
|
|
||||||
"avi",
|
|
||||||
"mov",
|
|
||||||
"wmv",
|
|
||||||
"flv",
|
|
||||||
"webm",
|
|
||||||
"mkv",
|
|
||||||
"zip",
|
|
||||||
"tar",
|
|
||||||
"gz",
|
|
||||||
"gzip",
|
|
||||||
"bz",
|
|
||||||
"bz2",
|
|
||||||
"bzip",
|
|
||||||
"bzip2",
|
|
||||||
"7z",
|
|
||||||
"rar",
|
|
||||||
"xz",
|
|
||||||
"lz",
|
|
||||||
"z",
|
|
||||||
"pdf",
|
|
||||||
"doc",
|
|
||||||
"docx",
|
|
||||||
"ppt",
|
|
||||||
"pptx",
|
|
||||||
"xls",
|
|
||||||
"xlsx",
|
|
||||||
"dmg",
|
|
||||||
"iso",
|
|
||||||
"img",
|
|
||||||
"vmdk",
|
|
||||||
"ttf",
|
|
||||||
"otf",
|
|
||||||
"woff",
|
|
||||||
"woff2",
|
|
||||||
"eot",
|
|
||||||
"sqlite",
|
|
||||||
"db",
|
|
||||||
"mdb",
|
|
||||||
"apk",
|
|
||||||
"ipa",
|
|
||||||
"aab",
|
|
||||||
"xapk",
|
|
||||||
"app",
|
|
||||||
"pkg",
|
|
||||||
"deb",
|
|
||||||
"rpm",
|
|
||||||
"snap",
|
|
||||||
"flatpak",
|
|
||||||
"appimage",
|
|
||||||
"msi",
|
|
||||||
"msp",
|
|
||||||
"jar",
|
|
||||||
"war",
|
|
||||||
"ear",
|
|
||||||
"class",
|
|
||||||
"kotlin_module",
|
|
||||||
"dex",
|
|
||||||
"vdex",
|
|
||||||
"odex",
|
|
||||||
"oat",
|
|
||||||
"art",
|
|
||||||
"wasm",
|
|
||||||
"wat",
|
|
||||||
"bc",
|
|
||||||
"ll",
|
|
||||||
"s",
|
|
||||||
"ko",
|
|
||||||
"sys",
|
|
||||||
"drv",
|
|
||||||
"efi",
|
|
||||||
"rom",
|
|
||||||
"com",
|
|
||||||
"cmd",
|
|
||||||
"ps1",
|
|
||||||
"sh",
|
|
||||||
"bash",
|
|
||||||
"zsh",
|
|
||||||
"fish",
|
|
||||||
])
|
|
||||||
|
|
||||||
const image = new Set([
|
|
||||||
"png",
|
|
||||||
"jpg",
|
|
||||||
"jpeg",
|
|
||||||
"gif",
|
|
||||||
"bmp",
|
|
||||||
"webp",
|
|
||||||
"ico",
|
|
||||||
"tif",
|
|
||||||
"tiff",
|
|
||||||
"svg",
|
|
||||||
"svgz",
|
|
||||||
"avif",
|
|
||||||
"apng",
|
|
||||||
"jxl",
|
|
||||||
"heic",
|
|
||||||
"heif",
|
|
||||||
"raw",
|
|
||||||
"cr2",
|
|
||||||
"nef",
|
|
||||||
"arw",
|
|
||||||
"dng",
|
|
||||||
"orf",
|
|
||||||
"raf",
|
|
||||||
"pef",
|
|
||||||
"x3f",
|
|
||||||
])
|
|
||||||
|
|
||||||
const text = new Set([
|
|
||||||
"ts",
|
|
||||||
"tsx",
|
|
||||||
"mts",
|
|
||||||
"cts",
|
|
||||||
"mtsx",
|
|
||||||
"ctsx",
|
|
||||||
"js",
|
|
||||||
"jsx",
|
|
||||||
"mjs",
|
|
||||||
"cjs",
|
|
||||||
"sh",
|
|
||||||
"bash",
|
|
||||||
"zsh",
|
|
||||||
"fish",
|
|
||||||
"ps1",
|
|
||||||
"psm1",
|
|
||||||
"cmd",
|
|
||||||
"bat",
|
|
||||||
"json",
|
|
||||||
"jsonc",
|
|
||||||
"json5",
|
|
||||||
"yaml",
|
|
||||||
"yml",
|
|
||||||
"toml",
|
|
||||||
"md",
|
|
||||||
"mdx",
|
|
||||||
"txt",
|
|
||||||
"xml",
|
|
||||||
"html",
|
|
||||||
"htm",
|
|
||||||
"css",
|
|
||||||
"scss",
|
|
||||||
"sass",
|
|
||||||
"less",
|
|
||||||
"graphql",
|
|
||||||
"gql",
|
|
||||||
"sql",
|
|
||||||
"ini",
|
|
||||||
"cfg",
|
|
||||||
"conf",
|
|
||||||
"env",
|
|
||||||
])
|
|
||||||
|
|
||||||
const textName = new Set([
|
|
||||||
"dockerfile",
|
|
||||||
"makefile",
|
|
||||||
".gitignore",
|
|
||||||
".gitattributes",
|
|
||||||
".editorconfig",
|
|
||||||
".npmrc",
|
|
||||||
".nvmrc",
|
|
||||||
".prettierrc",
|
|
||||||
".eslintrc",
|
|
||||||
])
|
|
||||||
|
|
||||||
const mime: Record<string, string> = {
|
|
||||||
png: "image/png",
|
|
||||||
jpg: "image/jpeg",
|
|
||||||
jpeg: "image/jpeg",
|
|
||||||
gif: "image/gif",
|
|
||||||
bmp: "image/bmp",
|
|
||||||
webp: "image/webp",
|
|
||||||
ico: "image/x-icon",
|
|
||||||
tif: "image/tiff",
|
|
||||||
tiff: "image/tiff",
|
|
||||||
svg: "image/svg+xml",
|
|
||||||
svgz: "image/svg+xml",
|
|
||||||
avif: "image/avif",
|
|
||||||
apng: "image/apng",
|
|
||||||
jxl: "image/jxl",
|
|
||||||
heic: "image/heic",
|
|
||||||
heif: "image/heif",
|
|
||||||
}
|
|
||||||
|
|
||||||
type Entry = { files: string[]; dirs: string[] }
|
|
||||||
|
|
||||||
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
|
|
||||||
const name = (file: string) => path.basename(file).toLowerCase()
|
|
||||||
const isImageByExtension = (file: string) => image.has(ext(file))
|
|
||||||
const isTextByExtension = (file: string) => text.has(ext(file))
|
|
||||||
const isTextByName = (file: string) => textName.has(name(file))
|
|
||||||
const isBinaryByExtension = (file: string) => binary.has(ext(file))
|
|
||||||
const isImage = (mimeType: string) => mimeType.startsWith("image/")
|
|
||||||
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
|
|
||||||
|
|
||||||
function shouldEncode(mimeType: string) {
|
|
||||||
const type = mimeType.toLowerCase()
|
|
||||||
log.info("shouldEncode", { type })
|
|
||||||
if (!type) return false
|
|
||||||
if (type.startsWith("text/")) return false
|
|
||||||
if (type.includes("charset=")) return false
|
|
||||||
const top = type.split("/", 2)[0]
|
|
||||||
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hidden = (item: string) => {
|
|
||||||
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
|
|
||||||
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortHiddenLast = (items: string[], prefer: boolean) => {
|
|
||||||
if (prefer) return items
|
|
||||||
const visible: string[] = []
|
|
||||||
const hiddenItems: string[] = []
|
|
||||||
for (const item of items) {
|
|
||||||
if (hidden(item)) hiddenItems.push(item)
|
|
||||||
else visible.push(item)
|
|
||||||
}
|
|
||||||
return [...visible, ...hiddenItems]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
readonly status: () => Effect.Effect<File.Info[]>
|
|
||||||
readonly read: (file: string) => Effect.Effect<File.Content>
|
|
||||||
readonly list: (dir?: string) => Effect.Effect<File.Node[]>
|
|
||||||
readonly search: (input: {
|
|
||||||
query: string
|
|
||||||
limit?: number
|
|
||||||
dirs?: boolean
|
|
||||||
type?: "file" | "directory"
|
|
||||||
}) => Effect.Effect<string[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/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 scan = Effect.fn("File.scan")(function* () {
|
|
||||||
if (instance.directory === path.parse(instance.directory).root) return
|
|
||||||
const next: Entry = { files: [], dirs: [] }
|
|
||||||
|
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
if (isGlobalHome) {
|
|
||||||
const dirs = new Set<string>()
|
|
||||||
const protectedNames = Protected.names()
|
|
||||||
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
|
||||||
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 })
|
|
||||||
.catch(() => [] as fs.Dirent[])
|
|
||||||
|
|
||||||
for (const entry of top) {
|
|
||||||
if (!entry.isDirectory()) continue
|
|
||||||
if (shouldIgnoreName(entry.name)) continue
|
|
||||||
dirs.add(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
|
|
||||||
if (shouldIgnoreNested(child.name)) continue
|
|
||||||
dirs.add(entry.name + "/" + child.name + "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
next.dirs = Array.from(dirs).toSorted()
|
|
||||||
} else {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
|
|
||||||
next.files.push(file)
|
|
||||||
let current = file
|
|
||||||
while (true) {
|
|
||||||
const dir = path.dirname(current)
|
|
||||||
if (dir === ".") break
|
|
||||||
if (dir === current) break
|
|
||||||
current = dir
|
|
||||||
if (seen.has(dir)) continue
|
|
||||||
seen.add(dir)
|
|
||||||
next.dirs.push(dir + "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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(
|
|
||||||
Effect.catchCause(() => Effect.void),
|
|
||||||
Effect.forkIn(scope),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
yield* Fiber.join(fiber)
|
|
||||||
})
|
|
||||||
|
|
||||||
const status = Effect.fn("File.status")(function* () {
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
).text()
|
|
||||||
|
|
||||||
const changed: File.Info[] = []
|
|
||||||
|
|
||||||
if (diffOutput.trim()) {
|
|
||||||
for (const line of diffOutput.trim().split("\n")) {
|
|
||||||
const [added, removed, file] = line.split("\t")
|
|
||||||
changed.push({
|
|
||||||
path: file,
|
|
||||||
added: added === "-" ? 0 : parseInt(added, 10),
|
|
||||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
|
||||||
status: "modified",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const untrackedOutput = (
|
|
||||||
await git(
|
|
||||||
[
|
|
||||||
"-c",
|
|
||||||
"core.fsmonitor=false",
|
|
||||||
"-c",
|
|
||||||
"core.quotepath=false",
|
|
||||||
"ls-files",
|
|
||||||
"--others",
|
|
||||||
"--exclude-standard",
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: instance.directory,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
).text()
|
|
||||||
|
|
||||||
if (untrackedOutput.trim()) {
|
|
||||||
for (const file of untrackedOutput.trim().split("\n")) {
|
|
||||||
try {
|
|
||||||
const content = await Filesystem.readText(path.join(instance.directory, file))
|
|
||||||
changed.push({
|
|
||||||
path: file,
|
|
||||||
added: content.split("\n").length,
|
|
||||||
removed: 0,
|
|
||||||
status: "added",
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletedOutput = (
|
|
||||||
await git(
|
|
||||||
[
|
|
||||||
"-c",
|
|
||||||
"core.fsmonitor=false",
|
|
||||||
"-c",
|
|
||||||
"core.quotepath=false",
|
|
||||||
"diff",
|
|
||||||
"--name-only",
|
|
||||||
"--diff-filter=D",
|
|
||||||
"HEAD",
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: instance.directory,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
).text()
|
|
||||||
|
|
||||||
if (deletedOutput.trim()) {
|
|
||||||
for (const file of deletedOutput.trim().split("\n")) {
|
|
||||||
changed.push({
|
|
||||||
path: file,
|
|
||||||
added: 0,
|
|
||||||
removed: 0,
|
|
||||||
status: "deleted",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return changed.map((item) => {
|
|
||||||
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
path: path.relative(instance.directory, full),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
if (!Instance.containsPath(full)) {
|
|
||||||
throw new Error("Access denied: path escapes project directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isImageByExtension(file)) {
|
|
||||||
if (await Filesystem.exists(full)) {
|
|
||||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
|
||||||
return {
|
|
||||||
type: "text",
|
|
||||||
content: buffer.toString("base64"),
|
|
||||||
mimeType: getImageMimeType(file),
|
|
||||||
encoding: "base64",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { type: "text", content: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const knownText = isTextByExtension(file) || isTextByName(file)
|
|
||||||
|
|
||||||
if (isBinaryByExtension(file) && !knownText) {
|
|
||||||
return { type: "binary", content: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await Filesystem.exists(full))) {
|
|
||||||
return { type: "text", content: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const mimeType = Filesystem.mimeType(full)
|
|
||||||
const encode = knownText ? false : shouldEncode(mimeType)
|
|
||||||
|
|
||||||
if (encode && !isImage(mimeType)) {
|
|
||||||
return { type: "binary", content: "", mimeType }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (encode) {
|
|
||||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
|
||||||
return {
|
|
||||||
type: "text",
|
|
||||||
content: buffer.toString("base64"),
|
|
||||||
mimeType,
|
|
||||||
encoding: "base64",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
|
||||||
|
|
||||||
if (instance.project.vcs === "git") {
|
|
||||||
let diff = (
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
).text()
|
|
||||||
}
|
|
||||||
if (diff.trim()) {
|
|
||||||
const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
|
|
||||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
|
||||||
context: Infinity,
|
|
||||||
ignoreWhitespace: true,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
type: "text",
|
|
||||||
content,
|
|
||||||
patch,
|
|
||||||
diff: formatPatch(patch),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { type: "text", content }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("File.list")(function* (dir?: string) {
|
|
||||||
return yield* Effect.promise(async () => {
|
|
||||||
const exclude = [".git", ".DS_Store"]
|
|
||||||
let ignored = (_: string) => false
|
|
||||||
if (instance.project.vcs === "git") {
|
|
||||||
const ig = ignore()
|
|
||||||
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")
|
|
||||||
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
|
|
||||||
if (!Instance.containsPath(resolved)) {
|
|
||||||
throw new Error("Access denied: path escapes project directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes: File.Node[] = []
|
|
||||||
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 type = entry.isDirectory() ? "directory" : "file"
|
|
||||||
nodes.push({
|
|
||||||
name: entry.name,
|
|
||||||
path: file,
|
|
||||||
absolute,
|
|
||||||
type,
|
|
||||||
ignored: ignored(type === "directory" ? file + "/" : file),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return nodes.sort((a, b) => {
|
|
||||||
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const search = Effect.fn("File.search")(function* (input: {
|
|
||||||
query: string
|
|
||||||
limit?: number
|
|
||||||
dirs?: boolean
|
|
||||||
type?: "file" | "directory"
|
|
||||||
}) {
|
|
||||||
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 preferHidden = query.startsWith(".") || query.includes("/.")
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
if (kind === "file") return result.files.slice(0, limit)
|
|
||||||
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
const items =
|
|
||||||
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
|
|
||||||
|
|
||||||
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
|
||||||
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
|
||||||
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
|
||||||
|
|
||||||
log.info("search", { query, kind, results: output.length })
|
|
||||||
return output
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
log.info("init")
|
|
||||||
return Service.of({ init, status, read, list, search })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,674 @@
|
|||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
|
import { git } from "@/util/git"
|
||||||
|
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
||||||
|
import { formatPatch, structuredPatch } from "diff"
|
||||||
|
import fs from "fs"
|
||||||
|
import fuzzysort from "fuzzysort"
|
||||||
|
import ignore from "ignore"
|
||||||
|
import path from "path"
|
||||||
|
import z from "zod"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import { Protected } from "./protected"
|
||||||
|
import { Ripgrep } from "./ripgrep"
|
||||||
|
|
||||||
|
export namespace File {
|
||||||
|
export const Info = z
|
||||||
|
.object({
|
||||||
|
path: z.string(),
|
||||||
|
added: z.number().int(),
|
||||||
|
removed: z.number().int(),
|
||||||
|
status: z.enum(["added", "deleted", "modified"]),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "File",
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
|
export const Node = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
absolute: z.string(),
|
||||||
|
type: z.enum(["file", "directory"]),
|
||||||
|
ignored: z.boolean(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "FileNode",
|
||||||
|
})
|
||||||
|
export type Node = z.infer<typeof Node>
|
||||||
|
|
||||||
|
export const Content = z
|
||||||
|
.object({
|
||||||
|
type: z.enum(["text", "binary"]),
|
||||||
|
content: z.string(),
|
||||||
|
diff: z.string().optional(),
|
||||||
|
patch: z
|
||||||
|
.object({
|
||||||
|
oldFileName: z.string(),
|
||||||
|
newFileName: z.string(),
|
||||||
|
oldHeader: z.string().optional(),
|
||||||
|
newHeader: z.string().optional(),
|
||||||
|
hunks: z.array(
|
||||||
|
z.object({
|
||||||
|
oldStart: z.number(),
|
||||||
|
oldLines: z.number(),
|
||||||
|
newStart: z.number(),
|
||||||
|
newLines: z.number(),
|
||||||
|
lines: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
index: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
encoding: z.literal("base64").optional(),
|
||||||
|
mimeType: z.string().optional(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "FileContent",
|
||||||
|
})
|
||||||
|
export type Content = z.infer<typeof Content>
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Edited: BusEvent.define(
|
||||||
|
"file.edited",
|
||||||
|
z.object({
|
||||||
|
file: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = Log.create({ service: "file" })
|
||||||
|
|
||||||
|
const binary = new Set([
|
||||||
|
"exe",
|
||||||
|
"dll",
|
||||||
|
"pdb",
|
||||||
|
"bin",
|
||||||
|
"so",
|
||||||
|
"dylib",
|
||||||
|
"o",
|
||||||
|
"a",
|
||||||
|
"lib",
|
||||||
|
"wav",
|
||||||
|
"mp3",
|
||||||
|
"ogg",
|
||||||
|
"oga",
|
||||||
|
"ogv",
|
||||||
|
"ogx",
|
||||||
|
"flac",
|
||||||
|
"aac",
|
||||||
|
"wma",
|
||||||
|
"m4a",
|
||||||
|
"weba",
|
||||||
|
"mp4",
|
||||||
|
"avi",
|
||||||
|
"mov",
|
||||||
|
"wmv",
|
||||||
|
"flv",
|
||||||
|
"webm",
|
||||||
|
"mkv",
|
||||||
|
"zip",
|
||||||
|
"tar",
|
||||||
|
"gz",
|
||||||
|
"gzip",
|
||||||
|
"bz",
|
||||||
|
"bz2",
|
||||||
|
"bzip",
|
||||||
|
"bzip2",
|
||||||
|
"7z",
|
||||||
|
"rar",
|
||||||
|
"xz",
|
||||||
|
"lz",
|
||||||
|
"z",
|
||||||
|
"pdf",
|
||||||
|
"doc",
|
||||||
|
"docx",
|
||||||
|
"ppt",
|
||||||
|
"pptx",
|
||||||
|
"xls",
|
||||||
|
"xlsx",
|
||||||
|
"dmg",
|
||||||
|
"iso",
|
||||||
|
"img",
|
||||||
|
"vmdk",
|
||||||
|
"ttf",
|
||||||
|
"otf",
|
||||||
|
"woff",
|
||||||
|
"woff2",
|
||||||
|
"eot",
|
||||||
|
"sqlite",
|
||||||
|
"db",
|
||||||
|
"mdb",
|
||||||
|
"apk",
|
||||||
|
"ipa",
|
||||||
|
"aab",
|
||||||
|
"xapk",
|
||||||
|
"app",
|
||||||
|
"pkg",
|
||||||
|
"deb",
|
||||||
|
"rpm",
|
||||||
|
"snap",
|
||||||
|
"flatpak",
|
||||||
|
"appimage",
|
||||||
|
"msi",
|
||||||
|
"msp",
|
||||||
|
"jar",
|
||||||
|
"war",
|
||||||
|
"ear",
|
||||||
|
"class",
|
||||||
|
"kotlin_module",
|
||||||
|
"dex",
|
||||||
|
"vdex",
|
||||||
|
"odex",
|
||||||
|
"oat",
|
||||||
|
"art",
|
||||||
|
"wasm",
|
||||||
|
"wat",
|
||||||
|
"bc",
|
||||||
|
"ll",
|
||||||
|
"s",
|
||||||
|
"ko",
|
||||||
|
"sys",
|
||||||
|
"drv",
|
||||||
|
"efi",
|
||||||
|
"rom",
|
||||||
|
"com",
|
||||||
|
"cmd",
|
||||||
|
"ps1",
|
||||||
|
"sh",
|
||||||
|
"bash",
|
||||||
|
"zsh",
|
||||||
|
"fish",
|
||||||
|
])
|
||||||
|
|
||||||
|
const image = new Set([
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"gif",
|
||||||
|
"bmp",
|
||||||
|
"webp",
|
||||||
|
"ico",
|
||||||
|
"tif",
|
||||||
|
"tiff",
|
||||||
|
"svg",
|
||||||
|
"svgz",
|
||||||
|
"avif",
|
||||||
|
"apng",
|
||||||
|
"jxl",
|
||||||
|
"heic",
|
||||||
|
"heif",
|
||||||
|
"raw",
|
||||||
|
"cr2",
|
||||||
|
"nef",
|
||||||
|
"arw",
|
||||||
|
"dng",
|
||||||
|
"orf",
|
||||||
|
"raf",
|
||||||
|
"pef",
|
||||||
|
"x3f",
|
||||||
|
])
|
||||||
|
|
||||||
|
const text = new Set([
|
||||||
|
"ts",
|
||||||
|
"tsx",
|
||||||
|
"mts",
|
||||||
|
"cts",
|
||||||
|
"mtsx",
|
||||||
|
"ctsx",
|
||||||
|
"js",
|
||||||
|
"jsx",
|
||||||
|
"mjs",
|
||||||
|
"cjs",
|
||||||
|
"sh",
|
||||||
|
"bash",
|
||||||
|
"zsh",
|
||||||
|
"fish",
|
||||||
|
"ps1",
|
||||||
|
"psm1",
|
||||||
|
"cmd",
|
||||||
|
"bat",
|
||||||
|
"json",
|
||||||
|
"jsonc",
|
||||||
|
"json5",
|
||||||
|
"yaml",
|
||||||
|
"yml",
|
||||||
|
"toml",
|
||||||
|
"md",
|
||||||
|
"mdx",
|
||||||
|
"txt",
|
||||||
|
"xml",
|
||||||
|
"html",
|
||||||
|
"htm",
|
||||||
|
"css",
|
||||||
|
"scss",
|
||||||
|
"sass",
|
||||||
|
"less",
|
||||||
|
"graphql",
|
||||||
|
"gql",
|
||||||
|
"sql",
|
||||||
|
"ini",
|
||||||
|
"cfg",
|
||||||
|
"conf",
|
||||||
|
"env",
|
||||||
|
])
|
||||||
|
|
||||||
|
const textName = new Set([
|
||||||
|
"dockerfile",
|
||||||
|
"makefile",
|
||||||
|
".gitignore",
|
||||||
|
".gitattributes",
|
||||||
|
".editorconfig",
|
||||||
|
".npmrc",
|
||||||
|
".nvmrc",
|
||||||
|
".prettierrc",
|
||||||
|
".eslintrc",
|
||||||
|
])
|
||||||
|
|
||||||
|
const mime: Record<string, string> = {
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
bmp: "image/bmp",
|
||||||
|
webp: "image/webp",
|
||||||
|
ico: "image/x-icon",
|
||||||
|
tif: "image/tiff",
|
||||||
|
tiff: "image/tiff",
|
||||||
|
svg: "image/svg+xml",
|
||||||
|
svgz: "image/svg+xml",
|
||||||
|
avif: "image/avif",
|
||||||
|
apng: "image/apng",
|
||||||
|
jxl: "image/jxl",
|
||||||
|
heic: "image/heic",
|
||||||
|
heif: "image/heif",
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry = { files: string[]; dirs: string[] }
|
||||||
|
|
||||||
|
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
|
||||||
|
const name = (file: string) => path.basename(file).toLowerCase()
|
||||||
|
const isImageByExtension = (file: string) => image.has(ext(file))
|
||||||
|
const isTextByExtension = (file: string) => text.has(ext(file))
|
||||||
|
const isTextByName = (file: string) => textName.has(name(file))
|
||||||
|
const isBinaryByExtension = (file: string) => binary.has(ext(file))
|
||||||
|
const isImage = (mimeType: string) => mimeType.startsWith("image/")
|
||||||
|
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
|
||||||
|
|
||||||
|
function shouldEncode(mimeType: string) {
|
||||||
|
const type = mimeType.toLowerCase()
|
||||||
|
log.info("shouldEncode", { type })
|
||||||
|
if (!type) return false
|
||||||
|
if (type.startsWith("text/")) return false
|
||||||
|
if (type.includes("charset=")) return false
|
||||||
|
const top = type.split("/", 2)[0]
|
||||||
|
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hidden = (item: string) => {
|
||||||
|
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
|
||||||
|
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortHiddenLast = (items: string[], prefer: boolean) => {
|
||||||
|
if (prefer) return items
|
||||||
|
const visible: string[] = []
|
||||||
|
const hiddenItems: string[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (hidden(item)) hiddenItems.push(item)
|
||||||
|
else visible.push(item)
|
||||||
|
}
|
||||||
|
return [...visible, ...hiddenItems]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly init: () => Effect.Effect<void>
|
||||||
|
readonly status: () => Effect.Effect<File.Info[]>
|
||||||
|
readonly read: (file: string) => Effect.Effect<File.Content>
|
||||||
|
readonly list: (dir?: string) => Effect.Effect<File.Node[]>
|
||||||
|
readonly search: (input: {
|
||||||
|
query: string
|
||||||
|
limit?: number
|
||||||
|
dirs?: boolean
|
||||||
|
type?: "file" | "directory"
|
||||||
|
}) => Effect.Effect<string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/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 scan = Effect.fn("File.scan")(function* () {
|
||||||
|
if (instance.directory === path.parse(instance.directory).root) return
|
||||||
|
const next: Entry = { files: [], dirs: [] }
|
||||||
|
|
||||||
|
yield* Effect.promise(async () => {
|
||||||
|
if (isGlobalHome) {
|
||||||
|
const dirs = new Set<string>()
|
||||||
|
const protectedNames = Protected.names()
|
||||||
|
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||||
|
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 })
|
||||||
|
.catch(() => [] as fs.Dirent[])
|
||||||
|
|
||||||
|
for (const entry of top) {
|
||||||
|
if (!entry.isDirectory()) continue
|
||||||
|
if (shouldIgnoreName(entry.name)) continue
|
||||||
|
dirs.add(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
|
||||||
|
if (shouldIgnoreNested(child.name)) continue
|
||||||
|
dirs.add(entry.name + "/" + child.name + "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next.dirs = Array.from(dirs).toSorted()
|
||||||
|
} else {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
|
||||||
|
next.files.push(file)
|
||||||
|
let current = file
|
||||||
|
while (true) {
|
||||||
|
const dir = path.dirname(current)
|
||||||
|
if (dir === ".") break
|
||||||
|
if (dir === current) break
|
||||||
|
current = dir
|
||||||
|
if (seen.has(dir)) continue
|
||||||
|
seen.add(dir)
|
||||||
|
next.dirs.push(dir + "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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(
|
||||||
|
Effect.catchCause(() => Effect.void),
|
||||||
|
Effect.forkIn(scope),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
yield* Fiber.join(fiber)
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = Effect.fn("File.status")(function* () {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
).text()
|
||||||
|
|
||||||
|
const changed: File.Info[] = []
|
||||||
|
|
||||||
|
if (diffOutput.trim()) {
|
||||||
|
for (const line of diffOutput.trim().split("\n")) {
|
||||||
|
const [added, removed, file] = line.split("\t")
|
||||||
|
changed.push({
|
||||||
|
path: file,
|
||||||
|
added: added === "-" ? 0 : parseInt(added, 10),
|
||||||
|
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||||
|
status: "modified",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const untrackedOutput = (
|
||||||
|
await git(
|
||||||
|
[
|
||||||
|
"-c",
|
||||||
|
"core.fsmonitor=false",
|
||||||
|
"-c",
|
||||||
|
"core.quotepath=false",
|
||||||
|
"ls-files",
|
||||||
|
"--others",
|
||||||
|
"--exclude-standard",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: instance.directory,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
).text()
|
||||||
|
|
||||||
|
if (untrackedOutput.trim()) {
|
||||||
|
for (const file of untrackedOutput.trim().split("\n")) {
|
||||||
|
try {
|
||||||
|
const content = await Filesystem.readText(path.join(instance.directory, file))
|
||||||
|
changed.push({
|
||||||
|
path: file,
|
||||||
|
added: content.split("\n").length,
|
||||||
|
removed: 0,
|
||||||
|
status: "added",
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedOutput = (
|
||||||
|
await git(
|
||||||
|
[
|
||||||
|
"-c",
|
||||||
|
"core.fsmonitor=false",
|
||||||
|
"-c",
|
||||||
|
"core.quotepath=false",
|
||||||
|
"diff",
|
||||||
|
"--name-only",
|
||||||
|
"--diff-filter=D",
|
||||||
|
"HEAD",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: instance.directory,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
).text()
|
||||||
|
|
||||||
|
if (deletedOutput.trim()) {
|
||||||
|
for (const file of deletedOutput.trim().split("\n")) {
|
||||||
|
changed.push({
|
||||||
|
path: file,
|
||||||
|
added: 0,
|
||||||
|
removed: 0,
|
||||||
|
status: "deleted",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed.map((item) => {
|
||||||
|
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
path: path.relative(instance.directory, full),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if (!Instance.containsPath(full)) {
|
||||||
|
throw new Error("Access denied: path escapes project directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isImageByExtension(file)) {
|
||||||
|
if (await Filesystem.exists(full)) {
|
||||||
|
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
content: buffer.toString("base64"),
|
||||||
|
mimeType: getImageMimeType(file),
|
||||||
|
encoding: "base64",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { type: "text", content: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownText = isTextByExtension(file) || isTextByName(file)
|
||||||
|
|
||||||
|
if (isBinaryByExtension(file) && !knownText) {
|
||||||
|
return { type: "binary", content: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await Filesystem.exists(full))) {
|
||||||
|
return { type: "text", content: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = Filesystem.mimeType(full)
|
||||||
|
const encode = knownText ? false : shouldEncode(mimeType)
|
||||||
|
|
||||||
|
if (encode && !isImage(mimeType)) {
|
||||||
|
return { type: "binary", content: "", mimeType }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encode) {
|
||||||
|
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
content: buffer.toString("base64"),
|
||||||
|
mimeType,
|
||||||
|
encoding: "base64",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
||||||
|
|
||||||
|
if (instance.project.vcs === "git") {
|
||||||
|
let diff = (
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
).text()
|
||||||
|
}
|
||||||
|
if (diff.trim()) {
|
||||||
|
const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
|
||||||
|
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||||
|
context: Infinity,
|
||||||
|
ignoreWhitespace: true,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
content,
|
||||||
|
patch,
|
||||||
|
diff: formatPatch(patch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "text", content }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("File.list")(function* (dir?: string) {
|
||||||
|
return yield* Effect.promise(async () => {
|
||||||
|
const exclude = [".git", ".DS_Store"]
|
||||||
|
let ignored = (_: string) => false
|
||||||
|
if (instance.project.vcs === "git") {
|
||||||
|
const ig = ignore()
|
||||||
|
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")
|
||||||
|
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
|
||||||
|
if (!Instance.containsPath(resolved)) {
|
||||||
|
throw new Error("Access denied: path escapes project directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes: File.Node[] = []
|
||||||
|
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 type = entry.isDirectory() ? "directory" : "file"
|
||||||
|
nodes.push({
|
||||||
|
name: entry.name,
|
||||||
|
path: file,
|
||||||
|
absolute,
|
||||||
|
type,
|
||||||
|
ignored: ignored(type === "directory" ? file + "/" : file),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes.sort((a, b) => {
|
||||||
|
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const search = Effect.fn("File.search")(function* (input: {
|
||||||
|
query: string
|
||||||
|
limit?: number
|
||||||
|
dirs?: boolean
|
||||||
|
type?: "file" | "directory"
|
||||||
|
}) {
|
||||||
|
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 preferHidden = query.startsWith(".") || query.includes("/.")
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
if (kind === "file") return result.files.slice(0, limit)
|
||||||
|
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items =
|
||||||
|
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
|
||||||
|
|
||||||
|
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
||||||
|
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
||||||
|
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
||||||
|
|
||||||
|
log.info("search", { query, kind, results: output.length })
|
||||||
|
return output
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info("init")
|
||||||
|
return Service.of({ init, status, read, list, search })
|
||||||
|
}),
|
||||||
|
).pipe(Layer.fresh)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import type { SessionID } from "@/session/schema"
|
||||||
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
|
export namespace FileTime {
|
||||||
|
const log = Log.create({ service: "file.time" })
|
||||||
|
|
||||||
|
export type Stamp = {
|
||||||
|
readonly read: Date
|
||||||
|
readonly mtime: number | undefined
|
||||||
|
readonly ctime: number | undefined
|
||||||
|
readonly size: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const stamp = Effect.fnUntraced(function* (file: string) {
|
||||||
|
const stat = Filesystem.stat(file)
|
||||||
|
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
||||||
|
return {
|
||||||
|
read: yield* DateTime.nowAsDate,
|
||||||
|
mtime: stat?.mtime?.getTime(),
|
||||||
|
ctime: stat?.ctime?.getTime(),
|
||||||
|
size,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
|
||||||
|
const value = reads.get(sessionID)
|
||||||
|
if (value) return value
|
||||||
|
|
||||||
|
const next = new Map<string, Stamp>()
|
||||||
|
reads.set(sessionID, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
||||||
|
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
||||||
|
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
||||||
|
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
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 getLock = (filepath: string) => {
|
||||||
|
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) {
|
||||||
|
log.info("read", { sessionID, file })
|
||||||
|
session(reads, sessionID).set(file, yield* stamp(file))
|
||||||
|
})
|
||||||
|
|
||||||
|
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
|
||||||
|
return reads.get(sessionID)?.get(file)?.read
|
||||||
|
})
|
||||||
|
|
||||||
|
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
|
||||||
|
if (disableCheck) return
|
||||||
|
|
||||||
|
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`)
|
||||||
|
|
||||||
|
const next = yield* stamp(filepath)
|
||||||
|
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
||||||
|
if (!changed) return
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
||||||
|
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ read, get, assert, withLock })
|
||||||
|
}),
|
||||||
|
).pipe(Layer.orDie, Layer.fresh)
|
||||||
|
}
|
||||||
@@ -1,110 +1,28 @@
|
|||||||
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { Flag } from "@/flag/flag"
|
|
||||||
import type { SessionID } from "@/session/schema"
|
import type { SessionID } from "@/session/schema"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { FileTime as S } from "./time-service"
|
||||||
import { Log } from "../util/log"
|
|
||||||
|
|
||||||
export namespace FileTime {
|
export namespace FileTime {
|
||||||
const log = Log.create({ service: "file.time" })
|
export type Stamp = S.Stamp
|
||||||
|
|
||||||
export type Stamp = {
|
export type Interface = S.Interface
|
||||||
readonly read: Date
|
|
||||||
readonly mtime: number | undefined
|
|
||||||
readonly ctime: number | undefined
|
|
||||||
readonly size: number | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const stamp = Effect.fnUntraced(function* (file: string) {
|
export const Service = S.Service
|
||||||
const stat = Filesystem.stat(file)
|
export const layer = S.layer
|
||||||
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
|
||||||
return {
|
|
||||||
read: yield* DateTime.nowAsDate,
|
|
||||||
mtime: stat?.mtime?.getTime(),
|
|
||||||
ctime: stat?.ctime?.getTime(),
|
|
||||||
size,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
|
|
||||||
const value = reads.get(sessionID)
|
|
||||||
if (value) return value
|
|
||||||
|
|
||||||
const next = new Map<string, Stamp>()
|
|
||||||
reads.set(sessionID, next)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
|
||||||
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
|
||||||
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
|
||||||
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
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 getLock = (filepath: string) => {
|
|
||||||
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) {
|
|
||||||
log.info("read", { sessionID, file })
|
|
||||||
session(reads, sessionID).set(file, yield* stamp(file))
|
|
||||||
})
|
|
||||||
|
|
||||||
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
|
|
||||||
return reads.get(sessionID)?.get(file)?.read
|
|
||||||
})
|
|
||||||
|
|
||||||
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
|
|
||||||
if (disableCheck) return
|
|
||||||
|
|
||||||
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`)
|
|
||||||
|
|
||||||
const next = yield* stamp(filepath)
|
|
||||||
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
|
||||||
if (!changed) return
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
|
||||||
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ read, get, assert, withLock })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export function read(sessionID: SessionID, file: string) {
|
export function read(sessionID: SessionID, file: string) {
|
||||||
return runPromiseInstance(Service.use((s) => s.read(sessionID, file)))
|
return runPromiseInstance(S.Service.use((s) => s.read(sessionID, file)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function get(sessionID: SessionID, file: string) {
|
export function get(sessionID: SessionID, file: string) {
|
||||||
return runPromiseInstance(Service.use((s) => s.get(sessionID, file)))
|
return runPromiseInstance(S.Service.use((s) => s.get(sessionID, file)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assert(sessionID: SessionID, filepath: string) {
|
export async function assert(sessionID: SessionID, filepath: string) {
|
||||||
return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath)))
|
return runPromiseInstance(S.Service.use((s) => s.assert(sessionID, filepath)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
||||||
return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn)))
|
return runPromiseInstance(S.Service.use((s) => s.withLock(filepath, fn)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,5 +137,5 @@ export namespace FileWatcher {
|
|||||||
return Effect.succeed(Service.of({}))
|
return Effect.succeed(Service.of({}))
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
).pipe(Layer.orDie, Layer.fresh)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,157 +1,16 @@
|
|||||||
import { Effect, Layer, ServiceMap } from "effect"
|
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
import { Format as S } from "./service"
|
||||||
import path from "path"
|
|
||||||
import { mergeDeep } from "remeda"
|
|
||||||
import z from "zod"
|
|
||||||
import { Bus } from "../bus"
|
|
||||||
import { Config } from "../config/config"
|
|
||||||
import { File } from "../file"
|
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { Process } from "../util/process"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
import * as Formatter from "./formatter"
|
|
||||||
|
|
||||||
export namespace Format {
|
export namespace Format {
|
||||||
const log = Log.create({ service: "format" })
|
export const Status = S.Status
|
||||||
|
export type Status = S.Status
|
||||||
|
|
||||||
export const Status = z
|
export type Interface = S.Interface
|
||||||
.object({
|
|
||||||
name: z.string(),
|
|
||||||
extensions: z.string().array(),
|
|
||||||
enabled: z.boolean(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "FormatterStatus",
|
|
||||||
})
|
|
||||||
export type Status = z.infer<typeof Status>
|
|
||||||
|
|
||||||
export interface Interface {
|
export const Service = S.Service
|
||||||
readonly status: () => Effect.Effect<Status[]>
|
export const layer = S.layer
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const instance = yield* InstanceContext
|
|
||||||
|
|
||||||
const enabled: Record<string, boolean> = {}
|
|
||||||
const formatters: Record<string, Formatter.Info> = {}
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(unsubscribe) => Effect.sync(unsubscribe),
|
|
||||||
)
|
|
||||||
log.info("init")
|
|
||||||
|
|
||||||
const status = Effect.fn("Format.status")(function* () {
|
|
||||||
const result: Status[] = []
|
|
||||||
for (const formatter of Object.values(formatters)) {
|
|
||||||
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
|
||||||
result.push({
|
|
||||||
name: formatter.name,
|
|
||||||
extensions: formatter.extensions,
|
|
||||||
enabled: isOn,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ status })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export async function status() {
|
export async function status() {
|
||||||
return runPromiseInstance(Service.use((s) => s.status()))
|
return runPromiseInstance(S.Service.use((s) => s.status()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
|
import path from "path"
|
||||||
|
import { mergeDeep } from "remeda"
|
||||||
|
import z from "zod"
|
||||||
|
import { Bus } from "../bus"
|
||||||
|
import { Config } from "../config/config"
|
||||||
|
import { File } from "../file/service"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
|
import { Process } from "../util/process"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import * as Formatter from "./formatter"
|
||||||
|
|
||||||
|
export namespace Format {
|
||||||
|
const log = Log.create({ service: "format" })
|
||||||
|
|
||||||
|
export const Status = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
extensions: z.string().array(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "FormatterStatus",
|
||||||
|
})
|
||||||
|
export type Status = z.infer<typeof Status>
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly status: () => Effect.Effect<Status[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const instance = yield* InstanceContext
|
||||||
|
|
||||||
|
const enabled: Record<string, boolean> = {}
|
||||||
|
const formatters: Record<string, Formatter.Info> = {}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(unsubscribe) => Effect.sync(unsubscribe),
|
||||||
|
)
|
||||||
|
log.info("init")
|
||||||
|
|
||||||
|
const status = Effect.fn("Format.status")(function* () {
|
||||||
|
const result: Status[] = []
|
||||||
|
for (const formatter of Object.values(formatters)) {
|
||||||
|
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
||||||
|
result.push({
|
||||||
|
name: formatter.name,
|
||||||
|
extensions: formatter.extensions,
|
||||||
|
enabled: isOn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ status })
|
||||||
|
}),
|
||||||
|
).pipe(Layer.fresh)
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
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 { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Log } from "../util/log"
|
|
||||||
import { iife } from "@/util/iife"
|
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { Process } from "@/util/process"
|
import { Log } from "../util/log"
|
||||||
import { buffer } from "node:stream/consumers"
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
const OPENCODE_VERSION: string
|
const OPENCODE_VERSION: string
|
||||||
@@ -16,39 +17,7 @@ declare global {
|
|||||||
export namespace Installation {
|
export namespace Installation {
|
||||||
const log = Log.create({ service: "installation" })
|
const log = Log.create({ service: "installation" })
|
||||||
|
|
||||||
async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) {
|
export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
|
||||||
return Process.text(cmd, {
|
|
||||||
cwd: opts.cwd,
|
|
||||||
env: opts.env,
|
|
||||||
nothrow: true,
|
|
||||||
}).then((x) => x.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upgradeCurl(target: string) {
|
|
||||||
const body = await fetch("https://opencode.ai/install").then((res) => {
|
|
||||||
if (!res.ok) throw new Error(res.statusText)
|
|
||||||
return res.text()
|
|
||||||
})
|
|
||||||
const proc = Process.spawn(["bash"], {
|
|
||||||
stdin: "pipe",
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
VERSION: target,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
|
|
||||||
proc.stdin.end(body)
|
|
||||||
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
stdout,
|
|
||||||
stderr,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Method = Awaited<ReturnType<typeof method>>
|
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
Updated: BusEvent.define(
|
Updated: BusEvent.define(
|
||||||
@@ -75,12 +44,9 @@ export namespace Installation {
|
|||||||
})
|
})
|
||||||
export type Info = z.infer<typeof Info>
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
export async function info() {
|
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
||||||
return {
|
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
||||||
version: VERSION,
|
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
|
||||||
latest: await latest(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPreview() {
|
export function isPreview() {
|
||||||
return CHANNEL !== "latest"
|
return CHANNEL !== "latest"
|
||||||
@@ -90,214 +56,300 @@ export namespace Installation {
|
|||||||
return CHANNEL === "local"
|
return CHANNEL === "local"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function method() {
|
export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
|
||||||
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
|
stderr: Schema.String,
|
||||||
if (process.execPath.includes(path.join(".local", "bin"))) return "curl"
|
}) {}
|
||||||
const exec = process.execPath.toLowerCase()
|
|
||||||
|
|
||||||
const checks = [
|
// Response schemas for external version APIs
|
||||||
{
|
const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
|
||||||
name: "npm" as const,
|
const NpmPackage = Schema.Struct({ version: Schema.String })
|
||||||
command: () => text(["npm", "list", "-g", "--depth=0"]),
|
const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })
|
||||||
},
|
const BrewInfoV2 = Schema.Struct({
|
||||||
{
|
formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })),
|
||||||
name: "yarn" as const,
|
})
|
||||||
command: () => text(["yarn", "global", "list"]),
|
const ChocoPackage = Schema.Struct({
|
||||||
},
|
d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }),
|
||||||
{
|
})
|
||||||
name: "pnpm" as const,
|
const ScoopManifest = NpmPackage
|
||||||
command: () => text(["pnpm", "list", "-g", "--depth=0"]),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bun" as const,
|
|
||||||
command: () => text(["bun", "pm", "ls", "-g"]),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "brew" as const,
|
|
||||||
command: () => text(["brew", "list", "--formula", "opencode"]),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "scoop" as const,
|
|
||||||
command: () => text(["scoop", "list", "opencode"]),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "choco" as const,
|
|
||||||
command: () => text(["choco", "list", "--limit-output", "opencode"]),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
checks.sort((a, b) => {
|
export interface Interface {
|
||||||
const aMatches = exec.includes(a.name)
|
readonly info: () => Effect.Effect<Info>
|
||||||
const bMatches = exec.includes(b.name)
|
readonly method: () => Effect.Effect<Method>
|
||||||
if (aMatches && !bMatches) return -1
|
readonly latest: (method?: Method) => Effect.Effect<string>
|
||||||
if (!aMatches && bMatches) return 1
|
readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError>
|
||||||
return 0
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const check of checks) {
|
|
||||||
const output = await check.command()
|
|
||||||
const installedName =
|
|
||||||
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
|
|
||||||
if (output.includes(installedName)) {
|
|
||||||
return check.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "unknown"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UpgradeFailedError = NamedError.create(
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Installation") {}
|
||||||
"UpgradeFailedError",
|
|
||||||
z.object({
|
|
||||||
stderr: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
async function getBrewFormula() {
|
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner> =
|
||||||
const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
|
Layer.effect(
|
||||||
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
|
Service,
|
||||||
const coreFormula = await text(["brew", "list", "--formula", "opencode"])
|
Effect.gen(function* () {
|
||||||
if (coreFormula.includes("opencode")) return "opencode"
|
const http = yield* HttpClient.HttpClient
|
||||||
return "opencode"
|
const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http))
|
||||||
}
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
|
|
||||||
export async function upgrade(method: Method, target: string) {
|
const text = Effect.fnUntraced(
|
||||||
let result: Awaited<ReturnType<typeof upgradeCurl>> | undefined
|
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
||||||
switch (method) {
|
const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
|
||||||
case "curl":
|
cwd: opts?.cwd,
|
||||||
result = await upgradeCurl(target)
|
env: opts?.env,
|
||||||
break
|
extendEnv: true,
|
||||||
case "npm":
|
})
|
||||||
result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
|
const handle = yield* spawner.spawn(proc)
|
||||||
break
|
const out = yield* Stream.mkString(Stream.decodeText(handle.stdout))
|
||||||
case "pnpm":
|
yield* handle.exitCode
|
||||||
result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
|
return out
|
||||||
break
|
},
|
||||||
case "bun":
|
Effect.scoped,
|
||||||
result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
|
Effect.catch(() => Effect.succeed("")),
|
||||||
break
|
)
|
||||||
case "brew": {
|
|
||||||
const formula = await getBrewFormula()
|
const run = Effect.fnUntraced(
|
||||||
const env = {
|
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
||||||
HOMEBREW_NO_AUTO_UPDATE: "1",
|
const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
|
||||||
...process.env,
|
cwd: opts?.cwd,
|
||||||
}
|
env: opts?.env,
|
||||||
if (formula.includes("/")) {
|
extendEnv: true,
|
||||||
const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true })
|
})
|
||||||
if (tap.code !== 0) {
|
const handle = yield* spawner.spawn(proc)
|
||||||
result = tap
|
const [stdout, stderr] = yield* Effect.all(
|
||||||
break
|
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||||
}
|
{ concurrency: 2 },
|
||||||
const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true })
|
)
|
||||||
if (repo.code !== 0) {
|
const code = yield* handle.exitCode
|
||||||
result = repo
|
return { code, stdout, stderr }
|
||||||
break
|
},
|
||||||
}
|
Effect.scoped,
|
||||||
const dir = repo.text.trim()
|
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
|
||||||
if (dir) {
|
)
|
||||||
const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true })
|
|
||||||
if (pull.code !== 0) {
|
const getBrewFormula = Effect.fnUntraced(function* () {
|
||||||
result = pull
|
const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
|
||||||
break
|
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
|
||||||
|
const coreFormula = yield* text(["brew", "list", "--formula", "opencode"])
|
||||||
|
if (coreFormula.includes("opencode")) return "opencode"
|
||||||
|
return "opencode"
|
||||||
|
})
|
||||||
|
|
||||||
|
const upgradeCurl = Effect.fnUntraced(
|
||||||
|
function* (target: string) {
|
||||||
|
const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
|
||||||
|
const body = yield* response.text
|
||||||
|
const bodyBytes = new TextEncoder().encode(body)
|
||||||
|
const proc = ChildProcess.make("bash", [], {
|
||||||
|
stdin: Stream.make(bodyBytes),
|
||||||
|
env: { VERSION: target },
|
||||||
|
extendEnv: true,
|
||||||
|
})
|
||||||
|
const handle = yield* spawner.spawn(proc)
|
||||||
|
const [stdout, 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, stdout, stderr }
|
||||||
|
},
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.orDie,
|
||||||
|
)
|
||||||
|
|
||||||
|
const methodImpl = Effect.fn("Installation.method")(function* () {
|
||||||
|
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method
|
||||||
|
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
|
||||||
|
const exec = process.execPath.toLowerCase()
|
||||||
|
|
||||||
|
const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [
|
||||||
|
{ name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
|
||||||
|
{ name: "yarn", command: () => text(["yarn", "global", "list"]) },
|
||||||
|
{ name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
|
||||||
|
{ name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
|
||||||
|
{ name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) },
|
||||||
|
{ name: "scoop", command: () => text(["scoop", "list", "opencode"]) },
|
||||||
|
{ name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) },
|
||||||
|
]
|
||||||
|
|
||||||
|
checks.sort((a, b) => {
|
||||||
|
const aMatches = exec.includes(a.name)
|
||||||
|
const bMatches = exec.includes(b.name)
|
||||||
|
if (aMatches && !bMatches) return -1
|
||||||
|
if (!aMatches && bMatches) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const check of checks) {
|
||||||
|
const output = yield* check.command()
|
||||||
|
const installedName =
|
||||||
|
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
|
||||||
|
if (output.includes(installedName)) {
|
||||||
|
return check.name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true })
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "choco":
|
return "unknown" as Method
|
||||||
result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true })
|
})
|
||||||
break
|
|
||||||
case "scoop":
|
const latestImpl = Effect.fn("Installation.latest")(function* (installMethod?: Method) {
|
||||||
result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true })
|
const detectedMethod = installMethod || (yield* methodImpl())
|
||||||
break
|
|
||||||
default:
|
if (detectedMethod === "brew") {
|
||||||
throw new Error(`Unknown method: ${method}`)
|
const formula = yield* getBrewFormula()
|
||||||
}
|
if (formula.includes("/")) {
|
||||||
if (!result || result.code !== 0) {
|
const infoJson = yield* text(["brew", "info", "--json=v2", formula])
|
||||||
const stderr =
|
const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
|
||||||
method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || ""
|
return info.formulae[0].versions.stable
|
||||||
throw new UpgradeFailedError({
|
}
|
||||||
stderr: stderr,
|
const response = yield* httpOk.execute(
|
||||||
})
|
HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
|
||||||
}
|
HttpClientRequest.acceptJson,
|
||||||
log.info("upgraded", {
|
),
|
||||||
method,
|
)
|
||||||
target,
|
const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
|
||||||
stdout: result.stdout.toString(),
|
return data.versions.stable
|
||||||
stderr: result.stderr.toString(),
|
}
|
||||||
})
|
|
||||||
await Process.text([process.execPath, "--version"], { nothrow: true })
|
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
|
||||||
|
const r = (yield* text(["npm", "config", "get", "registry"])).trim()
|
||||||
|
const reg = r || "https://registry.npmjs.org"
|
||||||
|
const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
|
||||||
|
const channel = CHANNEL
|
||||||
|
const response = yield* httpOk.execute(
|
||||||
|
HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
|
||||||
|
)
|
||||||
|
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
|
||||||
|
return data.version
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detectedMethod === "choco") {
|
||||||
|
const response = yield* httpOk.execute(
|
||||||
|
HttpClientRequest.get(
|
||||||
|
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
|
||||||
|
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
|
||||||
|
)
|
||||||
|
const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
|
||||||
|
return data.d.results[0].Version
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detectedMethod === "scoop") {
|
||||||
|
const response = yield* httpOk.execute(
|
||||||
|
HttpClientRequest.get(
|
||||||
|
"https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
|
||||||
|
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
|
||||||
|
)
|
||||||
|
const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
|
||||||
|
return data.version
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = yield* httpOk.execute(
|
||||||
|
HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
|
||||||
|
return data.tag_name.replace(/^v/, "")
|
||||||
|
}, Effect.orDie)
|
||||||
|
|
||||||
|
const upgradeImpl = Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
|
||||||
|
let result: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined
|
||||||
|
switch (m) {
|
||||||
|
case "curl":
|
||||||
|
result = yield* upgradeCurl(target)
|
||||||
|
break
|
||||||
|
case "npm":
|
||||||
|
result = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
|
||||||
|
break
|
||||||
|
case "pnpm":
|
||||||
|
result = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`])
|
||||||
|
break
|
||||||
|
case "bun":
|
||||||
|
result = yield* run(["bun", "install", "-g", `opencode-ai@${target}`])
|
||||||
|
break
|
||||||
|
case "brew": {
|
||||||
|
const formula = yield* getBrewFormula()
|
||||||
|
const env = { HOMEBREW_NO_AUTO_UPDATE: "1" }
|
||||||
|
if (formula.includes("/")) {
|
||||||
|
const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env })
|
||||||
|
if (tap.code !== 0) {
|
||||||
|
result = tap
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const repo = yield* text(["brew", "--repo", "anomalyco/tap"])
|
||||||
|
const dir = repo.trim()
|
||||||
|
if (dir) {
|
||||||
|
const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env })
|
||||||
|
if (pull.code !== 0) {
|
||||||
|
result = pull
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = yield* run(["brew", "upgrade", formula], { env })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "choco":
|
||||||
|
result = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"])
|
||||||
|
break
|
||||||
|
case "scoop":
|
||||||
|
result = yield* run(["scoop", "install", `opencode@${target}`])
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown method: ${m}`)
|
||||||
|
}
|
||||||
|
if (!result || result.code !== 0) {
|
||||||
|
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
|
||||||
|
return yield* new UpgradeFailedError({ stderr })
|
||||||
|
}
|
||||||
|
log.info("upgraded", {
|
||||||
|
method: m,
|
||||||
|
target,
|
||||||
|
stdout: result.stdout,
|
||||||
|
stderr: result.stderr,
|
||||||
|
})
|
||||||
|
yield* text([process.execPath, "--version"])
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
info: Effect.fn("Installation.info")(function* () {
|
||||||
|
return {
|
||||||
|
version: VERSION,
|
||||||
|
latest: yield* latestImpl(),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
method: methodImpl,
|
||||||
|
latest: latestImpl,
|
||||||
|
upgrade: upgradeImpl,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(FetchHttpClient.layer),
|
||||||
|
Layer.provide(NodeChildProcessSpawner.layer),
|
||||||
|
Layer.provide(NodeFileSystem.layer),
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
export function info(): Promise<Info> {
|
||||||
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
return runPromise((svc) => svc.info())
|
||||||
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
|
}
|
||||||
|
|
||||||
export async function latest(installMethod?: Method) {
|
export function method(): Promise<Method> {
|
||||||
const detectedMethod = installMethod || (await method())
|
return runPromise((svc) => svc.method())
|
||||||
|
}
|
||||||
|
|
||||||
if (detectedMethod === "brew") {
|
export function latest(installMethod?: Method): Promise<string> {
|
||||||
const formula = await getBrewFormula()
|
return runPromise((svc) => svc.latest(installMethod))
|
||||||
if (formula.includes("/")) {
|
}
|
||||||
const infoJson = await text(["brew", "info", "--json=v2", formula])
|
|
||||||
const info = JSON.parse(infoJson)
|
|
||||||
const version = info.formulae?.[0]?.versions?.stable
|
|
||||||
if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`)
|
|
||||||
return version
|
|
||||||
}
|
|
||||||
return fetch("https://formulae.brew.sh/api/formula/opencode.json")
|
|
||||||
.then((res) => {
|
|
||||||
if (!res.ok) throw new Error(res.statusText)
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
.then((data: any) => data.versions.stable)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
|
export function upgrade(m: Method, target: string): Promise<void> {
|
||||||
const registry = await iife(async () => {
|
return runPromise((svc) => svc.upgrade(m, target))
|
||||||
const r = (await text(["npm", "config", "get", "registry"])).trim()
|
|
||||||
const reg = r || "https://registry.npmjs.org"
|
|
||||||
return reg.endsWith("/") ? reg.slice(0, -1) : reg
|
|
||||||
})
|
|
||||||
const channel = CHANNEL
|
|
||||||
return fetch(`${registry}/opencode-ai/${channel}`)
|
|
||||||
.then((res) => {
|
|
||||||
if (!res.ok) throw new Error(res.statusText)
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
.then((data: any) => data.version)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detectedMethod === "choco") {
|
|
||||||
return fetch(
|
|
||||||
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
|
|
||||||
{ headers: { Accept: "application/json;odata=verbose" } },
|
|
||||||
)
|
|
||||||
.then((res) => {
|
|
||||||
if (!res.ok) throw new Error(res.statusText)
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
.then((data: any) => data.d.results[0].Version)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detectedMethod === "scoop") {
|
|
||||||
return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", {
|
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
if (!res.ok) throw new Error(res.statusText)
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
.then((data: any) => data.version)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest")
|
|
||||||
.then((res) => {
|
|
||||||
if (!res.ok) throw new Error(res.statusText)
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
.then((data: any) => data.tag_name.replace(/^v/, ""))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,292 +1,52 @@
|
|||||||
import { runPromiseInstance } from "@/effect/runtime"
|
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 { ProjectID } from "@/project/schema"
|
|
||||||
import { MessageID, SessionID } from "@/session/schema"
|
|
||||||
import { PermissionTable } from "@/session/session.sql"
|
|
||||||
import { Database, eq } from "@/storage/db"
|
|
||||||
import { fn } from "@/util/fn"
|
import { fn } from "@/util/fn"
|
||||||
import { Log } from "@/util/log"
|
|
||||||
import { Wildcard } from "@/util/wildcard"
|
|
||||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
|
||||||
import os from "os"
|
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { evaluate as evalRule } from "./evaluate"
|
import { Permission as S } from "./service"
|
||||||
import { PermissionID } from "./schema"
|
|
||||||
|
|
||||||
export namespace PermissionNext {
|
export namespace PermissionNext {
|
||||||
const log = Log.create({ service: "permission" })
|
export const Action = S.Action
|
||||||
|
export type Action = S.Action
|
||||||
|
|
||||||
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
export const Rule = S.Rule
|
||||||
ref: "PermissionAction",
|
export type Rule = S.Rule
|
||||||
})
|
|
||||||
export type Action = z.infer<typeof Action>
|
|
||||||
|
|
||||||
export const Rule = z
|
export const Ruleset = S.Ruleset
|
||||||
.object({
|
export type Ruleset = S.Ruleset
|
||||||
permission: z.string(),
|
|
||||||
pattern: z.string(),
|
|
||||||
action: Action,
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "PermissionRule",
|
|
||||||
})
|
|
||||||
export type Rule = z.infer<typeof Rule>
|
|
||||||
|
|
||||||
export const Ruleset = Rule.array().meta({
|
export const Request = S.Request
|
||||||
ref: "PermissionRuleset",
|
export type Request = S.Request
|
||||||
})
|
|
||||||
export type Ruleset = z.infer<typeof Ruleset>
|
|
||||||
|
|
||||||
export const Request = z
|
export const Reply = S.Reply
|
||||||
.object({
|
export type Reply = S.Reply
|
||||||
id: PermissionID.zod,
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
permission: z.string(),
|
|
||||||
patterns: z.string().array(),
|
|
||||||
metadata: z.record(z.string(), z.any()),
|
|
||||||
always: z.string().array(),
|
|
||||||
tool: z
|
|
||||||
.object({
|
|
||||||
messageID: MessageID.zod,
|
|
||||||
callID: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "PermissionRequest",
|
|
||||||
})
|
|
||||||
export type Request = z.infer<typeof Request>
|
|
||||||
|
|
||||||
export const Reply = z.enum(["once", "always", "reject"])
|
export const Approval = S.Approval
|
||||||
export type Reply = z.infer<typeof Reply>
|
export type Approval = z.infer<typeof S.Approval>
|
||||||
|
|
||||||
export const Approval = z.object({
|
export const Event = S.Event
|
||||||
projectID: ProjectID.zod,
|
|
||||||
patterns: z.string().array(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Event = {
|
export const RejectedError = S.RejectedError
|
||||||
Asked: BusEvent.define("permission.asked", Request),
|
export const CorrectedError = S.CorrectedError
|
||||||
Replied: BusEvent.define(
|
export const DeniedError = S.DeniedError
|
||||||
"permission.replied",
|
export type Error = S.Error
|
||||||
z.object({
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
requestID: PermissionID.zod,
|
|
||||||
reply: Reply,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
export const AskInput = S.AskInput
|
||||||
override get message() {
|
export const ReplyInput = S.ReplyInput
|
||||||
return "The user rejected permission to use this specific tool call."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
export type Interface = S.Interface
|
||||||
feedback: Schema.String,
|
|
||||||
}) {
|
|
||||||
override get message() {
|
|
||||||
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
export const Service = S.Service
|
||||||
ruleset: Schema.Any,
|
export const layer = S.layer
|
||||||
}) {
|
|
||||||
override get message() {
|
|
||||||
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Error = DeniedError | RejectedError | CorrectedError
|
export const evaluate = S.evaluate
|
||||||
|
export const fromConfig = S.fromConfig
|
||||||
|
export const merge = S.merge
|
||||||
|
export const disabled = S.disabled
|
||||||
|
|
||||||
export const AskInput = Request.partial({ id: true }).extend({
|
export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((s) => s.ask(input))))
|
||||||
ruleset: Ruleset,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ReplyInput = z.object({
|
export const reply = fn(S.ReplyInput, async (input) => runPromiseInstance(S.Service.use((s) => s.reply(input))))
|
||||||
requestID: PermissionID.zod,
|
|
||||||
reply: Reply,
|
|
||||||
message: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
|
|
||||||
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
|
||||||
readonly list: () => Effect.Effect<Request[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingEntry {
|
|
||||||
info: Request
|
|
||||||
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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 pending = new Map<PermissionID, PendingEntry>()
|
|
||||||
const approved: Ruleset = row?.data ?? []
|
|
||||||
|
|
||||||
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
|
|
||||||
const { ruleset, ...request } = input
|
|
||||||
let needsAsk = false
|
|
||||||
|
|
||||||
for (const pattern of request.patterns) {
|
|
||||||
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
|
||||||
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
|
||||||
if (rule.action === "deny") {
|
|
||||||
return yield* new DeniedError({
|
|
||||||
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (rule.action === "allow") continue
|
|
||||||
needsAsk = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!needsAsk) return
|
|
||||||
|
|
||||||
const id = request.id ?? PermissionID.ascending()
|
|
||||||
const info: Request = {
|
|
||||||
id,
|
|
||||||
...request,
|
|
||||||
}
|
|
||||||
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
|
||||||
|
|
||||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
|
||||||
pending.set(id, { info, deferred })
|
|
||||||
void Bus.publish(Event.Asked, info)
|
|
||||||
return yield* Effect.ensuring(
|
|
||||||
Deferred.await(deferred),
|
|
||||||
Effect.sync(() => {
|
|
||||||
pending.delete(id)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
|
||||||
const existing = pending.get(input.requestID)
|
|
||||||
if (!existing) return
|
|
||||||
|
|
||||||
pending.delete(input.requestID)
|
|
||||||
void Bus.publish(Event.Replied, {
|
|
||||||
sessionID: existing.info.sessionID,
|
|
||||||
requestID: existing.info.id,
|
|
||||||
reply: input.reply,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (input.reply === "reject") {
|
|
||||||
yield* Deferred.fail(
|
|
||||||
existing.deferred,
|
|
||||||
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const [id, item] of pending.entries()) {
|
|
||||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
|
||||||
pending.delete(id)
|
|
||||||
void Bus.publish(Event.Replied, {
|
|
||||||
sessionID: item.info.sessionID,
|
|
||||||
requestID: item.info.id,
|
|
||||||
reply: "reject",
|
|
||||||
})
|
|
||||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Deferred.succeed(existing.deferred, undefined)
|
|
||||||
if (input.reply === "once") return
|
|
||||||
|
|
||||||
for (const pattern of existing.info.always) {
|
|
||||||
approved.push({
|
|
||||||
permission: existing.info.permission,
|
|
||||||
pattern,
|
|
||||||
action: "allow",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, item] of pending.entries()) {
|
|
||||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
|
||||||
const ok = item.info.patterns.every(
|
|
||||||
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
|
||||||
)
|
|
||||||
if (!ok) continue
|
|
||||||
pending.delete(id)
|
|
||||||
void Bus.publish(Event.Replied, {
|
|
||||||
sessionID: item.info.sessionID,
|
|
||||||
requestID: item.info.id,
|
|
||||||
reply: "always",
|
|
||||||
})
|
|
||||||
yield* Deferred.succeed(item.deferred, undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("Permission.list")(function* () {
|
|
||||||
return Array.from(pending.values(), (item) => item.info)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ ask, reply, list })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
function expand(pattern: string): string {
|
|
||||||
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
|
||||||
if (pattern === "~") return os.homedir()
|
|
||||||
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
|
|
||||||
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
|
|
||||||
return pattern
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromConfig(permission: Config.Permission) {
|
|
||||||
const ruleset: Ruleset = []
|
|
||||||
for (const [key, value] of Object.entries(permission)) {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
ruleset.push({ permission: key, action: value, pattern: "*" })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ruleset.push(
|
|
||||||
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return ruleset
|
|
||||||
}
|
|
||||||
|
|
||||||
export function merge(...rulesets: Ruleset[]): Ruleset {
|
|
||||||
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() {
|
export async function list() {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.list()))
|
return runPromiseInstance(S.Service.use((s) => s.list()))
|
||||||
}
|
|
||||||
|
|
||||||
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
|
||||||
|
|
||||||
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
|
||||||
const result = new Set<string>()
|
|
||||||
for (const tool of tools) {
|
|
||||||
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
|
||||||
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
|
||||||
if (!rule) continue
|
|
||||||
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
|
import { ProjectID } from "@/project/schema"
|
||||||
|
import { MessageID, SessionID } from "@/session/schema"
|
||||||
|
import { PermissionTable } from "@/session/session.sql"
|
||||||
|
import { Database, eq } from "@/storage/db"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import { Wildcard } from "@/util/wildcard"
|
||||||
|
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||||
|
import os from "os"
|
||||||
|
import z from "zod"
|
||||||
|
import { evaluate as evalRule } from "./evaluate"
|
||||||
|
import { PermissionID } from "./schema"
|
||||||
|
|
||||||
|
export namespace Permission {
|
||||||
|
const log = Log.create({ service: "permission" })
|
||||||
|
|
||||||
|
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
||||||
|
ref: "PermissionAction",
|
||||||
|
})
|
||||||
|
export type Action = z.infer<typeof Action>
|
||||||
|
|
||||||
|
export const Rule = z
|
||||||
|
.object({
|
||||||
|
permission: z.string(),
|
||||||
|
pattern: z.string(),
|
||||||
|
action: Action,
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "PermissionRule",
|
||||||
|
})
|
||||||
|
export type Rule = z.infer<typeof Rule>
|
||||||
|
|
||||||
|
export const Ruleset = Rule.array().meta({
|
||||||
|
ref: "PermissionRuleset",
|
||||||
|
})
|
||||||
|
export type Ruleset = z.infer<typeof Ruleset>
|
||||||
|
|
||||||
|
export const Request = z
|
||||||
|
.object({
|
||||||
|
id: PermissionID.zod,
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
permission: z.string(),
|
||||||
|
patterns: z.string().array(),
|
||||||
|
metadata: z.record(z.string(), z.any()),
|
||||||
|
always: z.string().array(),
|
||||||
|
tool: z
|
||||||
|
.object({
|
||||||
|
messageID: MessageID.zod,
|
||||||
|
callID: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "PermissionRequest",
|
||||||
|
})
|
||||||
|
export type Request = z.infer<typeof Request>
|
||||||
|
|
||||||
|
export const Reply = z.enum(["once", "always", "reject"])
|
||||||
|
export type Reply = z.infer<typeof Reply>
|
||||||
|
|
||||||
|
export const Approval = z.object({
|
||||||
|
projectID: ProjectID.zod,
|
||||||
|
patterns: z.string().array(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Asked: BusEvent.define("permission.asked", Request),
|
||||||
|
Replied: BusEvent.define(
|
||||||
|
"permission.replied",
|
||||||
|
z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
requestID: PermissionID.zod,
|
||||||
|
reply: Reply,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
||||||
|
override get message() {
|
||||||
|
return "The user rejected permission to use this specific tool call."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
||||||
|
feedback: Schema.String,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
||||||
|
ruleset: Schema.Any,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Error = DeniedError | RejectedError | CorrectedError
|
||||||
|
|
||||||
|
export const AskInput = Request.partial({ id: true }).extend({
|
||||||
|
ruleset: Ruleset,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ReplyInput = z.object({
|
||||||
|
requestID: PermissionID.zod,
|
||||||
|
reply: Reply,
|
||||||
|
message: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
|
||||||
|
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
||||||
|
readonly list: () => Effect.Effect<Request[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingEntry {
|
||||||
|
info: Request
|
||||||
|
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 pending = new Map<PermissionID, PendingEntry>()
|
||||||
|
const approved: Ruleset = row?.data ?? []
|
||||||
|
|
||||||
|
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
|
||||||
|
const { ruleset, ...request } = input
|
||||||
|
let needsAsk = false
|
||||||
|
|
||||||
|
for (const pattern of request.patterns) {
|
||||||
|
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
||||||
|
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
||||||
|
if (rule.action === "deny") {
|
||||||
|
return yield* new DeniedError({
|
||||||
|
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (rule.action === "allow") continue
|
||||||
|
needsAsk = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needsAsk) return
|
||||||
|
|
||||||
|
const id = request.id ?? PermissionID.ascending()
|
||||||
|
const info: Request = {
|
||||||
|
id,
|
||||||
|
...request,
|
||||||
|
}
|
||||||
|
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
||||||
|
|
||||||
|
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||||
|
pending.set(id, { info, deferred })
|
||||||
|
void Bus.publish(Event.Asked, info)
|
||||||
|
return yield* Effect.ensuring(
|
||||||
|
Deferred.await(deferred),
|
||||||
|
Effect.sync(() => {
|
||||||
|
pending.delete(id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
||||||
|
const existing = pending.get(input.requestID)
|
||||||
|
if (!existing) return
|
||||||
|
|
||||||
|
pending.delete(input.requestID)
|
||||||
|
void Bus.publish(Event.Replied, {
|
||||||
|
sessionID: existing.info.sessionID,
|
||||||
|
requestID: existing.info.id,
|
||||||
|
reply: input.reply,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (input.reply === "reject") {
|
||||||
|
yield* Deferred.fail(
|
||||||
|
existing.deferred,
|
||||||
|
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const [id, item] of pending.entries()) {
|
||||||
|
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||||
|
pending.delete(id)
|
||||||
|
void Bus.publish(Event.Replied, {
|
||||||
|
sessionID: item.info.sessionID,
|
||||||
|
requestID: item.info.id,
|
||||||
|
reply: "reject",
|
||||||
|
})
|
||||||
|
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Deferred.succeed(existing.deferred, undefined)
|
||||||
|
if (input.reply === "once") return
|
||||||
|
|
||||||
|
for (const pattern of existing.info.always) {
|
||||||
|
approved.push({
|
||||||
|
permission: existing.info.permission,
|
||||||
|
pattern,
|
||||||
|
action: "allow",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, item] of pending.entries()) {
|
||||||
|
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||||
|
const ok = item.info.patterns.every(
|
||||||
|
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
||||||
|
)
|
||||||
|
if (!ok) continue
|
||||||
|
pending.delete(id)
|
||||||
|
void Bus.publish(Event.Replied, {
|
||||||
|
sessionID: item.info.sessionID,
|
||||||
|
requestID: item.info.id,
|
||||||
|
reply: "always",
|
||||||
|
})
|
||||||
|
yield* Deferred.succeed(item.deferred, undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("Permission.list")(function* () {
|
||||||
|
return Array.from(pending.values(), (item) => item.info)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ ask, reply, list })
|
||||||
|
}),
|
||||||
|
).pipe(Layer.fresh)
|
||||||
|
|
||||||
|
function expand(pattern: string): string {
|
||||||
|
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
||||||
|
if (pattern === "~") return os.homedir()
|
||||||
|
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
|
||||||
|
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromConfig(permission: Config.Permission) {
|
||||||
|
const ruleset: Ruleset = []
|
||||||
|
for (const [key, value] of Object.entries(permission)) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
ruleset.push({ permission: key, action: value, pattern: "*" })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ruleset.push(
|
||||||
|
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ruleset
|
||||||
|
}
|
||||||
|
|
||||||
|
export function merge(...rulesets: Ruleset[]): Ruleset {
|
||||||
|
return rulesets.flat()
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
||||||
|
|
||||||
|
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||||
|
const result = new Set<string>()
|
||||||
|
for (const tool of tools) {
|
||||||
|
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
||||||
|
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
||||||
|
if (!rule) continue
|
||||||
|
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import { CodexAuthPlugin } from "./codex"
|
|||||||
import { Session } from "../session"
|
import { Session } from "../session"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { CopilotAuthPlugin } from "./copilot"
|
import { CopilotAuthPlugin } from "./copilot"
|
||||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
|
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||||
|
|
||||||
export namespace Plugin {
|
export namespace Plugin {
|
||||||
const log = Log.create({ service: "plugin" })
|
const log = Log.create({ service: "plugin" })
|
||||||
|
|||||||
@@ -79,5 +79,5 @@ export namespace Vcs {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
).pipe(Layer.fresh)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import type { AuthOuathResult } from "@opencode-ai/plugin"
|
||||||
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
|
import * as Auth from "@/auth/effect"
|
||||||
|
import { ProviderID } from "./schema"
|
||||||
|
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect"
|
||||||
|
import z from "zod"
|
||||||
|
|
||||||
|
export namespace ProviderAuth {
|
||||||
|
export const Method = z
|
||||||
|
.object({
|
||||||
|
type: z.union([z.literal("oauth"), z.literal("api")]),
|
||||||
|
label: z.string(),
|
||||||
|
prompts: z
|
||||||
|
.array(
|
||||||
|
z.union([
|
||||||
|
z.object({
|
||||||
|
type: z.literal("text"),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
placeholder: z.string().optional(),
|
||||||
|
when: z
|
||||||
|
.object({
|
||||||
|
key: z.string(),
|
||||||
|
op: z.union([z.literal("eq"), z.literal("neq")]),
|
||||||
|
value: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("select"),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
options: z.array(
|
||||||
|
z.object({
|
||||||
|
label: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
hint: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
when: z
|
||||||
|
.object({
|
||||||
|
key: z.string(),
|
||||||
|
op: z.union([z.literal("eq"), z.literal("neq")]),
|
||||||
|
value: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "ProviderAuthMethod",
|
||||||
|
})
|
||||||
|
export type Method = z.infer<typeof Method>
|
||||||
|
|
||||||
|
export const Authorization = z
|
||||||
|
.object({
|
||||||
|
url: z.string(),
|
||||||
|
method: z.union([z.literal("auto"), z.literal("code")]),
|
||||||
|
instructions: z.string(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "ProviderAuthAuthorization",
|
||||||
|
})
|
||||||
|
export type Authorization = z.infer<typeof Authorization>
|
||||||
|
|
||||||
|
export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
|
||||||
|
|
||||||
|
export const OauthCodeMissing = NamedError.create(
|
||||||
|
"ProviderAuthOauthCodeMissing",
|
||||||
|
z.object({ providerID: ProviderID.zod }),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
||||||
|
|
||||||
|
export const ValidationFailed = NamedError.create(
|
||||||
|
"ProviderAuthValidationFailed",
|
||||||
|
z.object({
|
||||||
|
field: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export type Error =
|
||||||
|
| Auth.AuthError
|
||||||
|
| InstanceType<typeof OauthMissing>
|
||||||
|
| InstanceType<typeof OauthCodeMissing>
|
||||||
|
| InstanceType<typeof OauthCallbackFailed>
|
||||||
|
| InstanceType<typeof ValidationFailed>
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
|
||||||
|
readonly authorize: (input: {
|
||||||
|
providerID: ProviderID
|
||||||
|
method: number
|
||||||
|
inputs?: Record<string, string>
|
||||||
|
}) => Effect.Effect<Authorization | undefined, Error>
|
||||||
|
readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 methods = Effect.fn("ProviderAuth.methods")(function* () {
|
||||||
|
return Record.map(hooks, (item) =>
|
||||||
|
item.methods.map(
|
||||||
|
(method): Method => ({
|
||||||
|
type: method.type,
|
||||||
|
label: method.label,
|
||||||
|
prompts: method.prompts?.map((prompt) => {
|
||||||
|
if (prompt.type === "select") {
|
||||||
|
return {
|
||||||
|
type: "select" as const,
|
||||||
|
key: prompt.key,
|
||||||
|
message: prompt.message,
|
||||||
|
options: prompt.options,
|
||||||
|
when: prompt.when,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "text" as const,
|
||||||
|
key: prompt.key,
|
||||||
|
message: prompt.message,
|
||||||
|
placeholder: prompt.placeholder,
|
||||||
|
when: prompt.when,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
|
||||||
|
providerID: ProviderID
|
||||||
|
method: number
|
||||||
|
inputs?: Record<string, string>
|
||||||
|
}) {
|
||||||
|
const method = hooks[input.providerID].methods[input.method]
|
||||||
|
if (method.type !== "oauth") return
|
||||||
|
|
||||||
|
if (method.prompts && input.inputs) {
|
||||||
|
for (const prompt of method.prompts) {
|
||||||
|
if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
|
||||||
|
const error = prompt.validate(input.inputs[prompt.key])
|
||||||
|
if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = yield* Effect.promise(() => method.authorize(input.inputs))
|
||||||
|
pending.set(input.providerID, result)
|
||||||
|
return {
|
||||||
|
url: result.url,
|
||||||
|
method: result.method,
|
||||||
|
instructions: result.instructions,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
|
||||||
|
providerID: ProviderID
|
||||||
|
method: number
|
||||||
|
code?: string
|
||||||
|
}) {
|
||||||
|
const match = pending.get(input.providerID)
|
||||||
|
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
|
||||||
|
if (match.method === "code" && !input.code) {
|
||||||
|
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = yield* Effect.promise(() =>
|
||||||
|
match.method === "code" ? match.callback(input.code!) : match.callback(),
|
||||||
|
)
|
||||||
|
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
|
||||||
|
|
||||||
|
if ("key" in result) {
|
||||||
|
yield* auth.set(input.providerID, {
|
||||||
|
type: "api",
|
||||||
|
key: result.key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("refresh" in result) {
|
||||||
|
yield* auth.set(input.providerID, {
|
||||||
|
type: "oauth",
|
||||||
|
access: result.access,
|
||||||
|
refresh: result.refresh,
|
||||||
|
expires: result.expires,
|
||||||
|
...(result.accountId ? { accountId: result.accountId } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ methods, authorize, callback })
|
||||||
|
}),
|
||||||
|
).pipe(Layer.fresh)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(Auth.Auth.layer))
|
||||||
|
}
|
||||||
@@ -1,222 +1,30 @@
|
|||||||
import type { AuthOuathResult } from "@opencode-ai/plugin"
|
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
|
||||||
import * as Auth from "@/auth/effect"
|
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { fn } from "@/util/fn"
|
import { fn } from "@/util/fn"
|
||||||
import { ProviderID } from "./schema"
|
import { ProviderID } from "./schema"
|
||||||
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect"
|
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { ProviderAuth as S } from "./auth-service"
|
||||||
|
|
||||||
export namespace ProviderAuth {
|
export namespace ProviderAuth {
|
||||||
export const Method = z
|
export const Method = S.Method
|
||||||
.object({
|
export type Method = S.Method
|
||||||
type: z.union([z.literal("oauth"), z.literal("api")]),
|
|
||||||
label: z.string(),
|
|
||||||
prompts: z
|
|
||||||
.array(
|
|
||||||
z.union([
|
|
||||||
z.object({
|
|
||||||
type: z.literal("text"),
|
|
||||||
key: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
placeholder: z.string().optional(),
|
|
||||||
when: z
|
|
||||||
.object({
|
|
||||||
key: z.string(),
|
|
||||||
op: z.union([z.literal("eq"), z.literal("neq")]),
|
|
||||||
value: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
type: z.literal("select"),
|
|
||||||
key: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
options: z.array(
|
|
||||||
z.object({
|
|
||||||
label: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
hint: z.string().optional(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
when: z
|
|
||||||
.object({
|
|
||||||
key: z.string(),
|
|
||||||
op: z.union([z.literal("eq"), z.literal("neq")]),
|
|
||||||
value: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "ProviderAuthMethod",
|
|
||||||
})
|
|
||||||
export type Method = z.infer<typeof Method>
|
|
||||||
|
|
||||||
export const Authorization = z
|
export const Authorization = S.Authorization
|
||||||
.object({
|
export type Authorization = S.Authorization
|
||||||
url: z.string(),
|
|
||||||
method: z.union([z.literal("auto"), z.literal("code")]),
|
|
||||||
instructions: z.string(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "ProviderAuthAuthorization",
|
|
||||||
})
|
|
||||||
export type Authorization = z.infer<typeof Authorization>
|
|
||||||
|
|
||||||
export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
|
export const OauthMissing = S.OauthMissing
|
||||||
|
export const OauthCodeMissing = S.OauthCodeMissing
|
||||||
|
export const OauthCallbackFailed = S.OauthCallbackFailed
|
||||||
|
export const ValidationFailed = S.ValidationFailed
|
||||||
|
export type Error = S.Error
|
||||||
|
|
||||||
export const OauthCodeMissing = NamedError.create(
|
export type Interface = S.Interface
|
||||||
"ProviderAuthOauthCodeMissing",
|
|
||||||
z.object({ providerID: ProviderID.zod }),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
export const Service = S.Service
|
||||||
|
export const layer = S.layer
|
||||||
export const ValidationFailed = NamedError.create(
|
export const defaultLayer = S.defaultLayer
|
||||||
"ProviderAuthValidationFailed",
|
|
||||||
z.object({
|
|
||||||
field: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export type Error =
|
|
||||||
| Auth.AuthError
|
|
||||||
| InstanceType<typeof OauthMissing>
|
|
||||||
| InstanceType<typeof OauthCodeMissing>
|
|
||||||
| InstanceType<typeof OauthCallbackFailed>
|
|
||||||
| InstanceType<typeof ValidationFailed>
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
|
|
||||||
readonly authorize: (input: {
|
|
||||||
providerID: ProviderID
|
|
||||||
method: number
|
|
||||||
inputs?: Record<string, string>
|
|
||||||
}) => Effect.Effect<Authorization | undefined, Error>
|
|
||||||
readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const auth = yield* Auth.AuthEffect.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 methods = Effect.fn("ProviderAuth.methods")(function* () {
|
|
||||||
return Record.map(hooks, (item) =>
|
|
||||||
item.methods.map(
|
|
||||||
(method): Method => ({
|
|
||||||
type: method.type,
|
|
||||||
label: method.label,
|
|
||||||
prompts: method.prompts?.map((prompt) => {
|
|
||||||
if (prompt.type === "select") {
|
|
||||||
return {
|
|
||||||
type: "select" as const,
|
|
||||||
key: prompt.key,
|
|
||||||
message: prompt.message,
|
|
||||||
options: prompt.options,
|
|
||||||
when: prompt.when,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: "text" as const,
|
|
||||||
key: prompt.key,
|
|
||||||
message: prompt.message,
|
|
||||||
placeholder: prompt.placeholder,
|
|
||||||
when: prompt.when,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
|
|
||||||
providerID: ProviderID
|
|
||||||
method: number
|
|
||||||
inputs?: Record<string, string>
|
|
||||||
}) {
|
|
||||||
const method = hooks[input.providerID].methods[input.method]
|
|
||||||
if (method.type !== "oauth") return
|
|
||||||
|
|
||||||
if (method.prompts && input.inputs) {
|
|
||||||
for (const prompt of method.prompts) {
|
|
||||||
if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
|
|
||||||
const error = prompt.validate(input.inputs[prompt.key])
|
|
||||||
if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = yield* Effect.promise(() => method.authorize(input.inputs))
|
|
||||||
pending.set(input.providerID, result)
|
|
||||||
return {
|
|
||||||
url: result.url,
|
|
||||||
method: result.method,
|
|
||||||
instructions: result.instructions,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
|
|
||||||
providerID: ProviderID
|
|
||||||
method: number
|
|
||||||
code?: string
|
|
||||||
}) {
|
|
||||||
const match = pending.get(input.providerID)
|
|
||||||
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
|
|
||||||
if (match.method === "code" && !input.code) {
|
|
||||||
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = yield* Effect.promise(() =>
|
|
||||||
match.method === "code" ? match.callback(input.code!) : match.callback(),
|
|
||||||
)
|
|
||||||
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
|
|
||||||
|
|
||||||
if ("key" in result) {
|
|
||||||
yield* auth.set(input.providerID, {
|
|
||||||
type: "api",
|
|
||||||
key: result.key,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("refresh" in result) {
|
|
||||||
yield* auth.set(input.providerID, {
|
|
||||||
type: "oauth",
|
|
||||||
access: result.access,
|
|
||||||
refresh: result.refresh,
|
|
||||||
expires: result.expires,
|
|
||||||
...(result.accountId ? { accountId: result.accountId } : {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ methods, authorize, callback })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.layer))
|
|
||||||
|
|
||||||
export async function methods() {
|
export async function methods() {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.methods()))
|
return runPromiseInstance(S.Service.use((svc) => svc.methods()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authorize = fn(
|
export const authorize = fn(
|
||||||
@@ -225,7 +33,8 @@ export namespace ProviderAuth {
|
|||||||
method: z.number(),
|
method: z.number(),
|
||||||
inputs: z.record(z.string(), z.string()).optional(),
|
inputs: z.record(z.string(), z.string()).optional(),
|
||||||
}),
|
}),
|
||||||
async (input): Promise<Authorization | undefined> => runPromiseInstance(Service.use((svc) => svc.authorize(input))),
|
async (input): Promise<Authorization | undefined> =>
|
||||||
|
runPromiseInstance(S.Service.use((svc) => svc.authorize(input))),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const callback = fn(
|
export const callback = fn(
|
||||||
@@ -234,6 +43,6 @@ export namespace ProviderAuth {
|
|||||||
method: z.number(),
|
method: z.number(),
|
||||||
code: z.string().optional(),
|
code: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
async (input) => runPromiseInstance(Service.use((svc) => svc.callback(input))),
|
async (input) => runPromiseInstance(S.Service.use((svc) => svc.callback(input))),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,12 @@ import { createGateway } from "@ai-sdk/gateway"
|
|||||||
import { createTogetherAI } from "@ai-sdk/togetherai"
|
import { createTogetherAI } from "@ai-sdk/togetherai"
|
||||||
import { createPerplexity } from "@ai-sdk/perplexity"
|
import { createPerplexity } from "@ai-sdk/perplexity"
|
||||||
import { createVercel } from "@ai-sdk/vercel"
|
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 { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||||
import { GoogleAuth } from "google-auth-library"
|
import { GoogleAuth } from "google-auth-library"
|
||||||
import { ProviderTransform } from "./transform"
|
import { ProviderTransform } from "./transform"
|
||||||
@@ -124,18 +129,20 @@ export namespace Provider {
|
|||||||
"@ai-sdk/togetherai": createTogetherAI,
|
"@ai-sdk/togetherai": createTogetherAI,
|
||||||
"@ai-sdk/perplexity": createPerplexity,
|
"@ai-sdk/perplexity": createPerplexity,
|
||||||
"@ai-sdk/vercel": createVercel,
|
"@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)
|
// @ts-ignore (TODO: kill this code so we dont have to maintain it)
|
||||||
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
|
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
|
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
|
||||||
type CustomVarsLoader = (options: Record<string, any>) => Record<string, string>
|
type CustomVarsLoader = (options: Record<string, any>) => Record<string, string>
|
||||||
|
type CustomDiscoverModels = () => Promise<Record<string, Model>>
|
||||||
type CustomLoader = (provider: Info) => Promise<{
|
type CustomLoader = (provider: Info) => Promise<{
|
||||||
autoload: boolean
|
autoload: boolean
|
||||||
getModel?: CustomModelLoader
|
getModel?: CustomModelLoader
|
||||||
vars?: CustomVarsLoader
|
vars?: CustomVarsLoader
|
||||||
options?: Record<string, any>
|
options?: Record<string, any>
|
||||||
|
discoverModels?: CustomDiscoverModels
|
||||||
}>
|
}>
|
||||||
|
|
||||||
function useLanguageModel(sdk: any) {
|
function useLanguageModel(sdk: any) {
|
||||||
@@ -533,28 +540,105 @@ export namespace Provider {
|
|||||||
...(providerConfig?.options?.aiGatewayHeaders || {}),
|
...(providerConfig?.options?.aiGatewayHeaders || {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const featureFlags = {
|
||||||
|
duo_agent_platform_agentic_chat: true,
|
||||||
|
duo_agent_platform: true,
|
||||||
|
...(providerConfig?.options?.featureFlags || {}),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
autoload: !!apiKey,
|
autoload: !!apiKey,
|
||||||
options: {
|
options: {
|
||||||
instanceUrl,
|
instanceUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
aiGatewayHeaders,
|
aiGatewayHeaders,
|
||||||
featureFlags: {
|
featureFlags,
|
||||||
duo_agent_platform_agentic_chat: true,
|
|
||||||
duo_agent_platform: true,
|
|
||||||
...(providerConfig?.options?.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, {
|
return sdk.agenticChat(modelID, {
|
||||||
aiGatewayHeaders,
|
aiGatewayHeaders,
|
||||||
featureFlags: {
|
featureFlags,
|
||||||
duo_agent_platform_agentic_chat: true,
|
|
||||||
duo_agent_platform: true,
|
|
||||||
...(providerConfig?.options?.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) => {
|
"cloudflare-workers-ai": async (input) => {
|
||||||
@@ -853,6 +937,9 @@ export namespace Provider {
|
|||||||
const varsLoaders: {
|
const varsLoaders: {
|
||||||
[providerID: string]: CustomVarsLoader
|
[providerID: string]: CustomVarsLoader
|
||||||
} = {}
|
} = {}
|
||||||
|
const discoveryLoaders: {
|
||||||
|
[providerID: string]: CustomDiscoverModels
|
||||||
|
} = {}
|
||||||
const sdk = new Map<string, SDK>()
|
const sdk = new Map<string, SDK>()
|
||||||
|
|
||||||
log.info("init")
|
log.info("init")
|
||||||
@@ -1009,6 +1096,7 @@ export namespace Provider {
|
|||||||
if (result && (result.autoload || providers[providerID])) {
|
if (result && (result.autoload || providers[providerID])) {
|
||||||
if (result.getModel) modelLoaders[providerID] = result.getModel
|
if (result.getModel) modelLoaders[providerID] = result.getModel
|
||||||
if (result.vars) varsLoaders[providerID] = result.vars
|
if (result.vars) varsLoaders[providerID] = result.vars
|
||||||
|
if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels
|
||||||
const opts = result.options ?? {}
|
const opts = result.options ?? {}
|
||||||
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
|
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
|
||||||
mergeProvider(providerID, patch)
|
mergeProvider(providerID, patch)
|
||||||
@@ -1070,6 +1158,18 @@ export namespace Provider {
|
|||||||
log.info("found", { providerID })
|
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 {
|
return {
|
||||||
models: languages,
|
models: languages,
|
||||||
providers,
|
providers,
|
||||||
@@ -1250,7 +1350,7 @@ export namespace Provider {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const language = s.modelLoaders[model.providerID]
|
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)
|
: sdk.languageModel(model.api.id)
|
||||||
s.models.set(key, language)
|
s.models.set(key, language)
|
||||||
return language
|
return language
|
||||||
|
|||||||
@@ -1,193 +1,49 @@
|
|||||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { Bus } from "@/bus"
|
import type { MessageID, SessionID } from "@/session/schema"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import type { QuestionID } from "./schema"
|
||||||
import { SessionID, MessageID } from "@/session/schema"
|
import { Question as S } from "./service"
|
||||||
import { Log } from "@/util/log"
|
|
||||||
import z from "zod"
|
|
||||||
import { QuestionID } from "./schema"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "question" })
|
|
||||||
|
|
||||||
export namespace Question {
|
export namespace Question {
|
||||||
// Schemas
|
export const Option = S.Option
|
||||||
|
export type Option = S.Option
|
||||||
|
|
||||||
export const Option = z
|
export const Info = S.Info
|
||||||
.object({
|
export type Info = S.Info
|
||||||
label: z.string().describe("Display text (1-5 words, concise)"),
|
|
||||||
description: z.string().describe("Explanation of choice"),
|
|
||||||
})
|
|
||||||
.meta({ ref: "QuestionOption" })
|
|
||||||
export type Option = z.infer<typeof Option>
|
|
||||||
|
|
||||||
export const Info = z
|
export const Request = S.Request
|
||||||
.object({
|
export type Request = S.Request
|
||||||
question: z.string().describe("Complete question"),
|
|
||||||
header: z.string().describe("Very short label (max 30 chars)"),
|
|
||||||
options: z.array(Option).describe("Available choices"),
|
|
||||||
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
|
||||||
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
|
||||||
})
|
|
||||||
.meta({ ref: "QuestionInfo" })
|
|
||||||
export type Info = z.infer<typeof Info>
|
|
||||||
|
|
||||||
export const Request = z
|
export const Answer = S.Answer
|
||||||
.object({
|
export type Answer = S.Answer
|
||||||
id: QuestionID.zod,
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
questions: z.array(Info).describe("Questions to ask"),
|
|
||||||
tool: z
|
|
||||||
.object({
|
|
||||||
messageID: MessageID.zod,
|
|
||||||
callID: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.meta({ ref: "QuestionRequest" })
|
|
||||||
export type Request = z.infer<typeof Request>
|
|
||||||
|
|
||||||
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
|
export const Reply = S.Reply
|
||||||
export type Answer = z.infer<typeof Answer>
|
export type Reply = S.Reply
|
||||||
|
|
||||||
export const Reply = z.object({
|
export const Event = S.Event
|
||||||
answers: z
|
export const RejectedError = S.RejectedError
|
||||||
.array(Answer)
|
|
||||||
.describe("User answers in order of questions (each answer is an array of selected labels)"),
|
|
||||||
})
|
|
||||||
export type Reply = z.infer<typeof Reply>
|
|
||||||
|
|
||||||
export const Event = {
|
export type Interface = S.Interface
|
||||||
Asked: BusEvent.define("question.asked", Request),
|
|
||||||
Replied: BusEvent.define(
|
|
||||||
"question.replied",
|
|
||||||
z.object({
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
requestID: QuestionID.zod,
|
|
||||||
answers: z.array(Answer),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Rejected: BusEvent.define(
|
|
||||||
"question.rejected",
|
|
||||||
z.object({
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
requestID: QuestionID.zod,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
|
export const Service = S.Service
|
||||||
override get message() {
|
export const layer = S.layer
|
||||||
return "The user dismissed this question"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingEntry {
|
|
||||||
info: Request
|
|
||||||
deferred: Deferred.Deferred<Answer[], RejectedError>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly ask: (input: {
|
|
||||||
sessionID: SessionID
|
|
||||||
questions: Info[]
|
|
||||||
tool?: { messageID: MessageID; callID: string }
|
|
||||||
}) => Effect.Effect<Answer[], RejectedError>
|
|
||||||
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
|
|
||||||
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
|
|
||||||
readonly list: () => Effect.Effect<Request[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const pending = new Map<QuestionID, PendingEntry>()
|
|
||||||
|
|
||||||
const ask = Effect.fn("Question.ask")(function* (input: {
|
|
||||||
sessionID: SessionID
|
|
||||||
questions: Info[]
|
|
||||||
tool?: { messageID: MessageID; callID: string }
|
|
||||||
}) {
|
|
||||||
const id = QuestionID.ascending()
|
|
||||||
log.info("asking", { id, questions: input.questions.length })
|
|
||||||
|
|
||||||
const deferred = yield* Deferred.make<Answer[], RejectedError>()
|
|
||||||
const info: Request = {
|
|
||||||
id,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
questions: input.questions,
|
|
||||||
tool: input.tool,
|
|
||||||
}
|
|
||||||
pending.set(id, { info, deferred })
|
|
||||||
Bus.publish(Event.Asked, info)
|
|
||||||
|
|
||||||
return yield* Effect.ensuring(
|
|
||||||
Deferred.await(deferred),
|
|
||||||
Effect.sync(() => {
|
|
||||||
pending.delete(id)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
|
||||||
const existing = pending.get(input.requestID)
|
|
||||||
if (!existing) {
|
|
||||||
log.warn("reply for unknown request", { requestID: input.requestID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pending.delete(input.requestID)
|
|
||||||
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
|
||||||
Bus.publish(Event.Replied, {
|
|
||||||
sessionID: existing.info.sessionID,
|
|
||||||
requestID: existing.info.id,
|
|
||||||
answers: input.answers,
|
|
||||||
})
|
|
||||||
yield* Deferred.succeed(existing.deferred, input.answers)
|
|
||||||
})
|
|
||||||
|
|
||||||
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
|
|
||||||
const existing = pending.get(requestID)
|
|
||||||
if (!existing) {
|
|
||||||
log.warn("reject for unknown request", { requestID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pending.delete(requestID)
|
|
||||||
log.info("rejected", { requestID })
|
|
||||||
Bus.publish(Event.Rejected, {
|
|
||||||
sessionID: existing.info.sessionID,
|
|
||||||
requestID: existing.info.id,
|
|
||||||
})
|
|
||||||
yield* Deferred.fail(existing.deferred, new RejectedError())
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("Question.list")(function* () {
|
|
||||||
return Array.from(pending.values(), (x) => x.info)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ ask, reply, reject, list })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export async function ask(input: {
|
export async function ask(input: {
|
||||||
sessionID: SessionID
|
sessionID: SessionID
|
||||||
questions: Info[]
|
questions: Info[]
|
||||||
tool?: { messageID: MessageID; callID: string }
|
tool?: { messageID: MessageID; callID: string }
|
||||||
}): Promise<Answer[]> {
|
}): Promise<Answer[]> {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.ask(input)))
|
return runPromiseInstance(S.Service.use((s) => s.ask(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
|
export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.reply(input)))
|
return runPromiseInstance(S.Service.use((s) => s.reply(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reject(requestID: QuestionID): Promise<void> {
|
export async function reject(requestID: QuestionID) {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.reject(requestID)))
|
return runPromiseInstance(S.Service.use((s) => s.reject(requestID)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list(): Promise<Request[]> {
|
export async function list() {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.list()))
|
return runPromiseInstance(S.Service.use((s) => s.list()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
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 {
|
||||||
|
// Schemas
|
||||||
|
|
||||||
|
export const Option = z
|
||||||
|
.object({
|
||||||
|
label: z.string().describe("Display text (1-5 words, concise)"),
|
||||||
|
description: z.string().describe("Explanation of choice"),
|
||||||
|
})
|
||||||
|
.meta({ ref: "QuestionOption" })
|
||||||
|
export type Option = z.infer<typeof Option>
|
||||||
|
|
||||||
|
export const Info = z
|
||||||
|
.object({
|
||||||
|
question: z.string().describe("Complete question"),
|
||||||
|
header: z.string().describe("Very short label (max 30 chars)"),
|
||||||
|
options: z.array(Option).describe("Available choices"),
|
||||||
|
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
||||||
|
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
||||||
|
})
|
||||||
|
.meta({ ref: "QuestionInfo" })
|
||||||
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
|
export const Request = z
|
||||||
|
.object({
|
||||||
|
id: QuestionID.zod,
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
questions: z.array(Info).describe("Questions to ask"),
|
||||||
|
tool: z
|
||||||
|
.object({
|
||||||
|
messageID: MessageID.zod,
|
||||||
|
callID: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.meta({ ref: "QuestionRequest" })
|
||||||
|
export type Request = z.infer<typeof Request>
|
||||||
|
|
||||||
|
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
|
||||||
|
export type Answer = z.infer<typeof Answer>
|
||||||
|
|
||||||
|
export const Reply = z.object({
|
||||||
|
answers: z
|
||||||
|
.array(Answer)
|
||||||
|
.describe("User answers in order of questions (each answer is an array of selected labels)"),
|
||||||
|
})
|
||||||
|
export type Reply = z.infer<typeof Reply>
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Asked: BusEvent.define("question.asked", Request),
|
||||||
|
Replied: BusEvent.define(
|
||||||
|
"question.replied",
|
||||||
|
z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
requestID: QuestionID.zod,
|
||||||
|
answers: z.array(Answer),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Rejected: BusEvent.define(
|
||||||
|
"question.rejected",
|
||||||
|
z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
requestID: QuestionID.zod,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
|
||||||
|
override get message() {
|
||||||
|
return "The user dismissed this question"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingEntry {
|
||||||
|
info: Request
|
||||||
|
deferred: Deferred.Deferred<Answer[], RejectedError>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly ask: (input: {
|
||||||
|
sessionID: SessionID
|
||||||
|
questions: Info[]
|
||||||
|
tool?: { messageID: MessageID; callID: string }
|
||||||
|
}) => Effect.Effect<Answer[], RejectedError>
|
||||||
|
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
|
||||||
|
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
|
||||||
|
readonly list: () => Effect.Effect<Request[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pending = new Map<QuestionID, PendingEntry>()
|
||||||
|
|
||||||
|
const ask = Effect.fn("Question.ask")(function* (input: {
|
||||||
|
sessionID: SessionID
|
||||||
|
questions: Info[]
|
||||||
|
tool?: { messageID: MessageID; callID: string }
|
||||||
|
}) {
|
||||||
|
const id = QuestionID.ascending()
|
||||||
|
log.info("asking", { id, questions: input.questions.length })
|
||||||
|
|
||||||
|
const deferred = yield* Deferred.make<Answer[], RejectedError>()
|
||||||
|
const info: Request = {
|
||||||
|
id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
questions: input.questions,
|
||||||
|
tool: input.tool,
|
||||||
|
}
|
||||||
|
pending.set(id, { info, deferred })
|
||||||
|
Bus.publish(Event.Asked, info)
|
||||||
|
|
||||||
|
return yield* Effect.ensuring(
|
||||||
|
Deferred.await(deferred),
|
||||||
|
Effect.sync(() => {
|
||||||
|
pending.delete(id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
||||||
|
const existing = pending.get(input.requestID)
|
||||||
|
if (!existing) {
|
||||||
|
log.warn("reply for unknown request", { requestID: input.requestID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending.delete(input.requestID)
|
||||||
|
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
||||||
|
Bus.publish(Event.Replied, {
|
||||||
|
sessionID: existing.info.sessionID,
|
||||||
|
requestID: existing.info.id,
|
||||||
|
answers: input.answers,
|
||||||
|
})
|
||||||
|
yield* Deferred.succeed(existing.deferred, input.answers)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
|
||||||
|
const existing = pending.get(requestID)
|
||||||
|
if (!existing) {
|
||||||
|
log.warn("reject for unknown request", { requestID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending.delete(requestID)
|
||||||
|
log.info("rejected", { requestID })
|
||||||
|
Bus.publish(Event.Rejected, {
|
||||||
|
sessionID: existing.info.sessionID,
|
||||||
|
requestID: existing.info.id,
|
||||||
|
})
|
||||||
|
yield* Deferred.fail(existing.deferred, new RejectedError())
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("Question.list")(function* () {
|
||||||
|
return Array.from(pending.values(), (x) => x.info)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ ask, reply, reject, list })
|
||||||
|
}),
|
||||||
|
).pipe(Layer.fresh)
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ import { ProviderID } from "../../provider/schema"
|
|||||||
import { mapValues } from "remeda"
|
import { mapValues } from "remeda"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
|
import { Log } from "../../util/log"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "server" })
|
||||||
|
|
||||||
export const ProviderRoutes = lazy(() =>
|
export const ProviderRoutes = lazy(() =>
|
||||||
new Hono()
|
new Hono()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
jsonSchema,
|
jsonSchema,
|
||||||
} from "ai"
|
} from "ai"
|
||||||
import { mergeDeep, pipe } from "remeda"
|
import { mergeDeep, pipe } from "remeda"
|
||||||
|
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
|
||||||
import { ProviderTransform } from "@/provider/transform"
|
import { ProviderTransform } from "@/provider/transform"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
@@ -63,14 +64,14 @@ export namespace LLM {
|
|||||||
Provider.getProvider(input.model.providerID),
|
Provider.getProvider(input.model.providerID),
|
||||||
Auth.get(input.model.providerID),
|
Auth.get(input.model.providerID),
|
||||||
])
|
])
|
||||||
const isCodex = provider.id === "openai" && auth?.type === "oauth"
|
// TODO: move this to a proper hook
|
||||||
|
const isOpenaiOauth = provider.id === "openai" && auth?.type === "oauth"
|
||||||
|
|
||||||
const system = []
|
const system: string[] = []
|
||||||
system.push(
|
system.push(
|
||||||
[
|
[
|
||||||
// use agent prompt otherwise provider prompt
|
// use agent prompt otherwise provider prompt
|
||||||
// For Codex sessions, skip SystemPrompt.provider() since it's sent via options.instructions
|
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
|
||||||
...(input.agent.prompt ? [input.agent.prompt] : isCodex ? [] : SystemPrompt.provider(input.model)),
|
|
||||||
// any custom prompt passed into this call
|
// any custom prompt passed into this call
|
||||||
...input.system,
|
...input.system,
|
||||||
// any custom prompt from last user message
|
// any custom prompt from last user message
|
||||||
@@ -108,10 +109,22 @@ export namespace LLM {
|
|||||||
mergeDeep(input.agent.options),
|
mergeDeep(input.agent.options),
|
||||||
mergeDeep(variant),
|
mergeDeep(variant),
|
||||||
)
|
)
|
||||||
if (isCodex) {
|
if (isOpenaiOauth) {
|
||||||
options.instructions = SystemPrompt.instructions()
|
options.instructions = system.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messages = isOpenaiOauth
|
||||||
|
? input.messages
|
||||||
|
: [
|
||||||
|
...system.map(
|
||||||
|
(x): ModelMessage => ({
|
||||||
|
role: "system",
|
||||||
|
content: x,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
...input.messages,
|
||||||
|
]
|
||||||
|
|
||||||
const params = await Plugin.trigger(
|
const params = await Plugin.trigger(
|
||||||
"chat.params",
|
"chat.params",
|
||||||
{
|
{
|
||||||
@@ -146,7 +159,9 @@ export namespace LLM {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const maxOutputTokens =
|
const maxOutputTokens =
|
||||||
isCodex || provider.id.includes("github-copilot") ? undefined : ProviderTransform.maxOutputTokens(input.model)
|
isOpenaiOauth || provider.id.includes("github-copilot")
|
||||||
|
? undefined
|
||||||
|
: ProviderTransform.maxOutputTokens(input.model)
|
||||||
|
|
||||||
const tools = await resolveTools(input)
|
const tools = await resolveTools(input)
|
||||||
|
|
||||||
@@ -170,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({
|
return streamText({
|
||||||
onError(error) {
|
onError(error) {
|
||||||
l.error("stream error", {
|
l.error("stream error", {
|
||||||
@@ -217,15 +260,7 @@ export namespace LLM {
|
|||||||
...headers,
|
...headers,
|
||||||
},
|
},
|
||||||
maxRetries: input.retries ?? 0,
|
maxRetries: input.retries ?? 0,
|
||||||
messages: [
|
messages,
|
||||||
...system.map(
|
|
||||||
(x): ModelMessage => ({
|
|
||||||
role: "system",
|
|
||||||
content: x,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
...input.messages,
|
|
||||||
],
|
|
||||||
model: wrapLanguageModel({
|
model: wrapLanguageModel({
|
||||||
model: language,
|
model: language,
|
||||||
middleware: [
|
middleware: [
|
||||||
|
|||||||
@@ -1782,6 +1782,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
export async function command(input: CommandInput) {
|
export async function command(input: CommandInput) {
|
||||||
log.info("command", input)
|
log.info("command", input)
|
||||||
const command = await Command.get(input.command)
|
const command = await Command.get(input.command)
|
||||||
|
if (!command) {
|
||||||
|
throw new NamedError.Unknown({ message: `Command not found: "${input.command}"` })
|
||||||
|
}
|
||||||
const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent())
|
const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent())
|
||||||
|
|
||||||
const raw = input.arguments.match(argsRegex) ?? []
|
const raw = input.arguments.match(argsRegex) ?? []
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import PROMPT_DEFAULT from "./prompt/default.txt"
|
|||||||
import PROMPT_BEAST from "./prompt/beast.txt"
|
import PROMPT_BEAST from "./prompt/beast.txt"
|
||||||
import PROMPT_GEMINI from "./prompt/gemini.txt"
|
import PROMPT_GEMINI from "./prompt/gemini.txt"
|
||||||
|
|
||||||
import PROMPT_CODEX from "./prompt/codex_header.txt"
|
import PROMPT_CODEX from "./prompt/codex.txt"
|
||||||
import PROMPT_TRINITY from "./prompt/trinity.txt"
|
import PROMPT_TRINITY from "./prompt/trinity.txt"
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
import type { Agent } from "@/agent/agent"
|
import type { Agent } from "@/agent/agent"
|
||||||
@@ -15,14 +15,10 @@ import { PermissionNext } from "@/permission"
|
|||||||
import { Skill } from "@/skill"
|
import { Skill } from "@/skill"
|
||||||
|
|
||||||
export namespace SystemPrompt {
|
export namespace SystemPrompt {
|
||||||
export function instructions() {
|
|
||||||
return PROMPT_CODEX.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function provider(model: Provider.Model) {
|
export function provider(model: Provider.Model) {
|
||||||
if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX]
|
if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
|
||||||
if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3"))
|
|
||||||
return [PROMPT_BEAST]
|
return [PROMPT_BEAST]
|
||||||
|
if (model.api.id.includes("gpt")) return [PROMPT_CODEX]
|
||||||
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
|
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
|
||||||
if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
|
if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
|
||||||
if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY]
|
if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY]
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
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 { Flag } from "@/flag/flag"
|
||||||
|
import { Global } from "@/global"
|
||||||
|
import { Permission } from "@/permission/service"
|
||||||
|
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) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ get, all, dirs, available })
|
||||||
|
}),
|
||||||
|
).pipe(Layer.fresh)
|
||||||
|
|
||||||
|
export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,255 +1,35 @@
|
|||||||
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 { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { Flag } from "@/flag/flag"
|
import type { Agent } from "@/agent/agent"
|
||||||
import { Global } from "@/global"
|
import { Skill as S } from "./service"
|
||||||
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 {
|
export namespace Skill {
|
||||||
const log = Log.create({ service: "skill" })
|
export const Info = S.Info
|
||||||
const EXTERNAL_DIRS = [".claude", ".agents"]
|
export type Info = S.Info
|
||||||
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({
|
export const InvalidError = S.InvalidError
|
||||||
name: z.string(),
|
export const NameMismatchError = S.NameMismatchError
|
||||||
description: z.string(),
|
|
||||||
location: z.string(),
|
|
||||||
content: z.string(),
|
|
||||||
})
|
|
||||||
export type Info = z.infer<typeof Info>
|
|
||||||
|
|
||||||
export const InvalidError = NamedError.create(
|
export type Interface = S.Interface
|
||||||
"SkillInvalidError",
|
|
||||||
z.object({
|
|
||||||
path: z.string(),
|
|
||||||
message: z.string().optional(),
|
|
||||||
issues: z.custom<z.core.$ZodIssue[]>().optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const NameMismatchError = NamedError.create(
|
export const Service = S.Service
|
||||||
"SkillNameMismatchError",
|
export const layer = S.layer
|
||||||
z.object({
|
export const defaultLayer = S.defaultLayer
|
||||||
path: z.string(),
|
|
||||||
expected: z.string(),
|
|
||||||
actual: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
type State = {
|
export const fmt = S.fmt
|
||||||
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) {
|
export async function get(name: string) {
|
||||||
return runPromiseInstance(Service.use((skill) => skill.get(name)))
|
return runPromiseInstance(S.Service.use((skill) => skill.get(name)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function all() {
|
export async function all() {
|
||||||
return runPromiseInstance(Service.use((skill) => skill.all()))
|
return runPromiseInstance(S.Service.use((skill) => skill.all()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dirs() {
|
export async function dirs() {
|
||||||
return runPromiseInstance(Service.use((skill) => skill.dirs()))
|
return runPromiseInstance(S.Service.use((skill) => skill.dirs()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function available(agent?: Agent.Info) {
|
export async function available(agent?: Agent.Info) {
|
||||||
return runPromiseInstance(Service.use((skill) => skill.available(agent)))
|
return runPromiseInstance(S.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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,349 +1,44 @@
|
|||||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
|
||||||
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
|
|
||||||
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 { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { Snapshot as S } from "./service"
|
||||||
import { Config } from "../config/config"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
|
|
||||||
export namespace Snapshot {
|
export namespace Snapshot {
|
||||||
export const Patch = z.object({
|
export const Patch = S.Patch
|
||||||
hash: z.string(),
|
export type Patch = S.Patch
|
||||||
files: z.string().array(),
|
|
||||||
})
|
|
||||||
export type Patch = z.infer<typeof Patch>
|
|
||||||
|
|
||||||
export const FileDiff = z
|
export const FileDiff = S.FileDiff
|
||||||
.object({
|
export type FileDiff = S.FileDiff
|
||||||
file: z.string(),
|
|
||||||
before: z.string(),
|
export type Interface = S.Interface
|
||||||
after: z.string(),
|
|
||||||
additions: z.number(),
|
export const Service = S.Service
|
||||||
deletions: z.number(),
|
export const layer = S.layer
|
||||||
status: z.enum(["added", "deleted", "modified"]).optional(),
|
export const defaultLayer = S.defaultLayer
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "FileDiff",
|
|
||||||
})
|
|
||||||
export type FileDiff = z.infer<typeof FileDiff>
|
|
||||||
|
|
||||||
export async function cleanup() {
|
export async function cleanup() {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.cleanup()))
|
return runPromiseInstance(S.Service.use((svc) => svc.cleanup()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function track() {
|
export async function track() {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.track()))
|
return runPromiseInstance(S.Service.use((svc) => svc.track()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function patch(hash: string) {
|
export async function patch(hash: string) {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.patch(hash)))
|
return runPromiseInstance(S.Service.use((svc) => svc.patch(hash)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restore(snapshot: string) {
|
export async function restore(snapshot: string) {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.restore(snapshot)))
|
return runPromiseInstance(S.Service.use((svc) => svc.restore(snapshot)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revert(patches: Patch[]) {
|
export async function revert(patches: Patch[]) {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.revert(patches)))
|
return runPromiseInstance(S.Service.use((svc) => svc.revert(patches)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function diff(hash: string) {
|
export async function diff(hash: string) {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.diff(hash)))
|
return runPromiseInstance(S.Service.use((svc) => svc.diff(hash)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function diffFull(from: string, to: string) {
|
export async function diffFull(from: string, to: string) {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.diffFull(from, to)))
|
return runPromiseInstance(S.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"]
|
|
||||||
const cfg = ["-c", "core.autocrlf=false", ...core]
|
|
||||||
const quote = [...cfg, "-c", "core.quotepath=false"]
|
|
||||||
|
|
||||||
interface GitResult {
|
|
||||||
readonly code: ChildProcessSpawner.ExitCode
|
|
||||||
readonly text: string
|
|
||||||
readonly stderr: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly cleanup: () => Effect.Effect<void>
|
|
||||||
readonly track: () => Effect.Effect<string | undefined>
|
|
||||||
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
|
|
||||||
readonly restore: (snapshot: string) => Effect.Effect<void>
|
|
||||||
readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
|
|
||||||
readonly diff: (hash: string) => Effect.Effect<string>
|
|
||||||
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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 diffFull = Effect.fn("Snapshot.diffFull")(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: 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: 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 Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(NodeChildProcessSpawner.layer),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
|
|
||||||
Layer.provide(NodePath.layer),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
|
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
|
||||||
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
|
import path from "path"
|
||||||
|
import z from "zod"
|
||||||
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
|
import { AppFileSystem } from "@/filesystem"
|
||||||
|
import { Config } from "../config/config"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
|
export namespace Snapshot {
|
||||||
|
export const Patch = z.object({
|
||||||
|
hash: z.string(),
|
||||||
|
files: z.string().array(),
|
||||||
|
})
|
||||||
|
export type Patch = z.infer<typeof Patch>
|
||||||
|
|
||||||
|
export const FileDiff = z
|
||||||
|
.object({
|
||||||
|
file: z.string(),
|
||||||
|
before: z.string(),
|
||||||
|
after: z.string(),
|
||||||
|
additions: z.number(),
|
||||||
|
deletions: z.number(),
|
||||||
|
status: z.enum(["added", "deleted", "modified"]).optional(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "FileDiff",
|
||||||
|
})
|
||||||
|
export type FileDiff = z.infer<typeof FileDiff>
|
||||||
|
|
||||||
|
const log = Log.create({ service: "snapshot" })
|
||||||
|
const prune = "7.days"
|
||||||
|
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
|
||||||
|
const cfg = ["-c", "core.autocrlf=false", ...core]
|
||||||
|
const quote = [...cfg, "-c", "core.quotepath=false"]
|
||||||
|
|
||||||
|
interface GitResult {
|
||||||
|
readonly code: ChildProcessSpawner.ExitCode
|
||||||
|
readonly text: string
|
||||||
|
readonly stderr: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly cleanup: () => Effect.Effect<void>
|
||||||
|
readonly track: () => Effect.Effect<string | undefined>
|
||||||
|
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
|
||||||
|
readonly restore: (snapshot: string) => Effect.Effect<void>
|
||||||
|
readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
|
||||||
|
readonly diff: (hash: string) => Effect.Effect<string>
|
||||||
|
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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 diffFull = Effect.fn("Snapshot.diffFull")(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: 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: 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 Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
|
||||||
|
}),
|
||||||
|
).pipe(Layer.fresh)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(NodeChildProcessSpawner.layer),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
|
||||||
|
Layer.provide(NodePath.layer),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import { Log } from "../util/log"
|
|||||||
import { ToolID } from "./schema"
|
import { ToolID } from "./schema"
|
||||||
import { TRUNCATION_DIR } from "./truncation-dir"
|
import { TRUNCATION_DIR } from "./truncation-dir"
|
||||||
|
|
||||||
export namespace TruncateEffect {
|
export namespace Truncate {
|
||||||
const log = Log.create({ service: "truncation" })
|
const log = Log.create({ service: "truncation" })
|
||||||
const RETENTION = Duration.days(7)
|
const RETENTION = Duration.days(7)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Agent } from "../agent/agent"
|
import type { Agent } from "../agent/agent"
|
||||||
import { runtime } from "@/effect/runtime"
|
import { runtime } from "@/effect/runtime"
|
||||||
import { TruncateEffect as S } from "./truncate-effect"
|
import { Truncate as S } from "./truncate-effect"
|
||||||
|
|
||||||
export namespace Truncate {
|
export namespace Truncate {
|
||||||
export const MAX_LINES = S.MAX_LINES
|
export const MAX_LINES = S.MAX_LINES
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect"
|
|||||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
|
||||||
import { AccountRepo } from "../../src/account/repo"
|
import { AccountRepo } from "../../src/account/repo"
|
||||||
import { AccountEffect } from "../../src/account/effect"
|
import { Account } from "../../src/account/effect"
|
||||||
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
|
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
|
||||||
import { Database } from "../../src/storage/db"
|
import { Database } from "../../src/storage/db"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
@@ -19,7 +19,7 @@ const truncate = Layer.effectDiscard(
|
|||||||
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
||||||
|
|
||||||
const live = (client: HttpClient.HttpClient) =>
|
const live = (client: HttpClient.HttpClient) =>
|
||||||
AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
|
Account.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
|
||||||
|
|
||||||
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
|
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
|
||||||
HttpClientResponse.fromWeb(
|
HttpClientResponse.fromWeb(
|
||||||
@@ -52,7 +52,7 @@ const deviceTokenClient = (body: unknown, status = 400) =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const poll = (body: unknown, status = 400) =>
|
const poll = (body: unknown, status = 400) =>
|
||||||
AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
|
Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
|
||||||
|
|
||||||
it.effect("orgsByAccount groups orgs per account", () =>
|
it.effect("orgsByAccount groups orgs per account", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -97,7 +97,7 @@ it.effect("orgsByAccount groups orgs per account", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const rows = yield* AccountEffect.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
|
const rows = yield* Account.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
|
||||||
|
|
||||||
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
|
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
|
||||||
[AccountID.make("user-1"), [OrgID.make("org-1")]],
|
[AccountID.make("user-1"), [OrgID.make("org-1")]],
|
||||||
@@ -135,7 +135,7 @@ it.effect("token refresh persists the new token", () =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const token = yield* AccountEffect.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
|
const token = yield* Account.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
|
||||||
|
|
||||||
expect(Option.getOrThrow(token)).toBeDefined()
|
expect(Option.getOrThrow(token)).toBeDefined()
|
||||||
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
||||||
@@ -178,9 +178,7 @@ it.effect("config sends the selected org header", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const cfg = yield* AccountEffect.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(
|
const cfg = yield* Account.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
|
||||||
Effect.provide(live(client)),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
|
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
|
||||||
expect(seen).toEqual({
|
expect(seen).toEqual({
|
||||||
@@ -209,7 +207,7 @@ it.effect("poll stores the account and first org on success", () =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const res = yield* AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
|
const res = yield* Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
|
||||||
|
|
||||||
expect(res._tag).toBe("PollSuccess")
|
expect(res._tag).toBe("PollSuccess")
|
||||||
if (res._tag === "PollSuccess") {
|
if (res._tag === "PollSuccess") {
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { runtime, runPromiseInstance } from "../../src/effect/runtime"
|
||||||
|
import { Auth } from "../../src/auth/effect"
|
||||||
|
import { Instances } from "../../src/effect/instances"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { ProviderAuth } from "../../src/provider/auth"
|
||||||
|
import { Vcs } from "../../src/project/vcs"
|
||||||
|
import { Question } from "../../src/question"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests for the Effect runtime and LayerMap-based instance system.
|
||||||
|
*
|
||||||
|
* Each instance service layer has `.pipe(Layer.fresh)` at its definition site
|
||||||
|
* so it is always rebuilt per directory, while shared dependencies are provided
|
||||||
|
* outside the fresh boundary and remain memoizable.
|
||||||
|
*
|
||||||
|
* These tests verify the invariants using object identity (===) on the real
|
||||||
|
* production services — not mock services or return-value checks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const grabInstance = (service: any) => runPromiseInstance(service.use(Effect.succeed))
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const grabGlobal = (service: any) => runtime.runPromise(service.use(Effect.succeed))
|
||||||
|
|
||||||
|
describe("effect/runtime", () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await Instance.disposeAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("global services are shared across directories", async () => {
|
||||||
|
await using one = await tmpdir({ git: true })
|
||||||
|
await using two = await tmpdir({ git: true })
|
||||||
|
|
||||||
|
// Auth is a global service — it should be the exact same object
|
||||||
|
// regardless of which directory we're in.
|
||||||
|
const authOne = await Instance.provide({
|
||||||
|
directory: one.path,
|
||||||
|
fn: () => grabGlobal(Auth.Service),
|
||||||
|
})
|
||||||
|
|
||||||
|
const authTwo = await Instance.provide({
|
||||||
|
directory: two.path,
|
||||||
|
fn: () => grabGlobal(Auth.Service),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(authOne).toBe(authTwo)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("instance services with global deps share the global (ProviderAuth → Auth)", async () => {
|
||||||
|
await using one = await tmpdir({ git: true })
|
||||||
|
await using two = await tmpdir({ git: true })
|
||||||
|
|
||||||
|
// ProviderAuth depends on Auth via defaultLayer.
|
||||||
|
// The instance service itself should be different per directory,
|
||||||
|
// but the underlying Auth should be shared.
|
||||||
|
const paOne = await Instance.provide({
|
||||||
|
directory: one.path,
|
||||||
|
fn: () => grabInstance(ProviderAuth.Service),
|
||||||
|
})
|
||||||
|
|
||||||
|
const paTwo = await Instance.provide({
|
||||||
|
directory: two.path,
|
||||||
|
fn: () => grabInstance(ProviderAuth.Service),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Different directories → different ProviderAuth instances.
|
||||||
|
expect(paOne).not.toBe(paTwo)
|
||||||
|
|
||||||
|
// But the global Auth is the same object in both.
|
||||||
|
const authOne = await Instance.provide({
|
||||||
|
directory: one.path,
|
||||||
|
fn: () => grabGlobal(Auth.Service),
|
||||||
|
})
|
||||||
|
const authTwo = await Instance.provide({
|
||||||
|
directory: two.path,
|
||||||
|
fn: () => grabGlobal(Auth.Service),
|
||||||
|
})
|
||||||
|
expect(authOne).toBe(authTwo)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("instance services are shared within the same directory", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
expect(await grabInstance(Vcs.Service)).toBe(await grabInstance(Vcs.Service))
|
||||||
|
expect(await grabInstance(Question.Service)).toBe(await grabInstance(Question.Service))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("different directories get different service instances", async () => {
|
||||||
|
await using one = await tmpdir({ git: true })
|
||||||
|
await using two = await tmpdir({ git: true })
|
||||||
|
|
||||||
|
const vcsOne = await Instance.provide({
|
||||||
|
directory: one.path,
|
||||||
|
fn: () => grabInstance(Vcs.Service),
|
||||||
|
})
|
||||||
|
|
||||||
|
const vcsTwo = await Instance.provide({
|
||||||
|
directory: two.path,
|
||||||
|
fn: () => grabInstance(Vcs.Service),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(vcsOne).not.toBe(vcsTwo)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("disposal rebuilds services with a new instance", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const before = await grabInstance(Question.Service)
|
||||||
|
|
||||||
|
await runtime.runPromise(Instances.use((map) => map.invalidate(Instance.directory)))
|
||||||
|
|
||||||
|
const after = await grabInstance(Question.Service)
|
||||||
|
expect(after).not.toBe(before)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -16,7 +16,7 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
|
|||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
|
type BusUpdate = { directory?: string; payload: { type: string; properties: Record<string, unknown> } }
|
||||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||||
|
|
||||||
/** Run `body` with a live FileWatcher service. */
|
/** Run `body` with a live FileWatcher service. */
|
||||||
@@ -40,18 +40,18 @@ function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (
|
|||||||
if (done) return
|
if (done) return
|
||||||
if (evt.directory !== directory) return
|
if (evt.directory !== directory) return
|
||||||
if (evt.payload.type !== FileWatcher.Event.Updated.type) return
|
if (evt.payload.type !== FileWatcher.Event.Updated.type) return
|
||||||
if (!check(evt.payload.properties)) return
|
const props = evt.payload.properties as WatcherEvent
|
||||||
hit(evt.payload.properties)
|
if (!check(props)) return
|
||||||
|
hit(props)
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanup() {
|
GlobalBus.on("event", on)
|
||||||
|
|
||||||
|
return () => {
|
||||||
if (done) return
|
if (done) return
|
||||||
done = true
|
done = true
|
||||||
GlobalBus.off("event", on)
|
GlobalBus.off("event", on)
|
||||||
}
|
}
|
||||||
|
|
||||||
GlobalBus.on("event", on)
|
|
||||||
return cleanup
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
|
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function withServices<S>(
|
|||||||
project: Instance.project,
|
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) {
|
if (options?.provide) {
|
||||||
for (const l of options.provide) {
|
for (const l of options.provide) {
|
||||||
resolved = resolved.pipe(Layer.provide(l)) as any
|
resolved = resolved.pipe(Layer.provide(l)) as any
|
||||||
|
|||||||
@@ -1,47 +1,151 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Effect, Layer, Stream } from "effect"
|
||||||
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
import { Installation } from "../../src/installation"
|
import { Installation } from "../../src/installation"
|
||||||
|
|
||||||
const fetch0 = globalThis.fetch
|
const encoder = new TextEncoder()
|
||||||
|
|
||||||
afterEach(() => {
|
function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest) => Response) {
|
||||||
globalThis.fetch = fetch0
|
const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request))))
|
||||||
})
|
return Layer.succeed(HttpClient.HttpClient, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") {
|
||||||
|
const spawner = ChildProcessSpawner.make((command) => {
|
||||||
|
const std = ChildProcess.isStandardCommand(command) ? command : undefined
|
||||||
|
const output = handler(std?.command ?? "", std?.args ?? [])
|
||||||
|
return Effect.succeed(
|
||||||
|
ChildProcessSpawner.makeHandle({
|
||||||
|
pid: ChildProcessSpawner.ProcessId(0),
|
||||||
|
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
|
||||||
|
isRunning: Effect.succeed(false),
|
||||||
|
kill: () => Effect.void,
|
||||||
|
stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
|
||||||
|
stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty,
|
||||||
|
stderr: Stream.empty,
|
||||||
|
all: Stream.empty,
|
||||||
|
getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
|
||||||
|
getOutputFd: () => Stream.empty,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown) {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function testLayer(
|
||||||
|
httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response,
|
||||||
|
spawnHandler?: (cmd: string, args: readonly string[]) => string,
|
||||||
|
) {
|
||||||
|
return Installation.layer.pipe(Layer.provide(mockHttpClient(httpHandler)), Layer.provide(mockSpawner(spawnHandler)))
|
||||||
|
}
|
||||||
|
|
||||||
describe("installation", () => {
|
describe("installation", () => {
|
||||||
test("reads release version from GitHub releases", async () => {
|
describe("latest", () => {
|
||||||
globalThis.fetch = (async () =>
|
test("reads release version from GitHub releases", async () => {
|
||||||
new Response(JSON.stringify({ tag_name: "v1.2.3" }), {
|
const layer = testLayer(() => jsonResponse({ tag_name: "v1.2.3" }))
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
})) as unknown as typeof fetch
|
|
||||||
|
|
||||||
expect(await Installation.latest("unknown")).toBe("1.2.3")
|
const result = await Effect.runPromise(
|
||||||
})
|
Installation.Service.use((svc) => svc.latest("unknown")).pipe(Effect.provide(layer)),
|
||||||
|
)
|
||||||
|
expect(result).toBe("1.2.3")
|
||||||
|
})
|
||||||
|
|
||||||
test("reads scoop manifest versions", async () => {
|
test("strips v prefix from GitHub release tag", async () => {
|
||||||
globalThis.fetch = (async () =>
|
const layer = testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" }))
|
||||||
new Response(JSON.stringify({ version: "2.3.4" }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
})) as unknown as typeof fetch
|
|
||||||
|
|
||||||
expect(await Installation.latest("scoop")).toBe("2.3.4")
|
const result = await Effect.runPromise(
|
||||||
})
|
Installation.Service.use((svc) => svc.latest("curl")).pipe(Effect.provide(layer)),
|
||||||
|
)
|
||||||
|
expect(result).toBe("4.0.0-beta.1")
|
||||||
|
})
|
||||||
|
|
||||||
test("reads chocolatey feed versions", async () => {
|
test("reads npm registry versions", async () => {
|
||||||
globalThis.fetch = (async () =>
|
const layer = testLayer(
|
||||||
new Response(
|
() => jsonResponse({ version: "1.5.0" }),
|
||||||
JSON.stringify({
|
(cmd, args) => {
|
||||||
d: {
|
if (cmd === "npm" && args.includes("registry")) return "https://registry.npmjs.org\n"
|
||||||
results: [{ Version: "3.4.5" }],
|
return ""
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
},
|
},
|
||||||
)) as unknown as typeof fetch
|
)
|
||||||
|
|
||||||
expect(await Installation.latest("choco")).toBe("3.4.5")
|
const result = await Effect.runPromise(
|
||||||
|
Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)),
|
||||||
|
)
|
||||||
|
expect(result).toBe("1.5.0")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reads npm registry versions for bun method", async () => {
|
||||||
|
const layer = testLayer(
|
||||||
|
() => jsonResponse({ version: "1.6.0" }),
|
||||||
|
() => "",
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)),
|
||||||
|
)
|
||||||
|
expect(result).toBe("1.6.0")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reads scoop manifest versions", async () => {
|
||||||
|
const layer = testLayer(() => jsonResponse({ version: "2.3.4" }))
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Installation.Service.use((svc) => svc.latest("scoop")).pipe(Effect.provide(layer)),
|
||||||
|
)
|
||||||
|
expect(result).toBe("2.3.4")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reads chocolatey feed versions", async () => {
|
||||||
|
const layer = testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } }))
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Installation.Service.use((svc) => svc.latest("choco")).pipe(Effect.provide(layer)),
|
||||||
|
)
|
||||||
|
expect(result).toBe("3.4.5")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reads brew formulae API versions", async () => {
|
||||||
|
const layer = testLayer(
|
||||||
|
() => jsonResponse({ versions: { stable: "2.0.0" } }),
|
||||||
|
(cmd, args) => {
|
||||||
|
// getBrewFormula: return core formula (no tap)
|
||||||
|
if (cmd === "brew" && args.includes("--formula") && args.includes("anomalyco/tap/opencode")) return ""
|
||||||
|
if (cmd === "brew" && args.includes("--formula") && args.includes("opencode")) return "opencode"
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)),
|
||||||
|
)
|
||||||
|
expect(result).toBe("2.0.0")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reads brew tap info JSON via CLI", async () => {
|
||||||
|
const brewInfoJson = JSON.stringify({
|
||||||
|
formulae: [{ versions: { stable: "2.1.0" } }],
|
||||||
|
})
|
||||||
|
const layer = testLayer(
|
||||||
|
() => jsonResponse({}), // HTTP not used for tap formula
|
||||||
|
(cmd, args) => {
|
||||||
|
if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula")) return "opencode"
|
||||||
|
if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)),
|
||||||
|
)
|
||||||
|
expect(result).toBe("2.1.0")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { test, expect } from "bun:test"
|
import { test, expect, describe } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
import { ProviderID } from "../../src/provider/schema"
|
import { ProviderID, ModelID } from "../../src/provider/schema"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Provider } from "../../src/provider/provider"
|
import { Provider } from "../../src/provider/provider"
|
||||||
import { Env } from "../../src/env"
|
import { Env } from "../../src/env"
|
||||||
import { Global } from "../../src/global"
|
import { Global } from "../../src/global"
|
||||||
|
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
|
||||||
|
|
||||||
test("GitLab Duo: loads provider with API key from environment", async () => {
|
test("GitLab Duo: loads provider with API key from environment", async () => {
|
||||||
await using tmp = await tmpdir({
|
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")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -32933,7 +32933,7 @@
|
|||||||
"gitlab": {
|
"gitlab": {
|
||||||
"id": "gitlab",
|
"id": "gitlab",
|
||||||
"env": ["GITLAB_TOKEN"],
|
"env": ["GITLAB_TOKEN"],
|
||||||
"npm": "@gitlab/gitlab-ai-provider",
|
"npm": "gitlab-ai-provider",
|
||||||
"name": "GitLab Duo",
|
"name": "GitLab Duo",
|
||||||
"doc": "https://docs.gitlab.com/user/duo_agent_platform/",
|
"doc": "https://docs.gitlab.com/user/duo_agent_platform/",
|
||||||
"models": {
|
"models": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, test, expect } from "bun:test"
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { Effect, FileSystem, Layer } from "effect"
|
import { Effect, FileSystem, Layer } from "effect"
|
||||||
import { Truncate } from "../../src/tool/truncate"
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { TruncateEffect } from "../../src/tool/truncate-effect"
|
import { Truncate as TruncateSvc } from "../../src/tool/truncate-effect"
|
||||||
import { Identifier } from "../../src/id/id"
|
import { Identifier } from "../../src/id/id"
|
||||||
import { Process } from "../../src/util/process"
|
import { Process } from "../../src/util/process"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
@@ -139,7 +139,7 @@ describe("Truncate", () => {
|
|||||||
|
|
||||||
describe("cleanup", () => {
|
describe("cleanup", () => {
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
const it = testEffect(Layer.mergeAll(TruncateEffect.defaultLayer, NodeFileSystem.layer))
|
const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer))
|
||||||
|
|
||||||
it.effect("deletes files older than 7 days and preserves recent files", () =>
|
it.effect("deletes files older than 7 days and preserves recent files", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -152,7 +152,7 @@ describe("Truncate", () => {
|
|||||||
|
|
||||||
yield* writeFileStringScoped(old, "old content")
|
yield* writeFileStringScoped(old, "old content")
|
||||||
yield* writeFileStringScoped(recent, "recent content")
|
yield* writeFileStringScoped(recent, "recent content")
|
||||||
yield* TruncateEffect.Service.use((s) => s.cleanup())
|
yield* TruncateSvc.Service.use((s) => s.cleanup())
|
||||||
|
|
||||||
expect(yield* fs.exists(old)).toBe(false)
|
expect(yield* fs.exists(old)).toBe(false)
|
||||||
expect(yield* fs.exists(recent)).toBe(true)
|
expect(yield* fs.exists(recent)).toBe(true)
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import {
|
|||||||
createMemo,
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
For,
|
For,
|
||||||
Index,
|
|
||||||
Match,
|
Match,
|
||||||
onMount,
|
onMount,
|
||||||
Show,
|
Show,
|
||||||
Switch,
|
Switch,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
|
Index,
|
||||||
type JSX,
|
type JSX,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { createStore, unwrap } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import stripAnsi from "strip-ansi"
|
import stripAnsi from "strip-ansi"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import {
|
import {
|
||||||
@@ -481,15 +481,6 @@ function partDefaultOpen(part: PartType, shell = false, edit = false) {
|
|||||||
return toolDefaultOpen(part.tool, shell, edit)
|
return toolDefaultOpen(part.tool, shell, edit)
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindMessage<T extends MessageType>(input: T) {
|
|
||||||
const data = useData()
|
|
||||||
const base = structuredClone(unwrap(input)) as T
|
|
||||||
return createMemo(() => {
|
|
||||||
const next = data.store.message?.[base.sessionID]?.find((item) => item.id === base.id)
|
|
||||||
return (next as T | undefined) ?? base
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AssistantParts(props: {
|
export function AssistantParts(props: {
|
||||||
messages: AssistantMessage[]
|
messages: AssistantMessage[]
|
||||||
showAssistantCopyPartID?: string | null
|
showAssistantCopyPartID?: string | null
|
||||||
@@ -530,55 +521,62 @@ export function AssistantParts(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Index each={grouped()}>
|
<Index each={grouped()}>
|
||||||
{(entry) => {
|
{(entryAccessor) => {
|
||||||
const kind = createMemo(() => entry().type)
|
const entryType = createMemo(() => entryAccessor().type)
|
||||||
const parts = createMemo(
|
|
||||||
() => {
|
|
||||||
const value = entry()
|
|
||||||
if (value.type !== "context") return emptyTools
|
|
||||||
return value.refs
|
|
||||||
.map((ref) => part().get(ref.messageID)?.get(ref.partID))
|
|
||||||
.filter((part): part is ToolPart => !!part && isContextGroupTool(part))
|
|
||||||
},
|
|
||||||
emptyTools,
|
|
||||||
{ equals: same },
|
|
||||||
)
|
|
||||||
const busy = createMemo(() => props.working && last() === entry().key)
|
|
||||||
const message = createMemo(() => {
|
|
||||||
const value = entry()
|
|
||||||
if (value.type !== "part") return
|
|
||||||
return msgs().get(value.ref.messageID)
|
|
||||||
})
|
|
||||||
const item = createMemo(() => {
|
|
||||||
const value = entry()
|
|
||||||
if (value.type !== "part") return
|
|
||||||
return part().get(value.ref.messageID)?.get(value.ref.partID)
|
|
||||||
})
|
|
||||||
const ready = createMemo(() => {
|
|
||||||
if (kind() !== "part") return
|
|
||||||
const msg = message()
|
|
||||||
const value = item()
|
|
||||||
if (!msg || !value) return
|
|
||||||
return { msg, value }
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Switch>
|
||||||
<Show when={kind() === "context" && parts().length > 0}>
|
<Match when={entryType() === "context"}>
|
||||||
<ContextToolGroup parts={parts()} busy={busy()} />
|
{(() => {
|
||||||
</Show>
|
const parts = createMemo(
|
||||||
<Show when={ready()}>
|
() => {
|
||||||
{(ready) => (
|
const entry = entryAccessor()
|
||||||
<Part
|
if (entry.type !== "context") return emptyTools
|
||||||
part={ready().value}
|
return entry.refs
|
||||||
message={ready().msg}
|
.map((ref) => part().get(ref.messageID)?.get(ref.partID))
|
||||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
.filter((part): part is ToolPart => !!part && isContextGroupTool(part))
|
||||||
turnDurationMs={props.turnDurationMs}
|
},
|
||||||
defaultOpen={partDefaultOpen(ready().value, props.shellToolDefaultOpen, props.editToolDefaultOpen)}
|
emptyTools,
|
||||||
/>
|
{ equals: same },
|
||||||
)}
|
)
|
||||||
</Show>
|
const busy = createMemo(() => props.working && last() === entryAccessor().key)
|
||||||
</>
|
|
||||||
|
return (
|
||||||
|
<Show when={parts().length > 0}>
|
||||||
|
<ContextToolGroup parts={parts()} busy={busy()} />
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</Match>
|
||||||
|
<Match when={entryType() === "part"}>
|
||||||
|
{(() => {
|
||||||
|
const message = createMemo(() => {
|
||||||
|
const entry = entryAccessor()
|
||||||
|
if (entry.type !== "part") return
|
||||||
|
return msgs().get(entry.ref.messageID)
|
||||||
|
})
|
||||||
|
const item = createMemo(() => {
|
||||||
|
const entry = entryAccessor()
|
||||||
|
if (entry.type !== "part") return
|
||||||
|
return part().get(entry.ref.messageID)?.get(entry.ref.partID)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={message()}>
|
||||||
|
<Show when={item()}>
|
||||||
|
<Part
|
||||||
|
part={item()!}
|
||||||
|
message={message()!}
|
||||||
|
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||||
|
turnDurationMs={props.turnDurationMs}
|
||||||
|
defaultOpen={partDefaultOpen(item()!, props.shellToolDefaultOpen, props.editToolDefaultOpen)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Index>
|
</Index>
|
||||||
@@ -690,22 +688,25 @@ export function registerPartComponent(type: string, component: PartComponent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Message(props: MessageProps) {
|
export function Message(props: MessageProps) {
|
||||||
if (props.message.role === "user") {
|
return (
|
||||||
return <UserMessageDisplay message={props.message as UserMessage} parts={props.parts} actions={props.actions} />
|
<Switch>
|
||||||
}
|
<Match when={props.message.role === "user" && props.message}>
|
||||||
|
{(userMessage) => (
|
||||||
if (props.message.role === "assistant") {
|
<UserMessageDisplay message={userMessage() as UserMessage} parts={props.parts} actions={props.actions} />
|
||||||
return (
|
)}
|
||||||
<AssistantMessageDisplay
|
</Match>
|
||||||
message={props.message as AssistantMessage}
|
<Match when={props.message.role === "assistant" && props.message}>
|
||||||
parts={props.parts}
|
{(assistantMessage) => (
|
||||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
<AssistantMessageDisplay
|
||||||
showReasoningSummaries={props.showReasoningSummaries}
|
message={assistantMessage() as AssistantMessage}
|
||||||
/>
|
parts={props.parts}
|
||||||
)
|
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||||
}
|
showReasoningSummaries={props.showReasoningSummaries}
|
||||||
|
/>
|
||||||
return undefined
|
)}
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssistantMessageDisplay(props: {
|
export function AssistantMessageDisplay(props: {
|
||||||
@@ -732,42 +733,52 @@ export function AssistantMessageDisplay(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Index each={grouped()}>
|
<Index each={grouped()}>
|
||||||
{(entry) => {
|
{(entryAccessor) => {
|
||||||
const kind = createMemo(() => entry().type)
|
const entryType = createMemo(() => entryAccessor().type)
|
||||||
const parts = createMemo(
|
|
||||||
() => {
|
|
||||||
const value = entry()
|
|
||||||
if (value.type !== "context") return emptyTools
|
|
||||||
return value.refs
|
|
||||||
.map((ref) => part().get(ref.partID))
|
|
||||||
.filter((part): part is ToolPart => !!part && isContextGroupTool(part))
|
|
||||||
},
|
|
||||||
emptyTools,
|
|
||||||
{ equals: same },
|
|
||||||
)
|
|
||||||
const item = createMemo(() => {
|
|
||||||
const value = entry()
|
|
||||||
if (value.type !== "part") return
|
|
||||||
return part().get(value.ref.partID)
|
|
||||||
})
|
|
||||||
const ready = createMemo(() => {
|
|
||||||
if (kind() !== "part") return
|
|
||||||
const value = item()
|
|
||||||
if (!value) return
|
|
||||||
return value
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Switch>
|
||||||
<Show when={kind() === "context" && parts().length > 0}>
|
<Match when={entryType() === "context"}>
|
||||||
<ContextToolGroup parts={parts()} />
|
{(() => {
|
||||||
</Show>
|
const parts = createMemo(
|
||||||
<Show when={ready()}>
|
() => {
|
||||||
{(ready) => (
|
const entry = entryAccessor()
|
||||||
<Part part={ready()} message={props.message} showAssistantCopyPartID={props.showAssistantCopyPartID} />
|
if (entry.type !== "context") return emptyTools
|
||||||
)}
|
return entry.refs
|
||||||
</Show>
|
.map((ref) => part().get(ref.partID))
|
||||||
</>
|
.filter((part): part is ToolPart => !!part && isContextGroupTool(part))
|
||||||
|
},
|
||||||
|
emptyTools,
|
||||||
|
{ equals: same },
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={parts().length > 0}>
|
||||||
|
<ContextToolGroup parts={parts()} />
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</Match>
|
||||||
|
<Match when={entryType() === "part"}>
|
||||||
|
{(() => {
|
||||||
|
const item = createMemo(() => {
|
||||||
|
const entry = entryAccessor()
|
||||||
|
if (entry.type !== "part") return
|
||||||
|
return part().get(entry.ref.partID)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={item()}>
|
||||||
|
<Part
|
||||||
|
part={item()!}
|
||||||
|
message={props.message}
|
||||||
|
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Index>
|
</Index>
|
||||||
@@ -834,9 +845,11 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
|
|||||||
<Collapsible.Content>
|
<Collapsible.Content>
|
||||||
<div data-component="context-tool-group-list">
|
<div data-component="context-tool-group-list">
|
||||||
<Index each={props.parts}>
|
<Index each={props.parts}>
|
||||||
{(part) => {
|
{(partAccessor) => {
|
||||||
const trigger = createMemo(() => contextToolTrigger(part(), i18n))
|
const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n))
|
||||||
const running = createMemo(() => part().state.status === "pending" || part().state.status === "running")
|
const running = createMemo(
|
||||||
|
() => partAccessor().state.status === "pending" || partAccessor().state.status === "running",
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<div data-slot="context-tool-group-item">
|
<div data-slot="context-tool-group-item">
|
||||||
<div data-component="tool-trigger">
|
<div data-component="tool-trigger">
|
||||||
@@ -874,7 +887,6 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
|||||||
const data = useData()
|
const data = useData()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const message = bindMessage(props.message)
|
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
copied: false,
|
copied: false,
|
||||||
busy: undefined as "fork" | "revert" | undefined,
|
busy: undefined as "fork" | "revert" | undefined,
|
||||||
@@ -897,8 +909,8 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
|||||||
const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? [])
|
const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? [])
|
||||||
|
|
||||||
const model = createMemo(() => {
|
const model = createMemo(() => {
|
||||||
const providerID = message().model?.providerID
|
const providerID = props.message.model?.providerID
|
||||||
const modelID = message().model?.modelID
|
const modelID = props.message.model?.modelID
|
||||||
if (!providerID || !modelID) return ""
|
if (!providerID || !modelID) return ""
|
||||||
const match = data.store.provider?.all?.find((p) => p.id === providerID)
|
const match = data.store.provider?.all?.find((p) => p.id === providerID)
|
||||||
return match?.models?.[modelID]?.name ?? modelID
|
return match?.models?.[modelID]?.name ?? modelID
|
||||||
@@ -906,13 +918,13 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
|||||||
const timefmt = createMemo(() => new Intl.DateTimeFormat(i18n.locale(), { timeStyle: "short" }))
|
const timefmt = createMemo(() => new Intl.DateTimeFormat(i18n.locale(), { timeStyle: "short" }))
|
||||||
|
|
||||||
const stamp = createMemo(() => {
|
const stamp = createMemo(() => {
|
||||||
const created = message().time?.created
|
const created = props.message.time?.created
|
||||||
if (typeof created !== "number") return ""
|
if (typeof created !== "number") return ""
|
||||||
return timefmt().format(created)
|
return timefmt().format(created)
|
||||||
})
|
})
|
||||||
|
|
||||||
const metaHead = createMemo(() => {
|
const metaHead = createMemo(() => {
|
||||||
const agent = message().agent
|
const agent = props.message.agent
|
||||||
const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()]
|
const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()]
|
||||||
return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0")
|
return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0")
|
||||||
})
|
})
|
||||||
@@ -938,8 +950,8 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
|||||||
void Promise.resolve()
|
void Promise.resolve()
|
||||||
.then(() =>
|
.then(() =>
|
||||||
act({
|
act({
|
||||||
sessionID: message().sessionID,
|
sessionID: props.message.sessionID,
|
||||||
messageID: message().id,
|
messageID: props.message.id,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -1298,27 +1310,27 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
|||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale()))
|
const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale()))
|
||||||
const part = () => props.part as TextPart
|
const part = () => props.part as TextPart
|
||||||
const message = bindMessage(props.message)
|
|
||||||
const interrupted = createMemo(
|
const interrupted = createMemo(
|
||||||
() => message().role === "assistant" && (message() as AssistantMessage).error?.name === "MessageAbortedError",
|
() =>
|
||||||
|
props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError",
|
||||||
)
|
)
|
||||||
|
|
||||||
const model = createMemo(() => {
|
const model = createMemo(() => {
|
||||||
const current = message()
|
if (props.message.role !== "assistant") return ""
|
||||||
if (current.role !== "assistant") return ""
|
const message = props.message as AssistantMessage
|
||||||
const match = data.store.provider?.all?.find((p) => p.id === current.providerID)
|
const match = data.store.provider?.all?.find((p) => p.id === message.providerID)
|
||||||
return match?.models?.[current.modelID]?.name ?? current.modelID
|
return match?.models?.[message.modelID]?.name ?? message.modelID
|
||||||
})
|
})
|
||||||
|
|
||||||
const duration = createMemo(() => {
|
const duration = createMemo(() => {
|
||||||
const current = message()
|
if (props.message.role !== "assistant") return ""
|
||||||
if (current.role !== "assistant") return ""
|
const message = props.message as AssistantMessage
|
||||||
const completed = current.time.completed
|
const completed = message.time.completed
|
||||||
const ms =
|
const ms =
|
||||||
typeof props.turnDurationMs === "number"
|
typeof props.turnDurationMs === "number"
|
||||||
? props.turnDurationMs
|
? props.turnDurationMs
|
||||||
: typeof completed === "number"
|
: typeof completed === "number"
|
||||||
? completed - current.time.created
|
? completed - message.time.created
|
||||||
: -1
|
: -1
|
||||||
if (!(ms >= 0)) return ""
|
if (!(ms >= 0)) return ""
|
||||||
const total = Math.round(ms / 1000)
|
const total = Math.round(ms / 1000)
|
||||||
@@ -1332,9 +1344,8 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const meta = createMemo(() => {
|
const meta = createMemo(() => {
|
||||||
const current = message()
|
if (props.message.role !== "assistant") return ""
|
||||||
if (current.role !== "assistant") return ""
|
const agent = (props.message as AssistantMessage).agent
|
||||||
const agent = current.agent
|
|
||||||
const items = [
|
const items = [
|
||||||
agent ? agent[0]?.toUpperCase() + agent.slice(1) : "",
|
agent ? agent[0]?.toUpperCase() + agent.slice(1) : "",
|
||||||
model(),
|
model(),
|
||||||
@@ -1347,13 +1358,13 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
|||||||
const displayText = () => (part().text ?? "").trim()
|
const displayText = () => (part().text ?? "").trim()
|
||||||
const throttledText = createThrottledValue(displayText)
|
const throttledText = createThrottledValue(displayText)
|
||||||
const isLastTextPart = createMemo(() => {
|
const isLastTextPart = createMemo(() => {
|
||||||
const last = (data.store.part?.[message().id] ?? [])
|
const last = (data.store.part?.[props.message.id] ?? [])
|
||||||
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
|
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
|
||||||
.at(-1)
|
.at(-1)
|
||||||
return last?.id === part().id
|
return last?.id === part().id
|
||||||
})
|
})
|
||||||
const showCopy = createMemo(() => {
|
const showCopy = createMemo(() => {
|
||||||
if (message().role !== "assistant") return isLastTextPart()
|
if (props.message.role !== "assistant") return isLastTextPart()
|
||||||
if (props.showAssistantCopyPartID === null) return false
|
if (props.showAssistantCopyPartID === null) return false
|
||||||
if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id
|
if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id
|
||||||
return isLastTextPart()
|
return isLastTextPart()
|
||||||
|
|||||||
@@ -752,7 +752,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
:::note
|
:::note
|
||||||
يجب على مدير GitLab لديك تفعيل ما يلي:
|
يجب على مدير GitLab لديك تفعيل ما يلي:
|
||||||
|
|
||||||
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) للمستخدم أو المجموعة أو المثيل
|
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) للمستخدم أو المجموعة أو المثيل
|
||||||
2. Feature flags (عبر Rails console):
|
2. Feature flags (عبر Rails console):
|
||||||
- `agent_platform_claude_code`
|
- `agent_platform_claude_code`
|
||||||
- `third_party_agents_enabled`
|
- `third_party_agents_enabled`
|
||||||
@@ -774,7 +774,7 @@ callback URL `http://127.0.0.1:8080/callback` ونطاقات الصلاحيا
|
|||||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||||
```
|
```
|
||||||
|
|
||||||
مزيد من التوثيق على صفحة [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
|
مزيد من التوثيق على صفحة [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
|
||||||
|
|
||||||
##### التهيئة
|
##### التهيئة
|
||||||
|
|
||||||
@@ -786,11 +786,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
|||||||
"provider": {
|
"provider": {
|
||||||
"gitlab": {
|
"gitlab": {
|
||||||
"options": {
|
"options": {
|
||||||
"instanceUrl": "https://gitlab.com",
|
"instanceUrl": "https://gitlab.com"
|
||||||
"featureFlags": {
|
|
||||||
"duo_agent_platform_agentic_chat": true,
|
|
||||||
"duo_agent_platform": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -804,7 +800,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
|||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["@gitlab/opencode-gitlab-plugin"]
|
"plugin": ["opencode-gitlab-plugin"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -760,7 +760,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
:::note
|
:::note
|
||||||
Vaš GitLab administrator mora omogućiti sljedeće:
|
Vaš GitLab administrator mora omogućiti sljedeće:
|
||||||
|
|
||||||
1. [Duo Agent Platforma](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) za korisnika, grupu ili instancu
|
1. [Duo Agent Platforma](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) za korisnika, grupu ili instancu
|
||||||
2. Zastavice funkcija (preko Rails konzole):
|
2. Zastavice funkcija (preko Rails konzole):
|
||||||
- `agent_platform_claude_code`
|
- `agent_platform_claude_code`
|
||||||
- `third_party_agents_enabled`
|
- `third_party_agents_enabled`
|
||||||
@@ -782,7 +782,7 @@ Zatim izložite ID aplikacije kao varijablu okruženja:
|
|||||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||||
```
|
```
|
||||||
|
|
||||||
Više dokumentacije na početnoj stranici [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
|
Više dokumentacije na početnoj stranici [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
|
||||||
|
|
||||||
##### Konfiguracija
|
##### Konfiguracija
|
||||||
|
|
||||||
@@ -794,11 +794,7 @@ Prilagodite putem `opencode.json`:
|
|||||||
"provider": {
|
"provider": {
|
||||||
"gitlab": {
|
"gitlab": {
|
||||||
"options": {
|
"options": {
|
||||||
"instanceUrl": "https://gitlab.com",
|
"instanceUrl": "https://gitlab.com"
|
||||||
"featureFlags": {
|
|
||||||
"duo_agent_platform_agentic_chat": true,
|
|
||||||
"duo_agent_platform": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -812,7 +808,7 @@ Za pristup GitLab alatima (zahtjevi za spajanje, problemi, cjevovodi, CI/CD, itd
|
|||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["@gitlab/opencode-gitlab-plugin"]
|
"plugin": ["opencode-gitlab-plugin"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -749,7 +749,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
:::note
|
:::note
|
||||||
Din GitLab-administrator skal aktivere følgende:
|
Din GitLab-administrator skal aktivere følgende:
|
||||||
|
|
||||||
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) for brugeren, gruppen eller instansen
|
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) for brugeren, gruppen eller instansen
|
||||||
2. Funktionsflag (via Rails-konsollen):
|
2. Funktionsflag (via Rails-konsollen):
|
||||||
- `agent_platform_claude_code`
|
- `agent_platform_claude_code`
|
||||||
- `third_party_agents_enabled`
|
- `third_party_agents_enabled`
|
||||||
@@ -771,7 +771,7 @@ Udsæt derefter applikations-ID som miljøvariabel:
|
|||||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||||
```
|
```
|
||||||
|
|
||||||
Mere dokumentation på [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) hjemmesiden.
|
Mere dokumentation på [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) hjemmesiden.
|
||||||
|
|
||||||
##### Konfiguration
|
##### Konfiguration
|
||||||
|
|
||||||
@@ -783,11 +783,7 @@ Tilpas gennem `opencode.json`:
|
|||||||
"provider": {
|
"provider": {
|
||||||
"gitlab": {
|
"gitlab": {
|
||||||
"options": {
|
"options": {
|
||||||
"instanceUrl": "https://gitlab.com",
|
"instanceUrl": "https://gitlab.com"
|
||||||
"featureFlags": {
|
|
||||||
"duo_agent_platform_agentic_chat": true,
|
|
||||||
"duo_agent_platform": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -801,7 +797,7 @@ For at få adgang til GitLab-værktøjer (merge requests, problemer, pipelines,
|
|||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["@gitlab/opencode-gitlab-plugin"]
|
"plugin": ["opencode-gitlab-plugin"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -755,7 +755,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
:::note
|
:::note
|
||||||
Ihr GitLab-Administrator muss Folgendes aktivieren:
|
Ihr GitLab-Administrator muss Folgendes aktivieren:
|
||||||
|
|
||||||
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) für den Benutzer, die Gruppe oder die Instanz
|
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) für den Benutzer, die Gruppe oder die Instanz
|
||||||
2. Feature-Flags (über die Rails-Konsole):
|
2. Feature-Flags (über die Rails-Konsole):
|
||||||
- `agent_platform_claude_code`
|
- `agent_platform_claude_code`
|
||||||
- `third_party_agents_enabled`
|
- `third_party_agents_enabled`
|
||||||
@@ -777,7 +777,7 @@ Stellen Sie dann die Anwendung ID als Umgebungsvariable bereit:
|
|||||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||||
```
|
```
|
||||||
|
|
||||||
Weitere Dokumentation auf der [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth)-Homepage.
|
Weitere Dokumentation auf der [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth)-Homepage.
|
||||||
|
|
||||||
##### Konfiguration
|
##### Konfiguration
|
||||||
|
|
||||||
@@ -789,11 +789,7 @@ Anpassen über `opencode.json`:
|
|||||||
"provider": {
|
"provider": {
|
||||||
"gitlab": {
|
"gitlab": {
|
||||||
"options": {
|
"options": {
|
||||||
"instanceUrl": "https://gitlab.com",
|
"instanceUrl": "https://gitlab.com"
|
||||||
"featureFlags": {
|
|
||||||
"duo_agent_platform_agentic_chat": true,
|
|
||||||
"duo_agent_platform": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -807,7 +803,7 @@ So greifen Sie auf GitLab-Tools zu (Zusammenführungsanfragen, Probleme, Pipelin
|
|||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["@gitlab/opencode-gitlab-plugin"]
|
"plugin": ["opencode-gitlab-plugin"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -757,7 +757,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
:::note
|
:::note
|
||||||
Su administrador GitLab debe habilitar lo siguiente:
|
Su administrador GitLab debe habilitar lo siguiente:
|
||||||
|
|
||||||
1. [Plataforma de agente Duo](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) para el usuario, grupo o instancia
|
1. [Plataforma de agente Duo](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) para el usuario, grupo o instancia
|
||||||
2. Indicadores de funciones (a través de la consola Rails):
|
2. Indicadores de funciones (a través de la consola Rails):
|
||||||
- `agent_platform_claude_code`
|
- `agent_platform_claude_code`
|
||||||
- `third_party_agents_enabled`
|
- `third_party_agents_enabled`
|
||||||
@@ -779,7 +779,7 @@ Luego exponga el ID de la aplicación como variable de entorno:
|
|||||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||||
```
|
```
|
||||||
|
|
||||||
Más documentación en la página de inicio de [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
|
Más documentación en la página de inicio de [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
|
||||||
|
|
||||||
##### Configuración
|
##### Configuración
|
||||||
|
|
||||||
@@ -791,11 +791,7 @@ Personalizar a través de `opencode.json`:
|
|||||||
"provider": {
|
"provider": {
|
||||||
"gitlab": {
|
"gitlab": {
|
||||||
"options": {
|
"options": {
|
||||||
"instanceUrl": "https://gitlab.com",
|
"instanceUrl": "https://gitlab.com"
|
||||||
"featureFlags": {
|
|
||||||
"duo_agent_platform_agentic_chat": true,
|
|
||||||
"duo_agent_platform": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -809,7 +805,7 @@ Para acceder a herramientas GitLab (solicitudes de fusión, problemas, canalizac
|
|||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["@gitlab/opencode-gitlab-plugin"]
|
"plugin": ["opencode-gitlab-plugin"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -763,7 +763,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
:::note
|
:::note
|
||||||
Votre administrateur GitLab doit activer les éléments suivants :
|
Votre administrateur GitLab doit activer les éléments suivants :
|
||||||
|
|
||||||
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) pour l'utilisateur, le groupe ou l'instance
|
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) pour l'utilisateur, le groupe ou l'instance
|
||||||
2. Indicateurs de fonctionnalités (via la console Rails) :
|
2. Indicateurs de fonctionnalités (via la console Rails) :
|
||||||
- `agent_platform_claude_code`
|
- `agent_platform_claude_code`
|
||||||
- `third_party_agents_enabled`
|
- `third_party_agents_enabled`
|
||||||
@@ -785,7 +785,7 @@ Exposez ensuite l'ID de l'application en tant que variable d'environnement :
|
|||||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||||
```
|
```
|
||||||
|
|
||||||
Plus de documentation sur la page d'accueil [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
|
Plus de documentation sur la page d'accueil [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
|
||||||
|
|
||||||
##### Configuration
|
##### Configuration
|
||||||
|
|
||||||
@@ -797,11 +797,7 @@ Personnalisez via `opencode.json` :
|
|||||||
"provider": {
|
"provider": {
|
||||||
"gitlab": {
|
"gitlab": {
|
||||||
"options": {
|
"options": {
|
||||||
"instanceUrl": "https://gitlab.com",
|
"instanceUrl": "https://gitlab.com"
|
||||||
"featureFlags": {
|
|
||||||
"duo_agent_platform_agentic_chat": true,
|
|
||||||
"duo_agent_platform": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -815,7 +811,7 @@ Pour accéder aux outils GitLab (demandes de fusion, tickets, pipelines, CI/CD,
|
|||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["@gitlab/opencode-gitlab-plugin"]
|
"plugin": ["opencode-gitlab-plugin"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -733,7 +733,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
:::note
|
:::note
|
||||||
Il tuo amministratore GitLab deve abilitare quanto segue:
|
Il tuo amministratore GitLab deve abilitare quanto segue:
|
||||||
|
|
||||||
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) per l'utente, gruppo o istanza
|
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) per l'utente, gruppo o istanza
|
||||||
2. Feature flags (via Rails console):
|
2. Feature flags (via Rails console):
|
||||||
- `agent_platform_claude_code`
|
- `agent_platform_claude_code`
|
||||||
- `third_party_agents_enabled`
|
- `third_party_agents_enabled`
|
||||||
@@ -755,7 +755,7 @@ Poi esponi l'ID applicazione come variabile d'ambiente:
|
|||||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||||
```
|
```
|
||||||
|
|
||||||
Maggior documentazione sulla homepage di [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
|
Maggior documentazione sulla homepage di [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
|
||||||
|
|
||||||
##### Configurazione
|
##### Configurazione
|
||||||
|
|
||||||
@@ -767,11 +767,7 @@ Personalizza tramite `opencode.json`:
|
|||||||
"provider": {
|
"provider": {
|
||||||
"gitlab": {
|
"gitlab": {
|
||||||
"options": {
|
"options": {
|
||||||
"instanceUrl": "https://gitlab.com",
|
"instanceUrl": "https://gitlab.com"
|
||||||
"featureFlags": {
|
|
||||||
"duo_agent_platform_agentic_chat": true,
|
|
||||||
"duo_agent_platform": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -785,7 +781,7 @@ Per accedere agli strumenti GitLab (merge requests, issues, pipelines, CI/CD, ec
|
|||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["@gitlab/opencode-gitlab-plugin"]
|
"plugin": ["opencode-gitlab-plugin"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -797,7 +797,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
:::note
|
:::note
|
||||||
GitLab 管理者は以下を有効にする必要があります。
|
GitLab 管理者は以下を有効にする必要があります。
|
||||||
|
|
||||||
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) (ユーザー、グループ、またはインスタンス用)
|
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) (ユーザー、グループ、またはインスタンス用)
|
||||||
2. 機能フラグ (Rails コンソール経由):
|
2. 機能フラグ (Rails コンソール経由):
|
||||||
- `agent_platform_claude_code`
|
- `agent_platform_claude_code`
|
||||||
- `third_party_agents_enabled`
|
- `third_party_agents_enabled`
|
||||||
@@ -819,7 +819,7 @@ GitLab 管理者は以下を有効にする必要があります。
|
|||||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||||
```
|
```
|
||||||
|
|
||||||
詳細については、[opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) ホームページ。
|
詳細については、[opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) ホームページ。
|
||||||
|
|
||||||
##### 設定
|
##### 設定
|
||||||
|
|
||||||
@@ -831,11 +831,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
|||||||
"provider": {
|
"provider": {
|
||||||
"gitlab": {
|
"gitlab": {
|
||||||
"options": {
|
"options": {
|
||||||
"instanceUrl": "https://gitlab.com",
|
"instanceUrl": "https://gitlab.com"
|
||||||
"featureFlags": {
|
|
||||||
"duo_agent_platform_agentic_chat": true,
|
|
||||||
"duo_agent_platform": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -849,7 +845,7 @@ GitLab ツール (マージリクエスト、問題、パイプライン、CI/CD
|
|||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["@gitlab/opencode-gitlab-plugin"]
|
"plugin": ["opencode-gitlab-plugin"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -758,7 +758,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
:::note
|
:::note
|
||||||
GitLab 관리자는 다음을 활성화해야 합니다:
|
GitLab 관리자는 다음을 활성화해야 합니다:
|
||||||
|
|
||||||
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) (사용자, 그룹 또는 인스턴스)
|
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) (사용자, 그룹 또는 인스턴스)
|
||||||
2. 기능 플래그 (Rails 콘솔을 통해):
|
2. 기능 플래그 (Rails 콘솔을 통해):
|
||||||
- `agent_platform_claude_code`
|
- `agent_platform_claude_code`
|
||||||
- `third_party_agents_enabled`
|
- `third_party_agents_enabled`
|
||||||
@@ -779,7 +779,7 @@ GitLab 관리자는 다음을 활성화해야 합니다:
|
|||||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||||
```
|
```
|
||||||
|
|
||||||
[opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) 홈페이지에 추가 문서가 있습니다.
|
[opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) 홈페이지에 추가 문서가 있습니다.
|
||||||
|
|
||||||
#### 구성
|
#### 구성
|
||||||
|
|
||||||
@@ -791,11 +791,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
|||||||
"provider": {
|
"provider": {
|
||||||
"gitlab": {
|
"gitlab": {
|
||||||
"options": {
|
"options": {
|
||||||
"instanceUrl": "https://gitlab.com",
|
"instanceUrl": "https://gitlab.com"
|
||||||
"featureFlags": {
|
|
||||||
"duo_agent_platform_agentic_chat": true,
|
|
||||||
"duo_agent_platform": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -809,7 +805,7 @@ GitLab 도구(병합 요청, 이슈, 파이프라인, CI/CD 등)에 액세스하
|
|||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["@gitlab/opencode-gitlab-plugin"]
|
"plugin": ["opencode-gitlab-plugin"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -757,7 +757,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
:::note
|
:::note
|
||||||
GitLab-administratoren din må aktivere følgende:
|
GitLab-administratoren din må aktivere følgende:
|
||||||
|
|
||||||
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) for brukeren, gruppen eller forekomsten
|
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) for brukeren, gruppen eller forekomsten
|
||||||
2. Funksjonsflagg (via Rails-konsollen):
|
2. Funksjonsflagg (via Rails-konsollen):
|
||||||
- `agent_platform_claude_code`
|
- `agent_platform_claude_code`
|
||||||
- `third_party_agents_enabled`
|
- `third_party_agents_enabled`
|
||||||
@@ -779,7 +779,7 @@ Utsett deretter applikasjonen ID som miljøvariabel:
|
|||||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||||
```
|
```
|
||||||
|
|
||||||
Mer dokumentasjon på [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) hjemmeside.
|
Mer dokumentasjon på [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) hjemmeside.
|
||||||
|
|
||||||
##### Konfigurasjon
|
##### Konfigurasjon
|
||||||
|
|
||||||
@@ -791,11 +791,7 @@ Tilpass gjennom `opencode.json`:
|
|||||||
"provider": {
|
"provider": {
|
||||||
"gitlab": {
|
"gitlab": {
|
||||||
"options": {
|
"options": {
|
||||||
"instanceUrl": "https://gitlab.com",
|
"instanceUrl": "https://gitlab.com"
|
||||||
"featureFlags": {
|
|
||||||
"duo_agent_platform_agentic_chat": true,
|
|
||||||
"duo_agent_platform": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -809,7 +805,7 @@ For å få tilgang til GitLab-verktøy (sammenslåingsforespørsler, problemer,
|
|||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["@gitlab/opencode-gitlab-plugin"]
|
"plugin": ["opencode-gitlab-plugin"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -755,7 +755,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
:::note
|
:::note
|
||||||
Twój administrator GitLab musi włączyć następujące opcje:
|
Twój administrator GitLab musi włączyć następujące opcje:
|
||||||
|
|
||||||
1. [Platforma Duo Agent](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) dla użytkownika, grupy lub instancji
|
1. [Platforma Duo Agent](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) dla użytkownika, grupy lub instancji
|
||||||
2. Feature flags (via Rails console):
|
2. Feature flags (via Rails console):
|
||||||
- `agent_platform_claude_code`
|
- `agent_platform_claude_code`
|
||||||
- `third_party_agents_enabled`
|
- `third_party_agents_enabled`
|
||||||
@@ -777,7 +777,7 @@ Następnie ustaw ID aplikacji jako zmienną środowiskową:
|
|||||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||||
```
|
```
|
||||||
|
|
||||||
Więcej informacji znajdziesz na stronie [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
|
Więcej informacji znajdziesz na stronie [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
|
||||||
|
|
||||||
##### Konfiguracja
|
##### Konfiguracja
|
||||||
|
|
||||||
@@ -789,11 +789,7 @@ Customize through `opencode.json`:
|
|||||||
"provider": {
|
"provider": {
|
||||||
"gitlab": {
|
"gitlab": {
|
||||||
"options": {
|
"options": {
|
||||||
"instanceUrl": "https://gitlab.com",
|
"instanceUrl": "https://gitlab.com"
|
||||||
"featureFlags": {
|
|
||||||
"duo_agent_platform_agentic_chat": true,
|
|
||||||
"duo_agent_platform": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -807,7 +803,7 @@ To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.):
|
|||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["@gitlab/opencode-gitlab-plugin"]
|
"plugin": ["opencode-gitlab-plugin"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -544,6 +544,47 @@ Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI,
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Cloudflare Workers AI
|
||||||
|
|
||||||
|
Cloudflare Workers AI lets you run AI models on Cloudflare's global network directly via REST API, with no separate provider accounts needed for supported models.
|
||||||
|
|
||||||
|
1. Head over to the [Cloudflare dashboard](https://dash.cloudflare.com/), navigate to **Workers AI**, and select **Use REST API** to get your Account ID and create an API token.
|
||||||
|
|
||||||
|
2. Set your Account ID as an environment variable.
|
||||||
|
|
||||||
|
```bash title="~/.bash_profile"
|
||||||
|
export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run the `/connect` command and search for **Cloudflare Workers AI**.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
/connect
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Enter your Cloudflare API token.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
┌ API key
|
||||||
|
│
|
||||||
|
│
|
||||||
|
└ enter
|
||||||
|
```
|
||||||
|
|
||||||
|
Or set it as an environment variable.
|
||||||
|
|
||||||
|
```bash title="~/.bash_profile"
|
||||||
|
export CLOUDFLARE_API_KEY=your-api-token
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Run the `/models` command to select a model.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
/models
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Cortecs
|
### Cortecs
|
||||||
|
|
||||||
1. Head over to the [Cortecs console](https://cortecs.ai/), create an account, and generate an API key.
|
1. Head over to the [Cortecs console](https://cortecs.ai/), create an account, and generate an API key.
|
||||||
@@ -681,7 +722,20 @@ Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI,
|
|||||||
|
|
||||||
### GitLab Duo
|
### GitLab Duo
|
||||||
|
|
||||||
GitLab Duo provides AI-powered agentic chat with native tool calling capabilities through GitLab's Anthropic proxy.
|
:::caution[Experimental]
|
||||||
|
GitLab Duo support in OpenCode is experimental. Features, configuration, and
|
||||||
|
behavior may change in future releases.
|
||||||
|
:::
|
||||||
|
|
||||||
|
OpenCode integrates with the [GitLab Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/),
|
||||||
|
providing AI-powered agentic chat with native tool calling capabilities.
|
||||||
|
|
||||||
|
:::note[License requirements]
|
||||||
|
GitLab Duo Agent Platform requires a **Premium** or **Ultimate** GitLab
|
||||||
|
subscription. It is available on GitLab.com and GitLab Self-Managed.
|
||||||
|
See [GitLab Duo Agent Platform prerequisites](https://docs.gitlab.com/user/duo_agent_platform/#prerequisites)
|
||||||
|
for full requirements.
|
||||||
|
:::
|
||||||
|
|
||||||
1. Run the `/connect` command and select GitLab.
|
1. Run the `/connect` command and select GitLab.
|
||||||
|
|
||||||
@@ -766,13 +820,15 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
```
|
```
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
Your GitLab administrator must enable the following:
|
Your GitLab administrator must:
|
||||||
|
|
||||||
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) for the user, group, or instance
|
1. [Turn on GitLab Duo](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/#turn-gitlab-duo-on-or-off)
|
||||||
2. Feature flags (via Rails console):
|
for the user, group, or instance
|
||||||
- `agent_platform_claude_code`
|
2. [Turn on the Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/#turn-gitlab-duo-agent-platform-on-or-off)
|
||||||
- `third_party_agents_enabled`
|
(GitLab 18.8+) or [enable beta and experimental features](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/#turn-on-beta-and-experimental-features)
|
||||||
:::
|
(GitLab 18.7 and earlier)
|
||||||
|
3. For Self-Managed, [configure your instance](https://docs.gitlab.com/administration/gitlab_duo/configure/gitlab_self_managed/)
|
||||||
|
:::
|
||||||
|
|
||||||
##### OAuth for Self-Hosted instances
|
##### OAuth for Self-Hosted instances
|
||||||
|
|
||||||
@@ -790,7 +846,7 @@ Then expose application ID as environment variable:
|
|||||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||||
```
|
```
|
||||||
|
|
||||||
More documentation on [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) homepage.
|
More documentation on [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) homepage.
|
||||||
|
|
||||||
##### Configuration
|
##### Configuration
|
||||||
|
|
||||||
@@ -802,17 +858,27 @@ Customize through `opencode.json`:
|
|||||||
"provider": {
|
"provider": {
|
||||||
"gitlab": {
|
"gitlab": {
|
||||||
"options": {
|
"options": {
|
||||||
"instanceUrl": "https://gitlab.com",
|
"instanceUrl": "https://gitlab.com"
|
||||||
"featureFlags": {
|
|
||||||
"duo_agent_platform_agentic_chat": true,
|
|
||||||
"duo_agent_platform": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
##### GitLab Duo Agent Platform (DAP) Workflow Models
|
||||||
|
|
||||||
|
DAP workflow models provide an alternative execution path that routes tool calls
|
||||||
|
through GitLab's Duo Workflow Service (DWS) instead of the standard agentic chat.
|
||||||
|
When a `duo-workflow-*` model is selected, OpenCode will:
|
||||||
|
|
||||||
|
1. Discover available models from your GitLab namespace
|
||||||
|
2. Present a selection picker if multiple models are available
|
||||||
|
3. Cache the selected model to disk for fast subsequent startups
|
||||||
|
4. Route tool execution requests through OpenCode's permission-gated tool system
|
||||||
|
|
||||||
|
Available DAP workflow models follow the `duo-workflow-*` naming convention and
|
||||||
|
are dynamically discovered from your GitLab instance.
|
||||||
|
|
||||||
##### GitLab API Tools (Optional, but highly recommended)
|
##### GitLab API Tools (Optional, but highly recommended)
|
||||||
|
|
||||||
To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.):
|
To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.):
|
||||||
@@ -820,7 +886,7 @@ To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.):
|
|||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["@gitlab/opencode-gitlab-plugin"]
|
"plugin": ["opencode-gitlab-plugin"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -759,7 +759,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
:::note
|
:::note
|
||||||
Seu administrador do GitLab deve habilitar o seguinte:
|
Seu administrador do GitLab deve habilitar o seguinte:
|
||||||
|
|
||||||
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) para o usuário, grupo ou instância
|
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) para o usuário, grupo ou instância
|
||||||
2. Flags de recurso (via console Rails):
|
2. Flags de recurso (via console Rails):
|
||||||
- `agent_platform_claude_code`
|
- `agent_platform_claude_code`
|
||||||
- `third_party_agents_enabled`
|
- `third_party_agents_enabled`
|
||||||
@@ -781,7 +781,7 @@ Em seguida, exponha o ID do aplicativo como variável de ambiente:
|
|||||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||||
```
|
```
|
||||||
|
|
||||||
Mais documentação na página [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
|
Mais documentação na página [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
|
||||||
|
|
||||||
##### Configuração
|
##### Configuração
|
||||||
|
|
||||||
@@ -793,11 +793,7 @@ Personalize através do `opencode.json`:
|
|||||||
"provider": {
|
"provider": {
|
||||||
"gitlab": {
|
"gitlab": {
|
||||||
"options": {
|
"options": {
|
||||||
"instanceUrl": "https://gitlab.com",
|
"instanceUrl": "https://gitlab.com"
|
||||||
"featureFlags": {
|
|
||||||
"duo_agent_platform_agentic_chat": true,
|
|
||||||
"duo_agent_platform": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -811,7 +807,7 @@ Para acessar ferramentas do GitLab (merge requests, issues, pipelines, CI/CD, et
|
|||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["@gitlab/opencode-gitlab-plugin"]
|
"plugin": ["opencode-gitlab-plugin"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -755,7 +755,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
:::note
|
:::note
|
||||||
Ваш администратор GitLab должен включить следующее:
|
Ваш администратор GitLab должен включить следующее:
|
||||||
|
|
||||||
1. [Платформа Duo Agent](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) для пользователя, группы или экземпляра
|
1. [Платформа Duo Agent](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) для пользователя, группы или экземпляра
|
||||||
2. Флаги функций (через консоль Rails):
|
2. Флаги функций (через консоль Rails):
|
||||||
- `agent_platform_claude_code`
|
- `agent_platform_claude_code`
|
||||||
- `third_party_agents_enabled`
|
- `third_party_agents_enabled`
|
||||||
@@ -777,7 +777,7 @@ URL обратного вызова `http://127.0.0.1:8080/callback` и след
|
|||||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||||
```
|
```
|
||||||
|
|
||||||
Дополнительная документация на домашней странице [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
|
Дополнительная документация на домашней странице [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
|
||||||
|
|
||||||
##### Конфигурация
|
##### Конфигурация
|
||||||
|
|
||||||
@@ -789,11 +789,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
|||||||
"provider": {
|
"provider": {
|
||||||
"gitlab": {
|
"gitlab": {
|
||||||
"options": {
|
"options": {
|
||||||
"instanceUrl": "https://gitlab.com",
|
"instanceUrl": "https://gitlab.com"
|
||||||
"featureFlags": {
|
|
||||||
"duo_agent_platform_agentic_chat": true,
|
|
||||||
"duo_agent_platform": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -807,7 +803,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
|||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["@gitlab/opencode-gitlab-plugin"]
|
"plugin": ["opencode-gitlab-plugin"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -756,7 +756,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
:::note
|
:::note
|
||||||
ผู้ดูแลระบบ GitLab ของคุณต้องเปิดใช้งานสิ่งต่อไปนี้:
|
ผู้ดูแลระบบ GitLab ของคุณต้องเปิดใช้งานสิ่งต่อไปนี้:
|
||||||
|
|
||||||
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) สำหรับผู้ใช้ กลุ่ม หรืออินสแตนซ์
|
1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) สำหรับผู้ใช้ กลุ่ม หรืออินสแตนซ์
|
||||||
2. แฟล็กคุณลักษณะ (ผ่านคอนโซล Rails):
|
2. แฟล็กคุณลักษณะ (ผ่านคอนโซล Rails):
|
||||||
- `agent_platform_claude_code`
|
- `agent_platform_claude_code`
|
||||||
- `third_party_agents_enabled`
|
- `third_party_agents_enabled`
|
||||||
@@ -778,7 +778,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||||
```
|
```
|
||||||
|
|
||||||
เอกสารประกอบเพิ่มเติมเกี่ยวกับหน้าแรกของ [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth)
|
เอกสารประกอบเพิ่มเติมเกี่ยวกับหน้าแรกของ [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth)
|
||||||
|
|
||||||
##### การกำหนดค่า
|
##### การกำหนดค่า
|
||||||
|
|
||||||
@@ -790,11 +790,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
|||||||
"provider": {
|
"provider": {
|
||||||
"gitlab": {
|
"gitlab": {
|
||||||
"options": {
|
"options": {
|
||||||
"instanceUrl": "https://gitlab.com",
|
"instanceUrl": "https://gitlab.com"
|
||||||
"featureFlags": {
|
|
||||||
"duo_agent_platform_agentic_chat": true,
|
|
||||||
"duo_agent_platform": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -808,7 +804,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
|||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["@gitlab/opencode-gitlab-plugin"]
|
"plugin": ["opencode-gitlab-plugin"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -757,7 +757,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
:::note
|
:::note
|
||||||
GitLab yöneticiniz aşağıdakileri etkinleştirmelidir:
|
GitLab yöneticiniz aşağıdakileri etkinleştirmelidir:
|
||||||
|
|
||||||
1. Kullanıcı, grup veya örnek için [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/)
|
1. Kullanıcı, grup veya örnek için [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/)
|
||||||
2. Feature flags (via Rails console):
|
2. Feature flags (via Rails console):
|
||||||
- `agent_platform_claude_code`
|
- `agent_platform_claude_code`
|
||||||
- `third_party_agents_enabled`
|
- `third_party_agents_enabled`
|
||||||
@@ -779,7 +779,7 @@ Then expose application ID as environment variable:
|
|||||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||||
```
|
```
|
||||||
|
|
||||||
Daha fazla belge [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) ana sayfasında.
|
Daha fazla belge [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) ana sayfasında.
|
||||||
|
|
||||||
##### Yapılandırma
|
##### Yapılandırma
|
||||||
|
|
||||||
@@ -791,11 +791,7 @@ Daha fazla belge [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/op
|
|||||||
"provider": {
|
"provider": {
|
||||||
"gitlab": {
|
"gitlab": {
|
||||||
"options": {
|
"options": {
|
||||||
"instanceUrl": "https://gitlab.com",
|
"instanceUrl": "https://gitlab.com"
|
||||||
"featureFlags": {
|
|
||||||
"duo_agent_platform_agentic_chat": true,
|
|
||||||
"duo_agent_platform": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -809,7 +805,7 @@ GitLab araçlarına (birleştirme istekleri, sorunlar, işlem hatları, CI/CD vb
|
|||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["@gitlab/opencode-gitlab-plugin"]
|
"plugin": ["opencode-gitlab-plugin"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -725,7 +725,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
:::note
|
:::note
|
||||||
你的 GitLab 管理员必须启用以下功能:
|
你的 GitLab 管理员必须启用以下功能:
|
||||||
|
|
||||||
1. 为用户、群组或实例启用 [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/)
|
1. 为用户、群组或实例启用 [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/)
|
||||||
2. 功能标志(通过 Rails 控制台):
|
2. 功能标志(通过 Rails 控制台):
|
||||||
- `agent_platform_claude_code`
|
- `agent_platform_claude_code`
|
||||||
- `third_party_agents_enabled`
|
- `third_party_agents_enabled`
|
||||||
@@ -745,7 +745,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||||
```
|
```
|
||||||
|
|
||||||
更多文档请参阅 [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) 主页。
|
更多文档请参阅 [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) 主页。
|
||||||
|
|
||||||
##### 配置
|
##### 配置
|
||||||
|
|
||||||
@@ -757,11 +757,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
|||||||
"provider": {
|
"provider": {
|
||||||
"gitlab": {
|
"gitlab": {
|
||||||
"options": {
|
"options": {
|
||||||
"instanceUrl": "https://gitlab.com",
|
"instanceUrl": "https://gitlab.com"
|
||||||
"featureFlags": {
|
|
||||||
"duo_agent_platform_agentic_chat": true,
|
|
||||||
"duo_agent_platform": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -775,7 +771,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
|||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["@gitlab/opencode-gitlab-plugin"]
|
"plugin": ["opencode-gitlab-plugin"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -746,7 +746,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
:::note
|
:::note
|
||||||
您的 GitLab 管理員必須啟用以下功能:
|
您的 GitLab 管理員必須啟用以下功能:
|
||||||
|
|
||||||
1. 為使用者、群組或實例啟用 [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/)
|
1. 為使用者、群組或實例啟用 [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/)
|
||||||
2. 功能旗標(透過 Rails 控制台):
|
2. 功能旗標(透過 Rails 控制台):
|
||||||
- `agent_platform_claude_code`
|
- `agent_platform_claude_code`
|
||||||
- `third_party_agents_enabled`
|
- `third_party_agents_enabled`
|
||||||
@@ -766,7 +766,7 @@ export GITLAB_TOKEN=glpat-...
|
|||||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||||
```
|
```
|
||||||
|
|
||||||
更多文件請參閱 [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) 首頁。
|
更多文件請參閱 [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) 首頁。
|
||||||
|
|
||||||
##### 設定
|
##### 設定
|
||||||
|
|
||||||
@@ -778,11 +778,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
|||||||
"provider": {
|
"provider": {
|
||||||
"gitlab": {
|
"gitlab": {
|
||||||
"options": {
|
"options": {
|
||||||
"instanceUrl": "https://gitlab.com",
|
"instanceUrl": "https://gitlab.com"
|
||||||
"featureFlags": {
|
|
||||||
"duo_agent_platform_agentic_chat": true,
|
|
||||||
"duo_agent_platform": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -796,7 +792,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
|||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["@gitlab/opencode-gitlab-plugin"]
|
"plugin": ["opencode-gitlab-plugin"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
diff --git a/Users/brendonovich/github.com/anomalyco/opencode/node_modules/solid-js/.bun-tag-6fcb6b48d6947d2c b/.bun-tag-6fcb6b48d6947d2c
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||||
|
diff --git a/Users/brendonovich/github.com/anomalyco/opencode/node_modules/solid-js/.bun-tag-b272f631c12927b0 b/.bun-tag-b272f631c12927b0
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||||
|
diff --git a/dist/dev.cjs b/dist/dev.cjs
|
||||||
|
index 7104749486e4361e8c4ee7836a8046582cec7aa1..0501eb1ec5d13b81ecb13a5ac1a82db42502b976 100644
|
||||||
|
--- a/dist/dev.cjs
|
||||||
|
+++ b/dist/dev.cjs
|
||||||
|
@@ -764,6 +764,8 @@ function runComputation(node, value, time) {
|
||||||
|
if (node.updatedAt != null && "observers" in node) {
|
||||||
|
writeSignal(node, nextValue, true);
|
||||||
|
} else if (Transition && Transition.running && node.pure) {
|
||||||
|
+ // On first computation during transition, also set committed value #2046
|
||||||
|
+ if (!Transition.sources.has(node)) node.value = nextValue;
|
||||||
|
Transition.sources.add(node);
|
||||||
|
node.tValue = nextValue;
|
||||||
|
} else node.value = nextValue;
|
||||||
|
diff --git a/dist/dev.js b/dist/dev.js
|
||||||
|
index ea5e4bc2fd4f0b3922a73d9134439529dc81339f..4b3ec07e624d20fdd23d6941a4fdde6d3a78cca3 100644
|
||||||
|
--- a/dist/dev.js
|
||||||
|
+++ b/dist/dev.js
|
||||||
|
@@ -762,6 +762,8 @@ function runComputation(node, value, time) {
|
||||||
|
if (node.updatedAt != null && "observers" in node) {
|
||||||
|
writeSignal(node, nextValue, true);
|
||||||
|
} else if (Transition && Transition.running && node.pure) {
|
||||||
|
+ // On first computation during transition, also set committed value #2046
|
||||||
|
+ if (!Transition.sources.has(node)) node.value = nextValue;
|
||||||
|
Transition.sources.add(node);
|
||||||
|
node.tValue = nextValue;
|
||||||
|
} else node.value = nextValue;
|
||||||
|
diff --git a/dist/solid.cjs b/dist/solid.cjs
|
||||||
|
index 7c133a2b254678a84fd61d719fbeffad766e1331..2f68c99f2698210cc0bac62f074cc8cd3beb2881 100644
|
||||||
|
--- a/dist/solid.cjs
|
||||||
|
+++ b/dist/solid.cjs
|
||||||
|
@@ -717,6 +717,8 @@ function runComputation(node, value, time) {
|
||||||
|
if (node.updatedAt != null && "observers" in node) {
|
||||||
|
writeSignal(node, nextValue, true);
|
||||||
|
} else if (Transition && Transition.running && node.pure) {
|
||||||
|
+ // On first computation during transition, also set committed value #2046
|
||||||
|
+ if (!Transition.sources.has(node)) node.value = nextValue;
|
||||||
|
Transition.sources.add(node);
|
||||||
|
node.tValue = nextValue;
|
||||||
|
} else node.value = nextValue;
|
||||||
|
diff --git a/dist/solid.js b/dist/solid.js
|
||||||
|
index 656fd26e7e5c794aa22df19c2377ff5c0591fc29..f08e9f5a7157c3506e5b6922fe2ef991335a80be 100644
|
||||||
|
--- a/dist/solid.js
|
||||||
|
+++ b/dist/solid.js
|
||||||
|
@@ -715,6 +715,8 @@ function runComputation(node, value, time) {
|
||||||
|
if (node.updatedAt != null && "observers" in node) {
|
||||||
|
writeSignal(node, nextValue, true);
|
||||||
|
} else if (Transition && Transition.running && node.pure) {
|
||||||
|
+ // On first computation during transition, also set committed value #2046
|
||||||
|
+ if (!Transition.sources.has(node)) node.value = nextValue;
|
||||||
|
Transition.sources.add(node);
|
||||||
|
node.tValue = nextValue;
|
||||||
|
} else node.value = nextValue;
|
||||||
Reference in New Issue
Block a user