diff --git a/bun.lock b/bun.lock index ee8746c42f..58cfe892f3 100644 --- a/bun.lock +++ b/bun.lock @@ -325,8 +325,6 @@ "@aws-sdk/credential-providers": "3.993.0", "@clack/prompts": "1.0.0-alpha.1", "@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/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", @@ -358,6 +356,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", + "gitlab-ai-provider": "5.2.2", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -368,6 +367,7 @@ "mime-types": "3.0.2", "minimatch": "10.0.3", "open": "10.1.2", + "opencode-gitlab-auth": "2.0.0", "opentui-spinner": "0.0.6", "partial-json": "0.1.7", "remeda": "catalog:", @@ -1110,10 +1110,6 @@ "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], - "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="], - - "@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="], - "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="], @@ -3032,6 +3028,8 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], + "gitlab-ai-provider": ["gitlab-ai-provider@5.2.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-ejwnie62rimfVHbjYZ2tsnqwLjF9YLgXD3OQA458gHz8hUvw7vEnhuyuMv5PmWQtyS3ISAghiX7r5SBhUWeCTA=="], + "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -3784,6 +3782,8 @@ "opencode": ["opencode@workspace:packages/opencode"], + "opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="], + "opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="], "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="], @@ -4246,7 +4246,7 @@ "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], - "socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="], + "socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="], "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], @@ -5060,10 +5060,6 @@ "@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], - "@gitlab/gitlab-ai-provider/openai": ["openai@6.27.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ=="], - - "@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -5460,6 +5456,10 @@ "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "gitlab-ai-provider/openai": ["openai@6.32.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-j3k+BjydAf8yQlcOI7WUQMQTbbF5GEIMAE2iZYCOzwwB3S2pCheaWYp+XZRNAch4jWVc52PMDGRRjutao3lLCg=="], + + "gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -5536,6 +5536,8 @@ "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="], + "opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], "opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="], @@ -6286,6 +6288,8 @@ "node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], "opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], diff --git a/nix/hashes.json b/nix/hashes.json index 9ee3aeab2d..8f48d1aaba 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-Gv0pHYCinlj0SQXRQ/a9ozYPxECwdrC99ssTzpeOr1I=", - "aarch64-linux": "sha256-WzVt5goOrxoGe26juzRf73PWPqwnB1URu2TYjxye/Aw=", - "aarch64-darwin": "sha256-18Nn0TR1wK2gRUF/FFP4vFMY/td49XkfjOwFbD5iJNc=", - "x86_64-darwin": "sha256-zk2yaulPzUUiCerCPJaCOCLhklXKMp9mSv7v0N8AMfA=" + "x86_64-linux": "sha256-P0RJfQF8APTYVGP6hLJRrOkRSl5nVDNxdcGcZECPPJE=", + "aarch64-linux": "sha256-ZtMjTcd35X3JhJIdn3DilFsp7i/IZIcNaKZFnSzW/nk=", + "aarch64-darwin": "sha256-Uw/okFDRxxKQMfEsj8MXuHyhpugxZGgIKtu89Getlz8=", + "x86_64-darwin": "sha256-ZySIgT1HbWZWnaQ0W0eURKC43BTupRmmply92JDFPWA=" } } diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts index c0421f0283..07071239d9 100644 --- a/packages/app/e2e/session/session-review.spec.ts +++ b/packages/app/e2e/session/session-review.spec.ts @@ -169,6 +169,70 @@ async function overflow(page: Parameters[0]["page"], file: string) } } +async function openReviewFile(page: Parameters[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[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[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.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.skip(Boolean(process.env.CI), "Flaky in CI for now.") test.setTimeout(180_000) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 55cfaa490f..f3d3e135de 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1383,11 +1383,16 @@ export const PromptInput: Component = (props) => { { - const file = e.currentTarget.files?.[0] - if (file) void addAttachment(file) + const list = e.currentTarget.files + if (list) { + for (const file of Array.from(list)) { + void addAttachment(file) + } + } e.currentTarget.value = "" }} /> diff --git a/packages/app/src/components/prompt-input/files.ts b/packages/app/src/components/prompt-input/files.ts index 594991d07a..eae8af03d9 100644 --- a/packages/app/src/components/prompt-input/files.ts +++ b/packages/app/src/components/prompt-input/files.ts @@ -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_EXTS = new Map([ @@ -18,61 +20,6 @@ const TEXT_MIMES = new Set([ "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 function kind(type: string) { diff --git a/packages/app/src/constants/file-picker.ts b/packages/app/src/constants/file-picker.ts new file mode 100644 index 0000000000..c661bc8f36 --- /dev/null +++ b/packages/app/src/constants/file-picker.ts @@ -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 }] +} diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index b8ed58e343..3bdc46391b 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -5,7 +5,7 @@ import { ServerConnection } from "./server" type PickerPaths = string | string[] | null 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 UpdateInfo = { updateAvailable: boolean; version?: string } diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 6c870dfa4d..53063f48f8 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,4 +1,5 @@ export { AppBaseProviders, AppInterface } from "./app" +export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { useCommand } from "./context/command" export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" export { ServerConnection } from "./context/server" diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index a3379905d8..e3b57fdf28 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -217,17 +217,6 @@ export function FileTabContent(props: { tab: string }) { 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(() => { @@ -426,7 +415,6 @@ export function FileTabContent(props: { tab: string }) { commentsUi.onLineSelectionEnd(range) }} search={search} - overflow="scroll" class="select-text" media={{ mode: "auto", diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 71b3c33958..543f857a5e 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -6,6 +6,11 @@ import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, import { getStore } from "./store" import { setTitlebar } from "./windows" +const pickerFilters = (ext?: string[]) => { + if (!ext || ext.length === 0) return undefined + return [{ name: "Files", extensions: ext }] +} + type Deps = { killSidecar: () => void installCli: () => Promise @@ -94,11 +99,15 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle( "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({ properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])], title: opts?.title ?? "Choose a file", defaultPath: opts?.defaultPath, + filters: pickerFilters(opts?.extensions), }) if (result.canceled) return null return opts?.multiple ? result.filePaths : result.filePaths[0] diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index 100508fcdd..f8e6d52c7d 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -50,6 +50,8 @@ export type ElectronAPI = { multiple?: boolean title?: string defaultPath?: string + accept?: string[] + extensions?: string[] }) => Promise saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise openLink: (url: string) => void diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 30e882e237..ec2b4d1e7a 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -1,6 +1,8 @@ // @refresh reload import { + ACCEPTED_FILE_EXTENSIONS, + ACCEPTED_FILE_TYPES, AppBaseProviders, AppInterface, handleNotificationClick, @@ -111,6 +113,8 @@ const createPlatform = (): Platform => { const result = await window.api.openFilePicker({ multiple: opts?.multiple ?? false, title: opts?.title ?? t("desktop.dialog.chooseFile"), + accept: opts?.accept ?? ACCEPTED_FILE_TYPES, + extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS, }) return handleWslPicker(result) }, diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 65149f34bc..e677956440 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -1,6 +1,8 @@ // @refresh reload import { + ACCEPTED_FILE_EXTENSIONS, + filePickerFilters, AppBaseProviders, AppInterface, handleNotificationClick, @@ -98,6 +100,7 @@ const createPlatform = (): Platform => { directory: false, multiple: opts?.multiple ?? false, title: opts?.title ?? t("desktop.dialog.chooseFile"), + filters: filePickerFilters(opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS), }) return handleWslPicker(result) }, diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 3e73a7021d..f888cc829b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -89,9 +89,9 @@ "@ai-sdk/xai": "2.0.51", "@aws-sdk/credential-providers": "3.993.0", "@clack/prompts": "1.0.0-alpha.1", + "gitlab-ai-provider": "5.2.2", + "opencode-gitlab-auth": "2.0.0", "@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/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", diff --git a/packages/opencode/src/account/effect.ts b/packages/opencode/src/account/effect.ts index 2f1304d505..8686ef42a9 100644 --- a/packages/opencode/src/account/effect.ts +++ b/packages/opencode/src/account/effect.ts @@ -6,9 +6,9 @@ import { AccountRepo, type AccountRow } from "./repo" import { type AccountError, AccessToken, - Account, AccountID, DeviceCode, + Info, RefreshToken, AccountServiceError, Login, @@ -24,10 +24,30 @@ import { UserCode, } 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 = { - account: Account + account: Info orgs: readonly Org[] } @@ -108,10 +128,10 @@ const mapAccountServiceError = ), ) -export namespace AccountEffect { +export namespace Account { export interface Interface { - readonly active: () => Effect.Effect, AccountError> - readonly list: () => Effect.Effect + readonly active: () => Effect.Effect, AccountError> + readonly list: () => Effect.Effect readonly orgsByAccount: () => Effect.Effect readonly remove: (accountID: AccountID) => Effect.Effect readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index 3a9d758e2f..753b80c5f1 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -1,31 +1,24 @@ import { Effect, Option } from "effect" -import { - Account as AccountSchema, - type AccountError, - type AccessToken, - AccountID, - AccountEffect, - OrgID, -} from "./effect" +import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect" export { AccessToken, AccountID, OrgID } from "./effect" import { runtime } from "@/effect/runtime" -function runSync(f: (service: AccountEffect.Interface) => Effect.Effect) { - return runtime.runSync(AccountEffect.Service.use(f)) +function runSync(f: (service: S.Interface) => Effect.Effect) { + return runtime.runSync(S.Service.use(f)) } -function runPromise(f: (service: AccountEffect.Interface) => Effect.Effect) { - return runtime.runPromise(AccountEffect.Service.use(f)) +function runPromise(f: (service: S.Interface) => Effect.Effect) { + return runtime.runPromise(S.Service.use(f)) } export namespace Account { - export const Account = AccountSchema - export type Account = AccountSchema + export const Info = Model + export type Info = Model - export function active(): Account | undefined { + export function active(): Info | undefined { return Option.getOrUndefined(runSync((service) => service.active())) } diff --git a/packages/opencode/src/account/repo.ts b/packages/opencode/src/account/repo.ts index 5caf1a3b94..96f980cdad 100644 --- a/packages/opencode/src/account/repo.ts +++ b/packages/opencode/src/account/repo.ts @@ -3,7 +3,7 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect" import { Database } from "@/storage/db" 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"] @@ -13,8 +13,8 @@ const ACCOUNT_STATE_ID = 1 export namespace AccountRepo { export interface Service { - readonly active: () => Effect.Effect, AccountRepoError> - readonly list: () => Effect.Effect + readonly active: () => Effect.Effect, AccountRepoError> + readonly list: () => Effect.Effect readonly remove: (accountID: AccountID) => Effect.Effect readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect readonly getRow: (accountID: AccountID) => Effect.Effect, AccountRepoError> @@ -40,7 +40,7 @@ export class AccountRepo extends ServiceMap.Service = Layer.effect( AccountRepo, Effect.gen(function* () { - const decode = Schema.decodeUnknownSync(Account) + const decode = Schema.decodeUnknownSync(Info) const query = (f: (db: DbClient) => A) => Effect.try({ @@ -136,6 +136,8 @@ export class AccountRepo extends ServiceMap.Service -export class Account extends Schema.Class("Account")({ +export class Info extends Schema.Class("Account")({ id: AccountID, email: Schema.String, url: Schema.String, diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index e30d05e935..fd07ebc85b 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -322,11 +322,11 @@ export namespace Agent { }), } satisfies Parameters[0] + // TODO: clean this up so provider specific logic doesnt bleed over if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") { const result = streamObject({ ...params, providerOptions: ProviderTransform.providerOptions(model, { - instructions: SystemPrompt.instructions(), store: false, }), onError: () => {}, diff --git a/packages/opencode/src/auth/effect.ts b/packages/opencode/src/auth/effect.ts index e03ad95867..14a9708079 100644 --- a/packages/opencode/src/auth/effect.ts +++ b/packages/opencode/src/auth/effect.ts @@ -37,7 +37,7 @@ const file = path.join(Global.Path.data, "auth.json") const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause }) -export namespace AuthEffect { +export namespace Auth { export interface Interface { readonly get: (providerID: string) => Effect.Effect readonly all: () => Effect.Effect, AuthError> diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 6f588e9375..411d9dccc0 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -5,8 +5,8 @@ import * as S from "./effect" export { OAUTH_DUMMY_KEY } from "./effect" -function runPromise(f: (service: S.AuthEffect.Interface) => Effect.Effect) { - return runtime.runPromise(S.AuthEffect.Service.use(f)) +function runPromise(f: (service: S.Auth.Interface) => Effect.Effect) { + return runtime.runPromise(S.Auth.Service.use(f)) } export namespace Auth { diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index c2b47da11c..fb702c95a5 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -2,7 +2,7 @@ import { cmd } from "./cmd" import { Duration, Effect, Match, Option } from "effect" import { UI } from "../ui" 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 * as Prompt from "../effect/prompt" 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 const loginEffect = Effect.fn("login")(function* (url: string) { - const service = yield* AccountEffect.Service + const service = yield* Account.Service yield* Prompt.intro("Log in") 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 service = yield* AccountEffect.Service + const service = yield* Account.Service const accounts = yield* service.list() if (accounts.length === 0) return yield* println("Not logged in") @@ -98,7 +98,7 @@ interface OrgChoice { } const switchEffect = Effect.fn("switch")(function* () { - const service = yield* AccountEffect.Service + const service = yield* Account.Service const groups = yield* service.orgsByAccount() 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 service = yield* AccountEffect.Service + const service = yield* Account.Service const groups = yield* service.orgsByAccount() if (groups.length === 0) return yield* println("No accounts found") diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts index 4438fa3b84..0182056633 100644 --- a/packages/opencode/src/cli/cmd/upgrade.ts +++ b/packages/opencode/src/cli/cmd/upgrade.ts @@ -58,10 +58,10 @@ export const UpgradeCommand = { spinner.stop("Upgrade failed", 1) if (err instanceof Installation.UpgradeFailedError) { // 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") } else { - prompts.log.error(err.data.stderr) + prompts.log.error(err.stderr) } } else if (err instanceof Error) prompts.log.error(err.message) prompts.outro("Done") diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 3452f25ec4..bc070f3a92 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -1,15 +1,15 @@ import { Effect, Layer, LayerMap, ServiceMap } from "effect" -import { File } from "@/file" -import { FileTime } from "@/file/time" +import { File } from "@/file/service" +import { FileTime } from "@/file/time-service" import { FileWatcher } from "@/file/watcher" -import { Format } from "@/format" -import { PermissionNext } from "@/permission" +import { Format } from "@/format/service" +import { Permission } from "@/permission/service" import { Instance } from "@/project/instance" import { Vcs } from "@/project/vcs" -import { ProviderAuth } from "@/provider/auth" -import { Question } from "@/question" -import { Skill } from "@/skill/skill" -import { Snapshot } from "@/snapshot" +import { ProviderAuth } from "@/provider/auth-service" +import { Question } from "@/question/service" +import { Skill } from "@/skill/service" +import { Snapshot } from "@/snapshot/service" import { Worktree } from "@/worktree" import { InstanceContext } from "./instance-context" import { registerDisposer } from "./instance-registry" @@ -18,7 +18,7 @@ export { InstanceContext } from "./instance-context" export type InstanceServices = | Question.Service - | PermissionNext.Service + | Permission.Service | ProviderAuth.Service | FileWatcher.Service | Vcs.Service @@ -38,17 +38,17 @@ export type InstanceServices = function lookup(_key: string) { const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current)) return Layer.mergeAll( - Layer.fresh(Question.layer), - Layer.fresh(PermissionNext.layer), - Layer.fresh(ProviderAuth.defaultLayer), - Layer.fresh(FileWatcher.layer).pipe(Layer.orDie), - Layer.fresh(Vcs.layer), - Layer.fresh(FileTime.layer).pipe(Layer.orDie), - Layer.fresh(Format.layer), - Layer.fresh(File.layer), - Layer.fresh(Skill.defaultLayer), - Layer.fresh(Snapshot.defaultLayer), - Layer.fresh(Worktree.layer), + Question.layer, + Permission.layer, + ProviderAuth.defaultLayer, + FileWatcher.layer, + Vcs.layer, + FileTime.layer, + Format.layer, + File.layer, + Skill.defaultLayer, + Snapshot.defaultLayer, + Worktree.layer, ).pipe(Layer.provide(ctx)) } diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index f52203b222..e6f1f32626 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -1,17 +1,19 @@ import { Effect, Layer, ManagedRuntime } from "effect" -import { AccountEffect } from "@/account/effect" -import { AuthEffect } from "@/auth/effect" +import { Account } from "@/account/effect" +import { Auth } from "@/auth/effect" import { Instances } 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" export const runtime = ManagedRuntime.make( Layer.mergeAll( - AccountEffect.defaultLayer, // - TruncateEffect.defaultLayer, + Account.defaultLayer, // + Installation.defaultLayer, + Truncate.defaultLayer, Instances.layer, - ).pipe(Layer.provideMerge(AuthEffect.layer)), + ).pipe(Layer.provideMerge(Auth.layer)), ) export function runPromiseInstance(effect: Effect.Effect) { diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 6e9b917271..35a5b5e204 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,695 +1,40 @@ -import { BusEvent } from "@/bus/bus-event" -import { InstanceContext } from "@/effect/instance-context" import { runPromiseInstance } from "@/effect/runtime" -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" +import { File as S } from "./service" 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 const Info = S.Info + export type Info = S.Info - export type Info = z.infer + export const Node = S.Node + export type Node = S.Node - 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 + export const Content = S.Content + export type Content = S.Content - 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 + export const Event = S.Event - export const Event = { - Edited: BusEvent.define( - "file.edited", - z.object({ - file: z.string(), - }), - ), - } + export type Interface = S.Interface + + export const Service = S.Service + export const layer = S.layer export function init() { - return runPromiseInstance(Service.use((svc) => svc.init())) + return runPromiseInstance(S.Service.use((svc) => svc.init())) } 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 { - return runPromiseInstance(Service.use((svc) => svc.read(file))) + return runPromiseInstance(S.Service.use((svc) => svc.read(file))) } 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" }) { - 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 = { - 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 - readonly status: () => Effect.Effect - readonly read: (file: string) => Effect.Effect - readonly list: (dir?: string) => Effect.Effect - readonly search: (input: { - query: string - limit?: number - dirs?: boolean - type?: "file" | "directory" - }) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@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() - 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() - 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 | 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 => { - 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 }) - }), - ) } diff --git a/packages/opencode/src/file/service.ts b/packages/opencode/src/file/service.ts new file mode 100644 index 0000000000..d4f6b347f8 --- /dev/null +++ b/packages/opencode/src/file/service.ts @@ -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 + + 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 + + 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 + + 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 = { + 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 + readonly status: () => Effect.Effect + readonly read: (file: string) => Effect.Effect + readonly list: (dir?: string) => Effect.Effect + readonly search: (input: { + query: string + limit?: number + dirs?: boolean + type?: "file" | "directory" + }) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@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() + 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() + 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 | 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 => { + 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) +} diff --git a/packages/opencode/src/file/time-service.ts b/packages/opencode/src/file/time-service.ts new file mode 100644 index 0000000000..a0fa8bfabf --- /dev/null +++ b/packages/opencode/src/file/time-service.ts @@ -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: SessionID) => { + const value = reads.get(sessionID) + if (value) return value + + const next = new Map() + reads.set(sessionID, next) + return next + } + + export interface Interface { + readonly read: (sessionID: SessionID, file: string) => Effect.Effect + readonly get: (sessionID: SessionID, file: string) => Effect.Effect + readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect + readonly withLock: (filepath: string, fn: () => Promise) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/FileTime") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK + const reads = new Map>() + const locks = new Map() + + 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* (filepath: string, fn: () => Promise) { + return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1)) + }) + + return Service.of({ read, get, assert, withLock }) + }), + ).pipe(Layer.orDie, Layer.fresh) +} diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index 3d94bc1222..b6d572fe8b 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -1,110 +1,28 @@ -import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect" import { runPromiseInstance } from "@/effect/runtime" -import { Flag } from "@/flag/flag" import type { SessionID } from "@/session/schema" -import { Filesystem } from "../util/filesystem" -import { Log } from "../util/log" +import { FileTime as S } from "./time-service" export namespace FileTime { - const log = Log.create({ service: "file.time" }) + export type Stamp = S.Stamp - export type Stamp = { - readonly read: Date - readonly mtime: number | undefined - readonly ctime: number | undefined - readonly size: number | undefined - } + export type Interface = S.Interface - 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: SessionID) => { - const value = reads.get(sessionID) - if (value) return value - - const next = new Map() - reads.set(sessionID, next) - return next - } - - export interface Interface { - readonly read: (sessionID: SessionID, file: string) => Effect.Effect - readonly get: (sessionID: SessionID, file: string) => Effect.Effect - readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect - readonly withLock: (filepath: string, fn: () => Promise) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/FileTime") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK - const reads = new Map>() - const locks = new Map() - - 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* (filepath: string, fn: () => Promise) { - return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1)) - }) - - return Service.of({ read, get, assert, withLock }) - }), - ) + export const Service = S.Service + export const layer = S.layer 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) { - 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) { - return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath))) + return runPromiseInstance(S.Service.use((s) => s.assert(sessionID, filepath))) } export async function withLock(filepath: string, fn: () => Promise): Promise { - return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn))) + return runPromiseInstance(S.Service.use((s) => s.withLock(filepath, fn))) } } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 16ab3c6d33..7e5f5f7be3 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -137,5 +137,5 @@ export namespace FileWatcher { return Effect.succeed(Service.of({})) }), ), - ) + ).pipe(Layer.orDie, Layer.fresh) } diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 6da8caa08c..e4381c69b2 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -1,157 +1,16 @@ -import { Effect, Layer, ServiceMap } from "effect" import { runPromiseInstance } from "@/effect/runtime" -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" -import { Instance } from "../project/instance" -import { Process } from "../util/process" -import { Log } from "../util/log" -import * as Formatter from "./formatter" +import { Format as S } from "./service" export namespace Format { - const log = Log.create({ service: "format" }) + export const Status = S.Status + export type Status = S.Status - export const Status = z - .object({ - name: z.string(), - extensions: z.string().array(), - enabled: z.boolean(), - }) - .meta({ - ref: "FormatterStatus", - }) - export type Status = z.infer + export type Interface = S.Interface - export interface Interface { - readonly status: () => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/Format") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const instance = yield* InstanceContext - - const enabled: Record = {} - const formatters: Record = {} - - 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 const Service = S.Service + export const layer = S.layer export async function status() { - return runPromiseInstance(Service.use((s) => s.status())) + return runPromiseInstance(S.Service.use((s) => s.status())) } } diff --git a/packages/opencode/src/format/service.ts b/packages/opencode/src/format/service.ts new file mode 100644 index 0000000000..64fff79497 --- /dev/null +++ b/packages/opencode/src/format/service.ts @@ -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 + + export interface Interface { + readonly status: () => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Format") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const instance = yield* InstanceContext + + const enabled: Record = {} + const formatters: Record = {} + + 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) +} diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 92a3bfc796..d0bd103296 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -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 z from "zod" -import { NamedError } from "@opencode-ai/util/error" -import { Log } from "../util/log" -import { iife } from "@/util/iife" +import { BusEvent } from "@/bus/bus-event" import { Flag } from "../flag/flag" -import { Process } from "@/util/process" -import { buffer } from "node:stream/consumers" +import { Log } from "../util/log" declare global { const OPENCODE_VERSION: string @@ -16,39 +17,7 @@ declare global { export namespace Installation { const log = Log.create({ service: "installation" }) - async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) { - 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> + export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" export const Event = { Updated: BusEvent.define( @@ -75,12 +44,9 @@ export namespace Installation { }) export type Info = z.infer - export async function info() { - return { - version: VERSION, - latest: await latest(), - } - } + export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" + export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" + export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}` export function isPreview() { return CHANNEL !== "latest" @@ -90,214 +56,300 @@ export namespace Installation { return CHANNEL === "local" } - export async function method() { - if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" - if (process.execPath.includes(path.join(".local", "bin"))) return "curl" - const exec = process.execPath.toLowerCase() + export class UpgradeFailedError extends Schema.TaggedErrorClass()("UpgradeFailedError", { + stderr: Schema.String, + }) {} - const checks = [ - { - name: "npm" as const, - command: () => text(["npm", "list", "-g", "--depth=0"]), - }, - { - name: "yarn" as const, - command: () => text(["yarn", "global", "list"]), - }, - { - name: "pnpm" as const, - 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"]), - }, - ] + // Response schemas for external version APIs + const GitHubRelease = Schema.Struct({ tag_name: Schema.String }) + const NpmPackage = Schema.Struct({ version: Schema.String }) + 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 }) })), + }) + const ChocoPackage = Schema.Struct({ + d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }), + }) + const ScoopManifest = NpmPackage - 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 = 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 interface Interface { + readonly info: () => Effect.Effect + readonly method: () => Effect.Effect + readonly latest: (method?: Method) => Effect.Effect + readonly upgrade: (method: Method, target: string) => Effect.Effect } - export const UpgradeFailedError = NamedError.create( - "UpgradeFailedError", - z.object({ - stderr: z.string(), - }), - ) + export class Service extends ServiceMap.Service()("@opencode/Installation") {} - async function getBrewFormula() { - const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"]) - if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode" - const coreFormula = await text(["brew", "list", "--formula", "opencode"]) - if (coreFormula.includes("opencode")) return "opencode" - return "opencode" - } + export const layer: Layer.Layer = + Layer.effect( + Service, + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http)) + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - export async function upgrade(method: Method, target: string) { - let result: Awaited> | undefined - switch (method) { - case "curl": - result = await upgradeCurl(target) - break - case "npm": - result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true }) - break - case "pnpm": - result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true }) - break - case "bun": - result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true }) - break - case "brew": { - const formula = await getBrewFormula() - const env = { - HOMEBREW_NO_AUTO_UPDATE: "1", - ...process.env, - } - if (formula.includes("/")) { - const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true }) - if (tap.code !== 0) { - result = tap - break - } - const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true }) - if (repo.code !== 0) { - result = repo - break - } - const dir = repo.text.trim() - if (dir) { - const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true }) - if (pull.code !== 0) { - result = pull - break + const text = Effect.fnUntraced( + function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { + const proc = ChildProcess.make(cmd[0], cmd.slice(1), { + cwd: opts?.cwd, + env: opts?.env, + extendEnv: true, + }) + const handle = yield* spawner.spawn(proc) + const out = yield* Stream.mkString(Stream.decodeText(handle.stdout)) + yield* handle.exitCode + return out + }, + Effect.scoped, + Effect.catch(() => Effect.succeed("")), + ) + + const run = Effect.fnUntraced( + function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { + const proc = ChildProcess.make(cmd[0], cmd.slice(1), { + cwd: opts?.cwd, + env: opts?.env, + 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.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })), + ) + + const getBrewFormula = Effect.fnUntraced(function* () { + const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"]) + 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 }> = [ + { 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": - result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true }) - break - case "scoop": - result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true }) - break - default: - throw new Error(`Unknown method: ${method}`) - } - if (!result || result.code !== 0) { - const stderr = - method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || "" - throw new UpgradeFailedError({ - stderr: stderr, - }) - } - log.info("upgraded", { - method, - target, - stdout: result.stdout.toString(), - stderr: result.stderr.toString(), - }) - await Process.text([process.execPath, "--version"], { nothrow: true }) + return "unknown" as Method + }) + + const latestImpl = Effect.fn("Installation.latest")(function* (installMethod?: Method) { + const detectedMethod = installMethod || (yield* methodImpl()) + + if (detectedMethod === "brew") { + const formula = yield* getBrewFormula() + if (formula.includes("/")) { + const infoJson = yield* text(["brew", "info", "--json=v2", formula]) + const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson) + return info.formulae[0].versions.stable + } + const response = yield* httpOk.execute( + HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe( + HttpClientRequest.acceptJson, + ), + ) + const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response) + return data.versions.stable + } + + 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(f: (service: Interface) => Effect.Effect) { + const { runtime } = await import("@/effect/runtime") + return runtime.runPromise(Service.use(f)) } - export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" - export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" - export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}` + export function info(): Promise { + return runPromise((svc) => svc.info()) + } - export async function latest(installMethod?: Method) { - const detectedMethod = installMethod || (await method()) + export function method(): Promise { + return runPromise((svc) => svc.method()) + } - if (detectedMethod === "brew") { - const formula = await getBrewFormula() - 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) - } + export function latest(installMethod?: Method): Promise { + return runPromise((svc) => svc.latest(installMethod)) + } - if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { - const registry = await iife(async () => { - 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/, "")) + export function upgrade(m: Method, target: string): Promise { + return runPromise((svc) => svc.upgrade(m, target)) } } diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 321c5c374e..01ac768971 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -1,292 +1,52 @@ 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 { 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" +import { Permission as S } from "./service" 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({ - ref: "PermissionAction", - }) - export type Action = z.infer + export const Rule = S.Rule + export type Rule = S.Rule - export const Rule = z - .object({ - permission: z.string(), - pattern: z.string(), - action: Action, - }) - .meta({ - ref: "PermissionRule", - }) - export type Rule = z.infer + export const Ruleset = S.Ruleset + export type Ruleset = S.Ruleset - export const Ruleset = Rule.array().meta({ - ref: "PermissionRuleset", - }) - export type Ruleset = z.infer + export const Request = S.Request + export type Request = S.Request - 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 + export const Reply = S.Reply + export type Reply = S.Reply - export const Reply = z.enum(["once", "always", "reject"]) - export type Reply = z.infer + export const Approval = S.Approval + export type Approval = z.infer - export const Approval = z.object({ - projectID: ProjectID.zod, - patterns: z.string().array(), - }) + export const Event = S.Event - 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 const RejectedError = S.RejectedError + export const CorrectedError = S.CorrectedError + export const DeniedError = S.DeniedError + export type Error = S.Error - export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { - override get message() { - return "The user rejected permission to use this specific tool call." - } - } + export const AskInput = S.AskInput + export const ReplyInput = S.ReplyInput - export class CorrectedError extends Schema.TaggedErrorClass()("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 type Interface = S.Interface - export class DeniedError extends Schema.TaggedErrorClass()("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 const Service = S.Service + export const layer = S.layer - 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({ - ruleset: Ruleset, - }) + export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((s) => s.ask(input)))) - export const ReplyInput = z.object({ - requestID: PermissionID.zod, - reply: Reply, - message: z.string().optional(), - }) - - export interface Interface { - readonly ask: (input: z.infer) => Effect.Effect - readonly reply: (input: z.infer) => Effect.Effect - readonly list: () => Effect.Effect - } - - interface PendingEntry { - info: Request - deferred: Deferred.Deferred - } - - 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()("@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() - const approved: Ruleset = row?.data ?? [] - - const ask = Effect.fn("Permission.ask")(function* (input: z.infer) { - 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() - 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) { - 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 const reply = fn(S.ReplyInput, async (input) => runPromiseInstance(S.Service.use((s) => s.reply(input)))) export async function list() { - return runPromiseInstance(Service.use((svc) => svc.list())) - } - - const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"] - - export function disabled(tools: string[], ruleset: Ruleset): Set { - const result = new Set() - 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 + return runPromiseInstance(S.Service.use((s) => s.list())) } } diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts new file mode 100644 index 0000000000..08475520b2 --- /dev/null +++ b/packages/opencode/src/permission/service.ts @@ -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 + + export const Rule = z + .object({ + permission: z.string(), + pattern: z.string(), + action: Action, + }) + .meta({ + ref: "PermissionRule", + }) + export type Rule = z.infer + + export const Ruleset = Rule.array().meta({ + ref: "PermissionRuleset", + }) + export type Ruleset = z.infer + + 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 + + export const Reply = z.enum(["once", "always", "reject"]) + export type Reply = z.infer + + 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()("PermissionRejectedError", {}) { + override get message() { + return "The user rejected permission to use this specific tool call." + } + } + + export class CorrectedError extends Schema.TaggedErrorClass()("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()("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) => Effect.Effect + readonly reply: (input: z.infer) => Effect.Effect + readonly list: () => Effect.Effect + } + + interface PendingEntry { + info: Request + deferred: Deferred.Deferred + } + + 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()("@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() + const approved: Ruleset = row?.data ?? [] + + const ask = Effect.fn("Permission.ask")(function* (input: z.infer) { + 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() + 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) { + 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 { + const result = new Set() + 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 + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 755ce2c211..9e294f4f53 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -11,7 +11,7 @@ import { CodexAuthPlugin } from "./codex" import { Session } from "../session" import { NamedError } from "@opencode-ai/util/error" import { CopilotAuthPlugin } from "./copilot" -import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth" +import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" export namespace Plugin { const log = Log.create({ service: "plugin" }) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 9e85571c49..9a9e42ecf8 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -79,5 +79,5 @@ export namespace Vcs { }), }) }), - ) + ).pipe(Layer.fresh) } diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts new file mode 100644 index 0000000000..5045e1edd2 --- /dev/null +++ b/packages/opencode/src/provider/auth-service.ts @@ -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 + + 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 + + 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 + | InstanceType + | InstanceType + | InstanceType + + export interface Interface { + readonly methods: () => Effect.Effect> + readonly authorize: (input: { + providerID: ProviderID + method: number + inputs?: Record + }) => Effect.Effect + readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@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() + + 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 + }) { + 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)) +} diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 5204b5fb8d..8ede977a59 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -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 { fn } from "@/util/fn" import { ProviderID } from "./schema" -import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect" import z from "zod" +import { ProviderAuth as S } from "./auth-service" 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 + export const Method = S.Method + export type Method = S.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 + export const Authorization = S.Authorization + export type Authorization = S.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( - "ProviderAuthOauthCodeMissing", - z.object({ providerID: ProviderID.zod }), - ) + export type Interface = S.Interface - 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 - | InstanceType - | InstanceType - | InstanceType - - export interface Interface { - readonly methods: () => Effect.Effect> - readonly authorize: (input: { - providerID: ProviderID - method: number - inputs?: Record - }) => Effect.Effect - readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@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() - - 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 - }) { - 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 const Service = S.Service + export const layer = S.layer + export const defaultLayer = S.defaultLayer export async function methods() { - return runPromiseInstance(Service.use((svc) => svc.methods())) + return runPromiseInstance(S.Service.use((svc) => svc.methods())) } export const authorize = fn( @@ -225,7 +33,8 @@ export namespace ProviderAuth { method: z.number(), inputs: z.record(z.string(), z.string()).optional(), }), - async (input): Promise => runPromiseInstance(Service.use((svc) => svc.authorize(input))), + async (input): Promise => + runPromiseInstance(S.Service.use((svc) => svc.authorize(input))), ) export const callback = fn( @@ -234,6 +43,6 @@ export namespace ProviderAuth { method: z.number(), code: z.string().optional(), }), - async (input) => runPromiseInstance(Service.use((svc) => svc.callback(input))), + async (input) => runPromiseInstance(S.Service.use((svc) => svc.callback(input))), ) } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 9c9c8e8343..6ab45d028b 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -40,7 +40,12 @@ import { createGateway } from "@ai-sdk/gateway" import { createTogetherAI } from "@ai-sdk/togetherai" import { createPerplexity } from "@ai-sdk/perplexity" import { createVercel } from "@ai-sdk/vercel" -import { createGitLab, VERSION as GITLAB_PROVIDER_VERSION } from "@gitlab/gitlab-ai-provider" +import { + createGitLab, + VERSION as GITLAB_PROVIDER_VERSION, + isWorkflowModel, + discoverWorkflowModels, +} from "gitlab-ai-provider" import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { GoogleAuth } from "google-auth-library" import { ProviderTransform } from "./transform" @@ -124,18 +129,20 @@ export namespace Provider { "@ai-sdk/togetherai": createTogetherAI, "@ai-sdk/perplexity": createPerplexity, "@ai-sdk/vercel": createVercel, - "@gitlab/gitlab-ai-provider": createGitLab, + "gitlab-ai-provider": createGitLab, // @ts-ignore (TODO: kill this code so we dont have to maintain it) "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, } type CustomModelLoader = (sdk: any, modelID: string, options?: Record) => Promise type CustomVarsLoader = (options: Record) => Record + type CustomDiscoverModels = () => Promise> type CustomLoader = (provider: Info) => Promise<{ autoload: boolean getModel?: CustomModelLoader vars?: CustomVarsLoader options?: Record + discoverModels?: CustomDiscoverModels }> function useLanguageModel(sdk: any) { @@ -533,28 +540,105 @@ export namespace Provider { ...(providerConfig?.options?.aiGatewayHeaders || {}), } + const featureFlags = { + duo_agent_platform_agentic_chat: true, + duo_agent_platform: true, + ...(providerConfig?.options?.featureFlags || {}), + } + return { autoload: !!apiKey, options: { instanceUrl, apiKey, aiGatewayHeaders, - featureFlags: { - duo_agent_platform_agentic_chat: true, - duo_agent_platform: true, - ...(providerConfig?.options?.featureFlags || {}), - }, + featureFlags, }, - async getModel(sdk: ReturnType, modelID: string) { + async getModel(sdk: ReturnType, modelID: string, options?: Record) { + if (modelID.startsWith("duo-workflow-")) { + const workflowRef = options?.workflowRef as string | undefined + // Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef + const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow" + const model = sdk.workflowChat(sdkModelID, { + featureFlags, + }) + if (workflowRef) { + model.selectedModelRef = workflowRef + } + return model + } return sdk.agenticChat(modelID, { aiGatewayHeaders, - featureFlags: { - duo_agent_platform_agentic_chat: true, - duo_agent_platform: true, - ...(providerConfig?.options?.featureFlags || {}), - }, + featureFlags, }) }, + async discoverModels(): Promise> { + if (!apiKey) { + log.info("gitlab model discovery skipped: no apiKey") + return {} + } + + try { + const token = apiKey + const getHeaders = (): Record => + 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 = {} + for (const m of result.models) { + if (!input.models[m.id]) { + models[m.id] = { + id: ModelID.make(m.id), + providerID: ProviderID.make("gitlab"), + name: `Agent Platform (${m.name})`, + family: "", + api: { + id: m.id, + url: instanceUrl, + npm: "gitlab-ai-provider", + }, + status: "active", + headers: {}, + options: { workflowRef: m.ref }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: m.context, output: m.output }, + capabilities: { + temperature: false, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "", + variants: {}, + } + } + } + + log.info("gitlab model discovery complete", { + count: Object.keys(models).length, + models: Object.keys(models), + }) + return models + } catch (e) { + log.warn("gitlab model discovery failed", { error: e }) + return {} + } + }, } }, "cloudflare-workers-ai": async (input) => { @@ -853,6 +937,9 @@ export namespace Provider { const varsLoaders: { [providerID: string]: CustomVarsLoader } = {} + const discoveryLoaders: { + [providerID: string]: CustomDiscoverModels + } = {} const sdk = new Map() log.info("init") @@ -1009,6 +1096,7 @@ export namespace Provider { if (result && (result.autoload || providers[providerID])) { if (result.getModel) modelLoaders[providerID] = result.getModel if (result.vars) varsLoaders[providerID] = result.vars + if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels const opts = result.options ?? {} const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } mergeProvider(providerID, patch) @@ -1070,6 +1158,18 @@ export namespace Provider { log.info("found", { providerID }) } + const gitlab = ProviderID.make("gitlab") + if (discoveryLoaders[gitlab] && providers[gitlab]) { + await (async () => { + const discovered = await discoveryLoaders[gitlab]() + for (const [modelID, model] of Object.entries(discovered)) { + if (!providers[gitlab].models[modelID]) { + providers[gitlab].models[modelID] = model + } + } + })().catch((e) => log.warn("state discovery error", { id: "gitlab", error: e })) + } + return { models: languages, providers, @@ -1250,7 +1350,7 @@ export namespace Provider { try { const language = s.modelLoaders[model.providerID] - ? await s.modelLoaders[model.providerID](sdk, model.api.id, provider.options) + ? await s.modelLoaders[model.providerID](sdk, model.api.id, { ...provider.options, ...model.options }) : sdk.languageModel(model.api.id) s.models.set(key, language) return language diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 551c513999..de00951908 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,193 +1,49 @@ -import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" import { runPromiseInstance } from "@/effect/runtime" -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" }) +import type { MessageID, SessionID } from "@/session/schema" +import type { QuestionID } from "./schema" +import { Question as S } from "./service" export namespace Question { - // Schemas + export const Option = S.Option + export type Option = S.Option - 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 + export const Info = S.Info + export type Info = S.Info - 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 + export const Request = S.Request + export type Request = S.Request - 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 + export const Answer = S.Answer + export type Answer = S.Answer - export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" }) - export type Answer = z.infer + export const Reply = S.Reply + export type Reply = S.Reply - 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 + export const Event = S.Event + export const RejectedError = S.RejectedError - 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 type Interface = S.Interface - export class RejectedError extends Schema.TaggedErrorClass()("QuestionRejectedError", {}) { - override get message() { - return "The user dismissed this question" - } - } - - interface PendingEntry { - info: Request - deferred: Deferred.Deferred - } - - // Service - - export interface Interface { - readonly ask: (input: { - sessionID: SessionID - questions: Info[] - tool?: { messageID: MessageID; callID: string } - }) => Effect.Effect - readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect - readonly reject: (requestID: QuestionID) => Effect.Effect - readonly list: () => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/Question") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const pending = new Map() - - 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() - 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 const Service = S.Service + export const layer = S.layer export async function ask(input: { sessionID: SessionID questions: Info[] tool?: { messageID: MessageID; callID: string } }): Promise { - 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 { - return runPromiseInstance(Service.use((svc) => svc.reply(input))) + export async function reply(input: { requestID: QuestionID; answers: Answer[] }) { + return runPromiseInstance(S.Service.use((s) => s.reply(input))) } - export async function reject(requestID: QuestionID): Promise { - return runPromiseInstance(Service.use((svc) => svc.reject(requestID))) + export async function reject(requestID: QuestionID) { + return runPromiseInstance(S.Service.use((s) => s.reject(requestID))) } - export async function list(): Promise { - return runPromiseInstance(Service.use((svc) => svc.list())) + export async function list() { + return runPromiseInstance(S.Service.use((s) => s.list())) } } diff --git a/packages/opencode/src/question/service.ts b/packages/opencode/src/question/service.ts new file mode 100644 index 0000000000..a23703e97a --- /dev/null +++ b/packages/opencode/src/question/service.ts @@ -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 + + 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 + + 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 + + export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" }) + export type Answer = z.infer + + 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 + + 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()("QuestionRejectedError", {}) { + override get message() { + return "The user dismissed this question" + } + } + + interface PendingEntry { + info: Request + deferred: Deferred.Deferred + } + + // Service + + export interface Interface { + readonly ask: (input: { + sessionID: SessionID + questions: Info[] + tool?: { messageID: MessageID; callID: string } + }) => Effect.Effect + readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect + readonly reject: (requestID: QuestionID) => Effect.Effect + readonly list: () => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Question") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const pending = new Map() + + 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() + 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) +} diff --git a/packages/opencode/src/server/routes/provider.ts b/packages/opencode/src/server/routes/provider.ts index 3ac3e7c64a..64fe34f450 100644 --- a/packages/opencode/src/server/routes/provider.ts +++ b/packages/opencode/src/server/routes/provider.ts @@ -9,6 +9,9 @@ import { ProviderID } from "../../provider/schema" import { mapValues } from "remeda" import { errors } from "../error" import { lazy } from "../../util/lazy" +import { Log } from "../../util/log" + +const log = Log.create({ service: "server" }) export const ProviderRoutes = lazy(() => new Hono() diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index daf70180e5..748fd3eb21 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -12,6 +12,7 @@ import { jsonSchema, } from "ai" import { mergeDeep, pipe } from "remeda" +import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider" import { ProviderTransform } from "@/provider/transform" import { Config } from "@/config/config" import { Instance } from "@/project/instance" @@ -63,14 +64,14 @@ export namespace LLM { Provider.getProvider(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( [ // 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] : isCodex ? [] : SystemPrompt.provider(input.model)), + ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)), // any custom prompt passed into this call ...input.system, // any custom prompt from last user message @@ -108,10 +109,22 @@ export namespace LLM { mergeDeep(input.agent.options), mergeDeep(variant), ) - if (isCodex) { - options.instructions = SystemPrompt.instructions() + if (isOpenaiOauth) { + 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( "chat.params", { @@ -146,7 +159,9 @@ export namespace LLM { ) 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) @@ -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({ onError(error) { l.error("stream error", { @@ -217,15 +260,7 @@ export namespace LLM { ...headers, }, maxRetries: input.retries ?? 0, - messages: [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...input.messages, - ], + messages, model: wrapLanguageModel({ model: language, middleware: [ diff --git a/packages/opencode/src/session/prompt/codex_header.txt b/packages/opencode/src/session/prompt/codex.txt similarity index 100% rename from packages/opencode/src/session/prompt/codex_header.txt rename to packages/opencode/src/session/prompt/codex.txt diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 63230e6426..80c89618e3 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -7,7 +7,7 @@ import PROMPT_DEFAULT from "./prompt/default.txt" import PROMPT_BEAST from "./prompt/beast.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 type { Provider } from "@/provider/provider" import type { Agent } from "@/agent/agent" @@ -15,14 +15,10 @@ import { PermissionNext } from "@/permission" import { Skill } from "@/skill" export namespace SystemPrompt { - export function instructions() { - return PROMPT_CODEX.trim() - } - export function provider(model: Provider.Model) { - if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX] - if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3")) + if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3")) 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("claude")) return [PROMPT_ANTHROPIC] if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY] diff --git a/packages/opencode/src/skill/service.ts b/packages/opencode/src/skill/service.ts new file mode 100644 index 0000000000..434a51bad9 --- /dev/null +++ b/packages/opencode/src/skill/service.ts @@ -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 + + export const InvalidError = NamedError.create( + "SkillInvalidError", + z.object({ + path: z.string(), + message: z.string().optional(), + issues: z.custom().optional(), + }), + ) + + export const NameMismatchError = NamedError.create( + "SkillNameMismatchError", + z.object({ + path: z.string(), + expected: z.string(), + actual: z.string(), + }), + ) + + type State = { + skills: Record + dirs: Set + task?: Promise + } + + type Cache = State & { + ensure: () => Promise + } + + export interface Interface { + readonly get: (name: string) => Effect.Effect + readonly all: () => Effect.Effect + readonly dirs: () => Effect.Effect + readonly available: (agent?: Agent.Info) => Effect.Effect + } + + 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(), + } + + 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()("@opencode/Skill") {} + + export const layer: Layer.Layer = 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 = 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 [ + "", + ...list.flatMap((skill) => [ + " ", + ` ${skill.name}`, + ` ${skill.description}`, + ` ${pathToFileURL(skill.location).href}`, + " ", + ]), + "", + ].join("\n") + } + + return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n") + } +} diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 5339691a01..ed3e0a4b75 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -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 { Flag } from "@/flag/flag" -import { Global } from "@/global" -import { PermissionNext } from "@/permission" -import { Filesystem } from "@/util/filesystem" -import { Config } from "../config/config" -import { ConfigMarkdown } from "../config/markdown" -import { Glob } from "../util/glob" -import { Log } from "../util/log" -import { Discovery } from "./discovery" +import type { Agent } from "@/agent/agent" +import { Skill as S } from "./service" 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 = S.Info + export type Info = S.Info - export const Info = z.object({ - name: z.string(), - description: z.string(), - location: z.string(), - content: z.string(), - }) - export type Info = z.infer + export const InvalidError = S.InvalidError + export const NameMismatchError = S.NameMismatchError - export const InvalidError = NamedError.create( - "SkillInvalidError", - z.object({ - path: z.string(), - message: z.string().optional(), - issues: z.custom().optional(), - }), - ) + export type Interface = S.Interface - export const NameMismatchError = NamedError.create( - "SkillNameMismatchError", - z.object({ - path: z.string(), - expected: z.string(), - actual: z.string(), - }), - ) + export const Service = S.Service + export const layer = S.layer + export const defaultLayer = S.defaultLayer - type State = { - skills: Record - dirs: Set - task?: Promise - } - - type Cache = State & { - ensure: () => Promise - } - - export interface Interface { - readonly get: (name: string) => Effect.Effect - readonly all: () => Effect.Effect - readonly dirs: () => Effect.Effect - readonly available: (agent?: Agent.Info) => Effect.Effect - } - - 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(), - } - - 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()("@opencode/Skill") {} - - export const layer: Layer.Layer = 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 = layer.pipe( - Layer.provide(Discovery.defaultLayer), - ) + export const fmt = S.fmt 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() { - return runPromiseInstance(Service.use((skill) => skill.all())) + return runPromiseInstance(S.Service.use((skill) => skill.all())) } 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) { - return runPromiseInstance(Service.use((skill) => skill.available(agent))) - } - - export function fmt(list: Info[], opts: { verbose: boolean }) { - if (list.length === 0) return "No skills are currently available." - - if (opts.verbose) { - return [ - "", - ...list.flatMap((skill) => [ - " ", - ` ${skill.name}`, - ` ${skill.description}`, - ` ${pathToFileURL(skill.location).href}`, - " ", - ]), - "", - ].join("\n") - } - - return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n") + return runPromiseInstance(S.Service.use((skill) => skill.available(agent))) } } diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 887bce3341..4f845ca2de 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -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 { AppFileSystem } from "@/filesystem" -import { Config } from "../config/config" -import { Global } from "../global" -import { Log } from "../util/log" +import { Snapshot as S } from "./service" export namespace Snapshot { - export const Patch = z.object({ - hash: z.string(), - files: z.string().array(), - }) - export type Patch = z.infer + export const Patch = S.Patch + export type Patch = S.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 + export const FileDiff = S.FileDiff + export type FileDiff = S.FileDiff + + export type Interface = S.Interface + + export const Service = S.Service + export const layer = S.layer + export const defaultLayer = S.defaultLayer export async function cleanup() { - return runPromiseInstance(Service.use((svc) => svc.cleanup())) + return runPromiseInstance(S.Service.use((svc) => svc.cleanup())) } 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) { - return runPromiseInstance(Service.use((svc) => svc.patch(hash))) + return runPromiseInstance(S.Service.use((svc) => svc.patch(hash))) } 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[]) { - return runPromiseInstance(Service.use((svc) => svc.revert(patches))) + return runPromiseInstance(S.Service.use((svc) => svc.revert(patches))) } 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) { - 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 - readonly track: () => Effect.Effect - readonly patch: (hash: string) => Effect.Effect - readonly restore: (snapshot: string) => Effect.Effect - readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect - readonly diff: (hash: string) => Effect.Effect - readonly diffFull: (from: string, to: string) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@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 }) { - 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() - 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() - - 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), - ) } diff --git a/packages/opencode/src/snapshot/service.ts b/packages/opencode/src/snapshot/service.ts new file mode 100644 index 0000000000..50485d0a7f --- /dev/null +++ b/packages/opencode/src/snapshot/service.ts @@ -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 + + 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 + + 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 + readonly track: () => Effect.Effect + readonly patch: (hash: string) => Effect.Effect + readonly restore: (snapshot: string) => Effect.Effect + readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect + readonly diff: (hash: string) => Effect.Effect + readonly diffFull: (from: string, to: string) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@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 }) { + 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() + 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() + + 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), + ) +} diff --git a/packages/opencode/src/tool/truncate-effect.ts b/packages/opencode/src/tool/truncate-effect.ts index a263cd2943..1b4c6577f3 100644 --- a/packages/opencode/src/tool/truncate-effect.ts +++ b/packages/opencode/src/tool/truncate-effect.ts @@ -9,7 +9,7 @@ import { Log } from "../util/log" import { ToolID } from "./schema" import { TRUNCATION_DIR } from "./truncation-dir" -export namespace TruncateEffect { +export namespace Truncate { const log = Log.create({ service: "truncation" }) const RETENTION = Duration.days(7) diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 159b2d1d5b..1710546383 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -1,6 +1,6 @@ import type { Agent } from "../agent/agent" import { runtime } from "@/effect/runtime" -import { TruncateEffect as S } from "./truncate-effect" +import { Truncate as S } from "./truncate-effect" export namespace Truncate { export const MAX_LINES = S.MAX_LINES diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 2501640822..6effccb6aa 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -4,7 +4,6 @@ import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { Global } from "../global" import { Instance } from "../project/instance" -import { InstanceBootstrap } from "../project/bootstrap" import { Project } from "../project/project" import { Database, eq } from "../storage/db" import { ProjectTable } from "../project/project.sql" @@ -15,7 +14,6 @@ import { git } from "../util/git" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { InstanceContext } from "@/effect/instance-context" -import { runPromiseInstance } from "@/effect/runtime" import { Effect, Layer, ServiceMap } from "effect" export namespace Worktree { @@ -370,10 +368,7 @@ export namespace Worktree { }) }) - const createFromInfoEffect = Effect.fn("Worktree.createFromInfo")(function* ( - info: Info, - startCommand?: string, - ) { + const createFromInfoEffect = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) { return yield* Effect.promise(async (): Promise<() => Promise> => { const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], { cwd: instance.worktree, @@ -407,7 +402,10 @@ export namespace Worktree { const booted = await Instance.provide({ directory: info.directory, - init: InstanceBootstrap, + init: async () => { + const { InstanceBootstrap } = await import("../project/bootstrap") + return InstanceBootstrap() + }, fn: () => undefined, }) .then(() => true) @@ -720,35 +718,40 @@ export namespace Worktree { }), ) + async function run(effect: Effect.Effect) { + const { runPromiseInstance } = await import("@/effect/runtime") + return runPromiseInstance(effect) + } + // --------------------------------------------------------------------------- // Promise facades // --------------------------------------------------------------------------- export async function makeWorktreeInfo(name?: string): Promise { - return runPromiseInstance(Service.use((svc) => svc.makeWorktreeInfo(name))) + return run(Service.use((svc) => svc.makeWorktreeInfo(name))) } export async function createFromInfo(info: Info, startCommand?: string) { - return runPromiseInstance(Service.use((svc) => svc.createFromInfo(info, startCommand))) + return run(Service.use((svc) => svc.createFromInfo(info, startCommand))) } export const create = Object.assign( async (input?: CreateInput) => { - return runPromiseInstance(Service.use((svc) => svc.create(input))) + return run(Service.use((svc) => svc.create(input))) }, { schema: CreateInput.optional() }, ) export const remove = Object.assign( async (input: RemoveInput) => { - return runPromiseInstance(Service.use((svc) => svc.remove(input))) + return run(Service.use((svc) => svc.remove(input))) }, { schema: RemoveInput }, ) export const reset = Object.assign( async (input: ResetInput) => { - return runPromiseInstance(Service.use((svc) => svc.reset(input))) + return run(Service.use((svc) => svc.reset(input))) }, { schema: ResetInput }, ) diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index 098e00de50..f5436e5147 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AccountRepo } from "../../src/account/repo" -import { 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 { Database } from "../../src/storage/db" import { testEffect } from "../lib/effect" @@ -19,7 +19,7 @@ const truncate = Layer.effectDiscard( const it = testEffect(Layer.merge(AccountRepo.layer, truncate)) 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[0], body: unknown, status = 200) => HttpClientResponse.fromWeb( @@ -52,7 +52,7 @@ const deviceTokenClient = (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", () => 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([ [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(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( - Effect.provide(live(client)), - ) + const cfg = yield* Account.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client))) expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 }) 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") if (res._tag === "PollSuccess") { diff --git a/packages/opencode/test/effect/runtime.test.ts b/packages/opencode/test/effect/runtime.test.ts new file mode 100644 index 0000000000..70bf29aaf3 --- /dev/null +++ b/packages/opencode/test/effect/runtime.test.ts @@ -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) + }, + }) + }) +}) diff --git a/packages/opencode/test/fixture/instance.ts b/packages/opencode/test/fixture/instance.ts index ce880d70d9..67af82fc8b 100644 --- a/packages/opencode/test/fixture/instance.ts +++ b/packages/opencode/test/fixture/instance.ts @@ -34,7 +34,7 @@ export function withServices( project: Instance.project, }), ) - let resolved: Layer.Layer = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any + let resolved: Layer.Layer = layer.pipe(Layer.provide(ctx)) as any if (options?.provide) { for (const l of options.provide) { resolved = resolved.pipe(Layer.provide(l)) as any diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index a7cfe50d95..b05c31029e 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -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" -const fetch0 = globalThis.fetch +const encoder = new TextEncoder() -afterEach(() => { - globalThis.fetch = fetch0 -}) +function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest) => Response) { + 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", () => { - test("reads release version from GitHub releases", async () => { - globalThis.fetch = (async () => - new Response(JSON.stringify({ tag_name: "v1.2.3" }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as unknown as typeof fetch + describe("latest", () => { + test("reads release version from GitHub releases", async () => { + const layer = testLayer(() => jsonResponse({ tag_name: "v1.2.3" })) - 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 () => { - globalThis.fetch = (async () => - new Response(JSON.stringify({ version: "2.3.4" }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as unknown as typeof fetch + test("strips v prefix from GitHub release tag", async () => { + const layer = testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" })) - 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 () => { - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - d: { - results: [{ Version: "3.4.5" }], - }, - }), - { - status: 200, - headers: { "content-type": "application/json" }, + test("reads npm registry versions", async () => { + const layer = testLayer( + () => jsonResponse({ version: "1.5.0" }), + (cmd, args) => { + if (cmd === "npm" && args.includes("registry")) return "https://registry.npmjs.org\n" + return "" }, - )) 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") + }) }) }) diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts index f30e3297de..15c797022d 100644 --- a/packages/opencode/test/provider/gitlab-duo.test.ts +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -1,12 +1,13 @@ -import { test, expect } from "bun:test" +import { test, expect, describe } from "bun:test" import path from "path" -import { ProviderID } from "../../src/provider/schema" +import { ProviderID, ModelID } from "../../src/provider/schema" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Provider } from "../../src/provider/provider" import { Env } from "../../src/env" import { Global } from "../../src/global" +import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider" test("GitLab Duo: loads provider with API key from environment", async () => { await using tmp = await tmpdir({ @@ -287,3 +288,121 @@ test("GitLab Duo: has multiple agentic chat models available", async () => { }, }) }) + +describe("GitLab Duo: workflow model routing", () => { + test("duo-workflow-* model routes through workflowChat", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_TOKEN", "test-token") + }, + fn: async () => { + const providers = await Provider.list() + const gitlab = providers[ProviderID.gitlab] + expect(gitlab).toBeDefined() + gitlab.models["duo-workflow-sonnet-4-6"] = { + id: ModelID.make("duo-workflow-sonnet-4-6"), + providerID: ProviderID.make("gitlab"), + name: "Agent Platform (Claude Sonnet 4.6)", + family: "", + api: { id: "duo-workflow-sonnet-4-6", url: "https://gitlab.com", npm: "gitlab-ai-provider" }, + status: "active", + headers: {}, + options: { workflowRef: "claude_sonnet_4_6" }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200000, output: 64000 }, + capabilities: { + temperature: false, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "", + variants: {}, + } + const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-workflow-sonnet-4-6")) + expect(model).toBeDefined() + expect(model.options?.workflowRef).toBe("claude_sonnet_4_6") + const language = await Provider.getLanguage(model) + expect(language).toBeDefined() + expect(language).toBeInstanceOf(GitLabWorkflowLanguageModel) + }, + }) + }) + + test("duo-chat-* model routes through agenticChat (not workflow)", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_TOKEN", "test-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers[ProviderID.gitlab]).toBeDefined() + const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5")) + expect(model).toBeDefined() + const language = await Provider.getLanguage(model) + expect(language).toBeDefined() + expect(language).not.toBeInstanceOf(GitLabWorkflowLanguageModel) + }, + }) + }) + + test("model.options merged with provider.options in getLanguage", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_TOKEN", "test-token") + }, + fn: async () => { + const providers = await Provider.list() + const gitlab = providers[ProviderID.gitlab] + expect(gitlab.options?.featureFlags).toBeDefined() + const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5")) + expect(model).toBeDefined() + expect(model.options).toBeDefined() + }, + }) + }) +}) + +describe("GitLab Duo: static models", () => { + test("static duo-chat models always present regardless of discovery", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_TOKEN", "test-token") + }, + fn: async () => { + const providers = await Provider.list() + const models = Object.keys(providers[ProviderID.gitlab].models) + expect(models).toContain("duo-chat-haiku-4-5") + expect(models).toContain("duo-chat-sonnet-4-5") + expect(models).toContain("duo-chat-opus-4-5") + }, + }) + }) +}) diff --git a/packages/opencode/test/tool/fixtures/models-api.json b/packages/opencode/test/tool/fixtures/models-api.json index 391e783699..715224cd3a 100644 --- a/packages/opencode/test/tool/fixtures/models-api.json +++ b/packages/opencode/test/tool/fixtures/models-api.json @@ -32933,7 +32933,7 @@ "gitlab": { "id": "gitlab", "env": ["GITLAB_TOKEN"], - "npm": "@gitlab/gitlab-ai-provider", + "npm": "gitlab-ai-provider", "name": "GitLab Duo", "doc": "https://docs.gitlab.com/user/duo_agent_platform/", "models": { diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index a00e07e692..032f0bfee2 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect } from "bun:test" import { NodeFileSystem } from "@effect/platform-node" import { Effect, FileSystem, Layer } from "effect" 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 { Process } from "../../src/util/process" import { Filesystem } from "../../src/util/filesystem" @@ -139,7 +139,7 @@ describe("Truncate", () => { describe("cleanup", () => { 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", () => Effect.gen(function* () { @@ -152,7 +152,7 @@ describe("Truncate", () => { yield* writeFileStringScoped(old, "old 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(recent)).toBe(true) diff --git a/packages/web/src/content/docs/ar/providers.mdx b/packages/web/src/content/docs/ar/providers.mdx index f5dd70125f..951d6701cb 100644 --- a/packages/web/src/content/docs/ar/providers.mdx +++ b/packages/web/src/content/docs/ar/providers.mdx @@ -752,7 +752,7 @@ export GITLAB_TOKEN=glpat-... :::note يجب على مدير 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): - `agent_platform_claude_code` - `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 ``` -مزيد من التوثيق على صفحة [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": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -804,7 +800,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/bs/providers.mdx b/packages/web/src/content/docs/bs/providers.mdx index 6bdcf45778..1aae0a93a9 100644 --- a/packages/web/src/content/docs/bs/providers.mdx +++ b/packages/web/src/content/docs/bs/providers.mdx @@ -760,7 +760,7 @@ export GITLAB_TOKEN=glpat-... :::note 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): - `agent_platform_claude_code` - `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 ``` -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 @@ -794,11 +794,7 @@ Prilagodite putem `opencode.json`: "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -812,7 +808,7 @@ Za pristup GitLab alatima (zahtjevi za spajanje, problemi, cjevovodi, CI/CD, itd ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/da/providers.mdx b/packages/web/src/content/docs/da/providers.mdx index c5cfe23fa8..bcd82c8454 100644 --- a/packages/web/src/content/docs/da/providers.mdx +++ b/packages/web/src/content/docs/da/providers.mdx @@ -749,7 +749,7 @@ export GITLAB_TOKEN=glpat-... :::note 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): - `agent_platform_claude_code` - `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 ``` -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 @@ -783,11 +783,7 @@ Tilpas gennem `opencode.json`: "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -801,7 +797,7 @@ For at få adgang til GitLab-værktøjer (merge requests, problemer, pipelines, ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/de/providers.mdx b/packages/web/src/content/docs/de/providers.mdx index fa447594d6..c012a78fc2 100644 --- a/packages/web/src/content/docs/de/providers.mdx +++ b/packages/web/src/content/docs/de/providers.mdx @@ -755,7 +755,7 @@ export GITLAB_TOKEN=glpat-... :::note 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): - `agent_platform_claude_code` - `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 ``` -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 @@ -789,11 +789,7 @@ Anpassen über `opencode.json`: "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -807,7 +803,7 @@ So greifen Sie auf GitLab-Tools zu (Zusammenführungsanfragen, Probleme, Pipelin ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/es/providers.mdx b/packages/web/src/content/docs/es/providers.mdx index 2ee033f00d..b812971409 100644 --- a/packages/web/src/content/docs/es/providers.mdx +++ b/packages/web/src/content/docs/es/providers.mdx @@ -757,7 +757,7 @@ export GITLAB_TOKEN=glpat-... :::note 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): - `agent_platform_claude_code` - `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 ``` -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 @@ -791,11 +791,7 @@ Personalizar a través de `opencode.json`: "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -809,7 +805,7 @@ Para acceder a herramientas GitLab (solicitudes de fusión, problemas, canalizac ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/fr/providers.mdx b/packages/web/src/content/docs/fr/providers.mdx index 36e1ed2d2b..d7b0657971 100644 --- a/packages/web/src/content/docs/fr/providers.mdx +++ b/packages/web/src/content/docs/fr/providers.mdx @@ -763,7 +763,7 @@ export GITLAB_TOKEN=glpat-... :::note 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) : - `agent_platform_claude_code` - `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 ``` -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 @@ -797,11 +797,7 @@ Personnalisez via `opencode.json` : "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -815,7 +811,7 @@ Pour accéder aux outils GitLab (demandes de fusion, tickets, pipelines, CI/CD, ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/it/providers.mdx b/packages/web/src/content/docs/it/providers.mdx index c0c5489d08..58bb28407f 100644 --- a/packages/web/src/content/docs/it/providers.mdx +++ b/packages/web/src/content/docs/it/providers.mdx @@ -733,7 +733,7 @@ export GITLAB_TOKEN=glpat-... :::note 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): - `agent_platform_claude_code` - `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 ``` -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 @@ -767,11 +767,7 @@ Personalizza tramite `opencode.json`: "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -785,7 +781,7 @@ Per accedere agli strumenti GitLab (merge requests, issues, pipelines, CI/CD, ec ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/ja/providers.mdx b/packages/web/src/content/docs/ja/providers.mdx index 388dc8e41d..f7e88d8c5d 100644 --- a/packages/web/src/content/docs/ja/providers.mdx +++ b/packages/web/src/content/docs/ja/providers.mdx @@ -797,7 +797,7 @@ export GITLAB_TOKEN=glpat-... :::note 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 コンソール経由): - `agent_platform_claude_code` - `third_party_agents_enabled` @@ -819,7 +819,7 @@ GitLab 管理者は以下を有効にする必要があります。 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": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -849,7 +845,7 @@ GitLab ツール (マージリクエスト、問題、パイプライン、CI/CD ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/ko/providers.mdx b/packages/web/src/content/docs/ko/providers.mdx index c543c719dd..ccbbc48386 100644 --- a/packages/web/src/content/docs/ko/providers.mdx +++ b/packages/web/src/content/docs/ko/providers.mdx @@ -758,7 +758,7 @@ export GITLAB_TOKEN=glpat-... :::note 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 콘솔을 통해): - `agent_platform_claude_code` - `third_party_agents_enabled` @@ -779,7 +779,7 @@ GitLab 관리자는 다음을 활성화해야 합니다: 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": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -809,7 +805,7 @@ GitLab 도구(병합 요청, 이슈, 파이프라인, CI/CD 등)에 액세스하 ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/nb/providers.mdx b/packages/web/src/content/docs/nb/providers.mdx index 682f923f8c..9e025a96f8 100644 --- a/packages/web/src/content/docs/nb/providers.mdx +++ b/packages/web/src/content/docs/nb/providers.mdx @@ -757,7 +757,7 @@ export GITLAB_TOKEN=glpat-... :::note 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): - `agent_platform_claude_code` - `third_party_agents_enabled` @@ -779,7 +779,7 @@ Utsett deretter applikasjonen ID som miljøvariabel: 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 @@ -791,11 +791,7 @@ Tilpass gjennom `opencode.json`: "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -809,7 +805,7 @@ For å få tilgang til GitLab-verktøy (sammenslåingsforespørsler, problemer, ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/pl/providers.mdx b/packages/web/src/content/docs/pl/providers.mdx index fa50901103..aeb6272331 100644 --- a/packages/web/src/content/docs/pl/providers.mdx +++ b/packages/web/src/content/docs/pl/providers.mdx @@ -755,7 +755,7 @@ export GITLAB_TOKEN=glpat-... :::note 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): - `agent_platform_claude_code` - `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 ``` -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 @@ -789,11 +789,7 @@ Customize through `opencode.json`: "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -807,7 +803,7 @@ To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.): ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 0c0ba30a08..b14c8ab10a 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -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 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 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. @@ -766,13 +820,15 @@ export GITLAB_TOKEN=glpat-... ``` :::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 -2. Feature flags (via Rails console): - - `agent_platform_claude_code` - - `third_party_agents_enabled` - ::: +1. [Turn on GitLab Duo](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/#turn-gitlab-duo-on-or-off) + for the user, group, or instance +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) + (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 @@ -790,7 +846,7 @@ Then expose application ID as environment variable: 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 @@ -802,17 +858,27 @@ Customize through `opencode.json`: "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } } ``` +##### 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) 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" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/pt-br/providers.mdx b/packages/web/src/content/docs/pt-br/providers.mdx index 2ef2ebdc00..4424a55fc0 100644 --- a/packages/web/src/content/docs/pt-br/providers.mdx +++ b/packages/web/src/content/docs/pt-br/providers.mdx @@ -759,7 +759,7 @@ export GITLAB_TOKEN=glpat-... :::note 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): - `agent_platform_claude_code` - `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 ``` -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 @@ -793,11 +793,7 @@ Personalize através do `opencode.json`: "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -811,7 +807,7 @@ Para acessar ferramentas do GitLab (merge requests, issues, pipelines, CI/CD, et ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/ru/providers.mdx b/packages/web/src/content/docs/ru/providers.mdx index c36dfd9f78..1cb3873c32 100644 --- a/packages/web/src/content/docs/ru/providers.mdx +++ b/packages/web/src/content/docs/ru/providers.mdx @@ -755,7 +755,7 @@ export GITLAB_TOKEN=glpat-... :::note Ваш администратор 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): - `agent_platform_claude_code` - `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 ``` -Дополнительная документация на домашней странице [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": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -807,7 +803,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/th/providers.mdx b/packages/web/src/content/docs/th/providers.mdx index 122ade4277..73489b96ad 100644 --- a/packages/web/src/content/docs/th/providers.mdx +++ b/packages/web/src/content/docs/th/providers.mdx @@ -756,7 +756,7 @@ export GITLAB_TOKEN=glpat-... :::note ผู้ดูแลระบบ 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): - `agent_platform_claude_code` - `third_party_agents_enabled` @@ -778,7 +778,7 @@ export GITLAB_TOKEN=glpat-... 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": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -808,7 +804,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/tr/providers.mdx b/packages/web/src/content/docs/tr/providers.mdx index 1ddc65131c..871f9e128c 100644 --- a/packages/web/src/content/docs/tr/providers.mdx +++ b/packages/web/src/content/docs/tr/providers.mdx @@ -757,7 +757,7 @@ export GITLAB_TOKEN=glpat-... :::note 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): - `agent_platform_claude_code` - `third_party_agents_enabled` @@ -779,7 +779,7 @@ Then expose application ID as environment variable: 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 @@ -791,11 +791,7 @@ Daha fazla belge [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/op "provider": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -809,7 +805,7 @@ GitLab araçlarına (birleştirme istekleri, sorunlar, işlem hatları, CI/CD vb ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/zh-cn/providers.mdx b/packages/web/src/content/docs/zh-cn/providers.mdx index 571b730242..25b7d03a4d 100644 --- a/packages/web/src/content/docs/zh-cn/providers.mdx +++ b/packages/web/src/content/docs/zh-cn/providers.mdx @@ -725,7 +725,7 @@ export GITLAB_TOKEN=glpat-... :::note 你的 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 控制台): - `agent_platform_claude_code` - `third_party_agents_enabled` @@ -745,7 +745,7 @@ export GITLAB_TOKEN=glpat-... 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": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -775,7 +771,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ``` diff --git a/packages/web/src/content/docs/zh-tw/providers.mdx b/packages/web/src/content/docs/zh-tw/providers.mdx index b673b1ade5..1024444d18 100644 --- a/packages/web/src/content/docs/zh-tw/providers.mdx +++ b/packages/web/src/content/docs/zh-tw/providers.mdx @@ -746,7 +746,7 @@ export GITLAB_TOKEN=glpat-... :::note 您的 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 控制台): - `agent_platform_claude_code` - `third_party_agents_enabled` @@ -766,7 +766,7 @@ export GITLAB_TOKEN=glpat-... 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": { "gitlab": { "options": { - "instanceUrl": "https://gitlab.com", - "featureFlags": { - "duo_agent_platform_agentic_chat": true, - "duo_agent_platform": true - } + "instanceUrl": "https://gitlab.com" } } } @@ -796,7 +792,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": ["@gitlab/opencode-gitlab-plugin"] + "plugin": ["opencode-gitlab-plugin"] } ```