diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index eb287ad7e2..564b41e7b6 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -105,6 +105,7 @@ export const ClaudeDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; + const serverConfig = yield* ServerConfig; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ @@ -148,6 +149,7 @@ export const ClaudeDriver: ProviderDriver = { effectiveConfig, () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), processEnv, + serverConfig.cwd, ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 4350596700..62a84dd22c 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -33,7 +33,12 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import { compareCliVersions } from "../cliVersion.ts"; -import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { makeClaudeEnvironment, resolveClaudeHomePath } from "../Drivers/ClaudeHome.ts"; +import { + discoverClaudeSkills, + mergeProviderSkills, + mergeSkillsIntoSlashCommands, +} from "../SkillDiscovery.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -516,6 +521,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeSettings: ClaudeSettings, ) => Effect.Effect, environment: NodeJS.ProcessEnv = process.env, + cwd: string = process.cwd(), ): Effect.fn.Return< ServerProviderDraft, never, @@ -630,6 +636,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, models, slashCommands: dedupedSlashCommands, + skills: [], probe: { installed: true, version: parsedVersion, @@ -640,6 +647,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + const claudeHome = yield* resolveClaudeHomePath(claudeSettings); + const discoveredSkills = yield* discoverClaudeSkills({ cwd, homeDir: claudeHome }); + const skills = mergeProviderSkills([], discoveredSkills); + const mergedSlashCommands = mergeSkillsIntoSlashCommands(dedupedSlashCommands, skills); + const authMetadata = claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, authMethod: capabilities.tokenSource, @@ -649,7 +661,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models, - slashCommands: dedupedSlashCommands, + slashCommands: mergedSlashCommands, + skills, probe: { installed: true, version: parsedVersion, diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts new file mode 100644 index 0000000000..d1078a7a5c --- /dev/null +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { parseCodexSkillsListResponse } from "./CodexProvider.ts"; + +describe("parseCodexSkillsListResponse", () => { + it("omits app-backed skills from Codex app-server results", () => { + const skills = parseCodexSkillsListResponse( + { + data: [ + { + cwd: "/workspace", + errors: [], + skills: [ + { + name: "browser-use:browser", + path: "/Users/test/.codex/plugins/cache/openai-bundled/browser-use/skills/browser/SKILL.md", + description: "Drive a browser.", + scope: "user", + enabled: true, + }, + { + name: "review-follow-up", + path: "/Users/test/.codex/skills/review-follow-up/SKILL.md", + enabled: true, + description: "Review a follow-up change.", + scope: "user", + }, + { + name: "agent-plugin", + path: "C:\\Users\\test\\.agents\\plugins\\cache\\example\\skills\\agent-plugin\\SKILL.md", + description: "Run an app-backed agent skill.", + scope: "user", + enabled: true, + }, + ], + }, + ], + }, + "/workspace", + ); + + expect(skills).toEqual([ + { + name: "review-follow-up", + path: "/Users/test/.codex/skills/review-follow-up/SKILL.md", + enabled: true, + description: "Review a follow-up change.", + scope: "user", + }, + ]); + }); +}); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 618103883a..8066a2c4c2 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -15,6 +15,8 @@ import type { import { ServerSettingsError } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; +import { isCommandAvailable } from "@t3tools/shared/shell"; + import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { scopedSafeTeardown } from "./scopedSafeTeardown.ts"; @@ -168,7 +170,18 @@ function appendCustomCodexModels( return customEntries.length === 0 ? models : [...models, ...customEntries]; } -function parseCodexSkillsListResponse( +function normalizeSkillPathSeparators(pathValue: string): string { + return pathValue.replaceAll("\\", "/"); +} + +function isCodexAppBackedSkill(skill: CodexSchema.V2SkillsListResponse__SkillMetadata): boolean { + const normalizedPath = normalizeSkillPathSeparators(skill.path); + return ( + normalizedPath.includes("/.codex/plugins/") || normalizedPath.includes("/.agents/plugins/") + ); +} + +export function parseCodexSkillsListResponse( response: CodexSchema.V2SkillsListResponse, cwd: string, ): ReadonlyArray { @@ -177,31 +190,33 @@ function parseCodexSkillsListResponse( ? matchingEntry.skills : response.data.flatMap((entry) => entry.skills); - return skills.map((skill) => { - const shortDescription = - skill.shortDescription ?? skill.interface?.shortDescription ?? undefined; - - const parsedSkill: Types.Mutable = { - name: skill.name, - path: skill.path, - enabled: skill.enabled, - }; - - if (skill.description) { - parsedSkill.description = skill.description; - } - if (skill.scope) { - parsedSkill.scope = skill.scope; - } - if (skill.interface?.displayName) { - parsedSkill.displayName = skill.interface.displayName; - } - if (shortDescription) { - parsedSkill.shortDescription = shortDescription; - } - - return parsedSkill; - }); + return skills + .filter((skill) => !isCodexAppBackedSkill(skill)) + .map((skill) => { + const shortDescription = + skill.shortDescription ?? skill.interface?.shortDescription ?? undefined; + + const parsedSkill: Types.Mutable = { + name: skill.name, + path: skill.path, + enabled: skill.enabled, + }; + + if (skill.description) { + parsedSkill.description = skill.description; + } + if (skill.scope) { + parsedSkill.scope = skill.scope; + } + if (skill.interface?.displayName) { + parsedSkill.displayName = skill.interface.displayName; + } + if (shortDescription) { + parsedSkill.shortDescription = shortDescription; + } + + return parsedSkill; + }); } const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( @@ -403,6 +418,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu ChildProcessSpawner.ChildProcessSpawner > = probeCodexAppServerProvider, environment: NodeJS.ProcessEnv = process.env, + cwd: string = process.cwd(), ): Effect.fn.Return< ServerProviderDraft, ServerSettingsError, @@ -428,10 +444,30 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu }); } + if ( + probe === probeCodexAppServerProvider && + !isCommandAvailable(codexSettings.binaryPath, { env: environment }) + ) { + return buildServerProvider({ + presentation: CODEX_PRESENTATION, + enabled: codexSettings.enabled, + checkedAt, + models: emptyModels, + skills: [], + probe: { + installed: false, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Codex CLI (`codex`) is not installed or not on PATH.", + }, + }); + } + const probeResult = yield* probe({ binaryPath: codexSettings.binaryPath, homePath: codexSettings.homePath, - cwd: process.cwd(), + cwd, customModels: codexSettings.customModels, environment, }).pipe(Effect.timeoutOption(Duration.millis(PROVIDER_PROBE_TIMEOUT_MS)), Effect.result); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 7abe0be981..a79d7b9d77 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -1,4 +1,7 @@ import assert from "node:assert/strict"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -198,6 +201,48 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { assert.equal(runtimeMock.state.closeCalls, 1); }), ); + + it.effect("includes discovered OpenCode-compatible skills", () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-skills-")); + const home = NodePath.join(root, "home"); + const workspace = NodePath.join(root, "workspace", "apps", "web"); + const skillDir = NodePath.join(root, "workspace", ".opencode", "skills", "git-release"); + NodeFS.mkdirSync(NodePath.join(root, "workspace", ".git"), { recursive: true }); + NodeFS.mkdirSync(workspace, { recursive: true }); + NodeFS.mkdirSync(skillDir, { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(skillDir, "SKILL.md"), + ["---", "name: git-release", "description: Prepare a release.", "---"].join("\n"), + "utf8", + ); + runtimeMock.state.inventory = { + providerList: { connected: ["openai"], all: [], default: {} }, + agents: [], + }; + + return Effect.gen(function* () { + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), workspace, { + ...process.env, + HOME: home, + USERPROFILE: home, + }); + + assert.deepStrictEqual(snapshot.skills, [ + { + name: "git-release", + description: "Prepare a release.", + shortDescription: "Prepare a release.", + displayName: "git-release", + path: NodePath.resolve(NodePath.join(skillDir, "SKILL.md")), + scope: "project", + enabled: true, + invocationPrefix: "$", + }, + ]); + }).pipe( + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(root, { force: true, recursive: true }))), + ); + }); }); it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (it) => { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 6431282d63..0762317b8c 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -5,6 +5,7 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; import { Cause, Data, Effect } from "effect"; +import * as NodeOS from "node:os"; import { createModelCapabilities } from "@t3tools/shared/model"; import { @@ -20,6 +21,7 @@ import { openCodeRuntimeErrorDetail, type OpenCodeInventory, } from "../opencodeRuntime.ts"; +import { discoverOpenCodeSkills, mergeProviderSkills } from "../SkillDiscovery.ts"; import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; const PROVIDER = ProviderDriverKind.make("opencode"); @@ -48,6 +50,10 @@ function normalizeProbeMessage(message: string): string | undefined { return trimmed; } +function homeDirFromEnvironment(environment: NodeJS.ProcessEnv): string { + return environment.HOME ?? environment.USERPROFILE ?? NodeOS.homedir(); +} + function normalizedErrorMessage(cause: unknown): string | undefined { if (cause instanceof OpenCodeProbeError) { return normalizeProbeMessage(cause.detail); @@ -445,12 +451,18 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); + const discoveredSkills = yield* discoverOpenCodeSkills({ + cwd, + homeDir: homeDirFromEnvironment(environment), + }); + const skills = mergeProviderSkills([], discoveredSkills); const connectedCount = inventoryExit.value.providerList.connected.length; return buildServerProvider({ presentation: OPENCODE_PRESENTATION, enabled: true, checkedAt, models, + skills, probe: { installed: true, version, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index b599a9d1f8..1d9c1d1fea 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1,4 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + import { describe, it, assert, live } from "@effect/vitest"; import { Effect, Exit, Layer, PubSub, Ref, Schema, Scope, Sink, Stream } from "effect"; import * as CodexErrors from "effect-codex-app-server/errors"; @@ -39,7 +43,9 @@ import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.t import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; -const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); +const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({ + homePath: NodePath.join(NodeOS.tmpdir(), `t3code-claude-empty-home-${process.pid}`), +}); const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({ enabled: false, @@ -348,6 +354,25 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }), ); + it.effect("passes the configured cwd to the app-server probe", () => + Effect.gen(function* () { + const expectedCwd = NodePath.join(NodeOS.tmpdir(), "t3code-codex-cwd"); + let observedCwd: string | null = null; + const status = yield* checkCodexProviderStatus( + defaultCodexSettings, + (input) => { + observedCwd = input.cwd; + return Effect.succeed(makeCodexProbeSnapshot()); + }, + process.env, + expectedCwd, + ); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(observedCwd, expectedCwd); + }), + ); + it.effect("returns an api key label for codex api key auth", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => @@ -884,17 +909,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }), ); - // This test intentionally avoids `mockCommandSpawnerLayer` so the real - // `probeCodexAppServerProvider` path runs — including the full - // `codex app-server` RPC handshake via `CodexClient.layerCommand`. - // We point `binaryPath` at a name that cannot exist on any machine so - // the real `ChildProcessSpawner` deterministically returns ENOENT; the - // probe wraps that as `CodexAppServerSpawnError` and - // `checkCodexProviderStatus` turns it into the user-visible "not - // installed" error snapshot. If the aggregator's `syncLiveSources` - // breaks — the `codex_personal`-never-probes bug we are guarding - // against — that snapshot never lands in `getProviders` and the - // assertions below fail. + // avoids the spawner mock so the real codex provider availability path runs + // the missing binary must land in the aggregated not-installed snapshot it.effect("propagates real Codex probe failures to the aggregator at boot", () => Effect.gen(function* () { const missingBinary = `t3code_codex_missing_${process.pid}_${Date.now()}`; @@ -912,15 +928,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T cursor: { enabled: false }, opencode: { enabled: false }, }, - // `providerInstances` keys are branded `ProviderInstanceId`; - // the branded index signature rejects plain string literals - // at the TS level even though the runtime schema happily - // accepts + decodes them. Cast the patch to `unknown` so - // the `Schema.decodeSync` below does the real validation. providerInstances: { - // Matches the shape the user had in `.t3/dev/settings.json` - // when the bug was reported: a custom enabled Codex instance - // pointing at a binary the server has to actually spawn. + // matches the reported custom enabled codex instance shape + // with a configured binary that cannot be resolved codex_personal: { driver: "codex", displayName: "Codex Personal", @@ -947,11 +957,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(OpenCodeRuntimeLive), - // NO spawner mock — `ChildProcessSpawner` is supplied by the - // outer `NodeServices.layer` on `it.layer(...)` and will - // genuinely spawn a subprocess. The missing-binary ENOENT is - // what exercises the same failure mode as a misconfigured - // production `binaryPath`. ); const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( Scope.provide(scope), @@ -1044,12 +1049,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T yield* Effect.gen(function* () { const registry = yield* ProviderRegistry; - // Boot-time probe: the default codex instance is enabled with - // `firstMissing`, so the real spawner yields ENOENT and the - // snapshot should be `status: "error"`. What *distinguishes* - // the two probe runs is `checkedAt` — each probe stamps a - // fresh DateTime, so we capture it and assert it advances - // after the settings mutation. + // boot-time probe: the default codex instance is enabled with + // `firstMissing`, so the snapshot should be `status: "error"` + // `checkedAt` distinguishes the two probe runs const initialProviders = yield* registry.getProviders; const initialCodex = initialProviders.find( (provider) => provider.instanceId === "codex", @@ -1073,11 +1075,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }, }); - // Poll with real timers (via `it.live`) until `checkedAt` - // advances or we hit a generous 3-second ceiling. Anything - // slower than that is a regression — the real probe fails - // fast on ENOENT, and the reconcile + sync pipeline is - // purely in-process. + // poll with real timers until `checkedAt` advances const refreshed = yield* Effect.gen(function* () { for (let attempts = 0; attempts < 60; attempts += 1) { const providers = yield* registry.getProviders; @@ -1458,6 +1456,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T it.effect("runs Claude status probes with the configured Claude HOME", () => { const claudeHome = "/tmp/t3code-claude-home"; + const resolvedClaudeHome = NodePath.resolve(claudeHome); const recorded = recordingMockSpawnerLayer((args) => { const joined = args.join(" "); if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; @@ -1481,7 +1480,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T assert.strictEqual(status.status, "ready"); assert.deepStrictEqual( recorded.commands.map((command) => command.env?.HOME), - [claudeHome], + [resolvedClaudeHome], ); }).pipe(Effect.provide(recorded.layer)); }); @@ -1569,6 +1568,94 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ), ); + it.effect("includes discovered Claude skills and slash command entries", () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-provider-")); + const home = NodePath.join(root, "home"); + const workspace = NodePath.join(root, "workspace", "package"); + const skillDir = NodePath.join(root, "workspace", ".claude", "skills", "review-diff"); + NodeFS.mkdirSync(NodePath.join(root, "workspace", ".git"), { recursive: true }); + NodeFS.mkdirSync(workspace, { recursive: true }); + NodeFS.mkdirSync(skillDir, { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(skillDir, "SKILL.md"), + ["---", "name: review-diff", "description: Review the current diff.", "---"].join("\n"), + "utf8", + ); + + return Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + { ...defaultClaudeSettings, homePath: home }, + claudeCapabilities({ + slashCommands: [{ name: "review-diff", description: "Native command wins" }], + }), + process.env, + workspace, + ); + + assert.deepStrictEqual(status.skills, [ + { + name: "review-diff", + description: "Review the current diff.", + shortDescription: "Review the current diff.", + displayName: "review-diff", + path: NodePath.resolve(NodePath.join(skillDir, "SKILL.md")), + scope: "project", + enabled: true, + invocationPrefix: "/", + }, + ]); + assert.deepStrictEqual(status.slashCommands, [ + { name: "review-diff", description: "Native command wins" }, + ]); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(root, { force: true, recursive: true }))), + ); + }); + + it.effect("skips Claude skill discovery when capabilities are unavailable", () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-provider-")); + const home = NodePath.join(root, "home"); + const workspace = NodePath.join(root, "workspace", "package"); + const skillDir = NodePath.join(root, "workspace", ".claude", "skills", "review-diff"); + NodeFS.mkdirSync(NodePath.join(root, "workspace", ".git"), { recursive: true }); + NodeFS.mkdirSync(workspace, { recursive: true }); + NodeFS.mkdirSync(skillDir, { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(skillDir, "SKILL.md"), + ["---", "name: review-diff", "description: Review the current diff.", "---"].join("\n"), + "utf8", + ); + + return Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + { ...defaultClaudeSettings, homePath: home }, + noClaudeCapabilities, + process.env, + workspace, + ); + + assert.strictEqual(status.status, "warning"); + assert.deepStrictEqual(status.skills, []); + assert.deepStrictEqual(status.slashCommands, []); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(root, { force: true, recursive: true }))), + ); + }); + it.effect("returns an api key label for claude api key auth", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/provider/SkillDiscovery.test.ts b/apps/server/src/provider/SkillDiscovery.test.ts new file mode 100644 index 0000000000..1e1e29c193 --- /dev/null +++ b/apps/server/src/provider/SkillDiscovery.test.ts @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { Effect } from "effect"; +import { afterEach, describe, it } from "vitest"; + +import { + discoverClaudeSkills, + discoverSkillsFromRoots, + mergeProviderSkills, + parseSkillMarkdown, +} from "./SkillDiscovery.ts"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function writeSkill(root: string, name: string, contents: string): string { + const skillDir = NodePath.join(root, name); + NodeFS.mkdirSync(skillDir, { recursive: true }); + const skillPath = NodePath.join(skillDir, "SKILL.md"); + NodeFS.writeFileSync(skillPath, contents, "utf8"); + return skillPath; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + NodeFS.rmSync(dir, { force: true, recursive: true }); + } +}); + +describe("parseSkillMarkdown", () => { + it("parses minimal SKILL.md metadata", () => { + const skill = parseSkillMarkdown({ + path: NodePath.join("skills", "review", "SKILL.md"), + scope: "project", + invocationPrefix: "$", + contents: [ + "---", + "name: review", + "description: Review changes for correctness.", + "display_name: Review Changes", + "---", + "", + "## Instructions", + "Inspect the diff.", + ].join("\n"), + }); + + assert.deepStrictEqual(skill, { + name: "review", + description: "Review changes for correctness.", + shortDescription: "Review changes for correctness.", + displayName: "Review Changes", + path: NodePath.join("skills", "review", "SKILL.md"), + scope: "project", + enabled: true, + invocationPrefix: "$", + }); + }); + + it("falls back to directory name and body text", () => { + const skill = parseSkillMarkdown({ + path: NodePath.join("skills", "release-helper", "SKILL.md"), + scope: "user", + invocationPrefix: "/", + contents: ["# Release Helper", "", "Prepare release notes from merged pull requests."].join( + "\n", + ), + }); + + assert.equal(skill?.name, "release-helper"); + assert.equal(skill?.description, "Prepare release notes from merged pull requests."); + assert.equal(skill?.invocationPrefix, "/"); + }); + + it("ignores malformed frontmatter safely", () => { + const skill = parseSkillMarkdown({ + path: NodePath.join("skills", "broken", "SKILL.md"), + scope: "project", + invocationPrefix: "$", + contents: ["---", "name: invalid", "description: missing close", "", "Body"].join("\n"), + }); + + assert.equal(skill?.name, "broken"); + assert.equal(skill?.description, "Body"); + assert.equal(skill?.enabled, true); + }); + + it("marks non-user-invocable skills disabled", () => { + const skill = parseSkillMarkdown({ + path: NodePath.join("skills", "background-context", "SKILL.md"), + scope: "project", + invocationPrefix: "$", + contents: [ + "---", + "name: background-context", + "description: Internal context.", + "user-invocable: false", + "---", + ].join("\n"), + }); + + assert.equal(skill?.enabled, false); + }); +}); + +describe("skill discovery", () => { + it("discovers Claude project and user skills", async () => { + const repo = makeTempDir("t3-claude-skills-repo-"); + const home = makeTempDir("t3-claude-skills-home-"); + NodeFS.mkdirSync(NodePath.join(repo, ".git")); + writeSkill( + NodePath.join(repo, ".claude", "skills"), + "summarize-changes", + ["---", "name: summarize-changes", "description: Summarize changes.", "---"].join("\n"), + ); + writeSkill( + NodePath.join(home, ".claude", "skills"), + "personal-review", + ["---", "name: personal-review", "description: Review personal workflow.", "---"].join("\n"), + ); + + const skills = await Effect.runPromise(discoverClaudeSkills({ cwd: repo, homeDir: home })); + + assert.deepStrictEqual( + skills.map((skill) => [skill.name, skill.scope, skill.invocationPrefix]).toSorted(), + [ + ["personal-review", "user", "/"], + ["summarize-changes", "project", "/"], + ], + ); + }); + + it("deduplicates provider-native skills before discovered skills", () => { + const nativePath = NodePath.resolve("shared", "SKILL.md"); + const merged = mergeProviderSkills( + [ + { + name: "review", + path: nativePath, + scope: "project", + enabled: true, + invocationPrefix: "$", + }, + ], + [ + { + name: "review", + path: NodePath.resolve("other", "SKILL.md"), + scope: "project", + enabled: true, + invocationPrefix: "$", + }, + { + name: "unique", + path: NodePath.resolve("unique", "SKILL.md"), + scope: "project", + enabled: true, + invocationPrefix: "$", + }, + ], + ); + + assert.deepStrictEqual( + merged.map((skill) => skill.name), + ["review", "unique"], + ); + }); + + it("skips unreadable skill files without failing discovery", async () => { + const root = makeTempDir("t3-skills-unreadable-"); + writeSkill(root, "good", ["---", "name: good", "description: Good skill.", "---"].join("\n")); + NodeFS.mkdirSync(NodePath.join(root, "bad", "SKILL.md"), { recursive: true }); + + const skills = await Effect.runPromise( + discoverSkillsFromRoots({ + roots: [{ path: root, scope: "project" }], + invocationPrefix: "$", + }), + ); + + assert.deepStrictEqual( + skills.map((skill) => skill.name), + ["good"], + ); + }); +}); diff --git a/apps/server/src/provider/SkillDiscovery.ts b/apps/server/src/provider/SkillDiscovery.ts new file mode 100644 index 0000000000..40369bf213 --- /dev/null +++ b/apps/server/src/provider/SkillDiscovery.ts @@ -0,0 +1,422 @@ +import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; +import { Effect } from "effect"; +import * as nodeFs from "node:fs/promises"; +import * as nodeOs from "node:os"; +import * as nodePath from "node:path"; + +export type SkillInvocationPrefix = "$" | "/"; + +interface SkillRoot { + readonly path: string; + readonly scope: string; +} + +interface SkillDiscoveryInput { + readonly cwd: string; + readonly homeDir?: string | undefined; +} + +interface DiscoverSkillsFromRootsInput { + readonly roots: ReadonlyArray; + readonly invocationPrefix: SkillInvocationPrefix; +} + +const DESCRIPTION_MAX_CHARS = 1024; +const NAME_MAX_CHARS = 64; + +function normalizeDedupePath(pathValue: string): string { + const resolved = nodePath.resolve(pathValue); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function normalizeSkillName(raw: string | undefined, fallback: string): string { + const candidate = (raw ?? fallback).trim(); + if (!candidate) { + return fallback.trim(); + } + return candidate.slice(0, NAME_MAX_CHARS); +} + +function normalizeOptionalText(raw: unknown): string | undefined { + if (typeof raw !== "string") { + return undefined; + } + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function truncateDescription(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const normalized = value.replace(/\s+/g, " ").trim(); + if (!normalized) { + return undefined; + } + return normalized.length <= DESCRIPTION_MAX_CHARS + ? normalized + : normalized.slice(0, DESCRIPTION_MAX_CHARS).trimEnd(); +} + +function parseBoolean(value: string): boolean | undefined { + const normalized = value.trim().toLowerCase(); + if (["true", "yes", "on"].includes(normalized)) { + return true; + } + if (["false", "no", "off"].includes(normalized)) { + return false; + } + return undefined; +} + +function stripYamlQuotes(value: string): string { + const trimmed = value.trim(); + if (trimmed.length >= 2) { + const first = trimmed[0]; + const last = trimmed[trimmed.length - 1]; + if ((first === `"` && last === `"`) || (first === `'` && last === `'`)) { + return trimmed.slice(1, -1); + } + } + return trimmed; +} + +function parseFrontmatter(raw: string): { + readonly metadata: Readonly>; + readonly body: string; +} { + const normalized = raw.replace(/^\uFEFF/, ""); + if (!normalized.startsWith("---")) { + return { metadata: {}, body: raw }; + } + + const lines = normalized.split(/\r?\n/); + if (lines[0]?.trim() !== "---") { + return { metadata: {}, body: raw }; + } + + const endIndex = lines.findIndex((line, index) => index > 0 && line.trim() === "---"); + if (endIndex < 0) { + return { metadata: {}, body: raw }; + } + + const metadata: Record = {}; + for (const line of lines.slice(1, endIndex)) { + if (!line.trim() || /^\s/.test(line)) { + continue; + } + + const match = /^([a-zA-Z0-9_-]+):\s*(.*)$/.exec(line); + if (!match) { + continue; + } + + const key = match[1]?.trim(); + const rawValue = match[2] ?? ""; + if (!key || !rawValue.trim()) { + continue; + } + + const value = stripYamlQuotes(rawValue); + const booleanValue = parseBoolean(value); + metadata[key] = booleanValue ?? value; + } + + return { + metadata, + body: lines.slice(endIndex + 1).join("\n"), + }; +} + +function firstBodyParagraph(body: string): string | undefined { + const paragraphs = body + .split(/\n\s*\n/g) + .map((paragraph) => + paragraph + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean) + .join(" "), + ) + .map((paragraph) => paragraph.trim()) + .filter(Boolean); + + return paragraphs.find( + (paragraph) => + !paragraph.startsWith("#") && + !paragraph.startsWith("```") && + !paragraph.startsWith("!") && + !paragraph.startsWith("---"), + ); +} + +function firstSentence(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const match = /^(.+?[.!?])(?:\s|$)/.exec(value); + return (match?.[1] ?? value).trim(); +} + +function isUserInvocable(metadata: Readonly>): boolean { + const userInvocable = metadata["user-invocable"]; + if (typeof userInvocable === "boolean") { + return userInvocable; + } + if (typeof userInvocable === "string") { + return parseBoolean(userInvocable) ?? true; + } + const enabled = metadata.enabled; + if (typeof enabled === "boolean") { + return enabled; + } + if (typeof enabled === "string") { + return parseBoolean(enabled) ?? true; + } + return true; +} + +export function parseSkillMarkdown(input: { + readonly path: string; + readonly contents: string; + readonly scope: string; + readonly invocationPrefix: SkillInvocationPrefix; +}): ServerProviderSkill | undefined { + const { metadata, body } = parseFrontmatter(input.contents); + const directoryName = nodePath.basename(nodePath.dirname(input.path)); + const name = normalizeSkillName(normalizeOptionalText(metadata.name), directoryName); + if (!name) { + return undefined; + } + + const description = truncateDescription( + normalizeOptionalText(metadata.description) ?? firstBodyParagraph(body), + ); + const shortDescription = truncateDescription( + normalizeOptionalText(metadata.short_description) ?? + normalizeOptionalText(metadata.shortDescription) ?? + firstSentence(description), + ); + const displayName = + normalizeOptionalText(metadata.display_name) ?? + normalizeOptionalText(metadata.displayName) ?? + name; + + return { + name, + path: input.path, + scope: input.scope, + enabled: isUserInvocable(metadata), + invocationPrefix: input.invocationPrefix, + ...(description ? { description } : {}), + ...(shortDescription ? { shortDescription } : {}), + ...(displayName ? { displayName } : {}), + }; +} + +async function pathExists(pathValue: string): Promise { + try { + await nodeFs.stat(pathValue); + return true; + } catch { + return false; + } +} + +async function projectSkillSearchDirs(cwd: string): Promise> { + const start = nodePath.resolve(cwd); + const dirs: string[] = []; + let current = start; + while (true) { + dirs.push(current); + if (await pathExists(nodePath.join(current, ".git"))) { + return dirs; + } + const parent = nodePath.dirname(current); + if (parent === current) { + return [start]; + } + current = parent; + } +} + +function dedupeRoots(roots: ReadonlyArray): ReadonlyArray { + const seen = new Set(); + const deduped: SkillRoot[] = []; + for (const root of roots) { + const key = normalizeDedupePath(root.path); + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(root); + } + return deduped; +} + +async function listSkillFiles( + root: SkillRoot, +): Promise> { + let entries: Array<{ name: string; isDirectory: () => boolean; isSymbolicLink: () => boolean }>; + try { + entries = await nodeFs.readdir(root.path, { withFileTypes: true }); + } catch { + return []; + } + + return entries + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map((entry) => ({ + path: root.path, + scope: root.scope, + filePath: nodePath.join(root.path, entry.name, "SKILL.md"), + })); +} + +async function readSkill(input: { + readonly filePath: string; + readonly scope: string; + readonly invocationPrefix: SkillInvocationPrefix; +}): Promise { + try { + const contents = await nodeFs.readFile(input.filePath, "utf8"); + return parseSkillMarkdown({ + path: nodePath.resolve(input.filePath), + contents, + scope: input.scope, + invocationPrefix: input.invocationPrefix, + }); + } catch { + return undefined; + } +} + +async function discoverSkillsFromRootsPromise( + input: DiscoverSkillsFromRootsInput, +): Promise> { + const files = ( + await Promise.all(dedupeRoots(input.roots).map((root) => listSkillFiles(root))) + ).flat(); + const skills = await Promise.all( + files.map((file) => + readSkill({ + filePath: file.filePath, + scope: file.scope, + invocationPrefix: input.invocationPrefix, + }), + ), + ); + return skills.filter((skill): skill is ServerProviderSkill => skill !== undefined); +} + +export const discoverSkillsFromRoots = ( + input: DiscoverSkillsFromRootsInput, +): Effect.Effect> => + Effect.tryPromise({ + try: () => discoverSkillsFromRootsPromise(input), + catch: () => undefined, + }).pipe(Effect.catch(() => Effect.succeed([]))); + +async function claudeProjectRoots(cwd: string): Promise> { + const dirs = await projectSkillSearchDirs(cwd); + return dirs.map((dir) => ({ path: nodePath.join(dir, ".claude", "skills"), scope: "project" })); +} + +function claudeUserRoots(homeDir: string): ReadonlyArray { + return [{ path: nodePath.join(homeDir, ".claude", "skills"), scope: "user" }]; +} + +async function openCodeProjectRoots(cwd: string): Promise> { + const dirs = await projectSkillSearchDirs(cwd); + return dirs.flatMap((dir) => [ + { path: nodePath.join(dir, ".opencode", "skills"), scope: "project" }, + { path: nodePath.join(dir, ".claude", "skills"), scope: "project" }, + { path: nodePath.join(dir, ".agents", "skills"), scope: "project" }, + ]); +} + +function openCodeUserRoots(homeDir: string): ReadonlyArray { + return [ + { path: nodePath.join(homeDir, ".config", "opencode", "skills"), scope: "user" }, + { path: nodePath.join(homeDir, ".opencode", "skills"), scope: "user" }, + { path: nodePath.join(homeDir, ".claude", "skills"), scope: "user" }, + { path: nodePath.join(homeDir, ".agents", "skills"), scope: "user" }, + ]; +} + +function resolveHomeDir(input: SkillDiscoveryInput): string { + return input.homeDir?.trim() || nodeOs.homedir(); +} + +export const discoverClaudeSkills = ( + input: SkillDiscoveryInput, +): Effect.Effect> => + Effect.tryPromise({ + try: async () => { + const homeDir = resolveHomeDir(input); + return discoverSkillsFromRootsPromise({ + roots: [...claudeUserRoots(homeDir), ...(await claudeProjectRoots(input.cwd))], + invocationPrefix: "/", + }); + }, + catch: () => undefined, + }).pipe(Effect.catch(() => Effect.succeed([]))); + +export const discoverOpenCodeSkills = ( + input: SkillDiscoveryInput, +): Effect.Effect> => + Effect.tryPromise({ + try: async () => { + const homeDir = resolveHomeDir(input); + return discoverSkillsFromRootsPromise({ + roots: [...(await openCodeProjectRoots(input.cwd)), ...openCodeUserRoots(homeDir)], + invocationPrefix: "$", + }); + }, + catch: () => undefined, + }).pipe(Effect.catch(() => Effect.succeed([]))); + +export function mergeProviderSkills( + primary: ReadonlyArray, + secondary: ReadonlyArray, +): ReadonlyArray { + const byPath = new Set(); + const byNameAndScope = new Set(); + const merged: ServerProviderSkill[] = []; + + for (const skill of [...primary, ...secondary]) { + const pathKey = normalizeDedupePath(skill.path); + const nameScopeKey = `${skill.name.toLowerCase()}\u0000${(skill.scope ?? "").toLowerCase()}`; + if (byPath.has(pathKey) || byNameAndScope.has(nameScopeKey)) { + continue; + } + byPath.add(pathKey); + byNameAndScope.add(nameScopeKey); + merged.push(skill); + } + + return merged; +} + +export function mergeSkillsIntoSlashCommands( + slashCommands: ReadonlyArray, + skills: ReadonlyArray, +): ReadonlyArray { + const byName = new Map(); + for (const command of slashCommands) { + byName.set(command.name.toLowerCase(), command); + } + for (const skill of skills) { + const key = skill.name.toLowerCase(); + if (byName.has(key) || skill.enabled === false) { + continue; + } + byName.set(key, { + name: skill.name, + ...((skill.shortDescription ?? skill.description) + ? { description: skill.shortDescription ?? skill.description } + : {}), + }); + } + return [...byName.values()]; +} diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 453be25a93..b319376874 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -473,6 +473,7 @@ function skillSignature(skills: ReadonlyArray): string { skill.path, skill.scope ?? "", skill.enabled ? "1" : "0", + skill.invocationPrefix ?? "$", ].join("\u001f"), ) .join("\u001e"); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2c4743de3c..1802f7b9f0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -147,6 +147,23 @@ const COMPOSER_FLOATING_LAYER_SELECTOR = [ '[data-slot="autocomplete-popup"]', ].join(","); +function makeComposerSkillItem( + provider: ProviderDriverKind, + skill: ServerProvider["skills"][number], +): Extract { + return { + id: `skill:${provider}:${skill.scope ?? ""}:${skill.name}`, + type: "skill", + provider, + skill, + label: formatProviderSkillDisplayName(skill), + description: + skill.shortDescription ?? + skill.description ?? + (skill.scope ? `${skill.scope} skill` : "Run provider skill"), + }; +} + const extendReplacementRangeForTrailingSpace = ( text: string, rangeEnd: number, @@ -888,38 +905,36 @@ export const ChatComposer = memo( description: "Switch this thread back to normal build mode", }, ] satisfies ReadonlyArray>; - const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( - (command) => ({ - id: `provider-slash-command:${selectedProvider}:${command.name}`, - type: "provider-slash-command" as const, - provider: selectedProvider, - command, - label: `/${command.name}`, - description: command.description ?? command.input?.hint ?? "Run provider command", - }), - ); + const providerSkills = selectedProviderStatus?.skills ?? []; + const providerSlashCommands = selectedProviderStatus?.slashCommands ?? []; + const providerSlashCommandItems = providerSlashCommands.map((command) => ({ + id: `provider-slash-command:${selectedProvider}:${command.name}`, + type: "provider-slash-command" as const, + provider: selectedProvider, + command, + label: `/${command.name}`, + description: command.description ?? command.input?.hint ?? "Run provider command", + })); const query = composerTrigger.query.trim().toLowerCase(); const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems]; - if (!query) { - return slashCommandItems; - } - return searchSlashCommandItems(slashCommandItems, query); + const skillItems = searchProviderSkills(providerSkills, composerTrigger.query) + .filter( + (skill) => + !providerSlashCommands.some( + (command) => command.name.trim().toLowerCase() === skill.name.trim().toLowerCase(), + ), + ) + .map((skill) => makeComposerSkillItem(selectedProvider, skill)); + return [ + ...(!query ? slashCommandItems : searchSlashCommandItems(slashCommandItems, query)), + ...skillItems, + ]; } if (composerTrigger.kind === "skill") { return searchProviderSkills( selectedProviderStatus?.skills ?? [], composerTrigger.query, - ).map((skill) => ({ - id: `skill:${selectedProvider}:${skill.name}`, - type: "skill" as const, - provider: selectedProvider, - skill, - label: formatProviderSkillDisplayName(skill), - description: - skill.shortDescription ?? - skill.description ?? - (skill.scope ? `${skill.scope} skill` : "Run provider skill"), - })); + ).map((skill) => makeComposerSkillItem(selectedProvider, skill)); } return []; }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries]); @@ -997,7 +1012,7 @@ export const ChatComposer = memo( } return composerTriggerKind === "path" ? "No matching files or folders." - : "No matching command."; + : "No matching command or skill."; }, [composerTriggerKind]); // ------------------------------------------------------------------ diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index f687ec7ba2..e5c3f84400 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -92,6 +92,7 @@ function groupCommandItems( const builtInItems = items.filter((item) => item.type === "slash-command"); const providerItems = items.filter((item) => item.type === "provider-slash-command"); + const skillItems = items.filter((item) => item.type === "skill"); const groups: ComposerCommandGroup[] = []; if (builtInItems.length > 0) { @@ -100,6 +101,9 @@ function groupCommandItems( if (providerItems.length > 0) { groups.push({ id: "provider", label: "Provider", items: providerItems }); } + if (skillItems.length > 0) { + groups.push({ id: "skills", label: "Skills", items: skillItems }); + } return groups; } diff --git a/apps/web/src/providerSkillSearch.test.ts b/apps/web/src/providerSkillSearch.test.ts index ede929c8d3..f01667cee1 100644 --- a/apps/web/src/providerSkillSearch.test.ts +++ b/apps/web/src/providerSkillSearch.test.ts @@ -48,6 +48,14 @@ describe("searchProviderSkills", () => { expect(searchProviderSkills(skills, "gfc").map((skill) => skill.name)).toEqual(["gh-fix-ci"]); }); + it("strips skill invocation prefixes before searching", () => { + const skills = [makeSkill({ name: "review-diff", displayName: "Review Diff" })]; + + expect(searchProviderSkills(skills, "$/review-diff").map((skill) => skill.name)).toEqual([ + "review-diff", + ]); + }); + it("omits disabled skills from results", () => { const skills = [ makeSkill({ name: "ui", displayName: "Ui", enabled: false }), diff --git a/apps/web/src/providerSkillSearch.ts b/apps/web/src/providerSkillSearch.ts index 2391e81813..89ac65f1ec 100644 --- a/apps/web/src/providerSkillSearch.ts +++ b/apps/web/src/providerSkillSearch.ts @@ -72,7 +72,7 @@ export function searchProviderSkills( limit = Number.POSITIVE_INFINITY, ): ServerProviderSkill[] { const enabledSkills = skills.filter((skill) => skill.enabled); - const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ }); + const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^[/$]+/ }); if (!normalizedQuery) { return enabledSkills; diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index cae68e1f64..ffe8d587fd 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -71,4 +71,36 @@ describe("ServerProvider", () => { expect(parsed.continuation?.groupKey).toBe("codex:home:/Users/julius/.codex"); }); + + it("decodes skill invocation prefixes while preserving legacy payloads", () => { + const parsed = decodeServerProvider({ + instanceId: "claudeAgent", + driver: "claudeAgent", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { + status: "authenticated", + }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + skills: [ + { + name: "review", + path: "/workspace/.claude/skills/review/SKILL.md", + enabled: true, + invocationPrefix: "/", + }, + { + name: "legacy", + path: "/workspace/.agents/skills/legacy/SKILL.md", + enabled: true, + }, + ], + }); + + expect(parsed.skills[0]?.invocationPrefix).toBe("/"); + expect(parsed.skills[1]?.invocationPrefix).toBeUndefined(); + }); }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 15afea93ad..947dfda03e 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -87,6 +87,7 @@ export const ServerProviderSkill = Schema.Struct({ enabled: Schema.Boolean, displayName: Schema.optional(TrimmedNonEmptyString), shortDescription: Schema.optional(TrimmedNonEmptyString), + invocationPrefix: Schema.optional(Schema.Literals(["$", "/"])), }); export type ServerProviderSkill = typeof ServerProviderSkill.Type;