diff --git a/src/index.test.ts b/src/index.test.ts index 715eeeb99..0b8499983 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -151,9 +151,6 @@ describe("oh-my-openagent plugin module", () => { enabled: true, gateways: {}, hooks: {}, - replyListener: { - discordBotToken: "discord-token", - }, } mockLoadPluginConfig.mockReturnValue({ openclaw: openclawConfig, @@ -182,7 +179,7 @@ describe("oh-my-openagent plugin module", () => { // then expect(mockInitializeOpenClaw).not.toHaveBeenCalled() - }) + }, { timeout: 15000 }) it("exports a V1 PluginModule shape with id and server", () => { // given the plugin module is loaded diff --git a/src/shared/opencode-version.test.ts b/src/shared/opencode-version.test.ts index ef275e062..93025d927 100644 --- a/src/shared/opencode-version.test.ts +++ b/src/shared/opencode-version.test.ts @@ -132,6 +132,54 @@ describe("opencode-version", () => { // then returns null without executing command expect(result).toBe(null) }) + + test("reads adjacent package version before executing opencode binary", () => { + // given an opencode package next to the resolved binary + const calls: string[] = [] + + // when getting version + const result = getOpenCodeVersion({ + getBinaryPath: () => "/tmp/opencode-ai/bin/opencode", + realpath: (filePath) => filePath, + exists: (filePath) => filePath === "/tmp/opencode-ai/package.json", + readText: (filePath) => { + calls.push(`read:${filePath}`) + return JSON.stringify({ name: "opencode-ai", version: "1.14.41" }) + }, + execCommand: () => { + calls.push("exec") + return "1.14.41" + }, + }) + + // then the version is resolved without spawning the CLI + expect(result).toBe("1.14.41") + expect(calls).toEqual(["read:/tmp/opencode-ai/package.json"]) + }) + + test("falls back to opencode binary when package version is unavailable", () => { + // given no adjacent package version can be read + const calls: string[] = [] + + // when getting version + const result = getOpenCodeVersion({ + getBinaryPath: () => "/tmp/custom-opencode", + realpath: (filePath) => filePath, + exists: () => false, + readText: () => { + calls.push("read") + return "" + }, + execCommand: () => { + calls.push("exec") + return "opencode 1.14.42" + }, + }) + + // then the original CLI fallback remains intact + expect(result).toBe("1.14.42") + expect(calls).toEqual(["exec"]) + }) }) describe("isOpenCodeVersionAtLeast", () => { diff --git a/src/shared/opencode-version.ts b/src/shared/opencode-version.ts index e4eecd766..8bc2328b4 100644 --- a/src/shared/opencode-version.ts +++ b/src/shared/opencode-version.ts @@ -1,4 +1,6 @@ import { execSync } from "child_process" +import { existsSync, readFileSync, realpathSync } from "fs" +import { dirname, join } from "path" /** * Minimum OpenCode version required for this plugin. @@ -24,6 +26,38 @@ export const OPENCODE_SQLITE_VERSION = "1.1.53" const NOT_CACHED = Symbol("NOT_CACHED") let cachedVersion: string | null | typeof NOT_CACHED = NOT_CACHED +type RuntimeWithBun = typeof globalThis & { + Bun?: { + which(binary: string): string | null + } +} + +type ExecCommandOptions = { + encoding: "utf-8" + timeout: number + stdio: ["pipe", "pipe", "pipe"] +} + +export type OpenCodeVersionDeps = { + execCommand: (command: string, options: ExecCommandOptions) => string + getBinaryPath: () => string | null + exists: (filePath: string) => boolean + realpath: (filePath: string) => string + readText: (filePath: string) => string +} + +const defaultDeps: OpenCodeVersionDeps = { + execCommand: (command, options) => execSync(command, options), + getBinaryPath: () => { + const envPath = process.env.OPENCODE_BIN_PATH + if (envPath) return envPath + return (globalThis as RuntimeWithBun).Bun?.which("opencode") ?? null + }, + exists: existsSync, + realpath: realpathSync, + readText: (filePath) => readFileSync(filePath, "utf-8"), +} + export function parseVersion(version: string): number[] { const cleaned = version.replace(/^v/, "").split("-")[0] return cleaned.split(".").map((n) => parseInt(n, 10) || 0) @@ -43,14 +77,54 @@ export function compareVersions(a: string, b: string): -1 | 0 | 1 { return 0 } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} -export function getOpenCodeVersion(): string | null { +function parsePackageVersion(content: string): string | null { + try { + const parsed: unknown = JSON.parse(content) + if (!isRecord(parsed)) return null + + const name = parsed.name + const version = parsed.version + if (typeof name !== "string" || !name.includes("opencode")) return null + if (typeof version !== "string" || version.length === 0) return null + + return version + } catch { + return null + } +} + +function getPackageVersionFromBinary(binaryPath: string, deps: OpenCodeVersionDeps): string | null { + try { + const realBinaryPath = deps.realpath(binaryPath) + const packagePath = join(dirname(dirname(realBinaryPath)), "package.json") + if (!deps.exists(packagePath)) return null + return parsePackageVersion(deps.readText(packagePath)) + } catch { + return null + } +} + +export function getOpenCodeVersion(deps: Partial = {}): string | null { if (cachedVersion !== NOT_CACHED) { return cachedVersion } + const resolvedDeps: OpenCodeVersionDeps = { ...defaultDeps, ...deps } + const binaryPath = resolvedDeps.getBinaryPath() + if (binaryPath) { + const packageVersion = getPackageVersionFromBinary(binaryPath, resolvedDeps) + if (packageVersion) { + cachedVersion = packageVersion + return cachedVersion + } + } + try { - const result = execSync("opencode --version", { + const result = resolvedDeps.execCommand("opencode --version", { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], diff --git a/src/tools/skill/tools.factory.test.ts b/src/tools/skill/tools.factory.test.ts index b1e992607..dd0356143 100644 --- a/src/tools/skill/tools.factory.test.ts +++ b/src/tools/skill/tools.factory.test.ts @@ -5,6 +5,7 @@ import type { ToolContext } from "@opencode-ai/plugin/tool" import type { LoadedSkill } from "../../features/opencode-skill-loader/types" import * as skillContent from "../../features/opencode-skill-loader/skill-content" import * as commandDiscovery from "../slashcommand/command-discovery" +import type { CommandInfo } from "../slashcommand/types" const discoverCommandsSync = mock(() => []) @@ -128,4 +129,26 @@ describe("createSkillTool", () => { expect(clearSkillCache.mock.calls.length).toBe(baselineClearSkillCacheCalls + 2) expect(getAllSkills.mock.calls.length).toBe(baselineGetAllSkillsCalls + 4) }) + + it("executes precomputed commands without rediscovering commands", async () => { + // given + const baselineDiscoverCommandsSyncCalls = discoverCommandsSync.mock.calls.length + const command: CommandInfo = { + name: "seeded-command", + metadata: { + name: "seeded-command", + description: "Seeded command", + }, + content: "Seeded command body", + scope: "project", + } + const skillTool = await createSkillTool({ skills: [], commands: [command] }) + + // when + const result = await skillTool.execute({ name: "seeded-command" }, mockContext) + + // then + expect(result).toContain("Seeded command body") + expect(discoverCommandsSync.mock.calls.length).toBe(baselineDiscoverCommandsSyncCalls) + }) }) diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts index daece0351..0d4067505 100644 --- a/src/tools/skill/tools.ts +++ b/src/tools/skill/tools.ts @@ -52,6 +52,8 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition } const getCommands = (): CommandInfo[] => { + if (options.commands) return [...options.commands] + return commandDiscovery.discoverCommandsSync(undefined, { pluginsEnabled: options.pluginsEnabled, enabledPluginsOverride: options.enabledPluginsOverride,