From 634f4c7715892f9b948b58af5451f12fed0f02e2 Mon Sep 17 00:00:00 2001 From: torturado <73028283+torturado@users.noreply.github.com> Date: Tue, 26 May 2026 13:36:22 +0200 Subject: [PATCH] feat(server): load OpenCode skills from .agents/skills/ directory The OpenCode driver was not exposing skills in the provider snapshot, which meant skills defined in .agents/skills/SKILL.md files were not available when using the OpenCode provider in t3code. This adds: - parseSkillFrontmatter() to parse YAML frontmatter from SKILL.md files - loadOpenCodeSkills() to read .agents/skills/ directory and build ServerProviderSkill entries - Wiring of skills into makePendingOpenCodeProvider and checkOpenCodeProviderStatus so they appear in the provider snapshot - Provider layer setup in OpenCodeDriver for FileSystem and Path --- .../src/provider/Drivers/OpenCodeDriver.ts | 13 +- .../src/provider/Layers/OpenCodeProvider.ts | 127 +++++++++++++++++- 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 816e8b70f55..fcf8f6c11d8 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -17,10 +17,12 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; @@ -138,7 +140,11 @@ export const OpenCodeDriver: ProviderDriver effectiveConfig, serverConfig.cwd, processEnv, - ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); + ).pipe( + Effect.map(stampIdentity), + Effect.provideService(OpenCodeRuntime, openCodeRuntime), + Effect.provide(Layer.merge(Path.layer, NodeFileSystem.layer)), + ); const snapshot = yield* makeManagedServerProvider({ maintenanceCapabilities, @@ -146,7 +152,10 @@ export const OpenCodeDriver: ProviderDriver streamSettings: Stream.never, haveSettingsChanged: () => false, initialSnapshot: (settings) => - makePendingOpenCodeProvider(settings).pipe(Effect.map(stampIdentity)), + makePendingOpenCodeProvider(settings, serverConfig.cwd).pipe( + Effect.map(stampIdentity), + Effect.provide(Layer.merge(Path.layer, NodeFileSystem.layer)), + ), checkProvider, enrichSnapshot: ({ snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index dea95c990d2..cce011af23a 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -3,11 +3,14 @@ import { type ModelCapabilities, type OpenCodeSettings, type ServerProviderModel, + type ServerProviderSkill, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { createModelCapabilities } from "@t3tools/shared/model"; import { compareSemverVersions } from "@t3tools/shared/semver"; @@ -217,6 +220,112 @@ function openCodeCapabilitiesForModel(input: { }); } +const SKILLS_DIR_NAME = ".agents"; +const SKILLS_SUBDIR_NAME = "skills"; +const SKILL_FILE_NAME = "SKILL.md"; + +function parseSkillFrontmatter( + content: string, +): { name: string; description?: string; displayName?: string } | null { + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!frontmatterMatch) { + return null; + } + + const frontmatter = frontmatterMatch[1]!; + const nameMatch = frontmatter.match(/^name:\s*(.+)$/m); + if (!nameMatch) { + return null; + } + + const name = nameMatch[1]!.trim(); + if (name.length === 0) { + return null; + } + + const descMatch = frontmatter.match(/^description:\s*(?:>\s*\n([\s\S]*?)|(.+))?$/m); + let description: string | undefined; + if (descMatch) { + const raw = descMatch[1] ?? descMatch[2]; + if (raw) { + description = raw + .split("\n") + .map((line) => line.replace(/^\s*\|?\s*/, "")) + .join(" ") + .trim(); + } + } + + const displayNameMatch = frontmatter.match(/^displayName:\s*(.+)$/m); + const displayName = displayNameMatch ? displayNameMatch[1]!.trim() : undefined; + + const result: { name: string; description?: string; displayName?: string } = { name }; + if (description !== undefined) { + result.description = description; + } + if (displayName !== undefined) { + result.displayName = displayName; + } + return result; +} + +function loadOpenCodeSkills( + cwd: string, +): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const skillsDir = path.join(cwd, SKILLS_DIR_NAME, SKILLS_SUBDIR_NAME); + const entriesExit = yield* Effect.exit(fs.readDirectory(skillsDir, { recursive: false })); + + if (entriesExit._tag !== "Success") { + return []; + } + + const skills: Array = []; + + for (const entryName of entriesExit.value) { + const fullPath = path.join(skillsDir, entryName); + const statExit = yield* Effect.exit(fs.stat(fullPath)); + + if (statExit._tag !== "Success" || statExit.value.type !== "Directory") { + continue; + } + + const contentExit = yield* Effect.exit( + fs.readFileString(path.join(fullPath, SKILL_FILE_NAME)), + ); + + if (contentExit._tag !== "Success") { + continue; + } + + const parsed = parseSkillFrontmatter(contentExit.value); + if (!parsed) { + continue; + } + + const shortDescription = + parsed.description && parsed.description.length > 200 + ? parsed.description.slice(0, 200) + "..." + : parsed.description; + + const skill: ServerProviderSkill = { + name: parsed.name, + path: fullPath, + enabled: true, + ...(parsed.description !== undefined ? { description: parsed.description } : {}), + ...(parsed.displayName !== undefined ? { displayName: parsed.displayName } : {}), + ...(shortDescription !== undefined ? { shortDescription } : {}), + }; + skills.push(skill); + } + + return skills; + }); +} + function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray { const connected = new Set(input.providerList.connected); const models: Array = []; @@ -252,7 +361,8 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray => + cwd: string, +): Effect.Effect => Effect.gen(function* () { const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); const models = providerModelsFromSettings( @@ -261,6 +371,7 @@ export const makePendingOpenCodeProvider = ( openCodeSettings.customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); + const skills = yield* loadOpenCodeSkills(cwd); if (!openCodeSettings.enabled) { return buildServerProvider({ @@ -268,6 +379,7 @@ export const makePendingOpenCodeProvider = ( enabled: false, checkedAt, models, + skills, probe: { installed: false, version: null, @@ -286,6 +398,7 @@ export const makePendingOpenCodeProvider = ( enabled: true, checkedAt, models, + skills, probe: { installed: false, version: null, @@ -300,12 +413,18 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu openCodeSettings: OpenCodeSettings, cwd: string, environment: NodeJS.ProcessEnv = process.env, -): Effect.fn.Return { +): Effect.fn.Return< + ServerProviderDraft, + never, + OpenCodeRuntime | FileSystem.FileSystem | Path.Path +> { const openCodeRuntime = yield* OpenCodeRuntime; const checkedAt = DateTime.formatIso(yield* DateTime.now); const customModels = openCodeSettings.customModels; const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; + const skills = yield* loadOpenCodeSkills(cwd); + const fallback = (cause: unknown, version: string | null = null) => { const failure = formatOpenCodeProbeError({ cause, @@ -322,6 +441,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ), + skills, probe: { installed: failure.installed, version, @@ -343,6 +463,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ), + skills, probe: { installed: false, version: null, @@ -394,6 +515,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ), + skills, probe: { installed: true, version, @@ -455,6 +577,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu enabled: true, checkedAt, models, + skills, probe: { installed: true, version,