diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index eac9f0b43fb..66e830ceb72 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -38,6 +38,7 @@ const runtimeMock = { inventory: { providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, agents: [] as unknown[], + skills: [] as unknown[], } as unknown, }, reset() { @@ -48,6 +49,7 @@ const runtimeMock = { this.state.inventory = { providerList: { connected: [], all: [] as unknown[], default: {} }, agents: [] as unknown[], + skills: [] as unknown[], }; }, }; @@ -194,6 +196,76 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); + it.effect("includes OpenCode skills in the provider snapshot", () => + Effect.gen(function* () { + runtimeMock.state.inventory = { + providerList: { + connected: ["openai"], + all: [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + variants: {}, + }, + }, + }, + ], + default: {}, + }, + agents: [], + skills: [ + { + name: "openclaw-review", + description: "Review OpenClaw workflow changes.", + location: "/Users/test/.agents/skills/openclaw-review/SKILL.md", + content: "---\nname: openclaw-review\n---\n", + }, + { + name: "openclaw-triage", + description: "Triage OpenClaw routing issues.", + location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", + content: "---\nname: openclaw-triage\n---\n", + }, + { + name: "missing-location", + description: "This incomplete SDK row should be skipped.", + location: "", + content: "---\nname: missing-location\n---\n", + }, + ], + }; + + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + + assert.deepEqual( + snapshot.skills.map((skill) => ({ + name: skill.name, + path: skill.path, + enabled: skill.enabled, + shortDescription: skill.shortDescription, + })), + [ + { + name: "openclaw-review", + path: "/Users/test/.agents/skills/openclaw-review/SKILL.md", + enabled: true, + shortDescription: "Review OpenClaw workflow changes.", + }, + { + name: "openclaw-triage", + path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", + enabled: true, + shortDescription: "Triage OpenClaw routing issues.", + }, + ], + ); + }), + ); + it.effect("closes the local OpenCode server scope after provider refresh", () => Effect.gen(function* () { yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index a8285e960fc..9c361dd8f93 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -3,6 +3,7 @@ import { type ModelCapabilities, type OpenCodeSettings, type ServerProviderModel, + type ServerProviderSkill, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; @@ -252,6 +253,32 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray left.name.localeCompare(right.name)); } +function trimOptional(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +function flattenOpenCodeSkills(input: OpenCodeInventory): ReadonlyArray { + const skills: ServerProviderSkill[] = []; + for (const skill of input.skills ?? []) { + const name = trimOptional(skill.name); + const path = trimOptional(skill.location); + if (!name || !path) { + continue; + } + + const description = trimOptional(skill.description); + skills.push({ + name, + path, + enabled: true, + ...(description ? { description, shortDescription: description } : {}), + }); + } + + return skills.toSorted((left, right) => left.name.localeCompare(right.name)); +} + export const makePendingOpenCodeProvider = ( openCodeSettings: OpenCodeSettings, ): Effect.Effect => @@ -442,12 +469,14 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); + const skills = flattenOpenCodeSkills(inventoryExit.value); const connectedCount = inventoryExit.value.providerList.connected.length; return buildServerProvider({ presentation: OPENCODE_PRESENTATION, enabled: true, checkedAt, models, + skills, probe: { installed: true, version, diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 365884da85d..2895cb8f8aa 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -100,6 +100,7 @@ export interface OpenCodeCommandResult { export interface OpenCodeInventory { readonly providerList: ProviderListResponse; readonly agents: ReadonlyArray; + readonly skills: ReadonlyArray; } export interface ParsedOpenCodeModelSlug { @@ -107,6 +108,13 @@ export interface ParsedOpenCodeModelSlug { readonly modelID: string; } +export interface OpenCodeSkill { + readonly name?: string | null; + readonly description?: string | null; + readonly location?: string | null; + readonly content?: string | null; +} + export interface OpenCodeRuntimeShape { /** * Spawns a local OpenCode server process. Its lifetime is bound to the caller's @@ -537,11 +545,16 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Effect.map((result) => result.data ?? []), ); - const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => - Effect.all([loadProviders(client), loadAgents(client)], { concurrency: "unbounded" }).pipe( - Effect.map(([providerList, agents]) => ({ providerList, agents })), + const loadSkills = (client: OpencodeClient) => + runOpenCodeSdk("app.skills", () => client.app.skills()).pipe( + Effect.map((result) => (result.data ?? []) as ReadonlyArray), ); + const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => + Effect.all([loadProviders(client), loadAgents(client), loadSkills(client)], { + concurrency: "unbounded", + }).pipe(Effect.map(([providerList, agents, skills]) => ({ providerList, agents, skills }))); + return { startOpenCodeServerProcess, connectToOpenCodeServer,