From 508785d01edc271199e631aabe3ae8c34f6b85c5 Mon Sep 17 00:00:00 2001 From: Jay1 Date: Thu, 28 May 2026 11:38:45 -0400 Subject: [PATCH] Enable OpenCode skill discovery and composer capability flags - add OpenCode `listSkills` support using runtime inventory metadata - normalize skill descriptors from array/object/string metadata with safe fallback handling - report `supportsSkillDiscovery`/`supportsSkillMentions` as true for OpenCode - add focused adapter tests for capabilities and skills edge cases - add implementation plan and design docs for OpenCode skill discovery --- .../provider/Layers/OpenCodeAdapter.test.ts | 206 ++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 122 ++++++++++- .../2026-05-28-opencode-skill-discovery.md | 66 ++++++ ...6-05-28-opencode-skill-discovery-design.md | 115 ++++++++++ 4 files changed, 507 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-28-opencode-skill-discovery.md create mode 100644 docs/superpowers/specs/2026-05-28-opencode-skill-discovery-design.md diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index c26b0ee6..83fe324d 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -969,6 +969,212 @@ describe("flattenOpenCodeModels", () => { }); describe("OpenCodeAdapter runtime lifecycle", () => { + it("advertises OpenCode skill discovery capabilities", async () => { + const runtime = createMockOpenCodeRuntime(); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const getComposerCapabilities = adapter.getComposerCapabilities; + if (!getComposerCapabilities) { + throw new Error("Expected OpenCode adapter to expose composer capabilities."); + } + return yield* getComposerCapabilities(); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.supportsSkillDiscovery).toBe(true); + expect(result.supportsSkillMentions).toBe(true); + }); + + it("lists OpenCode skills from runtime console metadata", async () => { + const runtime = createMockOpenCodeRuntime({ + inventory: { + providerList: { connected: [], all: [], default: {} }, + agents: [], + consoleState: { + skills: [ + { + name: "security-review", + path: "/home/test/.opencode/skills/security-review/SKILL.md", + description: "Security code review for vulnerabilities.", + interface: { + displayName: "Security Review", + shortDescription: "Review code for security issues", + }, + scope: "user", + }, + { + id: "legacy-skill", + source: { path: "/home/test/.opencode/skills/legacy/SKILL.md" }, + summary: "Legacy metadata shape.", + disabled: true, + }, + ], + } as never, + }, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const listSkills = adapter.listSkills; + if (!listSkills) { + throw new Error("Expected OpenCode adapter to support skill listing."); + } + return yield* listSkills({ + provider: "opencode", + cwd: process.cwd(), + }); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result).toEqual({ + skills: [ + { + name: "security-review", + path: "/home/test/.opencode/skills/security-review/SKILL.md", + enabled: true, + description: "Security code review for vulnerabilities.", + scope: "user", + interface: { + displayName: "Security Review", + shortDescription: "Review code for security issues", + }, + }, + { + name: "legacy-skill", + path: "/home/test/.opencode/skills/legacy/SKILL.md", + enabled: false, + description: "Legacy metadata shape.", + interface: { + shortDescription: "Legacy metadata shape.", + }, + }, + ], + source: "opencode-runtime", + cached: false, + }); + }); + + it("normalizes keyed and string OpenCode skill metadata", async () => { + const runtime = createMockOpenCodeRuntime({ + inventory: { + providerList: { connected: [], all: [], default: {} }, + agents: [], + consoleState: { + skills: { + "code-review": { + description: "Review code changes for correctness.", + file: "/home/test/.opencode/skills/code-review/SKILL.md", + title: "Code Review", + }, + quickstart: true, + write: "write-a-skill", + }, + } as never, + }, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const listSkills = adapter.listSkills; + if (!listSkills) { + throw new Error("Expected OpenCode adapter to support skill listing."); + } + return yield* listSkills({ + provider: "opencode", + cwd: process.cwd(), + }); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.skills).toEqual([ + { + name: "code-review", + path: "/home/test/.opencode/skills/code-review/SKILL.md", + enabled: true, + description: "Review code changes for correctness.", + interface: { + displayName: "Code Review", + shortDescription: "Review code changes for correctness.", + }, + }, + { + name: "write-a-skill", + path: "opencode://skill/write-a-skill", + enabled: true, + }, + ]); + }); + + it("returns an empty OpenCode skill list for missing metadata", async () => { + const runtime = createMockOpenCodeRuntime({ + inventory: { + providerList: { connected: [], all: [], default: {} }, + agents: [], + consoleState: null, + }, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const listSkills = adapter.listSkills; + if (!listSkills) { + throw new Error("Expected OpenCode adapter to support skill listing."); + } + return yield* listSkills({ + provider: "opencode", + cwd: process.cwd(), + }); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result).toEqual({ + skills: [], + source: "opencode-runtime", + cached: false, + }); + }); + it("lists OpenCode models from the CLI before falling back to server inventory", async () => { const runtime = createMockOpenCodeRuntime({ cliModels: [ diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 0bce73f4..f06b7e5c 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -7,6 +7,7 @@ import { type ProviderComposerCapabilities, type ProviderListAgentsResult, type ProviderListModelsResult, + type ProviderListSkillsResult, type ProviderRuntimeEvent, type ProviderSession, RuntimeItemId, @@ -831,11 +832,13 @@ type OpenCodeModelInventory = { }; readonly consoleState?: { readonly consoleManagedProviders: ReadonlyArray; + readonly skills?: unknown; } | null; }; type OpenCodeInventoryProvider = OpenCodeModelInventory["providerList"]["all"][number]; type OpenCodeModelDescriptor = ProviderListModelsResult["models"][number]; +type OpenCodeSkillDescriptor = ProviderListSkillsResult["skills"][number]; function asNonNegativeInteger(value: unknown): number | undefined { return typeof value === "number" && @@ -1120,6 +1123,111 @@ function trimNonEmptyString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } +function asPlainRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readFirstNonEmptyString( + record: Record | undefined, + keys: readonly string[], +): string | undefined { + if (!record) return undefined; + for (const key of keys) { + const value = trimNonEmptyString(record[key]); + if (value) return value; + } + return undefined; +} + +function readOpenCodeSkillPath(record: Record | undefined): string | undefined { + const directPath = readFirstNonEmptyString(record, ["path", "file", "filename"]); + if (directPath) return directPath; + return readFirstNonEmptyString(asPlainRecord(record?.source), ["path", "file", "filename"]); +} + +function readOpenCodeSkillEnabled(record: Record | undefined): boolean { + if (record?.enabled === false || record?.disabled === true) { + return false; + } + return true; +} + +function normalizeOpenCodeSkillDescriptor( + value: unknown, + fallbackName?: string, +): OpenCodeSkillDescriptor | undefined { + if (typeof value === "boolean" || value == null) { + return undefined; + } + + if (typeof value === "string") { + const name = trimNonEmptyString(value); + return name + ? { name, path: `opencode://skill/${encodeURIComponent(name)}`, enabled: true } + : undefined; + } + + const record = asPlainRecord(value); + if (!record) return undefined; + const interfaceRecord = asPlainRecord(record.interface); + const name = + readFirstNonEmptyString(record, ["name", "id", "key"]) ?? trimNonEmptyString(fallbackName); + if (!name) return undefined; + + const description = readFirstNonEmptyString(record, [ + "description", + "shortDescription", + "summary", + ]); + const displayName = + readFirstNonEmptyString(interfaceRecord, ["displayName"]) ?? + readFirstNonEmptyString(record, ["displayName", "title"]); + const shortDescription = + readFirstNonEmptyString(interfaceRecord, ["shortDescription"]) ?? + readFirstNonEmptyString(record, ["shortDescription", "summary"]); + const skillInterface = + displayName || shortDescription || description + ? { + ...(displayName ? { displayName } : {}), + ...((shortDescription ?? description) + ? { shortDescription: shortDescription ?? description } + : {}), + } + : undefined; + const scope = readFirstNonEmptyString(record, ["scope"]); + + return { + name, + path: readOpenCodeSkillPath(record) ?? `opencode://skill/${encodeURIComponent(name)}`, + enabled: readOpenCodeSkillEnabled(record), + ...(description ? { description } : {}), + ...(scope ? { scope } : {}), + ...(skillInterface ? { interface: skillInterface } : {}), + }; +} + +function normalizeOpenCodeSkillDescriptors( + inventory: OpenCodeInventory, +): OpenCodeSkillDescriptor[] { + const consoleState = asPlainRecord(inventory.consoleState); + const skills = consoleState?.skills; + if (Array.isArray(skills)) { + return skills.flatMap((skill) => { + const descriptor = normalizeOpenCodeSkillDescriptor(skill); + return descriptor ? [descriptor] : []; + }); + } + + const skillRecord = asPlainRecord(skills); + if (!skillRecord) return []; + return Object.entries(skillRecord).flatMap(([key, value]) => { + const descriptor = normalizeOpenCodeSkillDescriptor(value, key); + return descriptor ? [descriptor] : []; + }); +} + function readOpenCodeInventoryVariantValue( variantKey: string, variant: Record, @@ -4032,6 +4140,15 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { ); }); + const listSkills: NonNullable = () => + withDiscoveryInventory({}, ({ inventory }) => + Effect.succeed({ + skills: normalizeOpenCodeSkillDescriptors(inventory), + source: "opencode-runtime", + cached: false, + } satisfies ProviderListSkillsResult), + ); + const listModels: NonNullable = (input) => { const binaryPath = input.binaryPath?.trim() || adapterConfig.defaultBinaryPath; const freeOnlyProviderID = adapterConfig.provider === "kilo" ? "kilo" : undefined; @@ -4118,8 +4235,8 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { > = () => Effect.succeed({ provider, - supportsSkillMentions: false, - supportsSkillDiscovery: false, + supportsSkillMentions: provider === "opencode", + supportsSkillDiscovery: provider === "opencode", supportsNativeSlashCommandDiscovery: false, supportsPluginMentions: false, supportsPluginDiscovery: false, @@ -4159,6 +4276,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { compactThread, forkThread, stopAll, + listSkills, listModels, listAgents, getComposerCapabilities, diff --git a/docs/superpowers/plans/2026-05-28-opencode-skill-discovery.md b/docs/superpowers/plans/2026-05-28-opencode-skill-discovery.md new file mode 100644 index 00000000..5057e221 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-opencode-skill-discovery.md @@ -0,0 +1,66 @@ +# OpenCode Skill Discovery Implementation Plan + +| Field | Value | +| ------ | ----------------------------------------------------------------------------------------- | +| Status | Ready | +| Date | 2026-05-28 | +| Design | [OpenCode Skill Discovery Design](../specs/2026-05-28-opencode-skill-discovery-design.md) | + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make OpenCode skills discoverable through JCode's existing active-provider `$skill` composer flow and Skills library. + +## File Structure + +- `apps/server/src/provider/Layers/OpenCodeAdapter.ts`: add defensive OpenCode skill metadata normalization, implement `listSkills`, and advertise skill capabilities for OpenCode. +- `apps/server/src/provider/Layers/OpenCodeAdapter.test.ts`: add focused tests for capability flags and normalization of array, keyed object, missing, disabled, and empty skill metadata. +- `apps/web/src/components/ChatView.tsx`: only touch if existing query gating blocks OpenCode once capabilities are enabled; otherwise leave unchanged. +- `apps/web/src/hooks/useComposerCommandMenuItems.ts`: only touch if existing filtering/rendering fails for OpenCode descriptors; otherwise leave unchanged. +- `docs/superpowers/specs/2026-05-28-opencode-skill-discovery-design.md`: source design; update only if implementation reveals a contradiction. + +## Acceptance Criteria + +- OpenCode composer capabilities report `supportsSkillDiscovery: true` and `supportsSkillMentions: true`. +- `provider.listSkills` for OpenCode returns normalized `ProviderSkillDescriptor[]` from OpenCode runtime inventory metadata. +- `$` with OpenCode selected can populate the existing composer skill menu from provider discovery. +- `$query` filtering matches name, display name, short description, and description through existing web search helpers. +- Selecting a skill inserts `$skillName ` and preserves the selected `ProviderSkillReference` send path. +- Empty or missing OpenCode skill metadata returns an empty list without breaking composer input. +- Focused OpenCode adapter tests pass. +- LSP diagnostics show no new errors in modified files. +- Aikido scan reports no introduced security or secret findings for changed code files. +- Manual QA exercises the actual OpenCode skill discovery path or documents why it cannot run in the current environment. + +## Tasks + +- [ ] Load the implementation discipline skills: `test-driven-development` before code changes and `verification-before-completion` before claiming completion. +- [ ] Inspect the existing OpenCode adapter test helpers around mocked `OpenCodeInventory` and `withDiscoveryInventory` behavior. +- [ ] Write failing tests in `apps/server/src/provider/Layers/OpenCodeAdapter.test.ts` for `getComposerCapabilities()` advertising skill discovery and skill mentions. +- [ ] Run the focused OpenCode adapter test file and confirm the new capability test fails for the current implementation. +- [ ] Write failing tests for `listSkills()` using mocked `consoleState.skills` as an array of records with name, path, description, display metadata, scope, and enabled state. +- [ ] Add failing tests for keyed-object skill metadata, string-only metadata, empty metadata, and explicitly disabled metadata. +- [ ] Implement a local OpenCode skill normalizer in `OpenCodeAdapter.ts` with small helper functions for trimming strings, reading records, selecting names, selecting paths, and filtering invalid entries. +- [ ] Implement `listSkills()` in `OpenCodeAdapter.ts` using `withDiscoveryInventory`, returning `source: "opencode-runtime"` and `cached: false`. +- [ ] Update OpenCode composer capabilities to set `supportsSkillMentions: true` and `supportsSkillDiscovery: true`. +- [ ] Run the focused OpenCode adapter test file and make the server tests pass. +- [ ] Inspect web query gating in `ChatView.tsx`; only change web code if OpenCode capabilities still do not enable `providerSkillsQueryOptions`. +- [ ] If web code changes are needed, add or update a focused web test for OpenCode skill trigger menu enablement. +- [ ] Run LSP diagnostics on each modified TypeScript file. +- [ ] Run focused server tests for OpenCode adapter changes. +- [ ] Run focused web tests only if web code changed. +- [ ] Run an Aikido scan on changed TypeScript files. +- [ ] Manually QA by exercising `provider.listSkills` or the browser composer path with OpenCode selected; record the observed output or blocker. +- [ ] Do not commit unless the user explicitly requests a commit. + +## Verification Commands + +- `bun run --cwd apps/server test src/provider/Layers/OpenCodeAdapter.test.ts` +- `bun run --cwd apps/web test ` if web tests are changed or added. +- `lsp_diagnostics` on modified files. +- Aikido scan on changed code files. + +## Notes + +- Keep the first implementation server-side. The existing web composer path is already provider-capability driven and should not need a new UI concept. +- Do not add a global skill catalog in this work. +- Do not scan local skill directories from the web app or server filesystem unless OpenCode runtime metadata proves insufficient and the design is revisited. diff --git a/docs/superpowers/specs/2026-05-28-opencode-skill-discovery-design.md b/docs/superpowers/specs/2026-05-28-opencode-skill-discovery-design.md new file mode 100644 index 00000000..41a6a109 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-opencode-skill-discovery-design.md @@ -0,0 +1,115 @@ +# OpenCode Skill Discovery Design + +## Goal + +Make OpenCode skills first-class in JCode so end users can discover, search, and insert available skills from the composer instead of memorizing `/skillname` invocations. + +## Approved Direction + +Use the existing active-provider discovery model. OpenCode should advertise skill discovery support and return its available skills through the existing `provider.listSkills` contract. The current `$skill` composer trigger, skill chips, and Skills library should then work for OpenCode without a parallel catalog system. + +## Context + +- `apps/web/src/composer-logic.ts` already detects `$` tokens as `skill` triggers. +- `apps/web/src/hooks/useComposerCommandMenuItems.ts` already maps provider skills into composer menu items. +- `apps/web/src/components/ChatView.tsx` already inserts selected skills with the provider-specific prefix and sends matching `ProviderSkillReference` entries with the turn. +- `packages/contracts/src/providerDiscovery.ts` already defines `ProviderSkillDescriptor`, `ProviderSkillReference`, `ProviderComposerCapabilities`, and `ProviderListSkills*` contracts. +- `apps/server/src/provider/Layers/OpenCodeAdapter.ts` currently reports `supportsSkillDiscovery: false`, so the web app does not request OpenCode skills. +- `apps/server/src/provider/openCodeRuntimeHealth.ts` already extracts skill names from OpenCode `consoleState.skills`, proving the runtime exposes at least some skill metadata. + +## Options Considered + +### Option A: Provider-native OpenCode discovery (recommended) + +OpenCode implements the existing provider discovery contract by reading skills from the OpenCode runtime inventory and returning normalized `ProviderSkillDescriptor` values. + +Trade-offs: + +- Best fit with current architecture; no new frontend discovery concept. +- Keeps UX scoped to the selected provider, matching models, plugins, commands, and agents. +- Depends on OpenCode console metadata shape, so parsing must be defensive and degrade to empty results rather than failing the composer. + +### Option B: Global JCode skill catalog + +JCode builds a provider-agnostic skill registry across configured providers and shows one combined catalog in `$` autocomplete. + +Trade-offs: + +- Better long-term if skills become portable across providers. +- Harder to explain when the same name maps to different provider syntax or runtime support. +- Requires new contracts, UI grouping, deduplication, and send semantics. + +### Option C: Static config/manual skill list + +JCode reads known skill directories or a local config file directly and surfaces those entries. + +Trade-offs: + +- Fastest to build for one setup. +- Not durable for end users because it bypasses provider runtime truth and risks stale or unavailable skills. +- Does not scale to remote or external OpenCode servers. + +## Design + +OpenCode becomes a first-class skill-discovery provider by implementing `listSkills` in `OpenCodeAdapter` and enabling `supportsSkillMentions` and `supportsSkillDiscovery` in its composer capabilities. + +Data flow: + +1. User selects OpenCode and types `$` or `$query` in the composer. +2. `detectComposerTrigger` returns a `skill` trigger. +3. `ChatView` enables `providerSkillsQueryOptions` because OpenCode capabilities advertise skill discovery. +4. Web calls `provider.listSkills` with provider, cwd, thread id, and optional force reload through the existing native API. +5. OpenCode adapter loads runtime inventory through the existing discovery inventory path. +6. Adapter normalizes OpenCode skill metadata into `ProviderSkillDescriptor[]`. +7. Composer menu filters skills client-side using `buildSkillSearchBlob`. +8. Selecting a skill inserts `$skillName ` and records `{ name, path }` for the provider turn. + +Skill descriptor normalization rules: + +- `name` is the stable skill id/name from runtime metadata. +- `path` is a stable runtime path when provided; otherwise use a synthetic `opencode://skill/` path. +- `description` comes from runtime `description` or equivalent summary fields when present. +- `interface.displayName` comes from display/title fields when present. +- `interface.shortDescription` comes from short description, summary, or description when concise enough. +- `enabled` defaults to true unless runtime metadata explicitly marks the skill disabled. +- `scope` is preserved if the runtime exposes it; otherwise omit it so current UI defaults to `Personal`. + +Error behavior: + +- Missing or unrecognized console skill metadata returns an empty skill list with `source: "opencode-runtime"` rather than failing the composer. +- Runtime inventory failures should surface through the existing provider discovery error path, preserving current loading and empty states. +- Kilo should remain unchanged unless its runtime exposes compatible skill metadata and product intent is explicitly expanded. + +## Implementation Notes + +- Reuse the `withDiscoveryInventory` helper in `OpenCodeAdapter` so active sessions and temporary discovery share one path. +- Add a small local normalizer for unknown skill metadata instead of exporting health-check internals. +- Extend `OpenCodeAdapter` tests with console state shapes covering arrays, keyed objects, missing fields, disabled skills, and empty metadata. +- Add a focused web test only if existing composer tests do not already cover OpenCode capability gating. + +## Non-Goals + +- No global cross-provider skill catalog. +- No new `$` syntax. +- No changes to slash command behavior. +- No skill installation or editing UI. +- No direct filesystem scanning of user skill directories. +- No changes to provider turn payload contracts. + +## Acceptance Criteria + +- OpenCode composer capabilities report skill discovery and skill mentions as supported. +- Typing `$` with OpenCode selected opens a skill menu populated from OpenCode runtime metadata. +- Typing `$query` filters by name, display name, short description, and description. +- Selecting a skill inserts `$skillName ` and the turn payload includes the matching `ProviderSkillReference`. +- The Skills library can show OpenCode skills through the existing provider toggle. +- Missing skill metadata degrades to an empty list without breaking composer input. +- Focused server tests pass for OpenCode skill metadata normalization and capability reporting. +- Focused web tests or manual browser QA confirm the composer menu path. + +## Self-Review + +- No placeholders remain. +- Scope is limited to OpenCode provider discovery, not a new catalog platform. +- The design uses existing contracts and UI surfaces instead of adding duplicate concepts. +- The main risk is OpenCode console metadata shape variance; the design mitigates it with defensive normalization and empty-list fallback.