Skip to content

Commit 532a15e

Browse files
committed
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
1 parent 4f0f24f commit 532a15e

2 files changed

Lines changed: 136 additions & 4 deletions

File tree

apps/server/src/provider/Drivers/OpenCodeDriver.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ import * as Duration from "effect/Duration";
1717
import * as Effect from "effect/Effect";
1818
import * as FileSystem from "effect/FileSystem";
1919
import * as Path from "effect/Path";
20+
import * as Layer from "effect/Layer";
2021
import * as Schema from "effect/Schema";
2122
import * as Stream from "effect/Stream";
2223
import { HttpClient } from "effect/unstable/http";
2324
import { ChildProcessSpawner } from "effect/unstable/process";
25+
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
2426

2527
import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts";
2628
import { ServerConfig } from "../../config.ts";
@@ -138,15 +140,22 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
138140
effectiveConfig,
139141
serverConfig.cwd,
140142
processEnv,
141-
).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime));
143+
).pipe(
144+
Effect.map(stampIdentity),
145+
Effect.provideService(OpenCodeRuntime, openCodeRuntime),
146+
Effect.provide(Layer.merge(Path.layer, NodeFileSystem.layer)),
147+
);
142148

143149
const snapshot = yield* makeManagedServerProvider<OpenCodeSettings>({
144150
maintenanceCapabilities,
145151
getSettings: Effect.succeed(effectiveConfig),
146152
streamSettings: Stream.never,
147153
haveSettingsChanged: () => false,
148154
initialSnapshot: (settings) =>
149-
makePendingOpenCodeProvider(settings).pipe(Effect.map(stampIdentity)),
155+
makePendingOpenCodeProvider(settings, serverConfig.cwd).pipe(
156+
Effect.map(stampIdentity),
157+
Effect.provide(Layer.merge(Path.layer, NodeFileSystem.layer)),
158+
),
150159
checkProvider,
151160
enrichSnapshot: ({ snapshot, publishSnapshot }) =>
152161
enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(

apps/server/src/provider/Layers/OpenCodeProvider.ts

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ import {
33
type ModelCapabilities,
44
type OpenCodeSettings,
55
type ServerProviderModel,
6+
type ServerProviderSkill,
67
} from "@t3tools/contracts";
78
import * as Cause from "effect/Cause";
89
import * as Data from "effect/Data";
910
import * as DateTime from "effect/DateTime";
1011
import * as Effect from "effect/Effect";
12+
import * as FileSystem from "effect/FileSystem";
13+
import * as Path from "effect/Path";
1114

1215
import { createModelCapabilities } from "@t3tools/shared/model";
1316
import { compareSemverVersions } from "@t3tools/shared/semver";
@@ -217,6 +220,112 @@ function openCodeCapabilitiesForModel(input: {
217220
});
218221
}
219222

223+
const SKILLS_DIR_NAME = ".agents";
224+
const SKILLS_SUBDIR_NAME = "skills";
225+
const SKILL_FILE_NAME = "SKILL.md";
226+
227+
function parseSkillFrontmatter(
228+
content: string,
229+
): { name: string; description?: string; displayName?: string } | null {
230+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
231+
if (!frontmatterMatch) {
232+
return null;
233+
}
234+
235+
const frontmatter = frontmatterMatch[1]!;
236+
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
237+
if (!nameMatch) {
238+
return null;
239+
}
240+
241+
const name = nameMatch[1]!.trim();
242+
if (name.length === 0) {
243+
return null;
244+
}
245+
246+
const descMatch = frontmatter.match(/^description:\s*(?:>\s*\n([\s\S]*?)|(.+))?$/m);
247+
let description: string | undefined;
248+
if (descMatch) {
249+
const raw = descMatch[1] ?? descMatch[2];
250+
if (raw) {
251+
description = raw
252+
.split("\n")
253+
.map((line) => line.replace(/^\s*\|?\s*/, ""))
254+
.join(" ")
255+
.trim();
256+
}
257+
}
258+
259+
const displayNameMatch = frontmatter.match(/^displayName:\s*(.+)$/m);
260+
const displayName = displayNameMatch ? displayNameMatch[1]!.trim() : undefined;
261+
262+
const result: { name: string; description?: string; displayName?: string } = { name };
263+
if (description !== undefined) {
264+
result.description = description;
265+
}
266+
if (displayName !== undefined) {
267+
result.displayName = displayName;
268+
}
269+
return result;
270+
}
271+
272+
function loadOpenCodeSkills(
273+
cwd: string,
274+
): Effect.Effect<ReadonlyArray<ServerProviderSkill>, never, FileSystem.FileSystem | Path.Path> {
275+
return Effect.gen(function* () {
276+
const fs = yield* FileSystem.FileSystem;
277+
const path = yield* Path.Path;
278+
279+
const skillsDir = path.join(cwd, SKILLS_DIR_NAME, SKILLS_SUBDIR_NAME);
280+
const entriesExit = yield* Effect.exit(fs.readDirectory(skillsDir, { recursive: false }));
281+
282+
if (entriesExit._tag !== "Success") {
283+
return [];
284+
}
285+
286+
const skills: Array<ServerProviderSkill> = [];
287+
288+
for (const entryName of entriesExit.value) {
289+
const fullPath = path.join(skillsDir, entryName);
290+
const statExit = yield* Effect.exit(fs.stat(fullPath));
291+
292+
if (statExit._tag !== "Success" || statExit.value.type !== "Directory") {
293+
continue;
294+
}
295+
296+
const contentExit = yield* Effect.exit(
297+
fs.readFileString(path.join(fullPath, SKILL_FILE_NAME)),
298+
);
299+
300+
if (contentExit._tag !== "Success") {
301+
continue;
302+
}
303+
304+
const parsed = parseSkillFrontmatter(contentExit.value);
305+
if (!parsed) {
306+
continue;
307+
}
308+
309+
const shortDescription =
310+
parsed.description && parsed.description.length > 200
311+
? parsed.description.slice(0, 200) + "..."
312+
: parsed.description;
313+
314+
const skill: ServerProviderSkill = {
315+
name: parsed.name,
316+
path: fullPath,
317+
enabled: true,
318+
...(parsed.description !== undefined ? { description: parsed.description } : {}),
319+
...(parsed.displayName !== undefined ? { displayName: parsed.displayName } : {}),
320+
...(shortDescription !== undefined ? { shortDescription } : {}),
321+
};
322+
skills.push(skill);
323+
}
324+
325+
return skills;
326+
});
327+
}
328+
220329
function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray<ServerProviderModel> {
221330
const connected = new Set(input.providerList.connected);
222331
const models: Array<ServerProviderModel> = [];
@@ -252,7 +361,8 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray<ServerPr
252361

253362
export const makePendingOpenCodeProvider = (
254363
openCodeSettings: OpenCodeSettings,
255-
): Effect.Effect<ServerProviderDraft> =>
364+
cwd: string,
365+
): Effect.Effect<ServerProviderDraft, never, FileSystem.FileSystem | Path.Path> =>
256366
Effect.gen(function* () {
257367
const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
258368
const models = providerModelsFromSettings(
@@ -261,13 +371,15 @@ export const makePendingOpenCodeProvider = (
261371
openCodeSettings.customModels,
262372
DEFAULT_OPENCODE_MODEL_CAPABILITIES,
263373
);
374+
const skills = yield* loadOpenCodeSkills(cwd);
264375

265376
if (!openCodeSettings.enabled) {
266377
return buildServerProvider({
267378
presentation: OPENCODE_PRESENTATION,
268379
enabled: false,
269380
checkedAt,
270381
models,
382+
skills,
271383
probe: {
272384
installed: false,
273385
version: null,
@@ -286,6 +398,7 @@ export const makePendingOpenCodeProvider = (
286398
enabled: true,
287399
checkedAt,
288400
models,
401+
skills,
289402
probe: {
290403
installed: false,
291404
version: null,
@@ -300,12 +413,18 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
300413
openCodeSettings: OpenCodeSettings,
301414
cwd: string,
302415
environment: NodeJS.ProcessEnv = process.env,
303-
): Effect.fn.Return<ServerProviderDraft, never, OpenCodeRuntime> {
416+
): Effect.fn.Return<
417+
ServerProviderDraft,
418+
never,
419+
OpenCodeRuntime | FileSystem.FileSystem | Path.Path
420+
> {
304421
const openCodeRuntime = yield* OpenCodeRuntime;
305422
const checkedAt = DateTime.formatIso(yield* DateTime.now);
306423
const customModels = openCodeSettings.customModels;
307424
const isExternalServer = openCodeSettings.serverUrl.trim().length > 0;
308425

426+
const skills = yield* loadOpenCodeSkills(cwd);
427+
309428
const fallback = (cause: unknown, version: string | null = null) => {
310429
const failure = formatOpenCodeProbeError({
311430
cause,
@@ -322,6 +441,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
322441
customModels,
323442
DEFAULT_OPENCODE_MODEL_CAPABILITIES,
324443
),
444+
skills,
325445
probe: {
326446
installed: failure.installed,
327447
version,
@@ -343,6 +463,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
343463
customModels,
344464
DEFAULT_OPENCODE_MODEL_CAPABILITIES,
345465
),
466+
skills,
346467
probe: {
347468
installed: false,
348469
version: null,
@@ -394,6 +515,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
394515
customModels,
395516
DEFAULT_OPENCODE_MODEL_CAPABILITIES,
396517
),
518+
skills,
397519
probe: {
398520
installed: true,
399521
version,
@@ -455,6 +577,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
455577
enabled: true,
456578
checkedAt,
457579
models,
580+
skills,
458581
probe: {
459582
installed: true,
460583
version,

0 commit comments

Comments
 (0)