mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 16:26:14 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d74d166acf |
@@ -239,7 +239,7 @@
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"semver": "^7.6.3",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"venice-ai-sdk-provider": "2.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -494,7 +494,7 @@
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
"ulid": "catalog:",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"venice-ai-sdk-provider": "2.0.1",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
@@ -4912,7 +4912,7 @@
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.2", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.47", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.27" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-aoa05nI3BTK5aGbjBflq+Gfln2AHAkwNbWuGGvCzUIsOfp5Y3iPD4O4PUGDAEiWVJWbjpPn0KfDa0H/HebwsaA=="],
|
||||
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.1", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.37", "@ai-sdk/provider": "^3.0.8", "@ai-sdk/provider-utils": "^4.0.21" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-6SxA8a4MoA6Q/c+D3q7My0Hfog76enN3n0MXhwosM+tso66rXBEGeBRD/0lravRDVzL2Q1w5QJPc86rAVJtfXg=="],
|
||||
|
||||
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
|
||||
|
||||
@@ -5956,11 +5956,7 @@
|
||||
|
||||
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
|
||||
@@ -6662,10 +6658,6 @@
|
||||
|
||||
"unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
|
||||
|
||||
"vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
+1
-10
@@ -3,7 +3,6 @@
|
||||
stdenv,
|
||||
bun,
|
||||
nodejs,
|
||||
darwin,
|
||||
electron_41,
|
||||
makeWrapper,
|
||||
writableTmpDirAsHomeHook,
|
||||
@@ -15,12 +14,7 @@ let
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "opencode-desktop";
|
||||
inherit (opencode)
|
||||
version
|
||||
src
|
||||
node_modules
|
||||
patches
|
||||
;
|
||||
inherit (opencode) version src node_modules;
|
||||
|
||||
nativeBuildInputs = [
|
||||
bun
|
||||
@@ -29,9 +23,6 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
writableTmpDirAsHomeHook
|
||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||
autoPatchelfHook
|
||||
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
|
||||
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
|
||||
darwin.autoSignDarwinBinariesHook
|
||||
];
|
||||
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-qq8cKNcW10NRbPx2T/Gpzby4nx2qHzj0LWDwqyPl7rY=",
|
||||
"aarch64-linux": "sha256-UguK4nObAL/ovGZQousveoXCmkcxEgWy7XSSzxVJNEY=",
|
||||
"aarch64-darwin": "sha256-NibSqZiF0XMbFhOtl2CQvFe4N/BXB5UAdt2iKv7dIcI=",
|
||||
"x86_64-darwin": "sha256-ZNeZSpYitdUjXx/sULAIoLXrM6+Vz+GXQxKpdfgXh60="
|
||||
"x86_64-linux": "sha256-pbVW7cOLT76Q7f++xaYYrwuN7eS6FRen80xoaVog3M4=",
|
||||
"aarch64-linux": "sha256-nk/q4PezhQSf/UHhJuL/188q6e+Gr+PFgrlhEEG5ouo=",
|
||||
"aarch64-darwin": "sha256-IfIJwgvsonrfjG+btK/5YedzX+MJtf/5bYuLrAmnHvU=",
|
||||
"x86_64-darwin": "sha256-Cwd9gEymjFn6XAgkkyX4ZmRTAE1JiurxF4VvVws9C/E="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,9 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
|
||||
const dict = i18n(localeFromRequest(request))
|
||||
|
||||
const limits = Subscription.getFreeLimits()
|
||||
// temporarily disable check headers
|
||||
//const headersExist = Object.entries(limits.checkHeaders).every(
|
||||
// ([name, value]) => request.headers.get(name)?.toLowerCase().includes(value) ?? false,
|
||||
//)
|
||||
//const dailyLimit = !headersExist ? limits.dailyRequestsFallback : (rateLimit ?? limits.dailyRequests)
|
||||
const headersExist = true
|
||||
const headersExist = Object.entries(limits.checkHeaders).every(
|
||||
([name, value]) => request.headers.get(name)?.toLowerCase().includes(value) ?? false,
|
||||
)
|
||||
const dailyLimit = !headersExist ? limits.dailyRequestsFallback : (rateLimit ?? limits.dailyRequests)
|
||||
const isDefaultModel = headersExist && !rateLimit
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"semver": "^7.6.3",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"venice-ai-sdk-provider": "2.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -3,10 +3,9 @@ import { dirname, join, relative, resolve as pathResolve } from "path"
|
||||
import { realpathSync } from "fs"
|
||||
import * as NFS from "fs/promises"
|
||||
import { lookup } from "mime-types"
|
||||
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
||||
import { Effect, FileSystem, Layer, Schema, Context } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { Glob } from "./util/glob"
|
||||
import { serviceUse } from "./effect/service-use"
|
||||
|
||||
export namespace AppFileSystem {
|
||||
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
|
||||
@@ -40,8 +39,6 @@ export namespace AppFileSystem {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
export * as Git from "./git"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
import { AppProcess } from "./process"
|
||||
|
||||
export interface Repo {
|
||||
/**
|
||||
* The root directory of the working tree that contains the input path.
|
||||
*
|
||||
* For `/home/me/app/src/file.ts` in a normal clone, this is `/home/me/app`.
|
||||
* For `/home/me/app-feature/src/file.ts` in a linked worktree, this is
|
||||
* `/home/me/app-feature`.
|
||||
*/
|
||||
readonly directory: AbsolutePath
|
||||
/**
|
||||
* The shared Git storage directory used by this repo and any linked worktrees.
|
||||
*
|
||||
* For a normal clone at `/home/me/app`, this is usually `/home/me/app/.git`.
|
||||
* For a linked worktree at `/home/me/app-feature` whose main checkout is
|
||||
* `/home/me/app`, this is usually `/home/me/app/.git`.
|
||||
*/
|
||||
readonly store: AbsolutePath
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
|
||||
readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
|
||||
readonly roots: (repo: Repo) => Effect.Effect<string[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const proc = yield* AppProcess.Service
|
||||
|
||||
const find = Effect.fn("Git.find")(function* (input: AbsolutePath) {
|
||||
const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
if (!dotgit) return undefined
|
||||
|
||||
const cwd = path.dirname(dotgit)
|
||||
const git = run(cwd, proc)
|
||||
const topLevel = yield* git(["rev-parse", "--show-toplevel"])
|
||||
const commonDir = yield* git(["rev-parse", "--git-common-dir"])
|
||||
if (commonDir.exitCode !== 0) return undefined
|
||||
|
||||
return {
|
||||
directory: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd),
|
||||
store: AbsolutePath.make(resolvePath(cwd, commonDir.text)),
|
||||
} satisfies Repo
|
||||
})
|
||||
|
||||
const remote = Effect.fn("Git.remote")(function* (repo: Repo, name = "origin") {
|
||||
const result = yield* run(repo.directory, proc)(["remote", "get-url", name])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const roots = Effect.fn("Git.roots")(function* (repo: Repo) {
|
||||
const result = yield* run(repo.directory, proc)(["rev-list", "--max-parents=0", "HEAD"])
|
||||
if (result.exitCode !== 0) return []
|
||||
return result.text
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.toSorted()
|
||||
})
|
||||
|
||||
return Service.of({ find, remote, roots })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
)
|
||||
|
||||
interface Result {
|
||||
readonly exitCode: number
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
function run(cwd: string, proc: AppProcess.Interface) {
|
||||
return (args: string[]) =>
|
||||
proc
|
||||
.run(
|
||||
ChildProcess.make("git", args, {
|
||||
cwd,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((result) => ({ exitCode: result.exitCode, text: result.stdout.toString("utf8") }) satisfies Result),
|
||||
Effect.catch(() => Effect.succeed({ exitCode: 1, text: "" } satisfies Result)),
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePath(cwd: string, value: string) {
|
||||
const trimmed = value.replace(/[\r\n]+$/, "")
|
||||
if (!trimmed) return cwd
|
||||
const normalized = AppFileSystem.windowsPath(trimmed)
|
||||
if (path.isAbsolute(normalized)) return path.normalize(normalized)
|
||||
return path.resolve(cwd, normalized)
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
export * as Project from "./project"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { AbsolutePath, withStatics } from "./schema"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
import { Git } from "./git"
|
||||
import { Hash } from "./util/hash"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("Project.ID"),
|
||||
withStatics((schema) => ({
|
||||
global: schema.make("global"),
|
||||
})),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const Vcs = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("git"),
|
||||
store: AbsolutePath,
|
||||
}),
|
||||
])
|
||||
export type Vcs = typeof Vcs.Type
|
||||
|
||||
export class Info extends Schema.Class<Info>("Project.Info")({
|
||||
id: ID,
|
||||
vcs: Schema.optional(Vcs),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly resolve: (input: AbsolutePath) => Effect.Effect<
|
||||
{
|
||||
previous?: ID
|
||||
id: ID
|
||||
directory: AbsolutePath
|
||||
vcs?: Vcs
|
||||
},
|
||||
never
|
||||
>
|
||||
/**
|
||||
* Temporary bridge method for writing the resolved project ID to the repo-local cache.
|
||||
*
|
||||
* This exists while the old opencode project service and this core project
|
||||
* service work together: core resolves the ID, while the old service still owns
|
||||
* database migration and persistence. The old service should call this after it
|
||||
* finishes migrating from `resolve().previous` to `resolve().id`; once project
|
||||
* persistence moves into core, this separate bridge method can go away.
|
||||
*/
|
||||
readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectV2") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
const cached = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
|
||||
Effect.map((value) => value.trim()),
|
||||
Effect.map((value) => (value ? ID.make(value) : undefined)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
})
|
||||
|
||||
const remote = Effect.fnUntraced(function* (repo: Git.Repo) {
|
||||
const origin = yield* git.remote(repo)
|
||||
if (!origin) return undefined
|
||||
const normalized = url(origin)
|
||||
if (!normalized) return undefined
|
||||
return ID.make(Hash.fast(`git-remote:${normalized}`))
|
||||
})
|
||||
|
||||
function url(input: string) {
|
||||
const value = input.trim()
|
||||
if (!value) return undefined
|
||||
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
if (parsed.protocol === "file:") return undefined
|
||||
return parts(parsed.hostname, parsed.pathname)
|
||||
} catch {
|
||||
const scp = value.match(/^([^@/:]+@)?([^/:]+):(.+)$/)
|
||||
if (scp) return parts(scp[2], scp[3])
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function parts(host: string, name: string) {
|
||||
const pathname = name
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/\.git\/?$/, "")
|
||||
.replace(/\/+$/, "")
|
||||
if (!host || !pathname) return undefined
|
||||
return `${host.toLowerCase()}/${pathname}`
|
||||
}
|
||||
|
||||
const root = Effect.fnUntraced(function* (repo: Git.Repo) {
|
||||
const root = (yield* git.roots(repo))[0]
|
||||
return root ? ID.make(root) : undefined
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
|
||||
const repo = yield* git.find(input)
|
||||
if (!repo) return { id: ID.global, directory: input, vcs: undefined }
|
||||
|
||||
const previous = yield* cached(repo.store)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
|
||||
return {
|
||||
previous,
|
||||
id: id ?? ID.global,
|
||||
directory: repo.directory,
|
||||
vcs: { type: "git" as const, store: repo.store },
|
||||
}
|
||||
})
|
||||
|
||||
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
|
||||
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
return Service.of({ resolve, commit })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
@@ -1,11 +1,5 @@
|
||||
import { Option, Schema, SchemaGetter } from "effect"
|
||||
|
||||
export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath"))
|
||||
export type AbsolutePath = typeof AbsolutePath.Type
|
||||
|
||||
export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath"))
|
||||
export type RelativePath = typeof RelativePath.Type
|
||||
|
||||
/**
|
||||
* Integer greater than zero.
|
||||
*/
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Project.defaultLayer)
|
||||
|
||||
function remoteID(remote: string) {
|
||||
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
|
||||
}
|
||||
|
||||
function abs(value: string) {
|
||||
return AbsolutePath.make(value)
|
||||
}
|
||||
|
||||
function real(value: string) {
|
||||
return Effect.promise(() => fs.realpath(value)).pipe(Effect.map((value) => AbsolutePath.make(value)))
|
||||
}
|
||||
|
||||
async function initRepo(dir: string, opts?: { commit?: boolean; remote?: string }) {
|
||||
await $`git init`.cwd(dir).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(dir).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(dir).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(dir).quiet()
|
||||
await $`git config user.name Test`.cwd(dir).quiet()
|
||||
if (opts?.commit) await $`git commit --allow-empty -m root`.cwd(dir).quiet()
|
||||
if (opts?.remote) await $`git remote add origin ${opts.remote}`.cwd(dir).quiet()
|
||||
}
|
||||
|
||||
async function rootCommit(dir: string) {
|
||||
return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim()
|
||||
}
|
||||
|
||||
describe("ProjectV2.resolve", () => {
|
||||
it.live("returns global for non-git directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.id).toBe(Project.ID.make("global"))
|
||||
expect(path.resolve(result.directory)).toBe(path.resolve(tmp.path))
|
||||
expect(result.previous).toBeUndefined()
|
||||
expect(result.vcs).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns git global for repo with no commits and no remote", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(tmp.path))
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.id).toBe(Project.ID.make("global"))
|
||||
expect(result.directory).toBe(yield* real(tmp.path))
|
||||
expect(result.previous).toBeUndefined()
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("falls back to root commit when origin is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
|
||||
expect(result.directory).toBe(yield* real(tmp.path))
|
||||
expect(result.previous).toBeUndefined()
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("prefers normalized origin over root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:Acme/App.git" }))
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.id).toBe(remoteID("github.com/Acme/App"))
|
||||
expect(result.id).not.toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
|
||||
expect(result.directory).toBe(yield* real(tmp.path))
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("normalizes ssh and https remotes to the same id", () =>
|
||||
Effect.gen(function* () {
|
||||
const ssh = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const https = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
|
||||
yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
|
||||
const project = yield* Project.Service
|
||||
|
||||
const a = yield* project.resolve(abs(ssh.path))
|
||||
const b = yield* project.resolve(abs(https.path))
|
||||
|
||||
expect(a.id).toBe(remoteID("github.com/owner/repo"))
|
||||
expect(b.id).toBe(a.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores file remotes and falls back to root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: `file://${tmp.path}` }))
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns previous cached id from common dir", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.previous).toBe(Project.ID.make("old-id"))
|
||||
expect(result.id).toBe(remoteID("github.com/owner/repo"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not write the cache while resolving", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
|
||||
const project = yield* Project.Service
|
||||
|
||||
yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "opencode")).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("resolves from nested directories to repo root", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true }))
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
|
||||
|
||||
expect(result.directory).toBe(yield* real(tmp.path))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("linked worktree returns opened worktree directory and previous from common dir", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const worktree = `${tmp.path}-worktree`
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => $`rm -rf ${worktree}`.quiet().nothrow()).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
|
||||
yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet())
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.resolve(abs(worktree))
|
||||
|
||||
expect(result.directory).toBe(yield* real(worktree))
|
||||
expect(result.previous).toBe(Project.ID.make("old-id"))
|
||||
expect(result.id).toBe(remoteID("github.com/owner/repo"))
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,4 @@
|
||||
import windowState from "electron-window-state"
|
||||
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
|
||||
import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
|
||||
import oc2ThemeJson from "../../../ui/src/theme/themes/oc-2.json"
|
||||
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } from "electron"
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
@@ -18,11 +15,6 @@ const rendererHost = "renderer"
|
||||
const clipboardWritePermission = "clipboard-sanitized-write"
|
||||
const notificationPermission = "notifications"
|
||||
const rendererPermissions = new Set([clipboardWritePermission, notificationPermission])
|
||||
const oc2Theme = oc2ThemeJson as DesktopTheme
|
||||
const oc2Background = {
|
||||
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
|
||||
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
|
||||
}
|
||||
const documentPolicyHeader = "Document-Policy"
|
||||
const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports"
|
||||
|
||||
@@ -54,7 +46,6 @@ export function setRelaunchHandler(handler: () => void) {
|
||||
|
||||
export function setBackgroundColor(color: string) {
|
||||
backgroundColor = color
|
||||
BrowserWindow.getAllWindows().forEach((win) => win.setBackgroundColor(color))
|
||||
}
|
||||
|
||||
export function getBackgroundColor(): string | undefined {
|
||||
@@ -74,10 +65,6 @@ function tone() {
|
||||
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
|
||||
}
|
||||
|
||||
function defaultBackgroundColor() {
|
||||
return oc2Background[tone()]
|
||||
}
|
||||
|
||||
function overlay(theme: Partial<TitlebarTheme> = {}, zoom = 1) {
|
||||
const mode = theme.mode ?? tone()
|
||||
return {
|
||||
@@ -133,7 +120,7 @@ export function createMainWindow() {
|
||||
autoHideMenuBar: true,
|
||||
title: "OpenCode",
|
||||
icon: iconPath(),
|
||||
backgroundColor: backgroundColor ?? defaultBackgroundColor(),
|
||||
backgroundColor,
|
||||
...(process.platform === "darwin"
|
||||
? {
|
||||
titleBarStyle: "hidden" as const,
|
||||
@@ -191,7 +178,7 @@ export function createLoadingWindow() {
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
icon: iconPath(),
|
||||
backgroundColor: backgroundColor ?? defaultBackgroundColor(),
|
||||
backgroundColor,
|
||||
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
|
||||
...(process.platform === "win32"
|
||||
? {
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
"ulid": "catalog:",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"venice-ai-sdk-provider": "2.0.1",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
HttpClient,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { Effect, Layer, Option, Schema, Context } from "effect"
|
||||
|
||||
import { Database } from "@/storage/db"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Config } from "@/config/config"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BusEvent } from "./bus-event"
|
||||
import { GlobalBus } from "./global"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { Identifier } from "@/id/id"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import os from "os"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Database } from "@/storage/db"
|
||||
import { asc } from "drizzle-orm"
|
||||
|
||||
+1
-6
@@ -15,7 +15,6 @@ type ServiceUse<Identifier, Shape> = {
|
||||
}
|
||||
|
||||
export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, Shape>) => {
|
||||
const cache = new Map<string, (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>>()
|
||||
// This is the only dynamic boundary: TypeScript knows the accessor shape,
|
||||
// but Proxy property names are runtime values.
|
||||
const access = new Proxy(
|
||||
@@ -23,9 +22,7 @@ export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, S
|
||||
{
|
||||
get: (_, key) => {
|
||||
if (typeof key !== "string") return undefined
|
||||
const cached = cache.get(key)
|
||||
if (cached) return cached
|
||||
const accessor = (...args: unknown[]) =>
|
||||
return (...args: unknown[]) =>
|
||||
tag.use((service) => {
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime.
|
||||
const method = service[key as keyof Shape]
|
||||
@@ -33,8 +30,6 @@ export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, S
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods.
|
||||
return (method as (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>)(...args)
|
||||
})
|
||||
cache.set(key, accessor)
|
||||
return accessor
|
||||
},
|
||||
},
|
||||
)
|
||||
Vendored
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
type State = Record<string, string | undefined>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from "path"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Layer, Schema, Context, Stream } from "effect"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { errorMessage } from "@/util/error"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from "path"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Effect, Layer, Context, Option, Schema } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { disposeInstance as runDisposers } from "@/effect/instance-registry"
|
||||
|
||||
@@ -2,8 +2,7 @@ import { and } from "drizzle-orm"
|
||||
import { Database } from "@/storage/db"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { ProjectTable } from "./project.sql"
|
||||
import { PermissionTable, SessionTable } from "../session/session.sql"
|
||||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
import { SessionTable } from "../session/session.sql"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
@@ -13,14 +12,13 @@ import { ProjectID } from "./schema"
|
||||
import { Bus } from "@/bus"
|
||||
import { Command } from "@/command"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Effect, Layer, Scope, Context, Stream, Types, Schema } from "effect"
|
||||
import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { Project as ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
||||
const log = Log.create({ service: "project" })
|
||||
@@ -88,10 +86,6 @@ export function fromRow(row: Row): Info {
|
||||
}
|
||||
}
|
||||
|
||||
function mergePermissionRules<T extends readonly unknown[]>(oldRules: T, newRules: T): T {
|
||||
return [...new Map([...oldRules, ...newRules].map((rule) => [JSON.stringify(rule), rule])).values()] as unknown as T
|
||||
}
|
||||
|
||||
export const UpdateInput = Schema.Struct({
|
||||
projectID: ProjectID,
|
||||
name: Schema.optional(Schema.String),
|
||||
@@ -138,13 +132,16 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pr
|
||||
|
||||
type GitResult = { code: number; text: string; stderr: string }
|
||||
|
||||
export const layer = Layer.effect(
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Bus.Service | RuntimeFlags.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const proc = yield* AppProcess.Service
|
||||
const pathSvc = yield* Path.Path
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const projectV2 = yield* ProjectV2.Service
|
||||
const bus = yield* Bus.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
@@ -178,74 +175,115 @@ export const layer = Layer.effect(
|
||||
|
||||
const fakeVcs = Schema.decodeUnknownSync(Schema.optional(ProjectVcs))(Flag.OPENCODE_FAKE_VCS)
|
||||
|
||||
const resolveGitPath = (cwd: string, name: string) => {
|
||||
if (!name) return cwd
|
||||
name = name.replace(/[\r\n]+$/, "")
|
||||
if (!name) return cwd
|
||||
name = AppFileSystem.windowsPath(name)
|
||||
if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
|
||||
return pathSvc.resolve(cwd, name)
|
||||
}
|
||||
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const migrateProjectId = Effect.fn("Project.migrateProjectId")(function* (
|
||||
oldID: ProjectID | undefined,
|
||||
newID: ProjectID,
|
||||
) {
|
||||
if (!oldID) return
|
||||
if (oldID === ProjectID.global) return
|
||||
if (oldID === newID) return
|
||||
|
||||
yield* Effect.sync(() =>
|
||||
Database.transaction(
|
||||
(d) => {
|
||||
const oldProject = d.select().from(ProjectTable).where(eq(ProjectTable.id, oldID)).get()
|
||||
const newProject = d.select().from(ProjectTable).where(eq(ProjectTable.id, newID)).get()
|
||||
if (oldProject && !newProject) {
|
||||
d.insert(ProjectTable)
|
||||
.values({
|
||||
...oldProject,
|
||||
id: newID,
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
const oldPermission = d.select().from(PermissionTable).where(eq(PermissionTable.project_id, oldID)).get()
|
||||
const newPermission = d.select().from(PermissionTable).where(eq(PermissionTable.project_id, newID)).get()
|
||||
if (oldPermission && newPermission) {
|
||||
d.update(PermissionTable)
|
||||
.set({
|
||||
data: mergePermissionRules(oldPermission.data, newPermission.data),
|
||||
time_created: Math.min(oldPermission.time_created, newPermission.time_created),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.where(eq(PermissionTable.project_id, newID))
|
||||
.run()
|
||||
d.delete(PermissionTable).where(eq(PermissionTable.project_id, oldID)).run()
|
||||
}
|
||||
if (oldPermission && !newPermission) {
|
||||
d.update(PermissionTable).set({ project_id: newID }).where(eq(PermissionTable.project_id, oldID)).run()
|
||||
}
|
||||
|
||||
d.update(SessionTable).set({ project_id: newID }).where(eq(SessionTable.project_id, oldID)).run()
|
||||
d.update(WorkspaceTable).set({ project_id: newID }).where(eq(WorkspaceTable.project_id, oldID)).run()
|
||||
|
||||
if (oldProject) d.delete(ProjectTable).where(eq(ProjectTable.id, oldID)).run()
|
||||
},
|
||||
{ behavior: "immediate" },
|
||||
),
|
||||
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
|
||||
Effect.map((x) => x.trim()),
|
||||
Effect.map((x) => ProjectID.make(x)),
|
||||
Effect.catch(() => Effect.void),
|
||||
)
|
||||
})
|
||||
|
||||
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
|
||||
log.info("fromDirectory", { directory })
|
||||
|
||||
const data = yield* projectV2.resolve(AbsolutePath.make(directory))
|
||||
const worktree = data.id === ProjectV2.ID.make("global") && !data.vcs ? "/" : data.directory
|
||||
// Phase 1: discover git info
|
||||
type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
|
||||
|
||||
const data: DiscoveryResult = yield* Effect.gen(function* () {
|
||||
const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
|
||||
const dotgit = dotgitMatches[0]
|
||||
|
||||
if (!dotgit) {
|
||||
return {
|
||||
id: ProjectID.global,
|
||||
worktree: "/",
|
||||
sandbox: "/",
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
}
|
||||
|
||||
let sandbox = pathSvc.dirname(dotgit)
|
||||
const gitBinary = yield* Effect.sync(() => which("git"))
|
||||
let id = yield* readCachedProjectId(dotgit)
|
||||
|
||||
if (!gitBinary) {
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
}
|
||||
|
||||
const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
|
||||
if (commonDir.code !== 0) {
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
}
|
||||
const common = resolveGitPath(sandbox, commonDir.text.trim())
|
||||
const bareCheck = yield* git(["config", "--bool", "core.bare"], { cwd: sandbox })
|
||||
const isBareRepo = bareCheck.code === 0 && bareCheck.text.trim() === "true"
|
||||
const worktree = common === sandbox ? sandbox : isBareRepo ? common : pathSvc.dirname(common)
|
||||
|
||||
if (id == null) {
|
||||
id = yield* readCachedProjectId(common)
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
|
||||
const roots = revList.text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((x) => x.trim())
|
||||
.toSorted()
|
||||
|
||||
id = roots[0] ? ProjectID.make(roots[0]) : undefined
|
||||
if (id) {
|
||||
yield* fs.writeFileString(pathSvc.join(common, "opencode"), id).pipe(Effect.ignore)
|
||||
}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
|
||||
}
|
||||
|
||||
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
|
||||
if (topLevel.code !== 0) {
|
||||
return {
|
||||
id,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
}
|
||||
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
|
||||
|
||||
return { id, sandbox, worktree, vcs: "git" as const }
|
||||
})
|
||||
|
||||
// Phase 2: upsert
|
||||
const projectID = ProjectID.make(data.id)
|
||||
yield* migrateProjectId(data.previous ? ProjectID.make(data.previous) : undefined, projectID)
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get())
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
|
||||
const existing = row
|
||||
? fromRow(row)
|
||||
: {
|
||||
id: projectID,
|
||||
worktree,
|
||||
vcs: data.vcs?.type ?? fakeVcs,
|
||||
id: data.id,
|
||||
worktree: data.worktree,
|
||||
vcs: data.vcs,
|
||||
sandboxes: [] as string[],
|
||||
time: { created: Date.now(), updated: Date.now() },
|
||||
}
|
||||
@@ -254,16 +292,12 @@ export const layer = Layer.effect(
|
||||
|
||||
const result: Info = {
|
||||
...existing,
|
||||
worktree: projectID === ProjectID.global ? worktree : existing.worktree,
|
||||
vcs: data.vcs?.type ?? fakeVcs,
|
||||
worktree: data.worktree,
|
||||
vcs: data.vcs,
|
||||
time: { ...existing.time, updated: Date.now() },
|
||||
}
|
||||
if (
|
||||
projectID !== ProjectID.global &&
|
||||
data.directory !== result.worktree &&
|
||||
!result.sandboxes.includes(data.directory)
|
||||
)
|
||||
result.sandboxes.push(data.directory)
|
||||
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
|
||||
result.sandboxes.push(data.sandbox)
|
||||
result.sandboxes = yield* Effect.forEach(
|
||||
result.sandboxes,
|
||||
(s) =>
|
||||
@@ -309,21 +343,18 @@ export const layer = Layer.effect(
|
||||
.run(),
|
||||
)
|
||||
|
||||
if (projectID !== ProjectID.global) {
|
||||
if (data.id !== ProjectID.global) {
|
||||
yield* db((d) =>
|
||||
d
|
||||
.update(SessionTable)
|
||||
.set({ project_id: projectID })
|
||||
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.directory)))
|
||||
.set({ project_id: data.id })
|
||||
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
|
||||
.run(),
|
||||
)
|
||||
}
|
||||
|
||||
yield* emitUpdated(result)
|
||||
if (projectID !== ProjectID.global && data.vcs?.type === "git") {
|
||||
yield* projectV2.commit({ store: data.vcs.store, id: data.id })
|
||||
}
|
||||
return { project: result, sandbox: data.vcs ? data.directory : worktree }
|
||||
return { project: result, sandbox: data.sandbox }
|
||||
})
|
||||
|
||||
const discover = Effect.fn("Project.discover")(function* (input: Info) {
|
||||
@@ -479,10 +510,9 @@ export const layer = Layer.effect(
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Bus.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { Auth } from "@/auth"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { Plugin } from "../plugin"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { type LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import * as ModelsDev from "@opencode-ai/core/models-dev"
|
||||
import { Auth } from "../auth"
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Effect, Layer, Context, Schema } from "effect"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { isOverflow as overflow, usable } from "./overflow"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { SessionEvent } from "@opencode-ai/core/session-event"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import path from "path"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type * as SDK from "@opencode-ai/sdk/v2"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Account } from "@/account/account"
|
||||
|
||||
@@ -16,7 +16,6 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Discovery } from "./discovery"
|
||||
import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" }
|
||||
import { isRecord } from "@/util/record"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
|
||||
const log = Log.create({ service: "skill" })
|
||||
const CLAUDE_EXTERNAL_DIR = ".claude"
|
||||
@@ -244,8 +243,6 @@ const loadSkills = Effect.fnUntraced(function* (state: State, discovered: Discov
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Skill") {}
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { EventID } from "./schema"
|
||||
import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
|
||||
@@ -55,7 +55,6 @@ import { Reference } from "@/reference/reference"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
|
||||
const log = Log.create({ service: "tool.registry" })
|
||||
|
||||
@@ -82,8 +81,6 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {}
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
|
||||
@@ -340,7 +340,7 @@ export const ShellTool = Tool.define(
|
||||
const trunc = yield* Truncate.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const defaultTimeoutMs = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000
|
||||
const defaultTimeout = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000
|
||||
|
||||
const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) {
|
||||
const lines = yield* spawner
|
||||
@@ -601,7 +601,7 @@ export const ShellTool = Tool.define(
|
||||
const shell = Shell.acceptable(cfg.shell)
|
||||
const name = Shell.name(shell)
|
||||
const limits = yield* trunc.limits()
|
||||
const prompt = ShellPrompt.render(name, process.platform, limits, defaultTimeoutMs)
|
||||
const prompt = ShellPrompt.render(name, process.platform, limits)
|
||||
log.info("shell tool using shell", { shell })
|
||||
|
||||
return {
|
||||
@@ -616,7 +616,7 @@ export const ShellTool = Tool.define(
|
||||
if (params.timeout !== undefined && params.timeout < 0) {
|
||||
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
||||
}
|
||||
const timeout = params.timeout ?? defaultTimeoutMs
|
||||
const timeout = params.timeout ?? defaultTimeout
|
||||
const ps = Shell.ps(shell)
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -83,7 +83,7 @@ function chainGuidance(name: string) {
|
||||
return "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
||||
}
|
||||
|
||||
function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: number) {
|
||||
function bashCommandSection(chain: string, limits: Limits) {
|
||||
return `Before executing the command, please follow these steps:
|
||||
|
||||
1. Directory Verification:
|
||||
@@ -102,7 +102,7 @@ function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: num
|
||||
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
|
||||
@@ -127,13 +127,7 @@ Usage notes:
|
||||
</bad-example>`
|
||||
}
|
||||
|
||||
function powershellCommandSection(
|
||||
name: string,
|
||||
chain: string,
|
||||
pathSep: string,
|
||||
limits: Limits,
|
||||
defaultTimeoutMs: number,
|
||||
) {
|
||||
function powershellCommandSection(name: string, chain: string, pathSep: string, limits: Limits) {
|
||||
return `${powershellNotes(name)}
|
||||
|
||||
Before executing the command, please follow these steps:
|
||||
@@ -154,7 +148,7 @@ Before executing the command, please follow these steps:
|
||||
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
|
||||
@@ -179,7 +173,7 @@ Usage notes:
|
||||
</bad-example>`
|
||||
}
|
||||
|
||||
function cmdCommandSection(chain: string, limits: Limits, defaultTimeoutMs: number) {
|
||||
function cmdCommandSection(chain: string, limits: Limits) {
|
||||
return `# cmd.exe shell notes
|
||||
- Use double quotes for paths with spaces.
|
||||
- Use %VAR% for environment variables.
|
||||
@@ -204,7 +198,7 @@ Before executing the command, please follow these steps:
|
||||
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
|
||||
@@ -229,7 +223,7 @@ Usage notes:
|
||||
</bad-example>`
|
||||
}
|
||||
|
||||
function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaultTimeoutMs: number) {
|
||||
function profile(name: string, platform: NodeJS.Platform, limits: Limits) {
|
||||
const isPowerShell = PS.has(name)
|
||||
const chain = chainGuidance(name)
|
||||
if (CMD.has(name)) {
|
||||
@@ -237,7 +231,7 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul
|
||||
intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`,
|
||||
workdirSection:
|
||||
"All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.",
|
||||
commandSection: cmdCommandSection(chain, limits, defaultTimeoutMs),
|
||||
commandSection: cmdCommandSection(chain, limits),
|
||||
gitCommands: "git commands",
|
||||
gitCommandRestriction: "git commands",
|
||||
createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.",
|
||||
@@ -250,13 +244,7 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul
|
||||
intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`,
|
||||
workdirSection:
|
||||
"All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.",
|
||||
commandSection: powershellCommandSection(
|
||||
name,
|
||||
chain,
|
||||
platform === "win32" ? "\\" : "/",
|
||||
limits,
|
||||
defaultTimeoutMs,
|
||||
),
|
||||
commandSection: powershellCommandSection(name, chain, platform === "win32" ? "\\" : "/", limits),
|
||||
gitCommands: "git commands",
|
||||
gitCommandRestriction: "git commands",
|
||||
createPrInstruction: "Create PR using gh pr create with a PowerShell here-string to pass the body correctly.",
|
||||
@@ -272,7 +260,7 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul
|
||||
"Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.",
|
||||
workdirSection:
|
||||
"All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd <directory> && <command>` patterns - use `workdir` instead.",
|
||||
commandSection: bashCommandSection(chain, limits, defaultTimeoutMs),
|
||||
commandSection: bashCommandSection(chain, limits),
|
||||
gitCommands: "bash commands",
|
||||
gitCommandRestriction: "git bash commands",
|
||||
createPrInstruction:
|
||||
@@ -284,8 +272,8 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul
|
||||
}
|
||||
}
|
||||
|
||||
export function render(name: string, platform: NodeJS.Platform, limits: Limits, defaultTimeoutMs: number) {
|
||||
const selected = profile(name, platform, limits, defaultTimeoutMs)
|
||||
export function render(name: string, platform: NodeJS.Platform, limits: Limits) {
|
||||
const selected = profile(name, platform, limits)
|
||||
return {
|
||||
description: renderPrompt(DESCRIPTION, {
|
||||
intro: selected.intro,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { $ } from "bun"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { File } from "../../src/file"
|
||||
import { disposeAllInstances, TestInstance, withTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -47,11 +48,6 @@ const gitAddAll = (directory: string) => Effect.promise(() => $`git add .`.cwd(d
|
||||
const gitCommit = (directory: string, message: string) =>
|
||||
Effect.promise(() => $`git commit -m ${message}`.cwd(directory).quiet())
|
||||
|
||||
const writeFixtureFile = (directory: string, file: string, content: string | Uint8Array) =>
|
||||
AppFileSystem.use.writeWithDirs(path.join(directory, file), content)
|
||||
|
||||
const removeFixtureFile = (directory: string, file: string) => AppFileSystem.use.remove(path.join(directory, file))
|
||||
|
||||
const failureMessage = <A, E, R>(self: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* self.pipe(Effect.exit)
|
||||
@@ -76,7 +72,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("reads text file via Filesystem.readText()", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeFixtureFile(test.directory, "test.txt", "Hello World")
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "Hello World", "utf-8"))
|
||||
|
||||
const result = yield* read("test.txt")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -95,7 +91,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("trims whitespace from text content", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeFixtureFile(test.directory, "test.txt", " content with spaces \n\n")
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "test.txt"), " content with spaces \n\n", "utf-8"),
|
||||
)
|
||||
|
||||
const result = yield* read("test.txt")
|
||||
expect(result.content).toBe("content with spaces")
|
||||
@@ -105,7 +103,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("handles empty text file", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeFixtureFile(test.directory, "empty.txt", "")
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "empty.txt"), "", "utf-8"))
|
||||
|
||||
const result = yield* read("empty.txt")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -116,7 +114,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("handles multi-line text files", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeFixtureFile(test.directory, "multiline.txt", "line1\nline2\nline3")
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "multiline.txt"), "line1\nline2\nline3", "utf-8"),
|
||||
)
|
||||
|
||||
const result = yield* read("multiline.txt")
|
||||
expect(result.content).toBe("line1\nline2\nline3")
|
||||
@@ -129,7 +129,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
yield* writeFixtureFile(test.directory, "image.png", binaryContent)
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "image.png"), binaryContent))
|
||||
|
||||
const result = yield* read("image.png")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -142,7 +142,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("returns empty for binary non-image files", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeFixtureFile(test.directory, "binary.so", Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "binary.so"), Buffer.from([0x7f, 0x45, 0x4c, 0x46])),
|
||||
)
|
||||
|
||||
const result = yield* read("binary.so")
|
||||
expect(result.type).toBe("binary")
|
||||
@@ -156,7 +158,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "test.json")
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, '{"key": "value"}')
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, '{"key": "value"}', "utf-8"))
|
||||
|
||||
expect(AppFileSystem.mimeType(filepath)).toContain("application/json")
|
||||
|
||||
@@ -177,7 +179,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const filepath = path.join(test.directory, `test.${testCase.ext}`)
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]))
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00])))
|
||||
expect(AppFileSystem.mimeType(filepath)).toContain(testCase.mime)
|
||||
}
|
||||
}),
|
||||
@@ -286,7 +288,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("treats .ts files as text", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeFixtureFile(test.directory, "test.ts", "export const value = 1")
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "test.ts"), "export const value = 1", "utf-8"),
|
||||
)
|
||||
|
||||
const result = yield* read("test.ts")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -297,7 +301,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("treats .mts files as text", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeFixtureFile(test.directory, "test.mts", "export const value = 1")
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "test.mts"), "export const value = 1", "utf-8"),
|
||||
)
|
||||
|
||||
const result = yield* read("test.mts")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -308,7 +314,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("treats .sh files as text", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeFixtureFile(test.directory, "test.sh", "#!/usr/bin/env bash\necho hello")
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "test.sh"), "#!/usr/bin/env bash\necho hello", "utf-8"),
|
||||
)
|
||||
|
||||
const result = yield* read("test.sh")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -319,7 +327,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("treats Dockerfile as text", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeFixtureFile(test.directory, "Dockerfile", "FROM alpine:3.20")
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "Dockerfile"), "FROM alpine:3.20", "utf-8"))
|
||||
|
||||
const result = yield* read("Dockerfile")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -330,7 +338,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("returns encoding info for text files", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeFixtureFile(test.directory, "test.txt", "simple text")
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "simple text", "utf-8"))
|
||||
|
||||
const result = yield* read("test.txt")
|
||||
expect(result.encoding).toBeUndefined()
|
||||
@@ -341,7 +349,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("returns base64 encoding for images", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeFixtureFile(test.directory, "test.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xe0]))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "test.jpg"), Buffer.from([0xff, 0xd8, 0xff, 0xe0])),
|
||||
)
|
||||
|
||||
const result = yield* read("test.jpg")
|
||||
expect(result.encoding).toBe("base64")
|
||||
@@ -371,10 +381,10 @@ describe("file/index Filesystem patterns", () => {
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "file.txt")
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, "original\n")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "original\n", "utf-8"))
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "add file")
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, "modified\nextra line\n")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "modified\nextra line\n", "utf-8"))
|
||||
|
||||
const result = yield* status()
|
||||
const entry = result.find((file) => file.path === "file.txt")
|
||||
@@ -391,7 +401,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeFixtureFile(test.directory, "new.txt", "line1\nline2\nline3\n")
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "new.txt"), "line1\nline2\nline3\n", "utf-8"),
|
||||
)
|
||||
|
||||
const result = yield* status()
|
||||
const entry = result.find((file) => file.path === "new.txt")
|
||||
@@ -409,10 +421,10 @@ describe("file/index Filesystem patterns", () => {
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "gone.txt")
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, "content\n")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "content\n", "utf-8"))
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "add file")
|
||||
yield* AppFileSystem.use.remove(filepath)
|
||||
yield* Effect.promise(() => fs.rm(filepath))
|
||||
|
||||
const result = yield* status()
|
||||
const entries = result.filter((file) => file.path === "gone.txt")
|
||||
@@ -426,14 +438,14 @@ describe("file/index Filesystem patterns", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeFixtureFile(test.directory, "keep.txt", "keep\n")
|
||||
yield* writeFixtureFile(test.directory, "remove.txt", "remove\n")
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "keep\n", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "remove.txt"), "remove\n", "utf-8"))
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "initial")
|
||||
|
||||
yield* writeFixtureFile(test.directory, "keep.txt", "changed\n")
|
||||
yield* removeFixtureFile(test.directory, "remove.txt")
|
||||
yield* writeFixtureFile(test.directory, "brand-new.txt", "hello\n")
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "changed\n", "utf-8"))
|
||||
yield* Effect.promise(() => fs.rm(path.join(test.directory, "remove.txt")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "brand-new.txt"), "hello\n", "utf-8"))
|
||||
|
||||
const result = yield* status()
|
||||
expect(result.some((file) => file.path === "keep.txt" && file.status === "modified")).toBe(true)
|
||||
@@ -464,15 +476,13 @@ describe("file/index Filesystem patterns", () => {
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "data.bin")
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
filepath,
|
||||
Buffer.from(Array.from({ length: 256 }, (_, index) => index)),
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(filepath, Buffer.from(Array.from({ length: 256 }, (_, index) => index))),
|
||||
)
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "add binary")
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
filepath,
|
||||
Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256)),
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(filepath, Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256))),
|
||||
)
|
||||
|
||||
const result = yield* status()
|
||||
@@ -492,9 +502,11 @@ describe("file/index Filesystem patterns", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "subdir"))
|
||||
yield* writeFixtureFile(test.directory, "file.txt", "content")
|
||||
yield* writeFixtureFile(test.directory, path.join("subdir", "nested.txt"), "nested")
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "subdir")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "content", "utf-8"))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "subdir", "nested.txt"), "nested", "utf-8"),
|
||||
)
|
||||
|
||||
const nodes = yield* list()
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(2)
|
||||
@@ -515,10 +527,10 @@ describe("file/index Filesystem patterns", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "beta"))
|
||||
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "alpha"))
|
||||
yield* writeFixtureFile(test.directory, "zz.txt", "")
|
||||
yield* writeFixtureFile(test.directory, "aa.txt", "")
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "beta")))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "alpha")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "zz.txt"), "", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "aa.txt"), "", "utf-8"))
|
||||
|
||||
const nodes = yield* list()
|
||||
const dirs = nodes.filter((node) => node.type === "directory")
|
||||
@@ -539,8 +551,8 @@ describe("file/index Filesystem patterns", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeFixtureFile(test.directory, ".DS_Store", "")
|
||||
yield* writeFixtureFile(test.directory, "visible.txt", "")
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".DS_Store"), "", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "visible.txt"), "", "utf-8"))
|
||||
|
||||
const names = (yield* list()).map((node) => node.name)
|
||||
expect(names).not.toContain(".git")
|
||||
@@ -555,10 +567,10 @@ describe("file/index Filesystem patterns", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeFixtureFile(test.directory, ".gitignore", "*.log\nbuild/\n")
|
||||
yield* writeFixtureFile(test.directory, "app.log", "log data")
|
||||
yield* writeFixtureFile(test.directory, "main.ts", "code")
|
||||
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "build"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".gitignore"), "*.log\nbuild/\n", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "app.log"), "log data", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "main.ts"), "code", "utf-8"))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "build")))
|
||||
|
||||
const nodes = yield* list()
|
||||
expect(nodes.find((node) => node.name === "app.log")?.ignored).toBe(true)
|
||||
@@ -573,9 +585,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "sub"))
|
||||
yield* writeFixtureFile(test.directory, path.join("sub", "a.txt"), "")
|
||||
yield* writeFixtureFile(test.directory, path.join("sub", "b.txt"), "")
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "sub")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "a.txt"), "", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "b.txt"), "", "utf-8"))
|
||||
|
||||
const nodes = yield* list("sub")
|
||||
expect(nodes.length).toBe(2)
|
||||
@@ -597,7 +609,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("works without git", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeFixtureFile(test.directory, "file.txt", "hi")
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "hi", "utf-8"))
|
||||
|
||||
const nodes = yield* list()
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(1)
|
||||
@@ -743,7 +755,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
yield* init()
|
||||
expect(yield* search({ query: "fresh", type: "file" })).toEqual([])
|
||||
|
||||
yield* writeFixtureFile(test.directory, "fresh.ts", "fresh")
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "fresh.ts"), "fresh", "utf-8"))
|
||||
|
||||
expect(yield* search({ query: "fresh", type: "file" })).toContain("fresh.ts")
|
||||
}),
|
||||
@@ -758,10 +770,10 @@ describe("file/index Filesystem patterns", () => {
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "file.txt")
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, "original content\n")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "original content\n", "utf-8"))
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "add file")
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, "modified content\n")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "modified content\n", "utf-8"))
|
||||
|
||||
const result = yield* read("file.txt")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -781,10 +793,10 @@ describe("file/index Filesystem patterns", () => {
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "staged.txt")
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, "before\n")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "before\n", "utf-8"))
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "add file")
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, "after\n")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "after\n", "utf-8"))
|
||||
yield* gitAddAll(test.directory)
|
||||
|
||||
const result = yield* read("staged.txt")
|
||||
@@ -800,7 +812,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "clean.txt")
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, "unchanged\n")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "unchanged\n", "utf-8"))
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "add file")
|
||||
|
||||
@@ -820,14 +832,14 @@ describe("file/index Filesystem patterns", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const one = yield* TestInstance
|
||||
yield* writeFixtureFile(one.directory, "a.ts", "one")
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(one.directory, "a.ts"), "one", "utf-8"))
|
||||
yield* init()
|
||||
expect(yield* search({ query: "a.ts", type: "file" })).toContain("a.ts")
|
||||
expect(yield* search({ query: "b.ts", type: "file" })).not.toContain("b.ts")
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const two = yield* TestInstance
|
||||
yield* writeFixtureFile(two.directory, "b.ts", "two")
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(two.directory, "b.ts"), "two", "utf-8"))
|
||||
yield* init()
|
||||
expect(yield* search({ query: "b.ts", type: "file" })).toContain("b.ts")
|
||||
expect(yield* search({ query: "a.ts", type: "file" })).not.toContain("a.ts")
|
||||
@@ -841,14 +853,14 @@ describe("file/index Filesystem patterns", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeFixtureFile(test.directory, "before.ts", "before")
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "before.ts"), "before", "utf-8"))
|
||||
yield* init()
|
||||
expect(yield* search({ query: "before", type: "file" })).toContain("before.ts")
|
||||
|
||||
yield* Effect.promise(() => disposeAllInstances())
|
||||
|
||||
yield* writeFixtureFile(test.directory, "after.ts", "after")
|
||||
yield* removeFixtureFile(test.directory, "before.ts")
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "after.ts"), "after", "utf-8"))
|
||||
yield* Effect.promise(() => fs.rm(path.join(test.directory, "before.ts")))
|
||||
|
||||
yield* init()
|
||||
expect(yield* search({ query: "after", type: "file" })).toContain("after.ts")
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect } from "effect"
|
||||
import { pollWithTimeout, testEffect } from "../lib/effect"
|
||||
import { requireInstance, TestInstance } from "../fixture/fixture"
|
||||
import { tmpdir, withTestInstance } from "../fixture/fixture"
|
||||
import { LSPClient } from "@/lsp/client"
|
||||
import * as LSPServer from "@/lsp/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const it = testEffect(AppFileSystem.defaultLayer)
|
||||
|
||||
function spawnFakeServer() {
|
||||
const { spawn } = require("child_process")
|
||||
const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js")
|
||||
@@ -21,164 +16,202 @@ function spawnFakeServer() {
|
||||
}
|
||||
}
|
||||
|
||||
const createClient = (handle: LSPServer.Handle, initialization?: LSPServer.Handle["initialization"]) =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const instance = yield* requireInstance
|
||||
return yield* Effect.promise(() =>
|
||||
LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: initialization ? { ...handle, initialization } : handle,
|
||||
root: test.directory,
|
||||
directory: test.directory,
|
||||
instance,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const createScopedClient = (handle: LSPServer.Handle, initialization?: LSPServer.Handle["initialization"]) =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* createClient(handle, initialization)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => client.shutdown()).pipe(Effect.ignore))
|
||||
return client
|
||||
})
|
||||
|
||||
const writeFile = (file: string, content: string) => AppFileSystem.use.writeWithDirs(file, content)
|
||||
|
||||
describe("LSPClient interop", () => {
|
||||
beforeEach(async () => {
|
||||
await Log.init({ print: true })
|
||||
})
|
||||
|
||||
it.instance("handles workspace/workspaceFolders request", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
test("handles workspace/workspaceFolders request", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
client.connection.sendNotification("test/trigger", {
|
||||
method: "workspace/workspaceFolders",
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: process.cwd(),
|
||||
directory: process.cwd(),
|
||||
instance: ctx,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
|
||||
expect(client.connection).toBeDefined()
|
||||
}),
|
||||
)
|
||||
await client.connection.sendNotification("test/trigger", {
|
||||
method: "workspace/workspaceFolders",
|
||||
})
|
||||
|
||||
it.instance("handles client/registerCapability request", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
expect(client.connection).toBeDefined()
|
||||
await client.shutdown()
|
||||
})
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
client.connection.sendNotification("test/trigger", {
|
||||
method: "client/registerCapability",
|
||||
test("handles client/registerCapability request", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: process.cwd(),
|
||||
directory: process.cwd(),
|
||||
instance: ctx,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
|
||||
expect(client.connection).toBeDefined()
|
||||
}),
|
||||
)
|
||||
await client.connection.sendNotification("test/trigger", {
|
||||
method: "client/registerCapability",
|
||||
})
|
||||
|
||||
it.instance("handles client/unregisterCapability request", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
expect(client.connection).toBeDefined()
|
||||
await client.shutdown()
|
||||
})
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
client.connection.sendNotification("test/trigger", {
|
||||
method: "client/unregisterCapability",
|
||||
test("handles client/unregisterCapability request", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: process.cwd(),
|
||||
directory: process.cwd(),
|
||||
instance: ctx,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
|
||||
expect(client.connection).toBeDefined()
|
||||
}),
|
||||
)
|
||||
await client.connection.sendNotification("test/trigger", {
|
||||
method: "client/unregisterCapability",
|
||||
})
|
||||
|
||||
it.instance("initialize does not overclaim unsupported diagnostics capabilities", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
expect(client.connection).toBeDefined()
|
||||
await client.shutdown()
|
||||
})
|
||||
|
||||
const params = yield* Effect.promise(() =>
|
||||
client.connection.sendRequest<{
|
||||
capabilities: {
|
||||
workspace: { diagnostics: { refreshSupport: boolean } }
|
||||
textDocument: { publishDiagnostics: { versionSupport: boolean } }
|
||||
}
|
||||
}>("test/get-initialize-params", {}),
|
||||
)
|
||||
expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false)
|
||||
expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false)
|
||||
}),
|
||||
)
|
||||
test("initialize does not overclaim unsupported diagnostics capabilities", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
|
||||
it.instance("workspace/configuration returns one result per requested item", () =>
|
||||
Effect.gen(function* () {
|
||||
const initialization = {
|
||||
alpha: {
|
||||
beta: 1,
|
||||
},
|
||||
gamma: true,
|
||||
}
|
||||
|
||||
const client = yield* createScopedClient(spawnFakeServer(), initialization)
|
||||
|
||||
const response = yield* Effect.promise(() =>
|
||||
client.connection.sendRequest<unknown[]>("test/request-configuration", {
|
||||
items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}],
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: process.cwd(),
|
||||
directory: process.cwd(),
|
||||
instance: ctx,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
expect(response).toEqual([{ beta: 1 }, 1, null, initialization])
|
||||
}),
|
||||
)
|
||||
const params = await client.connection.sendRequest<any>("test/get-initialize-params", {})
|
||||
expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false)
|
||||
expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false)
|
||||
|
||||
it.instance("sends ranged didChange for incremental sync servers", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = path.join(test.directory, "client.ts")
|
||||
yield* writeFile(file, "first\n")
|
||||
await client.shutdown()
|
||||
})
|
||||
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
test("workspace/configuration returns one result per requested item", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
const initialization = {
|
||||
alpha: {
|
||||
beta: 1,
|
||||
},
|
||||
gamma: true,
|
||||
}
|
||||
|
||||
yield* Effect.promise(() => client.notify.open({ path: file }))
|
||||
yield* writeFile(file, "second\nthird\n")
|
||||
yield* Effect.promise(() => client.notify.open({ path: file }))
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: {
|
||||
...(handle as unknown as LSPServer.Handle),
|
||||
initialization,
|
||||
},
|
||||
root: process.cwd(),
|
||||
directory: process.cwd(),
|
||||
instance: ctx,
|
||||
}),
|
||||
})
|
||||
|
||||
const change = yield* Effect.promise(() =>
|
||||
client.connection.sendRequest<{
|
||||
const response = await client.connection.sendRequest<any[]>("test/request-configuration", {
|
||||
items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}],
|
||||
})
|
||||
|
||||
expect(response).toEqual([{ beta: 1 }, 1, null, initialization])
|
||||
|
||||
await client.shutdown()
|
||||
})
|
||||
|
||||
test("sends ranged didChange for incremental sync servers", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.ts")
|
||||
await Bun.write(file, "first\n")
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
await client.notify.open({ path: file })
|
||||
await Bun.write(file, "second\nthird\n")
|
||||
await client.notify.open({ path: file })
|
||||
|
||||
const change = await client.connection.sendRequest<{
|
||||
textDocument: { version: number }
|
||||
contentChanges: {
|
||||
range?: { start: { line: number; character: number }; end: { line: number; character: number } }
|
||||
text: string
|
||||
}[]
|
||||
}>("test/get-last-change", {}),
|
||||
)
|
||||
expect(change.textDocument.version).toBe(1)
|
||||
expect(change.contentChanges).toEqual([
|
||||
{
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 1, character: 0 },
|
||||
}>("test/get-last-change", {})
|
||||
expect(change.textDocument.version).toBe(1)
|
||||
expect(change.contentChanges).toEqual([
|
||||
{
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 1, character: 0 },
|
||||
},
|
||||
text: "second\nthird\n",
|
||||
},
|
||||
text: "second\nthird\n",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
])
|
||||
|
||||
it.instance("document mode falls back to push diagnostics", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = path.join(test.directory, "client.ts")
|
||||
yield* writeFile(file, "const x = 1\n")
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
test("document mode falls back to push diagnostics", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.ts")
|
||||
await Bun.write(file, "const x = 1\n")
|
||||
|
||||
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
|
||||
const wait = client.waitForDiagnostics({ path: file, version, mode: "document" })
|
||||
yield* Effect.promise(() =>
|
||||
client.connection.sendNotification("test/publish-diagnostics", {
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
const version = await client.notify.open({ path: file })
|
||||
const wait = client.waitForDiagnostics({ path: file, version, mode: "document" })
|
||||
await client.connection.sendNotification("test/publish-diagnostics", {
|
||||
uri: pathToFileURL(file).href,
|
||||
version,
|
||||
diagnostics: [
|
||||
@@ -191,30 +224,40 @@ describe("LSPClient interop", () => {
|
||||
severity: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
yield* Effect.promise(() => wait)
|
||||
})
|
||||
await wait
|
||||
|
||||
const diagnostics = client.diagnostics.get(file) ?? []
|
||||
expect(diagnostics).toHaveLength(1)
|
||||
expect(diagnostics[0]?.message).toBe("push diagnostic")
|
||||
const diagnostics = client.diagnostics.get(file) ?? []
|
||||
expect(diagnostics).toHaveLength(1)
|
||||
expect(diagnostics[0]?.message).toBe("push diagnostic")
|
||||
|
||||
const count = yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
|
||||
expect(count).toBe(0)
|
||||
}),
|
||||
)
|
||||
const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {})
|
||||
expect(count).toBe(0)
|
||||
|
||||
it.instance("document mode accepts matching push diagnostics published before waiting", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = path.join(test.directory, "client.ts")
|
||||
yield* writeFile(file, "const x = 1\n")
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
test("document mode accepts matching push diagnostics published before waiting", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.ts")
|
||||
await Bun.write(file, "const x = 1\n")
|
||||
|
||||
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
|
||||
yield* Effect.promise(() =>
|
||||
client.connection.sendNotification("test/publish-diagnostics", {
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
const version = await client.notify.open({ path: file })
|
||||
await client.connection.sendNotification("test/publish-diagnostics", {
|
||||
uri: pathToFileURL(file).href,
|
||||
version,
|
||||
diagnostics: [
|
||||
@@ -227,31 +270,41 @@ describe("LSPClient interop", () => {
|
||||
severity: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const diagnostic = yield* pollWithTimeout(
|
||||
Effect.sync(() => client.diagnostics.get(file)?.[0]),
|
||||
"push diagnostic was not published",
|
||||
)
|
||||
expect(diagnostic.message).toBe("push diagnostic")
|
||||
for (let i = 0; i < 20 && (client.diagnostics.get(file)?.length ?? 0) === 0; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
}
|
||||
|
||||
const started = Date.now()
|
||||
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "document" }))
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
}),
|
||||
)
|
||||
expect(client.diagnostics.get(file)?.[0]?.message).toBe("push diagnostic")
|
||||
|
||||
it.instance("document mode waits for pull diagnostics", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = path.join(test.directory, "client.cs")
|
||||
yield* writeFile(file, "class C {}\n")
|
||||
const started = Date.now()
|
||||
await client.waitForDiagnostics({ path: file, version, mode: "document" })
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
test("document mode waits for pull diagnostics", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.cs")
|
||||
await Bun.write(file, "class C {}\n")
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
await client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
registerOn: "didOpen",
|
||||
registrations: [{ identifier: "DocumentCompilerSemantic" }],
|
||||
documentDiagnosticsByIdentifier: {
|
||||
@@ -266,31 +319,41 @@ describe("LSPClient interop", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
|
||||
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "document" }))
|
||||
const version = await client.notify.open({ path: file })
|
||||
await client.waitForDiagnostics({ path: file, version, mode: "document" })
|
||||
|
||||
const diagnostics = client.diagnostics.get(file) ?? []
|
||||
expect(diagnostics).toHaveLength(1)
|
||||
expect(diagnostics[0]?.message).toBe("pull diagnostic")
|
||||
const diagnostics = client.diagnostics.get(file) ?? []
|
||||
expect(diagnostics).toHaveLength(1)
|
||||
expect(diagnostics[0]?.message).toBe("pull diagnostic")
|
||||
|
||||
const count = yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
|
||||
expect(count).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {})
|
||||
expect(count).toBeGreaterThan(0)
|
||||
|
||||
it.instance("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = path.join(test.directory, "client.cs")
|
||||
yield* writeFile(file, "class C {}\n")
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
test("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.cs")
|
||||
await Bun.write(file, "class C {}\n")
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
await client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
registrations: [{ identifier: "fast" }, { identifier: "slow" }],
|
||||
documentDiagnosticsByIdentifier: {
|
||||
fast: [
|
||||
@@ -308,34 +371,43 @@ describe("LSPClient interop", () => {
|
||||
documentDelayMsByIdentifier: {
|
||||
slow: 2_500,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
|
||||
yield* Effect.promise(() => client.connection.sendRequest("test/register-configured-pull-diagnostics", {}))
|
||||
const started = Date.now()
|
||||
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "document" }))
|
||||
const version = await client.notify.open({ path: file })
|
||||
await client.connection.sendRequest("test/register-configured-pull-diagnostics", {})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
const started = Date.now()
|
||||
await client.waitForDiagnostics({ path: file, version, mode: "document" })
|
||||
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic")
|
||||
expect(
|
||||
yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {})),
|
||||
).toBeGreaterThan(1)
|
||||
}),
|
||||
)
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic")
|
||||
expect(await client.connection.sendRequest("test/get-diagnostic-request-count", {})).toBeGreaterThan(1)
|
||||
|
||||
it.instance("full mode includes workspace pull diagnostics", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = path.join(test.directory, "client.cs")
|
||||
const related = path.join(test.directory, "other.cs")
|
||||
yield* writeFile(file, "class C {}\n")
|
||||
yield* writeFile(related, "class D {}\n")
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
test("full mode includes workspace pull diagnostics", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.cs")
|
||||
const related = path.join(tmp.path, "other.cs")
|
||||
await Bun.write(file, "class C {}\n")
|
||||
await Bun.write(related, "class D {}\n")
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
await client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
registerOn: "didOpen",
|
||||
registrations: [
|
||||
{ identifier: "DocumentCompilerSemantic" },
|
||||
@@ -370,40 +442,52 @@ describe("LSPClient interop", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
|
||||
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "full" }))
|
||||
const version = await client.notify.open({ path: file })
|
||||
await client.waitForDiagnostics({ path: file, version, mode: "full" })
|
||||
|
||||
expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file")
|
||||
expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file")
|
||||
}),
|
||||
)
|
||||
expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file")
|
||||
expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file")
|
||||
|
||||
it.instance("full mode treats an empty workspace pull response as handled", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = path.join(test.directory, "client.cs")
|
||||
yield* writeFile(file, "class C {}\n")
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
test("full mode treats an empty workspace pull response as handled", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.cs")
|
||||
await Bun.write(file, "class C {}\n")
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
await client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
registerOn: "didOpen",
|
||||
registrations: [{ identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }],
|
||||
workspaceDiagnosticsByIdentifier: {
|
||||
WorkspaceDocumentsAndProject: [],
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
|
||||
const started = Date.now()
|
||||
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "full" }))
|
||||
const version = await client.notify.open({ path: file })
|
||||
const started = Date.now()
|
||||
await client.waitForDiagnostics({ path: file, version, mode: "full" })
|
||||
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
}),
|
||||
)
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,21 +7,10 @@ import path from "path"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { Database } from "@/storage/db"
|
||||
import { ProjectTable } from "@/project/project.sql"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import { PermissionTable } from "@/session/session.sql"
|
||||
import { WorkspaceTable } from "@/control-plane/workspace.sql"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Cause, Effect, Exit, Layer, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { Project as ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
@@ -30,11 +19,14 @@ void Log.init({ print: false })
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer)
|
||||
const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer)
|
||||
const it = testEffect(layer)
|
||||
|
||||
function remoteProjectID(remote: string) {
|
||||
return ProjectID.make(Hash.fast(`git-remote:${remote}`))
|
||||
function run<A, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
|
||||
return Effect.gen(function* () {
|
||||
const svc = yield* Project.Service
|
||||
return yield* fn(svc)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,9 +66,7 @@ function mockGitFailure(failArg: string) {
|
||||
|
||||
function projectLayerWithFailure(failArg: string) {
|
||||
return Project.layer.pipe(
|
||||
Layer.provide(AppProcess.layer.pipe(Layer.provide(mockGitFailure(failArg)))),
|
||||
Layer.provide(mockGitFailure(failArg)),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(Bus.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
@@ -87,8 +77,6 @@ function projectLayerWithFailure(failArg: string) {
|
||||
function projectLayerWithRuntimeFlags(flags: Parameters<typeof RuntimeFlags.layer>[0]) {
|
||||
return Project.layer.pipe(
|
||||
Layer.provide(Bus.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
Layer.provide(RuntimeFlags.layer(flags)),
|
||||
@@ -96,18 +84,10 @@ function projectLayerWithRuntimeFlags(flags: Parameters<typeof RuntimeFlags.laye
|
||||
}
|
||||
|
||||
const failureIt = (failArg: string) =>
|
||||
testEffect(
|
||||
Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer),
|
||||
)
|
||||
testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
const iconDiscoveryIt = testEffect(
|
||||
Layer.mergeAll(
|
||||
Layer.provideMerge(
|
||||
projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }),
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
),
|
||||
AppFileSystem.defaultLayer,
|
||||
),
|
||||
Layer.provideMerge(projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }), CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
function waitForProjectIcon(id: ProjectID, attempts = 50): Effect.Effect<Project.Info> {
|
||||
@@ -126,7 +106,7 @@ describe("Project.fromDirectory", () => {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
|
||||
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
|
||||
expect(project).toBeDefined()
|
||||
expect(project.id).toBe(ProjectID.global)
|
||||
@@ -134,7 +114,7 @@ describe("Project.fromDirectory", () => {
|
||||
expect(project.worktree).toBe(tmp)
|
||||
|
||||
const opencodeFile = path.join(tmp, ".git", "opencode")
|
||||
expect(yield* AppFileSystem.use.existsSafe(opencodeFile)).toBe(false)
|
||||
expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -142,19 +122,22 @@ describe("Project.fromDirectory", () => {
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
|
||||
expect(project).toBeDefined()
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
expect(project.vcs).toBe("git")
|
||||
expect(project.worktree).toBe(tmp)
|
||||
|
||||
const opencodeFile = path.join(tmp, ".git", "opencode")
|
||||
expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns global for non-git directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
expect(project.id).toBe(ProjectID.global)
|
||||
}),
|
||||
)
|
||||
@@ -162,98 +145,11 @@ describe("Project.fromDirectory", () => {
|
||||
it.live("derives stable project ID from root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project: a } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project: b } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project: a } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project: b } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
expect(b.id).toBe(a.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("prefers normalized origin remote over root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:Test-Org/Test-Repo.git`.cwd(tmp).quiet())
|
||||
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
expect(project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("normalizes equivalent origin URL forms to the same project ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const ssh = yield* tmpdirScoped({ git: true })
|
||||
const https = yield* tmpdirScoped({ git: true })
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:owner/repo.git`.cwd(ssh).quiet())
|
||||
yield* Effect.promise(() => $`git remote add origin https://github.com/owner/repo.git`.cwd(https).quiet())
|
||||
|
||||
const { project: a } = yield* Project.use.fromDirectory(ssh)
|
||||
const { project: b } = yield* Project.use.fromDirectory(https)
|
||||
|
||||
expect(a.id).toBe(remoteProjectID("github.com/owner/repo"))
|
||||
expect(b.id).toBe(a.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("migrates cached root project data when origin becomes available", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project: rootProject } = yield* Project.use.fromDirectory(tmp)
|
||||
const remoteID = remoteProjectID("github.com/acme/app")
|
||||
const sessionID = crypto.randomUUID() as SessionID
|
||||
const workspaceID = WorkspaceID.ascending()
|
||||
|
||||
yield* Effect.sync(() => {
|
||||
Database.use((db) => {
|
||||
db.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: rootProject.id,
|
||||
slug: sessionID,
|
||||
directory: tmp,
|
||||
title: "test",
|
||||
version: "0.0.0-test",
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.run()
|
||||
db.insert(PermissionTable)
|
||||
.values({
|
||||
project_id: rootProject.id,
|
||||
data: [{ permission: "edit", pattern: "*", action: "allow" }],
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.run()
|
||||
db.insert(WorkspaceTable)
|
||||
.values({
|
||||
id: workspaceID,
|
||||
type: "local",
|
||||
name: "test",
|
||||
project_id: rootProject.id,
|
||||
})
|
||||
.run()
|
||||
})
|
||||
})
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:acme/app.git`.cwd(tmp).quiet())
|
||||
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
expect(project.id).toBe(remoteID)
|
||||
expect(
|
||||
Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, rootProject.id)).get()),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())?.project_id,
|
||||
).toBe(remoteID)
|
||||
expect(
|
||||
Database.use((db) => db.select().from(PermissionTable).where(eq(PermissionTable.project_id, remoteID)).get()),
|
||||
).toBeDefined()
|
||||
expect(
|
||||
Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get())
|
||||
?.project_id,
|
||||
).toBe(remoteID)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Project.fromDirectory git failure paths", () => {
|
||||
@@ -263,7 +159,7 @@ describe("Project.fromDirectory git failure paths", () => {
|
||||
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
|
||||
|
||||
// rev-list fails because HEAD doesn't exist yet: this is the natural scenario.
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
expect(project.vcs).toBe("git")
|
||||
expect(project.id).toBe(ProjectID.global)
|
||||
expect(project.worktree).toBe(tmp)
|
||||
@@ -274,7 +170,7 @@ describe("Project.fromDirectory git failure paths", () => {
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const { project, sandbox } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
expect(project.worktree).toBe(tmp)
|
||||
expect(sandbox).toBe(tmp)
|
||||
}),
|
||||
@@ -284,7 +180,7 @@ describe("Project.fromDirectory git failure paths", () => {
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const { project, sandbox } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
expect(project.worktree).toBe(tmp)
|
||||
expect(sandbox).toBe(tmp)
|
||||
}),
|
||||
@@ -296,7 +192,7 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const { project, sandbox } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
|
||||
expect(project.worktree).toBe(tmp)
|
||||
expect(sandbox).toBe(tmp)
|
||||
@@ -304,7 +200,7 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("tracks a linked worktree as the opened project directory", () =>
|
||||
it.live("should set worktree to root when called from a worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
@@ -319,11 +215,11 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp).quiet())
|
||||
|
||||
const { project, sandbox } = yield* Project.use.fromDirectory(worktreePath)
|
||||
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(worktreePath))
|
||||
|
||||
expect(project.worktree).toBe(worktreePath)
|
||||
expect(project.worktree).toBe(tmp)
|
||||
expect(sandbox).toBe(worktreePath)
|
||||
expect(project.sandboxes).not.toContain(worktreePath)
|
||||
expect(project.sandboxes).toContain(worktreePath)
|
||||
expect(project.sandboxes).not.toContain(tmp)
|
||||
}),
|
||||
)
|
||||
@@ -332,7 +228,7 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const { project: main } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project: main } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
|
||||
const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-wt-shared")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
@@ -345,12 +241,13 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp).quiet())
|
||||
|
||||
const { project: wt } = yield* Project.use.fromDirectory(worktreePath)
|
||||
const { project: wt } = yield* run((svc) => svc.fromDirectory(worktreePath))
|
||||
|
||||
expect(wt.id).toBe(main.id)
|
||||
|
||||
// Cache should live in the common .git dir, not the worktree's .git file
|
||||
const cache = path.join(tmp, ".git", "opencode")
|
||||
const exists = yield* AppFileSystem.use.existsSafe(cache)
|
||||
const exists = yield* Effect.promise(() => Bun.file(cache).exists())
|
||||
expect(exists).toBe(true)
|
||||
}),
|
||||
)
|
||||
@@ -368,8 +265,8 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet())
|
||||
yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet())
|
||||
|
||||
const { project: a } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project: b } = yield* Project.use.fromDirectory(clone)
|
||||
const { project: a } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project: b } = yield* run((svc) => svc.fromDirectory(clone))
|
||||
|
||||
expect(b.id).toBe(a.id)
|
||||
}),
|
||||
@@ -400,10 +297,11 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
yield* Effect.promise(() => $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp).quiet())
|
||||
yield* Effect.promise(() => $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp).quiet())
|
||||
|
||||
yield* Project.use.fromDirectory(worktree1)
|
||||
const { project } = yield* Project.use.fromDirectory(worktree2)
|
||||
yield* run((svc) => svc.fromDirectory(worktree1))
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(worktree2))
|
||||
|
||||
expect(project.worktree).toBe(worktree1)
|
||||
expect(project.worktree).toBe(tmp)
|
||||
expect(project.sandboxes).toContain(worktree1)
|
||||
expect(project.sandboxes).toContain(worktree2)
|
||||
expect(project.sandboxes).not.toContain(tmp)
|
||||
}),
|
||||
@@ -415,9 +313,9 @@ describe("Project.discover", () => {
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.png"), pngData)
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
|
||||
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const updated = yield* waitForProjectIcon(project.id)
|
||||
|
||||
expect(updated.icon?.url).toStartWith("data:")
|
||||
@@ -428,12 +326,12 @@ describe("Project.discover", () => {
|
||||
it.live("should discover favicon.png in root", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
|
||||
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.png"), pngData)
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
|
||||
|
||||
yield* Project.use.discover(project)
|
||||
yield* run((svc) => svc.discover(project))
|
||||
|
||||
const updated = Project.get(project.id)
|
||||
expect(updated).toBeDefined()
|
||||
@@ -447,11 +345,11 @@ describe("Project.discover", () => {
|
||||
it.live("should not discover non-image files", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
|
||||
yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.txt"), "not an image")
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.txt"), "not an image"))
|
||||
|
||||
yield* Project.use.discover(project)
|
||||
yield* run((svc) => svc.discover(project))
|
||||
|
||||
const updated = Project.get(project.id)
|
||||
expect(updated).toBeDefined()
|
||||
@@ -462,20 +360,22 @@ describe("Project.discover", () => {
|
||||
it.live("should not discover favicon when override is set", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
|
||||
yield* Project.use.update({
|
||||
projectID: project.id,
|
||||
icon: { override: "data:image/png;base64,override" },
|
||||
})
|
||||
yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
icon: { override: "data:image/png;base64,override" },
|
||||
}),
|
||||
)
|
||||
|
||||
const updatedProject = yield* Project.use.get(project.id)
|
||||
const updatedProject = yield* run((svc) => svc.get(project.id))
|
||||
if (!updatedProject) throw new Error("Project not found")
|
||||
|
||||
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.png"), pngData)
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
|
||||
|
||||
yield* Project.use.discover(updatedProject)
|
||||
yield* run((svc) => svc.discover(updatedProject))
|
||||
|
||||
const updated = Project.get(project.id)
|
||||
expect(updated).toBeDefined()
|
||||
@@ -489,12 +389,14 @@ describe("Project.update", () => {
|
||||
it.live("should update name", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
|
||||
const updated = yield* Project.use.update({
|
||||
projectID: project.id,
|
||||
name: "New Project Name",
|
||||
})
|
||||
const updated = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
name: "New Project Name",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(updated.name).toBe("New Project Name")
|
||||
|
||||
@@ -506,12 +408,14 @@ describe("Project.update", () => {
|
||||
it.live("should update icon url", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
|
||||
const updated = yield* Project.use.update({
|
||||
projectID: project.id,
|
||||
icon: { url: "https://example.com/icon.png" },
|
||||
})
|
||||
const updated = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
icon: { url: "https://example.com/icon.png" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(updated.icon?.url).toBe("https://example.com/icon.png")
|
||||
|
||||
@@ -523,12 +427,14 @@ describe("Project.update", () => {
|
||||
it.live("should update icon color", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
|
||||
const updated = yield* Project.use.update({
|
||||
projectID: project.id,
|
||||
icon: { color: "#ff0000" },
|
||||
})
|
||||
const updated = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
icon: { color: "#ff0000" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(updated.icon?.color).toBe("#ff0000")
|
||||
|
||||
@@ -540,12 +446,14 @@ describe("Project.update", () => {
|
||||
it.live("should update icon override", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
|
||||
const updated = yield* Project.use.update({
|
||||
projectID: project.id,
|
||||
icon: { override: "data:image/png;base64,abc123" },
|
||||
})
|
||||
const updated = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
icon: { override: "data:image/png;base64,abc123" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
|
||||
|
||||
@@ -557,12 +465,14 @@ describe("Project.update", () => {
|
||||
it.live("should update commands", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
|
||||
const updated = yield* Project.use.update({
|
||||
projectID: project.id,
|
||||
commands: { start: "npm run dev" },
|
||||
})
|
||||
const updated = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
commands: { start: "npm run dev" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(updated.commands?.start).toBe("npm run dev")
|
||||
|
||||
@@ -573,12 +483,12 @@ describe("Project.update", () => {
|
||||
|
||||
it.live("should fail when project not found", () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Project.use
|
||||
.update({
|
||||
const exit = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: ProjectID.make("nonexistent-project-id"),
|
||||
name: "Should Fail",
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
}),
|
||||
).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const error = Cause.squash(exit.cause)
|
||||
@@ -590,7 +500,7 @@ describe("Project.update", () => {
|
||||
it.live("should emit GlobalBus event on update", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
|
||||
let eventPayload: any = null
|
||||
const on = (data: any) => {
|
||||
@@ -599,7 +509,7 @@ describe("Project.update", () => {
|
||||
GlobalBus.on("event", on)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
|
||||
|
||||
yield* Project.use.update({ projectID: project.id, name: "Updated Name" })
|
||||
yield* run((svc) => svc.update({ projectID: project.id, name: "Updated Name" }))
|
||||
|
||||
expect(eventPayload).not.toBeNull()
|
||||
expect(eventPayload.payload.type).toBe("project.updated")
|
||||
@@ -610,14 +520,16 @@ describe("Project.update", () => {
|
||||
it.live("should update multiple fields at once", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
|
||||
const updated = yield* Project.use.update({
|
||||
projectID: project.id,
|
||||
name: "Multi Update",
|
||||
icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
|
||||
commands: { start: "make start" },
|
||||
})
|
||||
const updated = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
name: "Multi Update",
|
||||
icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
|
||||
commands: { start: "make start" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(updated.name).toBe("Multi Update")
|
||||
expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
|
||||
@@ -632,7 +544,7 @@ describe("Project.list and Project.get", () => {
|
||||
it.live("list returns all projects", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
|
||||
const all = Project.list()
|
||||
expect(all.length).toBeGreaterThan(0)
|
||||
@@ -643,7 +555,7 @@ describe("Project.list and Project.get", () => {
|
||||
it.live("get returns project by id", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
|
||||
const found = Project.get(project.id)
|
||||
expect(found).toBeDefined()
|
||||
@@ -661,7 +573,7 @@ describe("Project.setInitialized", () => {
|
||||
it.live("sets time_initialized on project", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
|
||||
expect(project.time.initialized).toBeUndefined()
|
||||
|
||||
@@ -677,15 +589,15 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
|
||||
it.live("addSandbox adds directory and removeSandbox removes it", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const sandboxDir = path.join(tmp, "sandbox-test")
|
||||
|
||||
yield* Project.use.addSandbox(project.id, sandboxDir)
|
||||
yield* run((svc) => svc.addSandbox(project.id, sandboxDir))
|
||||
|
||||
let found = Project.get(project.id)
|
||||
expect(found?.sandboxes).toContain(sandboxDir)
|
||||
|
||||
yield* Project.use.removeSandbox(project.id, sandboxDir)
|
||||
yield* run((svc) => svc.removeSandbox(project.id, sandboxDir))
|
||||
|
||||
found = Project.get(project.id)
|
||||
expect(found?.sandboxes).not.toContain(sandboxDir)
|
||||
@@ -695,7 +607,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
|
||||
it.live("addSandbox emits GlobalBus event", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const sandboxDir = path.join(tmp, "sandbox-event")
|
||||
|
||||
const events: any[] = []
|
||||
@@ -703,7 +615,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
|
||||
GlobalBus.on("event", on)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
|
||||
|
||||
yield* Project.use.addSandbox(project.id, sandboxDir)
|
||||
yield* run((svc) => svc.addSandbox(project.id, sandboxDir))
|
||||
|
||||
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
|
||||
}),
|
||||
@@ -725,16 +637,16 @@ describe("Project.fromDirectory with bare repos", () => {
|
||||
yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
|
||||
yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
|
||||
|
||||
const { project } = yield* Project.use.fromDirectory(worktreePath)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
|
||||
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
expect(project.worktree).toBe(worktreePath)
|
||||
expect(project.worktree).toBe(barePath)
|
||||
|
||||
const correctCache = path.join(barePath, "opencode")
|
||||
const wrongCache = path.join(parentDir, ".git", "opencode")
|
||||
|
||||
expect(yield* AppFileSystem.use.existsSafe(correctCache)).toBe(true)
|
||||
expect(yield* AppFileSystem.use.existsSafe(wrongCache)).toBe(false)
|
||||
expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true)
|
||||
expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -759,8 +671,8 @@ describe("Project.fromDirectory with bare repos", () => {
|
||||
yield* Effect.promise(() => $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet())
|
||||
yield* Effect.promise(() => $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet())
|
||||
|
||||
const { project: projA } = yield* Project.use.fromDirectory(worktreeA)
|
||||
const { project: projB } = yield* Project.use.fromDirectory(worktreeB)
|
||||
const { project: projA } = yield* run((svc) => svc.fromDirectory(worktreeA))
|
||||
const { project: projB } = yield* run((svc) => svc.fromDirectory(worktreeB))
|
||||
|
||||
expect(projA.id).not.toBe(projB.id)
|
||||
|
||||
@@ -768,9 +680,9 @@ describe("Project.fromDirectory with bare repos", () => {
|
||||
const cacheB = path.join(bareB, "opencode")
|
||||
const wrongCache = path.join(parentDir, ".git", "opencode")
|
||||
|
||||
expect(yield* AppFileSystem.use.existsSafe(cacheA)).toBe(true)
|
||||
expect(yield* AppFileSystem.use.existsSafe(cacheB)).toBe(true)
|
||||
expect(yield* AppFileSystem.use.existsSafe(wrongCache)).toBe(false)
|
||||
expect(yield* Effect.promise(() => Bun.file(cacheA).exists())).toBe(true)
|
||||
expect(yield* Effect.promise(() => Bun.file(cacheB).exists())).toBe(true)
|
||||
expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -788,13 +700,13 @@ describe("Project.fromDirectory with bare repos", () => {
|
||||
yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
|
||||
yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
|
||||
|
||||
const { project } = yield* Project.use.fromDirectory(worktreePath)
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
|
||||
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
expect(project.worktree).toBe(worktreePath)
|
||||
expect(project.worktree).toBe(barePath)
|
||||
|
||||
const correctCache = path.join(barePath, "opencode")
|
||||
expect(yield* AppFileSystem.use.existsSafe(correctCache)).toBe(true)
|
||||
expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -8,13 +8,14 @@ import { Config } from "../../src/config/config"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Skill.defaultLayer, AppFileSystem.defaultLayer, node))
|
||||
const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node))
|
||||
const itWithoutClaudeCodeSkills = testEffect(
|
||||
Layer.mergeAll(
|
||||
Skill.layer.pipe(
|
||||
@@ -25,7 +26,6 @@ const itWithoutClaudeCodeSkills = testEffect(
|
||||
Layer.provide(Global.layer),
|
||||
Layer.provide(RuntimeFlags.layer({ disableClaudeCodeSkills: true })),
|
||||
),
|
||||
AppFileSystem.defaultLayer,
|
||||
node,
|
||||
),
|
||||
)
|
||||
@@ -39,18 +39,15 @@ const itWithoutExternalSkills = testEffect(
|
||||
Layer.provide(Global.layer),
|
||||
Layer.provide(RuntimeFlags.layer({ disableExternalSkills: true })),
|
||||
),
|
||||
AppFileSystem.defaultLayer,
|
||||
node,
|
||||
),
|
||||
)
|
||||
|
||||
const writeSkill = (dir: string, parts: string[], content: string) =>
|
||||
AppFileSystem.use.writeWithDirs(path.join(dir, ...parts, "SKILL.md"), content)
|
||||
|
||||
const createGlobalSkill = (homeDir: string) =>
|
||||
writeSkill(
|
||||
homeDir,
|
||||
[".claude", "skills", "global-test-skill"],
|
||||
async function createGlobalSkill(homeDir: string) {
|
||||
const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill")
|
||||
await fs.mkdir(skillDir, { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: global-test-skill
|
||||
description: A global skill from ~/.claude/skills for testing.
|
||||
@@ -61,6 +58,7 @@ description: A global skill from ~/.claude/skills for testing.
|
||||
This skill is loaded from the global home directory.
|
||||
`,
|
||||
)
|
||||
}
|
||||
|
||||
const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireUseRelease(
|
||||
@@ -77,14 +75,14 @@ const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
|
||||
)
|
||||
|
||||
describe("skill", () => {
|
||||
it.instance(
|
||||
"discovers skills from .opencode/skill/ directory",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeSkill(
|
||||
test.directory,
|
||||
[".opencode", "skill", "test-skill"],
|
||||
`---
|
||||
it.live("discovers skills from .opencode/skill/ directory", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "test-skill", "SKILL.md"),
|
||||
`---
|
||||
name: test-skill
|
||||
description: A test skill for verification.
|
||||
---
|
||||
@@ -93,111 +91,118 @@ description: A test skill for verification.
|
||||
|
||||
Instructions here.
|
||||
`,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "test-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.description).toBe("A test skill for verification.")
|
||||
expect(item!.location).toContain(path.join("skill", "test-skill", "SKILL.md"))
|
||||
}),
|
||||
{ git: true },
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "test-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.description).toBe("A test skill for verification.")
|
||||
expect(item!.location).toContain(path.join("skill", "test-skill", "SKILL.md"))
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns skill directories from Skill.dirs",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* withHome(
|
||||
test.directory,
|
||||
Effect.gen(function* () {
|
||||
yield* writeSkill(
|
||||
test.directory,
|
||||
[".opencode", "skill", "dir-skill"],
|
||||
`---
|
||||
it.live("returns skill directories from Skill.dirs", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
withHome(
|
||||
dir,
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "dir-skill", "SKILL.md"),
|
||||
`---
|
||||
name: dir-skill
|
||||
description: Skill for dirs test.
|
||||
---
|
||||
|
||||
# Dir Skill
|
||||
`,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
const dirs = yield* Skill.use.dirs()
|
||||
expect(dirs).toContain(path.join(test.directory, ".opencode", "skill", "dir-skill"))
|
||||
expect(dirs.length).toBe(1)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
{ git: true },
|
||||
const skill = yield* Skill.Service
|
||||
const dirs = yield* skill.dirs()
|
||||
expect(dirs).toContain(path.join(dir, ".opencode", "skill", "dir-skill"))
|
||||
expect(dirs.length).toBe(1)
|
||||
}),
|
||||
),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"discovers multiple skills from .opencode/skill/ directory",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.all(
|
||||
[
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".opencode", "skill", "skill-one"],
|
||||
`---
|
||||
it.live("discovers multiple skills from .opencode/skill/ directory", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "skill-one", "SKILL.md"),
|
||||
`---
|
||||
name: skill-one
|
||||
description: First test skill.
|
||||
---
|
||||
|
||||
# Skill One
|
||||
`,
|
||||
),
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".opencode", "skill", "skill-two"],
|
||||
`---
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "skill-two", "SKILL.md"),
|
||||
`---
|
||||
name: skill-two
|
||||
description: Second test skill.
|
||||
---
|
||||
|
||||
# Skill Two
|
||||
`,
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
]),
|
||||
)
|
||||
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(2)
|
||||
expect(list.find((x) => x.name === "skill-one")).toBeDefined()
|
||||
expect(list.find((x) => x.name === "skill-two")).toBeDefined()
|
||||
}),
|
||||
{ git: true },
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(2)
|
||||
expect(list.find((x) => x.name === "skill-one")).toBeDefined()
|
||||
expect(list.find((x) => x.name === "skill-two")).toBeDefined()
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"skips skills with missing frontmatter",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeSkill(
|
||||
test.directory,
|
||||
[".opencode", "skill", "no-frontmatter"],
|
||||
`# No Frontmatter
|
||||
it.live("skips skills with missing frontmatter", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "no-frontmatter", "SKILL.md"),
|
||||
`# No Frontmatter
|
||||
|
||||
Just some content without YAML frontmatter.
|
||||
`,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
expect((yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")).toEqual([])
|
||||
}),
|
||||
{ git: true },
|
||||
const skill = yield* Skill.Service
|
||||
expect((yield* skill.all()).filter((s) => s.location !== "<built-in>")).toEqual([])
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"discovers skills without descriptions",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeSkill(
|
||||
test.directory,
|
||||
[".opencode", "skill", "manual-skill"],
|
||||
`---
|
||||
it.live("discovers skills without descriptions", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "manual-skill", "SKILL.md"),
|
||||
`---
|
||||
name: manual-skill
|
||||
---
|
||||
|
||||
@@ -205,81 +210,98 @@ name: manual-skill
|
||||
|
||||
Instructions here.
|
||||
`,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "manual-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.description).toBeUndefined()
|
||||
expect(Skill.fmt(list, { verbose: false })).toBe("No skills are currently available.")
|
||||
expect(Skill.fmt(list, { verbose: true })).toBe("No skills are currently available.")
|
||||
}),
|
||||
{ git: true },
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "manual-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.description).toBeUndefined()
|
||||
expect(Skill.fmt(list, { verbose: false })).toBe("No skills are currently available.")
|
||||
expect(Skill.fmt(list, { verbose: true })).toBe("No skills are currently available.")
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"discovers skills from .claude/skills/ directory",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeSkill(
|
||||
test.directory,
|
||||
[".claude", "skills", "claude-skill"],
|
||||
`---
|
||||
it.live("discovers skills from .claude/skills/ directory", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
|
||||
`---
|
||||
name: claude-skill
|
||||
description: A skill in the .claude/skills directory.
|
||||
---
|
||||
|
||||
# Claude Skill
|
||||
`,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "claude-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md"))
|
||||
}),
|
||||
{ git: true },
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "claude-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md"))
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"discovers global skills from ~/.claude/skills/ directory",
|
||||
it.live("discovers global skills from ~/.claude/skills/ directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir({ git: true })),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
|
||||
yield* withHome(
|
||||
test.directory,
|
||||
tmp.path,
|
||||
Effect.gen(function* () {
|
||||
yield* createGlobalSkill(test.directory)
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
expect(list[0].name).toBe("global-test-skill")
|
||||
expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.")
|
||||
expect(list[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md"))
|
||||
yield* Effect.promise(() => createGlobalSkill(tmp.path))
|
||||
yield* Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
expect(list[0].name).toBe("global-test-skill")
|
||||
expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.")
|
||||
expect(list[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md"))
|
||||
}).pipe(provideInstance(tmp.path))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns empty array when no skills exist",
|
||||
Effect.gen(function* () {
|
||||
expect((yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")).toEqual([])
|
||||
}),
|
||||
{ git: true },
|
||||
it.live("returns empty array when no skills exist", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
expect((yield* skill.all()).filter((s) => s.location !== "<built-in>")).toEqual([])
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"fails with typed error when requiring a missing skill",
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Effect.flip(Skill.use.require("missing-skill"))
|
||||
expect(error).toBeInstanceOf(Skill.NotFoundError)
|
||||
expect(error._tag).toBe("Skill.NotFoundError")
|
||||
expect(error.name).toBe("missing-skill")
|
||||
expect(error.message).toContain('Skill "missing-skill" not found.')
|
||||
}),
|
||||
{ git: true },
|
||||
it.live("fails with typed error when requiring a missing skill", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const error = yield* Effect.flip(skill.require("missing-skill"))
|
||||
expect(error).toBeInstanceOf(Skill.NotFoundError)
|
||||
expect(error._tag).toBe("Skill.NotFoundError")
|
||||
expect(error.name).toBe("missing-skill")
|
||||
expect(error.message).toContain('Skill "missing-skill" not found.')
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("exposes tagged expected skill failure classes", () =>
|
||||
@@ -298,42 +320,50 @@ description: A skill in the .claude/skills directory.
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"discovers skills from .agents/skills/ directory",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeSkill(
|
||||
test.directory,
|
||||
[".agents", "skills", "agent-skill"],
|
||||
`---
|
||||
it.live("discovers skills from .agents/skills/ directory", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
name: agent-skill
|
||||
description: A skill in the .agents/skills directory.
|
||||
---
|
||||
|
||||
# Agent Skill
|
||||
`,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "agent-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md"))
|
||||
}),
|
||||
{ git: true },
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "agent-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md"))
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"discovers global skills from ~/.agents/skills/ directory",
|
||||
it.live("discovers global skills from ~/.agents/skills/ directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir({ git: true })),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
|
||||
yield* withHome(
|
||||
test.directory,
|
||||
tmp.path,
|
||||
Effect.gen(function* () {
|
||||
yield* writeSkill(
|
||||
test.directory,
|
||||
[".agents", "skills", "global-agent-skill"],
|
||||
`---
|
||||
const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill")
|
||||
yield* Effect.promise(() => fs.mkdir(skillDir, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: global-agent-skill
|
||||
description: A global skill from ~/.agents/skills for testing.
|
||||
---
|
||||
@@ -342,198 +372,198 @@ description: A global skill from ~/.agents/skills for testing.
|
||||
|
||||
This skill is loaded from the global home directory.
|
||||
`,
|
||||
),
|
||||
)
|
||||
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
expect(list[0].name).toBe("global-agent-skill")
|
||||
expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.")
|
||||
expect(list[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md"))
|
||||
yield* Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
expect(list[0].name).toBe("global-agent-skill")
|
||||
expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.")
|
||||
expect(list[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md"))
|
||||
}).pipe(provideInstance(tmp.path))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"discovers skills from both .claude/skills/ and .agents/skills/",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.all(
|
||||
[
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".claude", "skills", "claude-skill"],
|
||||
`---
|
||||
it.live("discovers skills from both .claude/skills/ and .agents/skills/", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
Bun.write(
|
||||
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
|
||||
`---
|
||||
name: claude-skill
|
||||
description: A skill in the .claude/skills directory.
|
||||
---
|
||||
|
||||
# Claude Skill
|
||||
`,
|
||||
),
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".agents", "skills", "agent-skill"],
|
||||
`---
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
name: agent-skill
|
||||
description: A skill in the .agents/skills directory.
|
||||
---
|
||||
|
||||
# Agent Skill
|
||||
`,
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
]),
|
||||
)
|
||||
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(2)
|
||||
expect(list.find((x) => x.name === "claude-skill")).toBeDefined()
|
||||
expect(list.find((x) => x.name === "agent-skill")).toBeDefined()
|
||||
}),
|
||||
{ git: true },
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(2)
|
||||
expect(list.find((x) => x.name === "claude-skill")).toBeDefined()
|
||||
expect(list.find((x) => x.name === "agent-skill")).toBeDefined()
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
itWithoutClaudeCodeSkills.instance(
|
||||
"skips Claude Code skills when disabled",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.all(
|
||||
[
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".claude", "skills", "claude-skill"],
|
||||
`---
|
||||
itWithoutClaudeCodeSkills.live("skips Claude Code skills when disabled", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
Bun.write(
|
||||
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
|
||||
`---
|
||||
name: claude-skill
|
||||
description: A skill in the .claude/skills directory.
|
||||
---
|
||||
|
||||
# Claude Skill
|
||||
`,
|
||||
),
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".agents", "skills", "agent-skill"],
|
||||
`---
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
name: agent-skill
|
||||
description: A skill in the .agents/skills directory.
|
||||
---
|
||||
|
||||
# Agent Skill
|
||||
`,
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
]),
|
||||
)
|
||||
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.map((s) => s.name)).toEqual(["agent-skill"])
|
||||
}),
|
||||
{ git: true },
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.map((s) => s.name)).toEqual(["agent-skill"])
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
itWithoutExternalSkills.instance(
|
||||
"skips external skill directories when disabled",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.all(
|
||||
[
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".claude", "skills", "claude-skill"],
|
||||
`---
|
||||
itWithoutExternalSkills.live("skips external skill directories when disabled", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
Bun.write(
|
||||
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
|
||||
`---
|
||||
name: claude-skill
|
||||
description: A skill in the .claude/skills directory.
|
||||
---
|
||||
|
||||
# Claude Skill
|
||||
`,
|
||||
),
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".agents", "skills", "agent-skill"],
|
||||
`---
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
name: agent-skill
|
||||
description: A skill in the .agents/skills directory.
|
||||
---
|
||||
|
||||
# Agent Skill
|
||||
`,
|
||||
),
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".opencode", "skill", "opencode-skill"],
|
||||
`---
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "opencode-skill", "SKILL.md"),
|
||||
`---
|
||||
name: opencode-skill
|
||||
description: A skill in the .opencode/skill directory.
|
||||
---
|
||||
|
||||
# OpenCode Skill
|
||||
`,
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
]),
|
||||
)
|
||||
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.map((s) => s.name)).toEqual(["opencode-skill"])
|
||||
}),
|
||||
{ git: true },
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.map((s) => s.name)).toEqual(["opencode-skill"])
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"properly resolves directories that skills live in",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.all(
|
||||
[
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".claude", "skills", "claude-skill"],
|
||||
`---
|
||||
it.live("properly resolves directories that skills live in", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
Bun.write(
|
||||
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
|
||||
`---
|
||||
name: claude-skill
|
||||
description: A skill in the .claude/skills directory.
|
||||
---
|
||||
|
||||
# Claude Skill
|
||||
`,
|
||||
),
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".agents", "skills", "agent-skill"],
|
||||
`---
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
name: agent-skill
|
||||
description: A skill in the .agents/skills directory.
|
||||
---
|
||||
|
||||
# Agent Skill
|
||||
`,
|
||||
),
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".opencode", "skill", "agent-skill"],
|
||||
`---
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
name: opencode-skill
|
||||
description: A skill in the .opencode/skill directory.
|
||||
---
|
||||
|
||||
# OpenCode Skill
|
||||
`,
|
||||
),
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".opencode", "skills", "agent-skill"],
|
||||
`---
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skills", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
name: opencode-skill
|
||||
description: A skill in the .opencode/skills directory.
|
||||
---
|
||||
|
||||
# OpenCode Skill
|
||||
`,
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
]),
|
||||
)
|
||||
|
||||
expect((yield* Skill.use.dirs()).length).toBe(4)
|
||||
}),
|
||||
{ git: true },
|
||||
const skill = yield* Skill.Service
|
||||
expect((yield* skill.dirs()).length).toBe(4)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -95,25 +95,15 @@ const brokenPluginLayer = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer, AppFileSystem.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer))
|
||||
const scout = testEffect(
|
||||
Layer.mergeAll(
|
||||
registryLayer({ flags: { experimentalScout: true } }),
|
||||
node,
|
||||
Agent.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
),
|
||||
Layer.mergeAll(registryLayer({ flags: { experimentalScout: true } }), node, Agent.defaultLayer),
|
||||
)
|
||||
const background = testEffect(
|
||||
Layer.mergeAll(
|
||||
registryLayer({ flags: { experimentalBackgroundSubagents: true } }),
|
||||
node,
|
||||
Agent.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
),
|
||||
Layer.mergeAll(registryLayer({ flags: { experimentalBackgroundSubagents: true } }), node, Agent.defaultLayer),
|
||||
)
|
||||
const withBrokenPlugin = testEffect(
|
||||
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer, AppFileSystem.defaultLayer),
|
||||
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer),
|
||||
)
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -123,7 +113,8 @@ afterEach(async () => {
|
||||
describe("tool.registry", () => {
|
||||
it.instance("hides repo research tools unless experimental", () =>
|
||||
Effect.gen(function* () {
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
|
||||
expect(ids).not.toContain("repo_clone")
|
||||
expect(ids).not.toContain("repo_overview")
|
||||
@@ -132,7 +123,8 @@ describe("tool.registry", () => {
|
||||
|
||||
scout.instance("shows repo research tools when experimental scout is enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
|
||||
expect(ids).toContain("repo_clone")
|
||||
expect(ids).toContain("repo_overview")
|
||||
@@ -141,7 +133,8 @@ describe("tool.registry", () => {
|
||||
|
||||
it.instance("hides task_status unless experimental background subagents are enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
|
||||
expect(ids).not.toContain("task_status")
|
||||
}),
|
||||
@@ -149,10 +142,11 @@ describe("tool.registry", () => {
|
||||
|
||||
it.instance("hides task background parameter unless experimental background subagents are enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const agent = yield* Agent.Service
|
||||
const build = yield* agent.get("build")
|
||||
if (!build) throw new Error("build agent not found")
|
||||
const task = (yield* ToolRegistry.use.tools({
|
||||
const task = (yield* registry.tools({
|
||||
providerID: ProviderID.opencode,
|
||||
modelID: ModelID.make("test"),
|
||||
agent: build,
|
||||
@@ -165,7 +159,8 @@ describe("tool.registry", () => {
|
||||
|
||||
background.instance("shows task_status when experimental background subagents are enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
|
||||
expect(ids).toContain("task_status")
|
||||
}),
|
||||
@@ -174,20 +169,26 @@ describe("tool.registry", () => {
|
||||
it.instance("loads tools from .opencode/tool (singular)", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "tool", "hello.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'hello tool',",
|
||||
" args: {},",
|
||||
" execute: async () => {",
|
||||
" return 'hello world'",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
const opencode = path.join(test.directory, ".opencode")
|
||||
const tool = path.join(opencode, "tool")
|
||||
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tool, "hello.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'hello tool',",
|
||||
" args: {},",
|
||||
" execute: async () => {",
|
||||
" return 'hello world'",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
expect(ids).toContain("hello")
|
||||
}),
|
||||
)
|
||||
@@ -195,20 +196,25 @@ describe("tool.registry", () => {
|
||||
it.instance("ignores non-tool exports in .opencode/tool files", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "tool", "mixed.ts"),
|
||||
[
|
||||
"export const helper = 'not a tool'",
|
||||
"export default {",
|
||||
" description: 'mixed tool',",
|
||||
" args: {},",
|
||||
" execute: async () => 'ok',",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
const tool = path.join(test.directory, ".opencode", "tool")
|
||||
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tool, "mixed.ts"),
|
||||
[
|
||||
"export const helper = 'not a tool'",
|
||||
"export default {",
|
||||
" description: 'mixed tool',",
|
||||
" args: {},",
|
||||
" execute: async () => 'ok',",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
expect(ids).toContain("mixed")
|
||||
expect(ids).not.toContain("mixed_helper")
|
||||
}),
|
||||
@@ -223,23 +229,28 @@ describe("tool.registry", () => {
|
||||
it.instance("tolerates a custom tool exporting null/undefined args (no-args fallback)", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "tool", "noargs.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'tool with no args',",
|
||||
" args: undefined,",
|
||||
" execute: async () => 'ok',",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
const tool = path.join(test.directory, ".opencode", "tool")
|
||||
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tool, "noargs.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'tool with no args',",
|
||||
" args: undefined,",
|
||||
" execute: async () => 'ok',",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
// Built-in tools must still load — a single malformed custom tool must
|
||||
// not poison the whole registry.
|
||||
expect(ids).toContain("read")
|
||||
const loaded = (yield* ToolRegistry.use.all()).find((t) => t.id === "noargs")
|
||||
const loaded = (yield* registry.all()).find((t) => t.id === "noargs")
|
||||
if (!loaded) throw new Error("noargs tool was not loaded")
|
||||
expect(loaded.jsonSchema).toMatchObject({ type: "object", properties: {} })
|
||||
}),
|
||||
@@ -253,7 +264,8 @@ describe("tool.registry", () => {
|
||||
// protection.
|
||||
withBrokenPlugin.instance("tolerates a plugin tool registered with null/undefined args", () =>
|
||||
Effect.gen(function* () {
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
expect(ids).toContain("read")
|
||||
expect(ids).toContain("broken_plugin_tool")
|
||||
}),
|
||||
@@ -262,20 +274,26 @@ describe("tool.registry", () => {
|
||||
it.instance("loads tools from .opencode/tools (plural)", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "tools", "hello.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'hello tool',",
|
||||
" args: {},",
|
||||
" execute: async () => {",
|
||||
" return 'hello world'",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
const opencode = path.join(test.directory, ".opencode")
|
||||
const tools = path.join(opencode, "tools")
|
||||
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tools, "hello.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'hello tool',",
|
||||
" args: {},",
|
||||
" execute: async () => {",
|
||||
" return 'hello world'",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
expect(ids).toContain("hello")
|
||||
}),
|
||||
)
|
||||
@@ -283,21 +301,26 @@ describe("tool.registry", () => {
|
||||
it.instance("loads Zod-schema custom tools with JSON Schema and validation", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const customTools = path.join(test.directory, ".opencode", "tools")
|
||||
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "tools", "sql.ts"),
|
||||
[
|
||||
`import { tool } from ${JSON.stringify(pluginTool)}`,
|
||||
"export default tool({",
|
||||
" description: 'query database',",
|
||||
" args: { query: tool.schema.string().describe('SQL query to execute') },",
|
||||
" execute: async ({ query }) => query,",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(customTools, "sql.ts"),
|
||||
[
|
||||
`import { tool } from ${JSON.stringify(pluginTool)}`,
|
||||
"export default tool({",
|
||||
" description: 'query database',",
|
||||
" args: { query: tool.schema.string().describe('SQL query to execute') },",
|
||||
" execute: async ({ query }) => query,",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
|
||||
const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "sql")
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const loaded = (yield* registry.all()).find((tool) => tool.id === "sql")
|
||||
if (!loaded) throw new Error("custom sql tool was not loaded")
|
||||
expect(loaded?.jsonSchema).toMatchObject({
|
||||
type: "object",
|
||||
@@ -310,7 +333,7 @@ describe("tool.registry", () => {
|
||||
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false)
|
||||
|
||||
const agents = yield* Agent.Service
|
||||
const promptTools = yield* ToolRegistry.use.tools({
|
||||
const promptTools = yield* registry.tools({
|
||||
providerID: ProviderID.opencode,
|
||||
modelID: ModelID.make("test"),
|
||||
agent: yield* agents.defaultInfo(),
|
||||
@@ -334,44 +357,53 @@ describe("tool.registry", () => {
|
||||
const opencode = path.join(test.directory, ".opencode")
|
||||
const customTools = path.join(opencode, "tools")
|
||||
const plugin = path.join(opencode, "node_modules", "@opencode-ai", "plugin")
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(plugin, "dist"), { recursive: true }))
|
||||
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
fs.cp(path.dirname(fileURLToPath(import.meta.resolve("zod"))), path.join(opencode, "node_modules", "zod"), {
|
||||
dereference: true,
|
||||
recursive: true,
|
||||
}),
|
||||
)
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(plugin, "package.json"),
|
||||
JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }),
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(plugin, "package.json"),
|
||||
JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }),
|
||||
),
|
||||
)
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(plugin, "dist", "index.js"),
|
||||
[
|
||||
"import { z } from 'zod'",
|
||||
"export function tool(input) {",
|
||||
" return input",
|
||||
"}",
|
||||
"tool.schema = z",
|
||||
"",
|
||||
].join("\n"),
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(plugin, "dist", "index.js"),
|
||||
[
|
||||
"import { z } from 'zod'",
|
||||
"export function tool(input) {",
|
||||
" return input",
|
||||
"}",
|
||||
"tool.schema = z",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(customTools, "addition.ts"),
|
||||
[
|
||||
'import { tool } from "@opencode-ai/plugin"',
|
||||
"export default tool({",
|
||||
" description: 'Use this tool to add two numbers and return their sum.',",
|
||||
" args: {",
|
||||
" left: tool.schema.number().describe('The first number to add'),",
|
||||
" right: tool.schema.number().describe('The second number to add'),",
|
||||
" },",
|
||||
" execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(customTools, "addition.ts"),
|
||||
[
|
||||
'import { tool } from "@opencode-ai/plugin"',
|
||||
"export default tool({",
|
||||
" description: 'Use this tool to add two numbers and return their sum.',",
|
||||
" args: {",
|
||||
" left: tool.schema.number().describe('The first number to add'),",
|
||||
" right: tool.schema.number().describe('The second number to add'),",
|
||||
" },",
|
||||
" execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
|
||||
const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "addition")
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const loaded = (yield* registry.all()).find((tool) => tool.id === "addition")
|
||||
if (!loaded) throw new Error("custom addition tool was not loaded")
|
||||
|
||||
expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
|
||||
@@ -387,24 +419,29 @@ describe("tool.registry", () => {
|
||||
it.instance("preserves attachments from structured custom tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const customTools = path.join(test.directory, ".opencode", "tools")
|
||||
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "tools", "image.ts"),
|
||||
[
|
||||
`import { tool } from ${JSON.stringify(pluginTool)}`,
|
||||
"export default tool({",
|
||||
" description: 'image tool',",
|
||||
" args: {},",
|
||||
" execute: async () => ({",
|
||||
" output: 'here is an image',",
|
||||
" attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],",
|
||||
" }),",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(customTools, "image.ts"),
|
||||
[
|
||||
`import { tool } from ${JSON.stringify(pluginTool)}`,
|
||||
"export default tool({",
|
||||
" description: 'image tool',",
|
||||
" args: {},",
|
||||
" execute: async () => ({",
|
||||
" output: 'here is an image',",
|
||||
" attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],",
|
||||
" }),",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
|
||||
const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "image")
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const loaded = (yield* registry.all()).find((tool) => tool.id === "image")
|
||||
if (!loaded) throw new Error("custom image tool was not loaded")
|
||||
const agents = yield* Agent.Service
|
||||
const result = yield* loaded.execute({}, {
|
||||
@@ -427,19 +464,24 @@ describe("tool.registry", () => {
|
||||
it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "tools", "legacy.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'legacy schema tool',",
|
||||
" args: { text: { type: 'string', description: 'Text to render' } },",
|
||||
" execute: async ({ text }) => text,",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
const tools = path.join(test.directory, ".opencode", "tools")
|
||||
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tools, "legacy.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'legacy schema tool',",
|
||||
" args: { text: { type: 'string', description: 'Text to render' } },",
|
||||
" execute: async ({ text }) => text,",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
|
||||
const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "legacy")
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy")
|
||||
if (!loaded) throw new Error("legacy custom tool was not loaded")
|
||||
expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
|
||||
type: "object",
|
||||
@@ -456,60 +498,73 @@ describe("tool.registry", () => {
|
||||
const test = yield* TestInstance
|
||||
const opencode = path.join(test.directory, ".opencode")
|
||||
const tools = path.join(opencode, "tools")
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(opencode, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "custom-tools",
|
||||
dependencies: {
|
||||
"@opencode-ai/plugin": "^0.0.0",
|
||||
cowsay: "^1.6.0",
|
||||
},
|
||||
}),
|
||||
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(opencode, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "custom-tools",
|
||||
dependencies: {
|
||||
"@opencode-ai/plugin": "^0.0.0",
|
||||
cowsay: "^1.6.0",
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(opencode, "package-lock.json"),
|
||||
JSON.stringify({
|
||||
name: "custom-tools",
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
dependencies: {
|
||||
"@opencode-ai/plugin": "^0.0.0",
|
||||
cowsay: "^1.6.0",
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(opencode, "package-lock.json"),
|
||||
JSON.stringify({
|
||||
name: "custom-tools",
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
dependencies: {
|
||||
"@opencode-ai/plugin": "^0.0.0",
|
||||
cowsay: "^1.6.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const cowsay = path.join(opencode, "node_modules", "cowsay")
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(cowsay, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "cowsay",
|
||||
type: "module",
|
||||
exports: "./index.js",
|
||||
}),
|
||||
yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(cowsay, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "cowsay",
|
||||
type: "module",
|
||||
exports: "./index.js",
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(cowsay, "index.js"),
|
||||
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(cowsay, "index.js"),
|
||||
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
|
||||
),
|
||||
)
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(tools, "cowsay.ts"),
|
||||
[
|
||||
"import { say } from 'cowsay'",
|
||||
"export default {",
|
||||
" description: 'tool that imports cowsay at top level',",
|
||||
" args: { text: { type: 'string' } },",
|
||||
" execute: async ({ text }: { text: string }) => {",
|
||||
" return say({ text })",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tools, "cowsay.ts"),
|
||||
[
|
||||
"import { say } from 'cowsay'",
|
||||
"export default {",
|
||||
" description: 'tool that imports cowsay at top level',",
|
||||
" args: { text: { type: 'string' } },",
|
||||
" execute: async ({ text }: { text: string }) => {",
|
||||
" return say({ text })",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
expect(ids).toContain("cowsay")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1085,15 +1085,10 @@ describe("tool.shell abort", () => {
|
||||
runIn(
|
||||
projectRoot,
|
||||
Effect.gen(function* () {
|
||||
const tool = yield* initShell()
|
||||
expect(tool.description).toContain("commands will time out after 500ms")
|
||||
const result = yield* tool.execute(
|
||||
{
|
||||
command: `echo started && sleep 60`,
|
||||
description: "Default timeout test",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
const result = yield* run({
|
||||
command: `echo started && sleep 60`,
|
||||
description: "Default timeout test",
|
||||
})
|
||||
expect(result.output).toContain("started")
|
||||
expect(result.output).toContain("exceeding timeout 500 ms")
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
@@ -8,7 +7,6 @@ import type { Permission } from "../../src/permission"
|
||||
import type { Tool } from "@/tool/tool"
|
||||
import { SkillTool } from "../../src/tool/skill"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -29,16 +27,17 @@ afterEach(async () => {
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
|
||||
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node, AppFileSystem.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
|
||||
|
||||
describe("tool.skill", () => {
|
||||
it.live("execute returns skill content block with files", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const skill = path.join(dir, ".opencode", "skill", "tool-skill")
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(skill, "SKILL.md"),
|
||||
`---
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(skill, "SKILL.md"),
|
||||
`---
|
||||
name: tool-skill
|
||||
description: Skill for tool tests.
|
||||
---
|
||||
@@ -47,8 +46,9 @@ description: Skill for tool tests.
|
||||
|
||||
Use this skill.
|
||||
`,
|
||||
),
|
||||
)
|
||||
yield* AppFileSystem.use.writeWithDirs(path.join(skill, "scripts", "demo.txt"), "demo")
|
||||
yield* Effect.promise(() => Bun.write(path.join(skill, "scripts", "demo.txt"), "demo"))
|
||||
|
||||
const home = process.env.OPENCODE_TEST_HOME
|
||||
process.env.OPENCODE_TEST_HOME = dir
|
||||
@@ -61,8 +61,8 @@ Use this skill.
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
|
||||
const tool = (yield* registry.tools({
|
||||
providerID: ProviderID.opencode,
|
||||
modelID: ModelID.make("gpt-5"),
|
||||
providerID: "opencode" as any,
|
||||
modelID: "gpt-5" as any,
|
||||
agent,
|
||||
})).find((tool) => tool.id === SkillTool.id)
|
||||
if (!tool) throw new Error("Skill tool not found")
|
||||
@@ -105,8 +105,8 @@ Use this skill.
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
|
||||
const tool = (yield* registry.tools({
|
||||
providerID: ProviderID.opencode,
|
||||
modelID: ModelID.make("gpt-5"),
|
||||
providerID: "opencode" as any,
|
||||
modelID: "gpt-5" as any,
|
||||
agent,
|
||||
})).find((tool) => tool.id === SkillTool.id)
|
||||
if (!tool) throw new Error("Skill tool not found")
|
||||
|
||||
@@ -137,8 +137,8 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
|
||||
يستخدم [model id](/docs/config/#models) في إعدادات OpenCode لديك التنسيق `opencode-go/<model-id>`. على سبيل المثال، بالنسبة إلى Kimi K2.6، ستستخدم `opencode-go/kimi-k2.6` في إعداداتك.
|
||||
|
||||
|
||||
@@ -84,8 +84,8 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -149,8 +149,8 @@ Također možete pristupiti Go modelima putem sljedećih API endpointa.
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
|
||||
[Model id](/docs/config/#models) u vašoj OpenCode konfiguraciji
|
||||
koristi format `opencode-go/<model-id>`. Na primjer, za Kimi K2.6, koristili biste
|
||||
|
||||
@@ -89,8 +89,8 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa.
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -149,8 +149,8 @@ Du kan også få adgang til Go-modeller gennem følgende API-endpoints.
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
|
||||
Dit [model id](/docs/config/#models) i din OpenCode config
|
||||
bruger formatet `opencode-go/<model-id>`. For eksempel for Kimi K2.6, vil du
|
||||
|
||||
@@ -89,8 +89,8 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints.
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -139,8 +139,8 @@ Du kannst auf die Go-Modelle auch über die folgenden API-Endpunkte zugreifen.
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
|
||||
Die [Modell-ID](/docs/config/#models) in deiner OpenCode Config verwendet das Format `opencode-go/<model-id>`. Für Kimi K2.6 würdest du beispielsweise `opencode-go/kimi-k2.6` in deiner Config verwenden.
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen.
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -149,8 +149,8 @@ También puedes acceder a los modelos de Go a través de los siguientes endpoint
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
|
||||
El [ID del modelo](/docs/config/#models) en tu configuración de OpenCode
|
||||
usa el formato `opencode-go/<model-id>`. Por ejemplo, para Kimi K2.6, usarías
|
||||
|
||||
@@ -89,8 +89,8 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -137,8 +137,8 @@ Vous pouvez également accéder aux modèles Go via les points de terminaison d'
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
|
||||
L'[ID de modèle](/docs/config/#models) dans votre configuration OpenCode utilise le format `opencode-go/<model-id>`. Par exemple, pour Kimi K2.6, vous utiliseriez `opencode-go/kimi-k2.6` dans votre configuration.
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -149,8 +149,8 @@ You can also access Go models through the following API endpoints.
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
|
||||
The [model id](/docs/config/#models) in your OpenCode config
|
||||
uses the format `opencode-go/<model-id>`. For example, for Kimi K2.6, you would
|
||||
|
||||
@@ -147,8 +147,8 @@ Puoi anche accedere ai modelli Go tramite i seguenti endpoint API.
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
|
||||
Il [model id](/docs/config/#models) nella tua OpenCode config
|
||||
utilizza il formato `opencode-go/<model-id>`. Ad esempio, per Kimi K2.6, useresti
|
||||
|
||||
@@ -89,8 +89,8 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API.
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -137,8 +137,8 @@ Zen残高にクレジットがある場合は、コンソールで**Use balance*
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
|
||||
OpenCode設定の[model id](/docs/config/#models)は、`opencode-go/<model-id>`という形式を使用します。たとえば、Kimi K2.6の場合は、設定で`opencode-go/kimi-k2.6`を使用します。
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -137,8 +137,8 @@ Zen 잔액에 크레딧도 있다면, console에서 **Use balance** 옵션을
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
|
||||
OpenCode config의 [model id](/docs/config/#models)는 `opencode-go/<model-id>` 형식을 사용합니다. 예를 들어 Kimi K2.6의 경우 config에서 `opencode-go/kimi-k2.6`를 사용하면 됩니다.
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다.
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -149,8 +149,8 @@ Du kan også få tilgang til Go-modeller gjennom følgende API-endepunkter.
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
|
||||
[Modell-ID-en](/docs/config/#models) i din OpenCode-konfigurasjon
|
||||
bruker formatet `opencode-go/<model-id>`. For eksempel, for Kimi K2.6, vil du
|
||||
|
||||
@@ -89,8 +89,8 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter.
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -141,8 +141,8 @@ Możesz również uzyskać dostęp do modeli Go za pośrednictwem następującyc
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
|
||||
[ID modelu](/docs/config/#models) w Twojej konfiguracji OpenCode
|
||||
używa formatu `opencode-go/<model-id>`. Na przykład dla Kimi K2.6 należy użyć
|
||||
|
||||
@@ -89,8 +89,8 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API.
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -149,8 +149,8 @@ Você também pode acessar os modelos do Go através dos seguintes endpoints de
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
|
||||
O [ID do modelo](/docs/config/#models) na sua configuração do OpenCode
|
||||
usa o formato `opencode-go/<model-id>`. Por exemplo, para o Kimi K2.6, você usaria
|
||||
|
||||
@@ -80,8 +80,8 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API.
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -149,8 +149,8 @@ OpenCode Go включает следующие лимиты:
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
|
||||
[ID модели](/docs/config/#models) в вашем конфиге OpenCode
|
||||
использует формат `opencode-go/<model-id>`. Например, для Kimi K2.6 вам нужно
|
||||
|
||||
@@ -89,8 +89,8 @@ OpenCode Zen работает как любой другой провайдер
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -137,8 +137,8 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้:
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
|
||||
[model id](/docs/config/#models) ใน OpenCode config ของคุณจะใช้รูปแบบ `opencode-go/<model-id>` ตัวอย่างเช่น สำหรับ Kimi K2.6 คุณจะใช้ `opencode-go/kimi-k2.6` ใน config ของคุณ
|
||||
|
||||
|
||||
@@ -82,8 +82,8 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -137,8 +137,8 @@ Go modellerine aşağıdaki API uç noktaları aracılığıyla da erişebilirsi
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
|
||||
OpenCode yapılandırmanızdaki [model id](/docs/config/#models) formatı `opencode-go/<model-id>` şeklindedir. Örneğin, Kimi K2.6 için yapılandırmanızda `opencode-go/kimi-k2.6` kullanmalısınız.
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -89,8 +89,8 @@ You can also access our models through the following API endpoints.
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -137,8 +137,8 @@ OpenCode Go 包含以下限制:
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
|
||||
你 OpenCode 配置中的 [模型 ID](/docs/config/#models) 使用 `opencode-go/<model-id>` 格式。例如,对于 Kimi K2.6,你将在配置中使用 `opencode-go/kimi-k2.6`。
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -137,8 +137,8 @@ OpenCode Go 包含以下限制:
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
|
||||
您的 OpenCode 設定中的 [model id](/docs/config/#models) 使用 `opencode-go/<model-id>` 格式。例如,Kimi K2.6 在設定中應使用 `opencode-go/kimi-k2.6`。
|
||||
|
||||
|
||||
@@ -84,8 +84,8 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
Reference in New Issue
Block a user