authorgravatar for dbalders@gmail.comDavid Balderston <dbalders@gmail.com> 2026-08-19 12:38:01-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-19 15:38:01-04:00
logcf251c3bd00ff0e0cc90a78b20c043cf58f330ad
tree1b7bd2b53b7594693dd3f5b73ad2c99a33d2e0a1
parent4347f14b89ed66777ae16def278fd44575ab499b
signature Signed by PGP key B5690EEEBB952194

Add OpenCode skill discovery (#3154)


5 files changed, 253 insertions(+), 19 deletions(-)

apps/server/src/provider/Layers/OpenCodeProvider.test.ts+80-3
......@@ -34,20 +34,24 @@ const runtimeMock = {
3434 runVersionError: null as Error | null,
3535 versionStdout: DEFAULT_VERSION_STDOUT,
3636 inventoryError: null as Error | null,
37 inventoryCwd: null as string | null,
3738 closeCalls: 0,
3839 inventory: {
3940 providerList: { connected: [] as string[], all: [] as unknown[], default: {} },
4041 agents: [] as unknown[],
42 skills: [] as unknown[],
4143 } as unknown,
4244 },
4345 reset() {
4446 this.state.runVersionError = null;
4547 this.state.versionStdout = DEFAULT_VERSION_STDOUT;
4648 this.state.inventoryError = null;
49 this.state.inventoryCwd = null;
4750 this.state.closeCalls = 0;
4851 this.state.inventory = {
4952 providerList: { connected: [], all: [] as unknown[], default: {} },
5053 agents: [] as unknown[],
54 skills: [] as unknown[],
5155 };
5256 },
5357};
......@@ -95,8 +99,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
9599 }),
96100 )
97101 : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory),
98 loadInventoryFromCli: () =>
99 runtimeMock.state.inventoryError
102 loadInventoryFromCli: ({ cwd }) => {
103 runtimeMock.state.inventoryCwd = cwd;
104 return runtimeMock.state.inventoryError
100105 ? Effect.fail(
101106 new OpenCodeRuntimeError({
102107 operation: "loadInventoryFromCli",
......@@ -104,7 +109,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
104109 cause: runtimeMock.state.inventoryError,
105110 }),
106111 )
107 : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory),
112 : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory);
113 },
108114};
109115
110116beforeEach(() => {
......@@ -207,11 +213,82 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => {
207213 }),
208214 );
209215
216 it.effect("includes OpenCode skills in the provider snapshot", () =>
217 Effect.gen(function* () {
218 runtimeMock.state.inventory = {
219 providerList: {
220 connected: ["openai"],
221 all: [
222 {
223 id: "openai",
224 name: "OpenAI",
225 models: {
226 "gpt-5.4": {
227 id: "gpt-5.4",
228 name: "GPT-5.4",
229 variants: {},
230 },
231 },
232 },
233 ],
234 default: {},
235 },
236 agents: [],
237 skills: [
238 {
239 name: "openclaw-review",
240 description: "Review OpenClaw workflow changes.",
241 location: "/Users/test/.agents/skills/openclaw-review/SKILL.md",
242 content: "---\nname: openclaw-review\n---\n",
243 },
244 {
245 name: "openclaw-triage",
246 description: "Triage OpenClaw routing issues.",
247 location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md",
248 content: "---\nname: openclaw-triage\n---\n",
249 },
250 {
251 name: "missing-location",
252 description: "This incomplete SDK row should be skipped.",
253 location: "",
254 content: "---\nname: missing-location\n---\n",
255 },
256 ],
257 };
258
259 const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd());
260
261 NodeAssert.deepEqual(
262 snapshot.skills.map((skill) => ({
263 name: skill.name,
264 path: skill.path,
265 enabled: skill.enabled,
266 shortDescription: skill.shortDescription,
267 })),
268 [
269 {
270 name: "openclaw-review",
271 path: "/Users/test/.agents/skills/openclaw-review/SKILL.md",
272 enabled: true,
273 shortDescription: "Review OpenClaw workflow changes.",
274 },
275 {
276 name: "openclaw-triage",
277 path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md",
278 enabled: true,
279 shortDescription: "Triage OpenClaw routing issues.",
280 },
281 ],
282 );
283 }),
284 );
285
210286 it.effect("does not spawn a local server for health check (uses CLI instead)", () =>
211287 Effect.gen(function* () {
212288 yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd());
213289
214290 NodeAssert.equal(runtimeMock.state.closeCalls, 0);
291 NodeAssert.equal(runtimeMock.state.inventoryCwd, process.cwd());
215292 }),
216293 );
217294
apps/server/src/provider/Layers/OpenCodeProvider.ts+30
......@@ -2,6 +2,7 @@ import {
22 type ModelCapabilities,
33 type OpenCodeSettings,
44 type ServerProviderModel,
5 type ServerProviderSkill,
56} from "@t3tools/contracts";
67import * as Cause from "effect/Cause";
78import * as Data from "effect/Data";
......@@ -250,6 +251,32 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray<ServerPr
250251 return models.toSorted((left, right) => left.name.localeCompare(right.name));
251252}
252253
254function trimOptional(value: string | null | undefined): string | undefined {
255 const trimmed = value?.trim();
256 return trimmed && trimmed.length > 0 ? trimmed : undefined;
257}
258
259function flattenOpenCodeSkills(input: OpenCodeInventory): ReadonlyArray<ServerProviderSkill> {
260 const skills: ServerProviderSkill[] = [];
261 for (const skill of input.skills ?? []) {
262 const name = trimOptional(skill.name);
263 const path = trimOptional(skill.location);
264 if (!name || !path) {
265 continue;
266 }
267
268 const description = trimOptional(skill.description);
269 skills.push({
270 name,
271 path,
272 enabled: true,
273 ...(description ? { description, shortDescription: description } : {}),
274 });
275 }
276
277 return skills.toSorted((left, right) => left.name.localeCompare(right.name));
278}
279
253280export const makePendingOpenCodeProvider = (
254281 openCodeSettings: OpenCodeSettings,
255282): Effect.Effect<ServerProviderDraft> =>
......@@ -412,6 +439,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
412439 )
413440 : openCodeRuntime.loadInventoryFromCli({
414441 binaryPath: openCodeSettings.binaryPath,
442 cwd,
415443 environment: resolvedEnvironment,
416444 })
417445 ).pipe(
......@@ -429,12 +457,14 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
429457 customModels,
430458 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
431459 );
460 const skills = flattenOpenCodeSkills(inventoryExit.value);
432461 const connectedCount = inventoryExit.value.providerList.connected.length;
433462 return buildServerProvider({
434463 presentation: OPENCODE_PRESENTATION,
435464 enabled: true,
436465 checkedAt,
437466 models,
467 skills,
438468 probe: {
439469 installed: true,
440470 version,
apps/server/src/provider/opencodeRuntime.cliParsers.test.ts+33-1
......@@ -2,7 +2,11 @@ import * as NodeAssert from "node:assert/strict";
22
33import { describe, it } from "vite-plus/test";
44
5import { parseModelsCliOutput, parseAgentListCliOutput } from "./opencodeRuntime.ts";
5import {
6 parseAgentListCliOutput,
7 parseModelsCliOutput,
8 parseSkillsCliOutput,
9} from "./opencodeRuntime.ts";
610
711describe("parseModelsCliOutput", () => {
812 it("parses a single model from a single provider", () => {
......@@ -252,3 +256,31 @@ describe("parseAgentListCliOutput", () => {
252256 NodeAssert.equal(result[1]!.hidden, false);
253257 });
254258});
259
260describe("parseSkillsCliOutput", () => {
261 it("parses skill metadata from the CLI JSON output", () => {
262 const result = parseSkillsCliOutput(
263 JSON.stringify([
264 {
265 name: "review-pr",
266 description: "Review a pull request.",
267 location: "/tmp/review-pr/SKILL.md",
268 content: "---\nname: review-pr\n---\n",
269 },
270 ]),
271 );
272
273 NodeAssert.deepEqual(result, [
274 {
275 name: "review-pr",
276 description: "Review a pull request.",
277 location: "/tmp/review-pr/SKILL.md",
278 content: "---\nname: review-pr\n---\n",
279 },
280 ]);
281 });
282
283 it("degrades malformed output to an empty skill list", () => {
284 NodeAssert.deepEqual(parseSkillsCliOutput("not json"), []);
285 });
286});
apps/server/src/provider/opencodeRuntime.inventory.test.ts created+41
......@@ -0,0 +1,41 @@
1import * as NodeAssert from "node:assert/strict";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import type { OpencodeClient } from "@opencode-ai/sdk/v2";
5import { it } from "@effect/vitest";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8
9import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts";
10
11const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer));
12
13it.layer(testLayer)("loadOpenCodeInventory", (it) => {
14 it.effect("keeps provider inventory when skill discovery fails", () =>
15 Effect.gen(function* () {
16 const runtime = yield* OpenCodeRuntime;
17 const client = {
18 provider: {
19 list: () =>
20 Promise.resolve({
21 data: {
22 connected: ["openai"],
23 all: [],
24 default: {},
25 },
26 }),
27 },
28 app: {
29 agents: () => Promise.resolve({ data: [] }),
30 skills: () => Promise.reject(new Error("skills endpoint unavailable")),
31 },
32 } as unknown as OpencodeClient;
33
34 const inventory = yield* runtime.loadOpenCodeInventory(client);
35
36 NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]);
37 NodeAssert.deepEqual(inventory.agents, []);
38 NodeAssert.deepEqual(inventory.skills, []);
39 }),
40 );
41});
apps/server/src/provider/opencodeRuntime.ts+69-15
......@@ -112,6 +112,7 @@ export interface OpenCodeCommandResult {
112112export interface OpenCodeInventory {
113113 readonly providerList: ProviderListResponse;
114114 readonly agents: ReadonlyArray<Agent>;
115 readonly skills: ReadonlyArray<OpenCodeSkill>;
115116}
116117
117118export interface ParsedOpenCodeModelSlug {
......@@ -119,6 +120,23 @@ export interface ParsedOpenCodeModelSlug {
119120 readonly modelID: string;
120121}
121122
123export interface OpenCodeSkill {
124 readonly name?: string | null;
125 readonly description?: string | null;
126 readonly location?: string | null;
127 readonly content?: string | null;
128}
129
130const OpenCodeSkillSchema = Schema.Struct({
131 name: Schema.optionalKey(Schema.NullOr(Schema.String)),
132 description: Schema.optionalKey(Schema.NullOr(Schema.String)),
133 location: Schema.optionalKey(Schema.NullOr(Schema.String)),
134 content: Schema.optionalKey(Schema.NullOr(Schema.String)),
135});
136const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit(
137 Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)),
138);
139
122140export interface OpenCodeRuntimeShape {
123141 /**
124142 * Spawns a local OpenCode server process. Its lifetime is bound to the caller's
......@@ -150,6 +168,7 @@ export interface OpenCodeRuntimeShape {
150168 readonly binaryPath: string;
151169 readonly args: ReadonlyArray<string>;
152170 readonly environment?: NodeJS.ProcessEnv;
171 readonly cwd?: string;
153172 }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>;
154173 readonly createOpenCodeSdkClient: (input: {
155174 readonly baseUrl: string;
......@@ -161,6 +180,7 @@ export interface OpenCodeRuntimeShape {
161180 ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>;
162181 readonly loadInventoryFromCli: (input: {
163182 readonly binaryPath: string;
183 readonly cwd: string;
164184 readonly environment?: NodeJS.ProcessEnv;
165185 }) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>;
166186}
......@@ -289,6 +309,12 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> {
289309 return agents;
290310}
291311
312/** @internal */
313export function parseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill> {
314 const result = decodeOpenCodeSkillsCliOutputExit(stdout);
315 return Exit.isSuccess(result) ? result.value : [];
316}
317
292318export function parseOpenCodeModelSlug(
293319 slug: string | null | undefined,
294320): ParsedOpenCodeModelSlug | null {
......@@ -417,6 +443,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
417443 const child = yield* spawner.spawn(
418444 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
419445 shell: spawnCommand.shell,
446 ...(input.cwd ? { cwd: input.cwd } : {}),
420447 ...(input.environment ? { env: input.environment } : { extendEnv: true }),
421448 }),
422449 );
......@@ -673,45 +700,67 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
673700 Effect.map((result) => result.data ?? []),
674701 );
675702
676 const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) =>
677 Effect.all([loadProviders(client), loadAgents(client)], { concurrency: "unbounded" }).pipe(
678 Effect.map(([providerList, agents]) => ({ providerList, agents })),
703 const loadSkills = (client: OpencodeClient) =>
704 runOpenCodeSdk("app.skills", () => client.app.skills()).pipe(
705 Effect.map((result) => (result.data ?? []) as ReadonlyArray<OpenCodeSkill>),
706 Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => []),
679707 );
680708
709 const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) =>
710 Effect.all([loadProviders(client), loadAgents(client), loadSkills(client)], {
711 concurrency: "unbounded",
712 }).pipe(Effect.map(([providerList, agents, skills]) => ({ providerList, agents, skills })));
713
681714 const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) =>
682715 Effect.gen(function* () {
683716 const env = input.environment !== undefined ? { environment: input.environment } : ({} as {});
717 const commandContext = { cwd: input.cwd, ...env };
684718
685719 const runModelsCli = () =>
686720 runOpenCodeCommand({
687721 binaryPath: input.binaryPath,
688722 args: ["models", "--verbose"],
689 ...env,
723 ...commandContext,
690724 }).pipe(Effect.exit);
691725 const runAgentsCli = () =>
692 runOpenCodeCommand({ binaryPath: input.binaryPath, args: ["agent", "list"], ...env }).pipe(
693 Effect.exit,
694 );
726 runOpenCodeCommand({
727 binaryPath: input.binaryPath,
728 args: ["agent", "list"],
729 ...commandContext,
730 }).pipe(Effect.exit);
731 const runSkillsCli = () =>
732 runOpenCodeCommand({
733 binaryPath: input.binaryPath,
734 args: ["debug", "skill"],
735 ...commandContext,
736 }).pipe(Effect.exit);
695737
696 // First attempt — run both in parallel
697 let [modelsResult, agentsResult] = yield* Effect.all([runModelsCli(), runAgentsCli()], {
698 concurrency: "unbounded",
699 });
738 // First attempt — run all inventory commands in parallel.
739 const [initialModelsResult, initialAgentsResult, initialSkillsResult] = yield* Effect.all(
740 [runModelsCli(), runAgentsCli(), runSkillsCli()],
741 { concurrency: "unbounded" },
742 );
743 let modelsResult = initialModelsResult;
744 let agentsResult = initialAgentsResult;
745 let skillsResult = initialSkillsResult;
700746
701747 // Retry once after 1s on transient failures (e.g. SQLite "database is locked")
702748 const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0;
703749 const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0;
704 if (needsModelsRetry || needsAgentsRetry) {
750 const needsSkillsRetry = skillsResult._tag === "Failure" || skillsResult.value.code !== 0;
751 if (needsModelsRetry || needsAgentsRetry || needsSkillsRetry) {
705752 yield* Effect.sleep("1 second");
706 const [m2, a2] = yield* Effect.all(
753 const [m2, a2, s2] = yield* Effect.all(
707754 [
708755 needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult),
709756 needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult),
757 needsSkillsRetry ? runSkillsCli() : Effect.succeed(skillsResult),
710758 ],
711759 { concurrency: "unbounded" },
712760 );
713761 modelsResult = m2;
714762 agentsResult = a2;
763 skillsResult = s2;
715764 }
716765
717766 if (modelsResult._tag === "Failure") {
......@@ -742,16 +791,21 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
742791 }),
743792 );
744793
745 // Agent metadata enriches model capabilities but is not required for an
746 // authoritative model inventory, so it may still degrade to an empty list.
794 // Agent and skill metadata enrich the provider snapshot but are not required
795 // for an authoritative model inventory, so either may degrade to an empty list.
747796 let agents: ReadonlyArray<Agent> = [];
748797 if (agentsResult._tag === "Success" && agentsResult.value.code === 0) {
749798 agents = parseAgentListCliOutput(agentsResult.value.stdout);
750799 }
800 let skills: ReadonlyArray<OpenCodeSkill> = [];
801 if (skillsResult._tag === "Success" && skillsResult.value.code === 0) {
802 skills = parseSkillsCliOutput(skillsResult.value.stdout);
803 }
751804
752805 return {
753806 providerList: { all: allProviders, default: {}, connected },
754807 agents,
808 skills,
755809 };
756810 });
757811