chore: initialize qiming workspace repository
This commit is contained in:
11
qimingcode/packages/opencode/test/fixture/db.ts
Normal file
11
qimingcode/packages/opencode/test/fixture/db.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { rm } from "fs/promises"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Database } from "../../src/storage"
|
||||
|
||||
export async function resetDatabase() {
|
||||
await Instance.disposeAll().catch(() => undefined)
|
||||
Database.close()
|
||||
await rm(Database.Path, { force: true }).catch(() => undefined)
|
||||
await rm(`${Database.Path}-wal`, { force: true }).catch(() => undefined)
|
||||
await rm(`${Database.Path}-shm`, { force: true }).catch(() => undefined)
|
||||
}
|
||||
26
qimingcode/packages/opencode/test/fixture/fixture.test.ts
Normal file
26
qimingcode/packages/opencode/test/fixture/fixture.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import { tmpdir } from "./fixture"
|
||||
|
||||
describe("tmpdir", () => {
|
||||
test("disables fsmonitor for git fixtures", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const value = (await $`git config core.fsmonitor`.cwd(tmp.path).quiet().text()).trim()
|
||||
expect(value).toBe("false")
|
||||
})
|
||||
|
||||
test("removes directories on dispose", async () => {
|
||||
const tmp = await tmpdir({ git: true })
|
||||
const dir = tmp.path
|
||||
|
||||
await tmp[Symbol.asyncDispose]()
|
||||
|
||||
const exists = await fs
|
||||
.stat(dir)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
expect(exists).toBe(false)
|
||||
})
|
||||
})
|
||||
174
qimingcode/packages/opencode/test/fixture/fixture.ts
Normal file
174
qimingcode/packages/opencode/test/fixture/fixture.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { $ } from "bun"
|
||||
import * as fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Effect, Context } from "effect"
|
||||
import type * as PlatformError from "effect/PlatformError"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import type { Config } from "../../src/config"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { TestLLMServer } from "../lib/llm-server"
|
||||
|
||||
// Strip null bytes from paths (defensive fix for CI environment issues)
|
||||
function sanitizePath(p: string): string {
|
||||
return p.replace(/\0/g, "")
|
||||
}
|
||||
|
||||
function exists(dir: string) {
|
||||
return fs
|
||||
.stat(dir)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
function clean(dir: string) {
|
||||
return fs.rm(dir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100,
|
||||
})
|
||||
}
|
||||
|
||||
async function stop(dir: string) {
|
||||
if (!(await exists(dir))) return
|
||||
await $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow()
|
||||
}
|
||||
|
||||
type TmpDirOptions<T> = {
|
||||
git?: boolean
|
||||
config?: Partial<Config.Info>
|
||||
init?: (dir: string) => Promise<T>
|
||||
dispose?: (dir: string) => Promise<T>
|
||||
}
|
||||
export async function tmpdir<T>(options?: TmpDirOptions<T>) {
|
||||
const dirpath = sanitizePath(path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)))
|
||||
await fs.mkdir(dirpath, { recursive: true })
|
||||
if (options?.git) {
|
||||
await $`git init`.cwd(dirpath).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(dirpath).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(dirpath).quiet()
|
||||
await $`git config user.email "test@opencode.test"`.cwd(dirpath).quiet()
|
||||
await $`git config user.name "Test"`.cwd(dirpath).quiet()
|
||||
await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
|
||||
}
|
||||
if (options?.config) {
|
||||
await Bun.write(
|
||||
path.join(dirpath, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
...options.config,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const realpath = sanitizePath(await fs.realpath(dirpath))
|
||||
const extra = await options?.init?.(realpath)
|
||||
const result = {
|
||||
[Symbol.asyncDispose]: async () => {
|
||||
try {
|
||||
await options?.dispose?.(realpath)
|
||||
} finally {
|
||||
if (options?.git) await stop(realpath).catch(() => undefined)
|
||||
await clean(realpath).catch(() => undefined)
|
||||
}
|
||||
},
|
||||
path: realpath,
|
||||
extra: extra as T,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Effectful scoped tmpdir. Cleaned up when the scope closes. Make sure these stay in sync */
|
||||
export function tmpdirScoped(options?: { git?: boolean; config?: Partial<Config.Info> }) {
|
||||
return Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const dirpath = sanitizePath(path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)))
|
||||
yield* Effect.promise(() => fs.mkdir(dirpath, { recursive: true }))
|
||||
const dir = sanitizePath(yield* Effect.promise(() => fs.realpath(dirpath)))
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
if (options?.git) await stop(dir).catch(() => undefined)
|
||||
await clean(dir).catch(() => undefined)
|
||||
}),
|
||||
)
|
||||
|
||||
const git = (...args: string[]) =>
|
||||
spawner.spawn(ChildProcess.make("git", args, { cwd: dir })).pipe(Effect.flatMap((handle) => handle.exitCode))
|
||||
|
||||
if (options?.git) {
|
||||
yield* git("init")
|
||||
yield* git("config", "core.fsmonitor", "false")
|
||||
yield* git("config", "commit.gpgsign", "false")
|
||||
yield* git("config", "user.email", "test@opencode.test")
|
||||
yield* git("config", "user.name", "Test")
|
||||
yield* git("commit", "--allow-empty", "-m", "root commit")
|
||||
}
|
||||
|
||||
if (options?.config) {
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({ $schema: "https://opencode.ai/config.json", ...options.config }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return dir
|
||||
})
|
||||
}
|
||||
|
||||
export const provideInstance =
|
||||
(directory: string) =>
|
||||
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
||||
Effect.contextWith((services: Context.Context<R>) =>
|
||||
Effect.promise<A>(async () =>
|
||||
Instance.provide({
|
||||
directory,
|
||||
fn: () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, Instance.current))),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export function provideTmpdirInstance<A, E, R>(
|
||||
self: (path: string) => Effect.Effect<A, E, R>,
|
||||
options?: { git?: boolean; config?: Partial<Config.Info> },
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const path = yield* tmpdirScoped(options)
|
||||
let provided = false
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
provided
|
||||
? Effect.promise(() =>
|
||||
Instance.provide({
|
||||
directory: path,
|
||||
fn: () => Instance.dispose(),
|
||||
}),
|
||||
).pipe(Effect.ignore)
|
||||
: Effect.void,
|
||||
)
|
||||
|
||||
provided = true
|
||||
return yield* self(path).pipe(provideInstance(path))
|
||||
})
|
||||
}
|
||||
|
||||
export function provideTmpdirServer<A, E, R>(
|
||||
self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect<A, E, R>,
|
||||
options?: { git?: boolean; config?: (url: string) => Partial<Config.Info> },
|
||||
): Effect.Effect<
|
||||
A,
|
||||
E | PlatformError.PlatformError,
|
||||
R | TestLLMServer | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
|
||||
> {
|
||||
return Effect.gen(function* () {
|
||||
const llm = yield* TestLLMServer
|
||||
return yield* provideTmpdirInstance((dir) => self({ dir, llm }), {
|
||||
git: options?.git,
|
||||
config: options?.config?.(llm.url),
|
||||
})
|
||||
})
|
||||
}
|
||||
72
qimingcode/packages/opencode/test/fixture/flock-worker.ts
Normal file
72
qimingcode/packages/opencode/test/fixture/flock-worker.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import fs from "fs/promises"
|
||||
import { Flock } from "@opencode-ai/core/util/flock"
|
||||
|
||||
type Msg = {
|
||||
key: string
|
||||
dir: string
|
||||
staleMs?: number
|
||||
timeoutMs?: number
|
||||
baseDelayMs?: number
|
||||
maxDelayMs?: number
|
||||
holdMs?: number
|
||||
ready?: string
|
||||
active?: string
|
||||
done?: string
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
function input() {
|
||||
const raw = process.argv[2]
|
||||
if (!raw) {
|
||||
throw new Error("Missing flock worker input")
|
||||
}
|
||||
|
||||
return JSON.parse(raw) as Msg
|
||||
}
|
||||
|
||||
async function job(input: Msg) {
|
||||
if (input.ready) {
|
||||
await fs.writeFile(input.ready, String(process.pid))
|
||||
}
|
||||
|
||||
if (input.active) {
|
||||
await fs.writeFile(input.active, String(process.pid), { flag: "wx" })
|
||||
}
|
||||
|
||||
try {
|
||||
if (input.holdMs && input.holdMs > 0) {
|
||||
await sleep(input.holdMs)
|
||||
}
|
||||
|
||||
if (input.done) {
|
||||
await fs.appendFile(input.done, "1\n")
|
||||
}
|
||||
} finally {
|
||||
if (input.active) {
|
||||
await fs.rm(input.active, { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const msg = input()
|
||||
|
||||
await Flock.withLock(msg.key, () => job(msg), {
|
||||
dir: msg.dir,
|
||||
staleMs: msg.staleMs,
|
||||
timeoutMs: msg.timeoutMs,
|
||||
baseDelayMs: msg.baseDelayMs,
|
||||
maxDelayMs: msg.maxDelayMs,
|
||||
})
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
const text = err instanceof Error ? (err.stack ?? err.message) : String(err)
|
||||
process.stderr.write(text)
|
||||
process.exit(1)
|
||||
})
|
||||
249
qimingcode/packages/opencode/test/fixture/lsp/fake-lsp-server.js
Normal file
249
qimingcode/packages/opencode/test/fixture/lsp/fake-lsp-server.js
Normal file
@@ -0,0 +1,249 @@
|
||||
// Simple JSON-RPC 2.0 LSP-like fake server over stdio
|
||||
|
||||
let nextId = 1
|
||||
let readBuffer = Buffer.alloc(0)
|
||||
let lastChange = null
|
||||
let initializeParams = null
|
||||
let diagnosticRequestCount = 0
|
||||
let registeredCapability = false
|
||||
const pendingClientRequests = new Map()
|
||||
let pullConfig = {
|
||||
delayMs: 0,
|
||||
registerOn: undefined,
|
||||
registrations: [],
|
||||
documentDiagnostics: [],
|
||||
documentDiagnosticsByIdentifier: {},
|
||||
documentDelayMsByIdentifier: {},
|
||||
workspaceDiagnostics: [],
|
||||
workspaceDiagnosticsByIdentifier: {},
|
||||
workspaceDelayMsByIdentifier: {},
|
||||
}
|
||||
|
||||
function encode(message) {
|
||||
const json = JSON.stringify(message)
|
||||
const header = `Content-Length: ${Buffer.byteLength(json, "utf8")}\r\n\r\n`
|
||||
return Buffer.concat([Buffer.from(header, "utf8"), Buffer.from(json, "utf8")])
|
||||
}
|
||||
|
||||
function decodeFrames(buffer) {
|
||||
const results = []
|
||||
let idx
|
||||
while ((idx = buffer.indexOf("\r\n\r\n")) !== -1) {
|
||||
const header = buffer.slice(0, idx).toString("utf8")
|
||||
const match = /Content-Length:\s*(\d+)/i.exec(header)
|
||||
const length = match ? parseInt(match[1], 10) : 0
|
||||
const bodyStart = idx + 4
|
||||
const bodyEnd = bodyStart + length
|
||||
if (buffer.length < bodyEnd) break
|
||||
results.push(buffer.slice(bodyStart, bodyEnd).toString("utf8"))
|
||||
buffer = buffer.slice(bodyEnd)
|
||||
}
|
||||
return { messages: results, rest: buffer }
|
||||
}
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(encode(message))
|
||||
}
|
||||
|
||||
function sendRequest(method, params) {
|
||||
const id = nextId++
|
||||
send({ jsonrpc: "2.0", id, method, params })
|
||||
return id
|
||||
}
|
||||
|
||||
function sendResponse(id, result) {
|
||||
send({ jsonrpc: "2.0", id, result })
|
||||
}
|
||||
|
||||
function sendNotification(method, params) {
|
||||
send({ jsonrpc: "2.0", method, params })
|
||||
}
|
||||
|
||||
function maybeRegister(method) {
|
||||
if (pullConfig.registerOn !== method || registeredCapability) return
|
||||
registeredCapability = true
|
||||
sendRequest("client/registerCapability", {
|
||||
registrations: pullConfig.registrations.map((registration, index) => ({
|
||||
id: registration.id ?? `pull-${index}`,
|
||||
method: registration.method ?? "textDocument/diagnostic",
|
||||
registerOptions: registration.registerOptions ?? registration,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
function delayed(id, result, delayMs = pullConfig.delayMs) {
|
||||
if (!delayMs) {
|
||||
sendResponse(id, result)
|
||||
return
|
||||
}
|
||||
setTimeout(() => sendResponse(id, result), delayMs)
|
||||
}
|
||||
|
||||
function diagnosticsForIdentifier(identifier) {
|
||||
return pullConfig.documentDiagnosticsByIdentifier[identifier] ?? pullConfig.documentDiagnostics
|
||||
}
|
||||
|
||||
function workspaceDiagnosticsForIdentifier(identifier) {
|
||||
return pullConfig.workspaceDiagnosticsByIdentifier[identifier] ?? pullConfig.workspaceDiagnostics
|
||||
}
|
||||
|
||||
function documentDelayForIdentifier(identifier) {
|
||||
return pullConfig.documentDelayMsByIdentifier[identifier] ?? pullConfig.delayMs
|
||||
}
|
||||
|
||||
function workspaceDelayForIdentifier(identifier) {
|
||||
return pullConfig.workspaceDelayMsByIdentifier[identifier] ?? pullConfig.delayMs
|
||||
}
|
||||
|
||||
function handle(raw) {
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(raw)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof data.method === "undefined" && typeof data.id !== "undefined") {
|
||||
const pending = pendingClientRequests.get(data.id)
|
||||
if (!pending) return
|
||||
pendingClientRequests.delete(data.id)
|
||||
sendResponse(pending, data.result ?? null)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.method === "initialize") {
|
||||
initializeParams = data.params
|
||||
sendResponse(data.id, {
|
||||
capabilities: {
|
||||
textDocumentSync: {
|
||||
change: 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (data.method === "test/get-initialize-params") {
|
||||
sendResponse(data.id, initializeParams)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.method === "test/request-configuration") {
|
||||
const id = sendRequest("workspace/configuration", data.params)
|
||||
pendingClientRequests.set(id, data.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.method === "initialized" || data.method === "workspace/didChangeConfiguration") {
|
||||
return
|
||||
}
|
||||
|
||||
if (data.method === "textDocument/didOpen") {
|
||||
maybeRegister("didOpen")
|
||||
return
|
||||
}
|
||||
|
||||
if (data.method === "textDocument/didChange") {
|
||||
lastChange = data.params
|
||||
maybeRegister("didChange")
|
||||
return
|
||||
}
|
||||
|
||||
if (data.method === "test/trigger") {
|
||||
const method = data.params && data.params.method
|
||||
if (method === "client/registerCapability") {
|
||||
sendRequest(method, {
|
||||
registrations: [
|
||||
{
|
||||
id: "test-diagnostic-registration",
|
||||
method: "textDocument/diagnostic",
|
||||
registerOptions: { identifier: "syntax" },
|
||||
},
|
||||
],
|
||||
})
|
||||
return
|
||||
}
|
||||
if (method === "client/unregisterCapability") {
|
||||
sendRequest(method, {
|
||||
unregisterations: [{ id: "test-diagnostic-registration", method: "textDocument/diagnostic" }],
|
||||
})
|
||||
return
|
||||
}
|
||||
if (method) sendRequest(method, {})
|
||||
return
|
||||
}
|
||||
|
||||
if (data.method === "test/configure-pull-diagnostics") {
|
||||
pullConfig = {
|
||||
delayMs: data.params?.delayMs ?? 0,
|
||||
registerOn: data.params?.registerOn,
|
||||
registrations: data.params?.registrations ?? [],
|
||||
documentDiagnostics: data.params?.documentDiagnostics ?? [],
|
||||
documentDiagnosticsByIdentifier: data.params?.documentDiagnosticsByIdentifier ?? {},
|
||||
documentDelayMsByIdentifier: data.params?.documentDelayMsByIdentifier ?? {},
|
||||
workspaceDiagnostics: data.params?.workspaceDiagnostics ?? [],
|
||||
workspaceDiagnosticsByIdentifier: data.params?.workspaceDiagnosticsByIdentifier ?? {},
|
||||
workspaceDelayMsByIdentifier: data.params?.workspaceDelayMsByIdentifier ?? {},
|
||||
}
|
||||
registeredCapability = false
|
||||
sendResponse(data.id, null)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.method === "test/register-configured-pull-diagnostics") {
|
||||
maybeRegister(undefined)
|
||||
sendResponse(data.id, null)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.method === "test/publish-diagnostics") {
|
||||
sendNotification("textDocument/publishDiagnostics", data.params)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.method === "test/get-last-change") {
|
||||
sendResponse(data.id, lastChange)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.method === "test/get-diagnostic-request-count") {
|
||||
sendResponse(data.id, diagnosticRequestCount)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.method === "textDocument/diagnostic") {
|
||||
diagnosticRequestCount += 1
|
||||
delayed(
|
||||
data.id,
|
||||
{
|
||||
kind: "full",
|
||||
items: diagnosticsForIdentifier(data.params?.identifier ?? ""),
|
||||
},
|
||||
documentDelayForIdentifier(data.params?.identifier ?? ""),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.method === "workspace/diagnostic") {
|
||||
diagnosticRequestCount += 1
|
||||
delayed(
|
||||
data.id,
|
||||
{
|
||||
items: workspaceDiagnosticsForIdentifier(data.params?.identifier ?? ""),
|
||||
},
|
||||
workspaceDelayForIdentifier(data.params?.identifier ?? ""),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof data.id !== "undefined") {
|
||||
sendResponse(data.id, null)
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.on("data", (chunk) => {
|
||||
readBuffer = Buffer.concat([readBuffer, chunk])
|
||||
const { messages, rest } = decodeFrames(readBuffer)
|
||||
readBuffer = rest
|
||||
for (const message of messages) handle(message)
|
||||
})
|
||||
93
qimingcode/packages/opencode/test/fixture/plug-worker.ts
Normal file
93
qimingcode/packages/opencode/test/fixture/plug-worker.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import path from "path"
|
||||
|
||||
import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug"
|
||||
import { Filesystem } from "../../src/util"
|
||||
|
||||
type Msg = {
|
||||
dir: string
|
||||
target: string
|
||||
mod: string
|
||||
global?: boolean
|
||||
force?: boolean
|
||||
globalDir?: string
|
||||
vcs?: string
|
||||
worktree?: string
|
||||
directory?: string
|
||||
holdMs?: number
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
function input() {
|
||||
const raw = process.argv[2]
|
||||
if (!raw) {
|
||||
throw new Error("Missing plug worker input")
|
||||
}
|
||||
|
||||
const msg = JSON.parse(raw) as Partial<Msg>
|
||||
if (!msg.dir || !msg.target || !msg.mod) {
|
||||
throw new Error("Invalid plug worker input")
|
||||
}
|
||||
|
||||
return msg as Msg
|
||||
}
|
||||
|
||||
function deps(msg: Msg): PlugDeps {
|
||||
return {
|
||||
spinner: () => ({
|
||||
start() {},
|
||||
stop() {},
|
||||
}),
|
||||
log: {
|
||||
error() {},
|
||||
info() {},
|
||||
success() {},
|
||||
},
|
||||
resolve: async () => msg.target,
|
||||
readText: (file) => Filesystem.readText(file),
|
||||
write: async (file, text) => {
|
||||
if (msg.holdMs && msg.holdMs > 0) {
|
||||
await sleep(msg.holdMs)
|
||||
}
|
||||
await Filesystem.write(file, text)
|
||||
},
|
||||
exists: (file) => Filesystem.exists(file),
|
||||
files: (dir, name) => [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)],
|
||||
global: msg.globalDir ?? path.join(msg.dir, ".global"),
|
||||
}
|
||||
}
|
||||
|
||||
function ctx(msg: Msg): PlugCtx {
|
||||
return {
|
||||
vcs: msg.vcs ?? "git",
|
||||
worktree: msg.worktree ?? msg.dir,
|
||||
directory: msg.directory ?? msg.dir,
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const msg = input()
|
||||
const run = createPlugTask(
|
||||
{
|
||||
mod: msg.mod,
|
||||
global: msg.global,
|
||||
force: msg.force,
|
||||
},
|
||||
deps(msg),
|
||||
)
|
||||
|
||||
const ok = await run(ctx(msg))
|
||||
if (!ok) {
|
||||
throw new Error("Plug task failed")
|
||||
}
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
const text = err instanceof Error ? (err.stack ?? err.message) : String(err)
|
||||
process.stderr.write(text)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
const raw = process.argv[2]
|
||||
if (!raw) throw new Error("Missing worker payload")
|
||||
|
||||
const value = JSON.parse(raw)
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error("Invalid worker payload")
|
||||
}
|
||||
|
||||
const msg = Object.fromEntries(Object.entries(value))
|
||||
if (typeof msg.file !== "string" || typeof msg.spec !== "string" || typeof msg.target !== "string") {
|
||||
throw new Error("Invalid worker payload")
|
||||
}
|
||||
if (typeof msg.id !== "string") throw new Error("Invalid worker payload")
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = msg.file
|
||||
|
||||
const { PluginMeta } = await import("../../src/plugin/meta")
|
||||
|
||||
await PluginMeta.touch(msg.spec, msg.target, msg.id)
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
name: agents-sdk
|
||||
description: Build AI agents on Cloudflare Workers using the Agents SDK. Load when creating stateful agents, durable workflows, real-time WebSocket apps, scheduled tasks, MCP servers, or chat applications. Covers Agent class, state management, callable RPC, Workflows integration, and React hooks.
|
||||
---
|
||||
|
||||
# Cloudflare Agents SDK
|
||||
|
||||
**STOP.** Your knowledge of the Agents SDK may be outdated. Prefer retrieval over pre-training for any Agents SDK task.
|
||||
|
||||
## Documentation
|
||||
|
||||
Fetch current docs from `https://github.com/cloudflare/agents/tree/main/docs` before implementing.
|
||||
|
||||
| Topic | Doc | Use for |
|
||||
| ------------------- | ----------------------------- | ---------------------------------------------- |
|
||||
| Getting started | `docs/getting-started.md` | First agent, project setup |
|
||||
| State | `docs/state.md` | `setState`, `validateStateChange`, persistence |
|
||||
| Routing | `docs/routing.md` | URL patterns, `routeAgentRequest`, `basePath` |
|
||||
| Callable methods | `docs/callable-methods.md` | `@callable`, RPC, streaming, timeouts |
|
||||
| Scheduling | `docs/scheduling.md` | `schedule()`, `scheduleEvery()`, cron |
|
||||
| Workflows | `docs/workflows.md` | `AgentWorkflow`, durable multi-step tasks |
|
||||
| HTTP/WebSockets | `docs/http-websockets.md` | Lifecycle hooks, hibernation |
|
||||
| Email | `docs/email.md` | Email routing, secure reply resolver |
|
||||
| MCP client | `docs/mcp-client.md` | Connecting to MCP servers |
|
||||
| MCP server | `docs/mcp-servers.md` | Building MCP servers with `McpAgent` |
|
||||
| Client SDK | `docs/client-sdk.md` | `useAgent`, `useAgentChat`, React hooks |
|
||||
| Human-in-the-loop | `docs/human-in-the-loop.md` | Approval flows, pausing workflows |
|
||||
| Resumable streaming | `docs/resumable-streaming.md` | Stream recovery on disconnect |
|
||||
|
||||
Cloudflare docs: https://developers.cloudflare.com/agents/
|
||||
|
||||
## Capabilities
|
||||
|
||||
The Agents SDK provides:
|
||||
|
||||
- **Persistent state** - SQLite-backed, auto-synced to clients
|
||||
- **Callable RPC** - `@callable()` methods invoked over WebSocket
|
||||
- **Scheduling** - One-time, recurring (`scheduleEvery`), and cron tasks
|
||||
- **Workflows** - Durable multi-step background processing via `AgentWorkflow`
|
||||
- **MCP integration** - Connect to MCP servers or build your own with `McpAgent`
|
||||
- **Email handling** - Receive and reply to emails with secure routing
|
||||
- **Streaming chat** - `AIChatAgent` with resumable streams
|
||||
- **React hooks** - `useAgent`, `useAgentChat` for client apps
|
||||
|
||||
## FIRST: Verify Installation
|
||||
|
||||
```bash
|
||||
npm ls agents # Should show agents package
|
||||
```
|
||||
|
||||
If not installed:
|
||||
|
||||
```bash
|
||||
npm install agents
|
||||
```
|
||||
|
||||
## Wrangler Configuration
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"durable_objects": {
|
||||
"bindings": [{ "name": "MyAgent", "class_name": "MyAgent" }],
|
||||
},
|
||||
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyAgent"] }],
|
||||
}
|
||||
```
|
||||
|
||||
## Agent Class
|
||||
|
||||
```typescript
|
||||
import { Agent, routeAgentRequest, callable } from "agents"
|
||||
|
||||
type State = { count: number }
|
||||
|
||||
export class Counter extends Agent<Env, State> {
|
||||
initialState = { count: 0 }
|
||||
|
||||
// Validation hook - runs before state persists (sync, throwing rejects the update)
|
||||
validateStateChange(nextState: State, source: Connection | "server") {
|
||||
if (nextState.count < 0) throw new Error("Count cannot be negative")
|
||||
}
|
||||
|
||||
// Notification hook - runs after state persists (async, non-blocking)
|
||||
onStateUpdate(state: State, source: Connection | "server") {
|
||||
console.log("State updated:", state)
|
||||
}
|
||||
|
||||
@callable()
|
||||
increment() {
|
||||
this.setState({ count: this.state.count + 1 })
|
||||
return this.state.count
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
fetch: (req, env) => routeAgentRequest(req, env) ?? new Response("Not found", { status: 404 }),
|
||||
}
|
||||
```
|
||||
|
||||
## Routing
|
||||
|
||||
Requests route to `/agents/{agent-name}/{instance-name}`:
|
||||
|
||||
| Class | URL |
|
||||
| ---------- | -------------------------- |
|
||||
| `Counter` | `/agents/counter/user-123` |
|
||||
| `ChatRoom` | `/agents/chat-room/lobby` |
|
||||
|
||||
Client: `useAgent({ agent: "Counter", name: "user-123" })`
|
||||
|
||||
## Core APIs
|
||||
|
||||
| Task | API |
|
||||
| ------------------- | ------------------------------------------------------ |
|
||||
| Read state | `this.state.count` |
|
||||
| Write state | `this.setState({ count: 1 })` |
|
||||
| SQL query | `` this.sql`SELECT * FROM users WHERE id = ${id}` `` |
|
||||
| Schedule (delay) | `await this.schedule(60, "task", payload)` |
|
||||
| Schedule (cron) | `await this.schedule("0 * * * *", "task", payload)` |
|
||||
| Schedule (interval) | `await this.scheduleEvery(30, "poll")` |
|
||||
| RPC method | `@callable() myMethod() { ... }` |
|
||||
| Streaming RPC | `@callable({ streaming: true }) stream(res) { ... }` |
|
||||
| Start workflow | `await this.runWorkflow("ProcessingWorkflow", params)` |
|
||||
|
||||
## React Client
|
||||
|
||||
```tsx
|
||||
import { useAgent } from "agents/react"
|
||||
|
||||
function App() {
|
||||
const [state, setLocalState] = useState({ count: 0 })
|
||||
|
||||
const agent = useAgent({
|
||||
agent: "Counter",
|
||||
name: "my-instance",
|
||||
onStateUpdate: (newState) => setLocalState(newState),
|
||||
onIdentity: (name, agentType) => console.log(`Connected to ${name}`),
|
||||
})
|
||||
|
||||
return <button onClick={() => agent.setState({ count: state.count + 1 })}>Count: {state.count}</button>
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- **[references/workflows.md](references/workflows.md)** - Durable Workflows integration
|
||||
- **[references/callable.md](references/callable.md)** - RPC methods, streaming, timeouts
|
||||
- **[references/state-scheduling.md](references/state-scheduling.md)** - State persistence, scheduling
|
||||
- **[references/streaming-chat.md](references/streaming-chat.md)** - AIChatAgent, resumable streams
|
||||
- **[references/mcp.md](references/mcp.md)** - MCP server integration
|
||||
- **[references/email.md](references/email.md)** - Email routing and handling
|
||||
- **[references/codemode.md](references/codemode.md)** - Code Mode (experimental)
|
||||
@@ -0,0 +1,92 @@
|
||||
# Callable Methods
|
||||
|
||||
Fetch `docs/callable-methods.md` from `https://github.com/cloudflare/agents/tree/main/docs` for complete documentation.
|
||||
|
||||
## Overview
|
||||
|
||||
`@callable()` exposes agent methods to clients via WebSocket RPC.
|
||||
|
||||
```typescript
|
||||
import { Agent, callable } from "agents"
|
||||
|
||||
export class MyAgent extends Agent<Env, State> {
|
||||
@callable()
|
||||
async greet(name: string): Promise<string> {
|
||||
return `Hello, ${name}!`
|
||||
}
|
||||
|
||||
@callable()
|
||||
async processData(data: unknown): Promise<Result> {
|
||||
// Long-running work
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Client Usage
|
||||
|
||||
```typescript
|
||||
// Basic call
|
||||
const greeting = await agent.call("greet", ["World"])
|
||||
|
||||
// With timeout
|
||||
const result = await agent.call("processData", [data], {
|
||||
timeout: 5000, // 5 second timeout
|
||||
})
|
||||
```
|
||||
|
||||
## Streaming Responses
|
||||
|
||||
```typescript
|
||||
import { Agent, callable, StreamingResponse } from "agents"
|
||||
|
||||
export class MyAgent extends Agent<Env, State> {
|
||||
@callable({ streaming: true })
|
||||
async streamResults(stream: StreamingResponse, query: string) {
|
||||
for await (const item of fetchResults(query)) {
|
||||
stream.send(JSON.stringify(item))
|
||||
}
|
||||
stream.close()
|
||||
}
|
||||
|
||||
@callable({ streaming: true })
|
||||
async streamWithError(stream: StreamingResponse) {
|
||||
try {
|
||||
// ... work
|
||||
} catch (error) {
|
||||
stream.error(error.message) // Signal error to client
|
||||
return
|
||||
}
|
||||
stream.close()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Client with streaming:
|
||||
|
||||
```typescript
|
||||
await agent.call("streamResults", ["search term"], {
|
||||
stream: {
|
||||
onChunk: (data) => console.log("Chunk:", data),
|
||||
onDone: () => console.log("Complete"),
|
||||
onError: (error) => console.error("Error:", error),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Introspection
|
||||
|
||||
```typescript
|
||||
// Get list of callable methods on an agent
|
||||
const methods = await agent.call("getCallableMethods", [])
|
||||
// Returns: ["greet", "processData", "streamResults", ...]
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
| Scenario | Use |
|
||||
| ------------------------------------ | --------------------------- |
|
||||
| Browser/mobile calling agent | `@callable()` |
|
||||
| External service calling agent | `@callable()` |
|
||||
| Worker calling agent (same codebase) | DO RPC directly |
|
||||
| Agent calling another agent | `getAgentByName()` + DO RPC |
|
||||
@@ -0,0 +1,211 @@
|
||||
---
|
||||
name: cloudflare
|
||||
description: Comprehensive Cloudflare platform skill covering Workers, Pages, storage (KV, D1, R2), AI (Workers AI, Vectorize, Agents SDK), networking (Tunnel, Spectrum), security (WAF, DDoS), and infrastructure-as-code (Terraform, Pulumi). Use for any Cloudflare development task.
|
||||
references:
|
||||
- workers
|
||||
- pages
|
||||
- d1
|
||||
- durable-objects
|
||||
- workers-ai
|
||||
---
|
||||
|
||||
# Cloudflare Platform Skill
|
||||
|
||||
Consolidated skill for building on the Cloudflare platform. Use decision trees below to find the right product, then load detailed references.
|
||||
|
||||
## Quick Decision Trees
|
||||
|
||||
### "I need to run code"
|
||||
|
||||
```
|
||||
Need to run code?
|
||||
├─ Serverless functions at the edge → workers/
|
||||
├─ Full-stack web app with Git deploys → pages/
|
||||
├─ Stateful coordination/real-time → durable-objects/
|
||||
├─ Long-running multi-step jobs → workflows/
|
||||
├─ Run containers → containers/
|
||||
├─ Multi-tenant (customers deploy code) → workers-for-platforms/
|
||||
├─ Scheduled tasks (cron) → cron-triggers/
|
||||
├─ Lightweight edge logic (modify HTTP) → snippets/
|
||||
├─ Process Worker execution events (logs/observability) → tail-workers/
|
||||
└─ Optimize latency to backend infrastructure → smart-placement/
|
||||
```
|
||||
|
||||
### "I need to store data"
|
||||
|
||||
```
|
||||
Need storage?
|
||||
├─ Key-value (config, sessions, cache) → kv/
|
||||
├─ Relational SQL → d1/ (SQLite) or hyperdrive/ (existing Postgres/MySQL)
|
||||
├─ Object/file storage (S3-compatible) → r2/
|
||||
├─ Message queue (async processing) → queues/
|
||||
├─ Vector embeddings (AI/semantic search) → vectorize/
|
||||
├─ Strongly-consistent per-entity state → durable-objects/ (DO storage)
|
||||
├─ Secrets management → secrets-store/
|
||||
├─ Streaming ETL to R2 → pipelines/
|
||||
└─ Persistent cache (long-term retention) → cache-reserve/
|
||||
```
|
||||
|
||||
### "I need AI/ML"
|
||||
|
||||
```
|
||||
Need AI?
|
||||
├─ Run inference (LLMs, embeddings, images) → workers-ai/
|
||||
├─ Vector database for RAG/search → vectorize/
|
||||
├─ Build stateful AI agents → agents-sdk/
|
||||
├─ Gateway for any AI provider (caching, routing) → ai-gateway/
|
||||
└─ AI-powered search widget → ai-search/
|
||||
```
|
||||
|
||||
### "I need networking/connectivity"
|
||||
|
||||
```
|
||||
Need networking?
|
||||
├─ Expose local service to internet → tunnel/
|
||||
├─ TCP/UDP proxy (non-HTTP) → spectrum/
|
||||
├─ WebRTC TURN server → turn/
|
||||
├─ Private network connectivity → network-interconnect/
|
||||
├─ Optimize routing → argo-smart-routing/
|
||||
├─ Optimize latency to backend (not user) → smart-placement/
|
||||
└─ Real-time video/audio → realtimekit/ or realtime-sfu/
|
||||
```
|
||||
|
||||
### "I need security"
|
||||
|
||||
```
|
||||
Need security?
|
||||
├─ Web Application Firewall → waf/
|
||||
├─ DDoS protection → ddos/
|
||||
├─ Bot detection/management → bot-management/
|
||||
├─ API protection → api-shield/
|
||||
├─ CAPTCHA alternative → turnstile/
|
||||
└─ Credential leak detection → waf/ (managed ruleset)
|
||||
```
|
||||
|
||||
### "I need media/content"
|
||||
|
||||
```
|
||||
Need media?
|
||||
├─ Image optimization/transformation → images/
|
||||
├─ Video streaming/encoding → stream/
|
||||
├─ Browser automation/screenshots → browser-rendering/
|
||||
└─ Third-party script management → zaraz/
|
||||
```
|
||||
|
||||
### "I need infrastructure-as-code"
|
||||
|
||||
```
|
||||
Need IaC? → pulumi/ (Pulumi), terraform/ (Terraform), or api/ (REST API)
|
||||
```
|
||||
|
||||
## Product Index
|
||||
|
||||
### Compute & Runtime
|
||||
|
||||
| Product | Reference |
|
||||
| --------------------- | ----------------------------------- |
|
||||
| Workers | `references/workers/` |
|
||||
| Pages | `references/pages/` |
|
||||
| Pages Functions | `references/pages-functions/` |
|
||||
| Durable Objects | `references/durable-objects/` |
|
||||
| Workflows | `references/workflows/` |
|
||||
| Containers | `references/containers/` |
|
||||
| Workers for Platforms | `references/workers-for-platforms/` |
|
||||
| Cron Triggers | `references/cron-triggers/` |
|
||||
| Tail Workers | `references/tail-workers/` |
|
||||
| Snippets | `references/snippets/` |
|
||||
| Smart Placement | `references/smart-placement/` |
|
||||
|
||||
### Storage & Data
|
||||
|
||||
| Product | Reference |
|
||||
| --------------- | ----------------------------- |
|
||||
| KV | `references/kv/` |
|
||||
| D1 | `references/d1/` |
|
||||
| R2 | `references/r2/` |
|
||||
| Queues | `references/queues/` |
|
||||
| Hyperdrive | `references/hyperdrive/` |
|
||||
| DO Storage | `references/do-storage/` |
|
||||
| Secrets Store | `references/secrets-store/` |
|
||||
| Pipelines | `references/pipelines/` |
|
||||
| R2 Data Catalog | `references/r2-data-catalog/` |
|
||||
| R2 SQL | `references/r2-sql/` |
|
||||
|
||||
### AI & Machine Learning
|
||||
|
||||
| Product | Reference |
|
||||
| ---------- | ------------------------ |
|
||||
| Workers AI | `references/workers-ai/` |
|
||||
| Vectorize | `references/vectorize/` |
|
||||
| Agents SDK | `references/agents-sdk/` |
|
||||
| AI Gateway | `references/ai-gateway/` |
|
||||
| AI Search | `references/ai-search/` |
|
||||
|
||||
### Networking & Connectivity
|
||||
|
||||
| Product | Reference |
|
||||
| -------------------- | ---------------------------------- |
|
||||
| Tunnel | `references/tunnel/` |
|
||||
| Spectrum | `references/spectrum/` |
|
||||
| TURN | `references/turn/` |
|
||||
| Network Interconnect | `references/network-interconnect/` |
|
||||
| Argo Smart Routing | `references/argo-smart-routing/` |
|
||||
| Workers VPC | `references/workers-vpc/` |
|
||||
|
||||
### Security
|
||||
|
||||
| Product | Reference |
|
||||
| --------------- | ---------------------------- |
|
||||
| WAF | `references/waf/` |
|
||||
| DDoS Protection | `references/ddos/` |
|
||||
| Bot Management | `references/bot-management/` |
|
||||
| API Shield | `references/api-shield/` |
|
||||
| Turnstile | `references/turnstile/` |
|
||||
|
||||
### Media & Content
|
||||
|
||||
| Product | Reference |
|
||||
| ----------------- | ------------------------------- |
|
||||
| Images | `references/images/` |
|
||||
| Stream | `references/stream/` |
|
||||
| Browser Rendering | `references/browser-rendering/` |
|
||||
| Zaraz | `references/zaraz/` |
|
||||
|
||||
### Real-Time Communication
|
||||
|
||||
| Product | Reference |
|
||||
| ------------ | -------------------------- |
|
||||
| RealtimeKit | `references/realtimekit/` |
|
||||
| Realtime SFU | `references/realtime-sfu/` |
|
||||
|
||||
### Developer Tools
|
||||
|
||||
| Product | Reference |
|
||||
| ------------------ | -------------------------------- |
|
||||
| Wrangler | `references/wrangler/` |
|
||||
| Miniflare | `references/miniflare/` |
|
||||
| C3 | `references/c3/` |
|
||||
| Observability | `references/observability/` |
|
||||
| Analytics Engine | `references/analytics-engine/` |
|
||||
| Web Analytics | `references/web-analytics/` |
|
||||
| Sandbox | `references/sandbox/` |
|
||||
| Workerd | `references/workerd/` |
|
||||
| Workers Playground | `references/workers-playground/` |
|
||||
|
||||
### Infrastructure as Code
|
||||
|
||||
| Product | Reference |
|
||||
| --------- | ----------------------- |
|
||||
| Pulumi | `references/pulumi/` |
|
||||
| Terraform | `references/terraform/` |
|
||||
| API | `references/api/` |
|
||||
|
||||
### Other Services
|
||||
|
||||
| Product | Reference |
|
||||
| ------------- | --------------------------- |
|
||||
| Email Routing | `references/email-routing/` |
|
||||
| Email Workers | `references/email-workers/` |
|
||||
| Static Assets | `references/static-assets/` |
|
||||
| Bindings | `references/bindings/` |
|
||||
| Cache Reserve | `references/cache-reserve/` |
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"skills": [
|
||||
{ "name": "agents-sdk", "description": "Cloudflare Agents SDK", "files": ["SKILL.md", "references/callable.md"] },
|
||||
{ "name": "cloudflare", "description": "Cloudflare Platform Skill", "files": ["SKILL.md"] }
|
||||
]
|
||||
}
|
||||
323
qimingcode/packages/opencode/test/fixture/tui-plugin.ts
Normal file
323
qimingcode/packages/opencode/test/fixture/tui-plugin.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { RGBA, type CliRenderer } from "@opentui/core"
|
||||
import { createPluginKeybind } from "../../src/cli/cmd/tui/context/plugin-keybinds"
|
||||
import type { HostPluginApi } from "../../src/cli/cmd/tui/plugin/slots"
|
||||
|
||||
type Count = {
|
||||
event_add: number
|
||||
event_drop: number
|
||||
route_add: number
|
||||
route_drop: number
|
||||
command_add: number
|
||||
command_drop: number
|
||||
}
|
||||
|
||||
function themeCurrent(): HostPluginApi["theme"]["current"] {
|
||||
const a = RGBA.fromInts(0, 120, 240)
|
||||
const b = RGBA.fromInts(120, 120, 120)
|
||||
const c = RGBA.fromInts(230, 230, 230)
|
||||
const d = RGBA.fromInts(120, 30, 30)
|
||||
const e = RGBA.fromInts(140, 100, 40)
|
||||
const f = RGBA.fromInts(20, 140, 80)
|
||||
const g = RGBA.fromInts(20, 80, 160)
|
||||
const h = RGBA.fromInts(40, 40, 40)
|
||||
const i = RGBA.fromInts(60, 60, 60)
|
||||
const j = RGBA.fromInts(80, 80, 80)
|
||||
return {
|
||||
primary: a,
|
||||
secondary: b,
|
||||
accent: a,
|
||||
error: d,
|
||||
warning: e,
|
||||
success: f,
|
||||
info: g,
|
||||
text: c,
|
||||
textMuted: b,
|
||||
selectedListItemText: h,
|
||||
background: h,
|
||||
backgroundPanel: h,
|
||||
backgroundElement: i,
|
||||
backgroundMenu: i,
|
||||
border: j,
|
||||
borderActive: c,
|
||||
borderSubtle: i,
|
||||
diffAdded: f,
|
||||
diffRemoved: d,
|
||||
diffContext: b,
|
||||
diffHunkHeader: b,
|
||||
diffHighlightAdded: f,
|
||||
diffHighlightRemoved: d,
|
||||
diffAddedBg: h,
|
||||
diffRemovedBg: h,
|
||||
diffContextBg: h,
|
||||
diffLineNumber: b,
|
||||
diffAddedLineNumberBg: h,
|
||||
diffRemovedLineNumberBg: h,
|
||||
markdownText: c,
|
||||
markdownHeading: c,
|
||||
markdownLink: a,
|
||||
markdownLinkText: g,
|
||||
markdownCode: f,
|
||||
markdownBlockQuote: e,
|
||||
markdownEmph: e,
|
||||
markdownStrong: c,
|
||||
markdownHorizontalRule: b,
|
||||
markdownListItem: a,
|
||||
markdownListEnumeration: g,
|
||||
markdownImage: a,
|
||||
markdownImageText: g,
|
||||
markdownCodeBlock: c,
|
||||
syntaxComment: b,
|
||||
syntaxKeyword: a,
|
||||
syntaxFunction: g,
|
||||
syntaxVariable: c,
|
||||
syntaxString: f,
|
||||
syntaxNumber: e,
|
||||
syntaxType: a,
|
||||
syntaxOperator: a,
|
||||
syntaxPunctuation: c,
|
||||
thinkingOpacity: 0.6,
|
||||
}
|
||||
}
|
||||
|
||||
type Opts = {
|
||||
client?: HostPluginApi["client"] | (() => HostPluginApi["client"])
|
||||
renderer?: HostPluginApi["renderer"]
|
||||
count?: Count
|
||||
keybind?: Partial<HostPluginApi["keybind"]>
|
||||
tuiConfig?: HostPluginApi["tuiConfig"]
|
||||
app?: Partial<HostPluginApi["app"]>
|
||||
state?: {
|
||||
ready?: HostPluginApi["state"]["ready"]
|
||||
config?: HostPluginApi["state"]["config"]
|
||||
provider?: HostPluginApi["state"]["provider"]
|
||||
path?: HostPluginApi["state"]["path"]
|
||||
vcs?: HostPluginApi["state"]["vcs"]
|
||||
session?: Partial<HostPluginApi["state"]["session"]>
|
||||
part?: HostPluginApi["state"]["part"]
|
||||
lsp?: HostPluginApi["state"]["lsp"]
|
||||
mcp?: HostPluginApi["state"]["mcp"]
|
||||
}
|
||||
theme?: {
|
||||
selected?: string
|
||||
has?: HostPluginApi["theme"]["has"]
|
||||
set?: HostPluginApi["theme"]["set"]
|
||||
install?: HostPluginApi["theme"]["install"]
|
||||
mode?: HostPluginApi["theme"]["mode"]
|
||||
ready?: boolean
|
||||
current?: HostPluginApi["theme"]["current"]
|
||||
}
|
||||
}
|
||||
|
||||
export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
|
||||
const kv: Record<string, unknown> = {}
|
||||
const count = opts.count
|
||||
const ctrl = new AbortController()
|
||||
const own = createOpencodeClient({
|
||||
baseUrl: "http://localhost:4096",
|
||||
})
|
||||
const fallback = () => own
|
||||
const read =
|
||||
typeof opts.client === "function"
|
||||
? opts.client
|
||||
: opts.client
|
||||
? () => opts.client as HostPluginApi["client"]
|
||||
: fallback
|
||||
const client = () => read()
|
||||
let depth = 0
|
||||
let size: "medium" | "large" | "xlarge" = "medium"
|
||||
const has = opts.theme?.has ?? (() => false)
|
||||
let selected = opts.theme?.selected ?? "opencode"
|
||||
const key = {
|
||||
match: opts.keybind?.match ?? (() => false),
|
||||
print: opts.keybind?.print ?? ((name: string) => name),
|
||||
}
|
||||
const set =
|
||||
opts.theme?.set ??
|
||||
((name: string) => {
|
||||
if (!has(name)) return false
|
||||
selected = name
|
||||
return true
|
||||
})
|
||||
const renderer: CliRenderer = opts.renderer ?? {
|
||||
...Object.create(null),
|
||||
once(this: CliRenderer) {
|
||||
return this
|
||||
},
|
||||
}
|
||||
|
||||
function kvGet(name: string): unknown
|
||||
function kvGet<Value>(name: string, fallback: Value): Value
|
||||
function kvGet(name: string, fallback?: unknown) {
|
||||
const value = kv[name]
|
||||
if (value === undefined) return fallback
|
||||
return value
|
||||
}
|
||||
|
||||
return {
|
||||
app: {
|
||||
get version() {
|
||||
return opts.app?.version ?? "0.0.0-test"
|
||||
},
|
||||
},
|
||||
get client() {
|
||||
return client()
|
||||
},
|
||||
event: {
|
||||
on: () => {
|
||||
if (count) count.event_add += 1
|
||||
return () => {
|
||||
if (!count) return
|
||||
count.event_drop += 1
|
||||
}
|
||||
},
|
||||
},
|
||||
renderer,
|
||||
slots: {
|
||||
register: () => "fixture-slot",
|
||||
},
|
||||
plugins: {
|
||||
list: () => [],
|
||||
activate: async () => false,
|
||||
deactivate: async () => false,
|
||||
add: async () => false,
|
||||
install: async () => ({
|
||||
ok: false,
|
||||
message: "not implemented in fixture",
|
||||
}),
|
||||
},
|
||||
lifecycle: {
|
||||
signal: ctrl.signal,
|
||||
onDispose() {
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
command: {
|
||||
register: () => {
|
||||
if (count) count.command_add += 1
|
||||
return () => {
|
||||
if (!count) return
|
||||
count.command_drop += 1
|
||||
}
|
||||
},
|
||||
trigger: () => {},
|
||||
show: () => {},
|
||||
},
|
||||
route: {
|
||||
register: () => {
|
||||
if (count) count.route_add += 1
|
||||
return () => {
|
||||
if (!count) return
|
||||
count.route_drop += 1
|
||||
}
|
||||
},
|
||||
navigate: () => {},
|
||||
get current() {
|
||||
return { name: "home" }
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
Dialog: () => null,
|
||||
DialogAlert: () => null,
|
||||
DialogConfirm: () => null,
|
||||
DialogPrompt: () => null,
|
||||
DialogSelect: () => null,
|
||||
Slot: () => null,
|
||||
Prompt: () => null,
|
||||
toast: () => {},
|
||||
dialog: {
|
||||
replace: () => {
|
||||
depth = 1
|
||||
},
|
||||
clear: () => {
|
||||
depth = 0
|
||||
size = "medium"
|
||||
},
|
||||
setSize: (next) => {
|
||||
size = next
|
||||
},
|
||||
get size() {
|
||||
return size
|
||||
},
|
||||
get depth() {
|
||||
return depth
|
||||
},
|
||||
get open() {
|
||||
return depth > 0
|
||||
},
|
||||
},
|
||||
},
|
||||
keybind: {
|
||||
...key,
|
||||
create:
|
||||
opts.keybind?.create ??
|
||||
((defaults, over) => {
|
||||
return createPluginKeybind(key, defaults, over)
|
||||
}),
|
||||
},
|
||||
tuiConfig: opts.tuiConfig ?? {},
|
||||
kv: {
|
||||
get: kvGet,
|
||||
set(name, value) {
|
||||
kv[name] = value
|
||||
},
|
||||
get ready() {
|
||||
return true
|
||||
},
|
||||
},
|
||||
state: {
|
||||
get ready() {
|
||||
return opts.state?.ready ?? true
|
||||
},
|
||||
get config() {
|
||||
return opts.state?.config ?? {}
|
||||
},
|
||||
get provider() {
|
||||
return opts.state?.provider ?? []
|
||||
},
|
||||
get path() {
|
||||
return opts.state?.path ?? { home: "", state: "", config: "", worktree: "", directory: "" }
|
||||
},
|
||||
get vcs() {
|
||||
return opts.state?.vcs
|
||||
},
|
||||
session: {
|
||||
count: opts.state?.session?.count ?? (() => 0),
|
||||
diff: opts.state?.session?.diff ?? (() => []),
|
||||
todo: opts.state?.session?.todo ?? (() => []),
|
||||
messages: opts.state?.session?.messages ?? (() => []),
|
||||
status: opts.state?.session?.status ?? (() => undefined),
|
||||
permission: opts.state?.session?.permission ?? (() => []),
|
||||
question: opts.state?.session?.question ?? (() => []),
|
||||
},
|
||||
part: opts.state?.part ?? (() => []),
|
||||
lsp: opts.state?.lsp ?? (() => []),
|
||||
mcp: opts.state?.mcp ?? (() => []),
|
||||
},
|
||||
theme: {
|
||||
get current() {
|
||||
return opts.theme?.current ?? themeCurrent()
|
||||
},
|
||||
get selected() {
|
||||
return selected
|
||||
},
|
||||
has(name) {
|
||||
return has(name)
|
||||
},
|
||||
set(name) {
|
||||
return set(name)
|
||||
},
|
||||
async install(file) {
|
||||
if (opts.theme?.install) return opts.theme.install(file)
|
||||
throw new Error("base theme.install should not run")
|
||||
},
|
||||
mode() {
|
||||
if (opts.theme?.mode) return opts.theme.mode()
|
||||
return "dark"
|
||||
},
|
||||
get ready() {
|
||||
return opts.theme?.ready ?? true
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
31
qimingcode/packages/opencode/test/fixture/tui-runtime.ts
Normal file
31
qimingcode/packages/opencode/test/fixture/tui-runtime.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { spyOn } from "bun:test"
|
||||
import path from "path"
|
||||
import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
|
||||
|
||||
type PluginSpec = string | [string, Record<string, unknown>]
|
||||
|
||||
export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugin_enabled?: Record<string, boolean> }) {
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
|
||||
const plugin_origins = plugin.map((spec) => ({
|
||||
spec,
|
||||
scope: "local" as const,
|
||||
source: path.join(dir, "tui.json"),
|
||||
}))
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
|
||||
|
||||
const config: TuiConfig.Info = {
|
||||
plugin,
|
||||
plugin_origins,
|
||||
...(opts?.plugin_enabled && { plugin_enabled: opts.plugin_enabled }),
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
restore: () => {
|
||||
cwd.mockRestore()
|
||||
wait.mockRestore()
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user