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 = {...@@ -34,20 +34,24 @@ const runtimeMock = {
34 runVersionError: null as Error | null,34 runVersionError: null as Error | null,
35 versionStdout: DEFAULT_VERSION_STDOUT,35 versionStdout: DEFAULT_VERSION_STDOUT,
36 inventoryError: null as Error | null,36 inventoryError: null as Error | null,
37 inventoryCwd: null as string | null,
37 closeCalls: 0,38 closeCalls: 0,
38 inventory: {39 inventory: {
39 providerList: { connected: [] as string[], all: [] as unknown[], default: {} },40 providerList: { connected: [] as string[], all: [] as unknown[], default: {} },
40 agents: [] as unknown[],41 agents: [] as unknown[],
42 skills: [] as unknown[],
41 } as unknown,43 } as unknown,
42 },44 },
43 reset() {45 reset() {
44 this.state.runVersionError = null;46 this.state.runVersionError = null;
45 this.state.versionStdout = DEFAULT_VERSION_STDOUT;47 this.state.versionStdout = DEFAULT_VERSION_STDOUT;
46 this.state.inventoryError = null;48 this.state.inventoryError = null;
49 this.state.inventoryCwd = null;
47 this.state.closeCalls = 0;50 this.state.closeCalls = 0;
48 this.state.inventory = {51 this.state.inventory = {
49 providerList: { connected: [], all: [] as unknown[], default: {} },52 providerList: { connected: [], all: [] as unknown[], default: {} },
50 agents: [] as unknown[],53 agents: [] as unknown[],
54 skills: [] as unknown[],
51 };55 };
52 },56 },
53};57};
...@@ -95,8 +99,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -95,8 +99,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
95 }),99 }),
96 )100 )
97 : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory),101 : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory),
98 loadInventoryFromCli: () =>102 loadInventoryFromCli: ({ cwd }) => {
99 runtimeMock.state.inventoryError103 runtimeMock.state.inventoryCwd = cwd;
104 return runtimeMock.state.inventoryError
100 ? Effect.fail(105 ? Effect.fail(
101 new OpenCodeRuntimeError({106 new OpenCodeRuntimeError({
102 operation: "loadInventoryFromCli",107 operation: "loadInventoryFromCli",
...@@ -104,7 +109,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -104,7 +109,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
104 cause: runtimeMock.state.inventoryError,109 cause: runtimeMock.state.inventoryError,
105 }),110 }),
106 )111 )
107 : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory),112 : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory);
113 },
108};114};
109115
110beforeEach(() => {116beforeEach(() => {
...@@ -207,11 +213,82 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => {...@@ -207,11 +213,82 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => {
207 }),213 }),
208 );214 );
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
210 it.effect("does not spawn a local server for health check (uses CLI instead)", () =>286 it.effect("does not spawn a local server for health check (uses CLI instead)", () =>
211 Effect.gen(function* () {287 Effect.gen(function* () {
212 yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd());288 yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd());
213289
214 NodeAssert.equal(runtimeMock.state.closeCalls, 0);290 NodeAssert.equal(runtimeMock.state.closeCalls, 0);
291 NodeAssert.equal(runtimeMock.state.inventoryCwd, process.cwd());
215 }),292 }),
216 );293 );
217294
apps/server/src/provider/Layers/OpenCodeProvider.ts+30
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 type ModelCapabilities,2 type ModelCapabilities,
3 type OpenCodeSettings,3 type OpenCodeSettings,
4 type ServerProviderModel,4 type ServerProviderModel,
5 type ServerProviderSkill,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
6import * as Cause from "effect/Cause";7import * as Cause from "effect/Cause";
7import * as Data from "effect/Data";8import * as Data from "effect/Data";
...@@ -250,6 +251,32 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray<ServerPr...@@ -250,6 +251,32 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray<ServerPr
250 return models.toSorted((left, right) => left.name.localeCompare(right.name));251 return models.toSorted((left, right) => left.name.localeCompare(right.name));
251}252}
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
253export const makePendingOpenCodeProvider = (280export const makePendingOpenCodeProvider = (
254 openCodeSettings: OpenCodeSettings,281 openCodeSettings: OpenCodeSettings,
255): Effect.Effect<ServerProviderDraft> =>282): Effect.Effect<ServerProviderDraft> =>
...@@ -412,6 +439,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu...@@ -412,6 +439,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
412 )439 )
413 : openCodeRuntime.loadInventoryFromCli({440 : openCodeRuntime.loadInventoryFromCli({
414 binaryPath: openCodeSettings.binaryPath,441 binaryPath: openCodeSettings.binaryPath,
442 cwd,
415 environment: resolvedEnvironment,443 environment: resolvedEnvironment,
416 })444 })
417 ).pipe(445 ).pipe(
...@@ -429,12 +457,14 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu...@@ -429,12 +457,14 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
429 customModels,457 customModels,
430 DEFAULT_OPENCODE_MODEL_CAPABILITIES,458 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
431 );459 );
460 const skills = flattenOpenCodeSkills(inventoryExit.value);
432 const connectedCount = inventoryExit.value.providerList.connected.length;461 const connectedCount = inventoryExit.value.providerList.connected.length;
433 return buildServerProvider({462 return buildServerProvider({
434 presentation: OPENCODE_PRESENTATION,463 presentation: OPENCODE_PRESENTATION,
435 enabled: true,464 enabled: true,
436 checkedAt,465 checkedAt,
437 models,466 models,
467 skills,
438 probe: {468 probe: {
439 installed: true,469 installed: true,
440 version,470 version,
apps/server/src/provider/opencodeRuntime.cliParsers.test.ts+33-1
...@@ -2,7 +2,11 @@ import * as NodeAssert from "node:assert/strict";...@@ -2,7 +2,11 @@ import * as NodeAssert from "node:assert/strict";
22
3import { describe, it } from "vite-plus/test";3import { 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
7describe("parseModelsCliOutput", () => {11describe("parseModelsCliOutput", () => {
8 it("parses a single model from a single provider", () => {12 it("parses a single model from a single provider", () => {
...@@ -252,3 +256,31 @@ describe("parseAgentListCliOutput", () => {...@@ -252,3 +256,31 @@ describe("parseAgentListCliOutput", () => {
252 NodeAssert.equal(result[1]!.hidden, false);256 NodeAssert.equal(result[1]!.hidden, false);
253 });257 });
254});258});
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 {...@@ -112,6 +112,7 @@ export interface OpenCodeCommandResult {
112export interface OpenCodeInventory {112export interface OpenCodeInventory {
113 readonly providerList: ProviderListResponse;113 readonly providerList: ProviderListResponse;
114 readonly agents: ReadonlyArray<Agent>;114 readonly agents: ReadonlyArray<Agent>;
115 readonly skills: ReadonlyArray<OpenCodeSkill>;
115}116}
116117
117export interface ParsedOpenCodeModelSlug {118export interface ParsedOpenCodeModelSlug {
...@@ -119,6 +120,23 @@ export interface ParsedOpenCodeModelSlug {...@@ -119,6 +120,23 @@ export interface ParsedOpenCodeModelSlug {
119 readonly modelID: string;120 readonly modelID: string;
120}121}
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
122export interface OpenCodeRuntimeShape {140export interface OpenCodeRuntimeShape {
123 /**141 /**
124 * Spawns a local OpenCode server process. Its lifetime is bound to the caller's142 * Spawns a local OpenCode server process. Its lifetime is bound to the caller's
...@@ -150,6 +168,7 @@ export interface OpenCodeRuntimeShape {...@@ -150,6 +168,7 @@ export interface OpenCodeRuntimeShape {
150 readonly binaryPath: string;168 readonly binaryPath: string;
151 readonly args: ReadonlyArray<string>;169 readonly args: ReadonlyArray<string>;
152 readonly environment?: NodeJS.ProcessEnv;170 readonly environment?: NodeJS.ProcessEnv;
171 readonly cwd?: string;
153 }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>;172 }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>;
154 readonly createOpenCodeSdkClient: (input: {173 readonly createOpenCodeSdkClient: (input: {
155 readonly baseUrl: string;174 readonly baseUrl: string;
...@@ -161,6 +180,7 @@ export interface OpenCodeRuntimeShape {...@@ -161,6 +180,7 @@ export interface OpenCodeRuntimeShape {
161 ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>;180 ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>;
162 readonly loadInventoryFromCli: (input: {181 readonly loadInventoryFromCli: (input: {
163 readonly binaryPath: string;182 readonly binaryPath: string;
183 readonly cwd: string;
164 readonly environment?: NodeJS.ProcessEnv;184 readonly environment?: NodeJS.ProcessEnv;
165 }) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>;185 }) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>;
166}186}
...@@ -289,6 +309,12 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> {...@@ -289,6 +309,12 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> {
289 return agents;309 return agents;
290}310}
291311
312/** @internal */
313export function parseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill> {
314 const result = decodeOpenCodeSkillsCliOutputExit(stdout);
315 return Exit.isSuccess(result) ? result.value : [];
316}
317
292export function parseOpenCodeModelSlug(318export function parseOpenCodeModelSlug(
293 slug: string | null | undefined,319 slug: string | null | undefined,
294): ParsedOpenCodeModelSlug | null {320): ParsedOpenCodeModelSlug | null {
...@@ -417,6 +443,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {...@@ -417,6 +443,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
417 const child = yield* spawner.spawn(443 const child = yield* spawner.spawn(
418 ChildProcess.make(spawnCommand.command, spawnCommand.args, {444 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
419 shell: spawnCommand.shell,445 shell: spawnCommand.shell,
446 ...(input.cwd ? { cwd: input.cwd } : {}),
420 ...(input.environment ? { env: input.environment } : { extendEnv: true }),447 ...(input.environment ? { env: input.environment } : { extendEnv: true }),
421 }),448 }),
422 );449 );
...@@ -673,45 +700,67 @@ const makeOpenCodeRuntime = Effect.gen(function* () {...@@ -673,45 +700,67 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
673 Effect.map((result) => result.data ?? []),700 Effect.map((result) => result.data ?? []),
674 );701 );
675702
676 const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) =>703 const loadSkills = (client: OpencodeClient) =>
677 Effect.all([loadProviders(client), loadAgents(client)], { concurrency: "unbounded" }).pipe(704 runOpenCodeSdk("app.skills", () => client.app.skills()).pipe(
678 Effect.map(([providerList, agents]) => ({ providerList, agents })),705 Effect.map((result) => (result.data ?? []) as ReadonlyArray<OpenCodeSkill>),
706 Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => []),
679 );707 );
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
681 const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) =>714 const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) =>
682 Effect.gen(function* () {715 Effect.gen(function* () {
683 const env = input.environment !== undefined ? { environment: input.environment } : ({} as {});716 const env = input.environment !== undefined ? { environment: input.environment } : ({} as {});
717 const commandContext = { cwd: input.cwd, ...env };
684718
685 const runModelsCli = () =>719 const runModelsCli = () =>
686 runOpenCodeCommand({720 runOpenCodeCommand({
687 binaryPath: input.binaryPath,721 binaryPath: input.binaryPath,
688 args: ["models", "--verbose"],722 args: ["models", "--verbose"],
689 ...env,723 ...commandContext,
690 }).pipe(Effect.exit);724 }).pipe(Effect.exit);
691 const runAgentsCli = () =>725 const runAgentsCli = () =>
692 runOpenCodeCommand({ binaryPath: input.binaryPath, args: ["agent", "list"], ...env }).pipe(726 runOpenCodeCommand({
693 Effect.exit,727 binaryPath: input.binaryPath,
694 );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 parallel738 // First attempt — run all inventory commands in parallel.
697 let [modelsResult, agentsResult] = yield* Effect.all([runModelsCli(), runAgentsCli()], {739 const [initialModelsResult, initialAgentsResult, initialSkillsResult] = yield* Effect.all(
698 concurrency: "unbounded",740 [runModelsCli(), runAgentsCli(), runSkillsCli()],
699 });741 { concurrency: "unbounded" },
742 );
743 let modelsResult = initialModelsResult;
744 let agentsResult = initialAgentsResult;
745 let skillsResult = initialSkillsResult;
700746
701 // Retry once after 1s on transient failures (e.g. SQLite "database is locked")747 // Retry once after 1s on transient failures (e.g. SQLite "database is locked")
702 const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0;748 const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0;
703 const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0;749 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) {
705 yield* Effect.sleep("1 second");752 yield* Effect.sleep("1 second");
706 const [m2, a2] = yield* Effect.all(753 const [m2, a2, s2] = yield* Effect.all(
707 [754 [
708 needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult),755 needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult),
709 needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult),756 needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult),
757 needsSkillsRetry ? runSkillsCli() : Effect.succeed(skillsResult),
710 ],758 ],
711 { concurrency: "unbounded" },759 { concurrency: "unbounded" },
712 );760 );
713 modelsResult = m2;761 modelsResult = m2;
714 agentsResult = a2;762 agentsResult = a2;
763 skillsResult = s2;
715 }764 }
716765
717 if (modelsResult._tag === "Failure") {766 if (modelsResult._tag === "Failure") {
...@@ -742,16 +791,21 @@ const makeOpenCodeRuntime = Effect.gen(function* () {...@@ -742,16 +791,21 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
742 }),791 }),
743 );792 );
744793
745 // Agent metadata enriches model capabilities but is not required for an794 // Agent and skill metadata enrich the provider snapshot but are not required
746 // authoritative model inventory, so it may still degrade to an empty list.795 // for an authoritative model inventory, so either may degrade to an empty list.
747 let agents: ReadonlyArray<Agent> = [];796 let agents: ReadonlyArray<Agent> = [];
748 if (agentsResult._tag === "Success" && agentsResult.value.code === 0) {797 if (agentsResult._tag === "Success" && agentsResult.value.code === 0) {
749 agents = parseAgentListCliOutput(agentsResult.value.stdout);798 agents = parseAgentListCliOutput(agentsResult.value.stdout);
750 }799 }
800 let skills: ReadonlyArray<OpenCodeSkill> = [];
801 if (skillsResult._tag === "Success" && skillsResult.value.code === 0) {
802 skills = parseSkillsCliOutput(skillsResult.value.stdout);
803 }
751804
752 return {805 return {
753 providerList: { all: allProviders, default: {}, connected },806 providerList: { all: allProviders, default: {}, connected },
754 agents,807 agents,
808 skills,
755 };809 };
756 });810 });
757811