From 482faebdb57805577dcb72654c2541f1d134e114 Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Wed, 6 May 2026 20:40:30 +0300 Subject: [PATCH 1/9] Add provider skill discovery and composer picker --- .../src/provider/Drivers/ClaudeDriver.ts | 2 + .../src/provider/Layers/ClaudeProvider.ts | 18 +- .../src/provider/Layers/CodexProvider.test.ts | 52 ++ .../src/provider/Layers/CodexProvider.ts | 87 +++- .../provider/Layers/OpenCodeProvider.test.ts | 45 ++ .../src/provider/Layers/OpenCodeProvider.ts | 12 + .../provider/Layers/ProviderRegistry.test.ts | 105 +++-- .../src/provider/SkillDiscovery.test.ts | 214 +++++++++ apps/server/src/provider/SkillDiscovery.ts | 445 ++++++++++++++++++ .../src/components/ComposerPromptEditor.tsx | 1 + apps/web/src/components/chat/ChatComposer.tsx | 63 ++- .../components/chat/ComposerCommandMenu.tsx | 4 + .../web/src/providerSkillPresentation.test.ts | 13 + apps/web/src/providerSkillPresentation.ts | 6 + packages/contracts/src/server.test.ts | 32 ++ packages/contracts/src/server.ts | 1 + 16 files changed, 1012 insertions(+), 88 deletions(-) create mode 100644 apps/server/src/provider/Layers/CodexProvider.test.ts create mode 100644 apps/server/src/provider/SkillDiscovery.test.ts create mode 100644 apps/server/src/provider/SkillDiscovery.ts diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index eb287ad7e22..564b41e7b6f 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 43505967002..9dffa67eed2 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, @@ -622,6 +628,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( : undefined; const slashCommands = capabilities?.slashCommands ?? []; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); + const claudeHome = yield* resolveClaudeHomePath(claudeSettings); + const discoveredSkills = yield* discoverClaudeSkills({ cwd, homeDir: claudeHome }); + const skills = mergeProviderSkills([], discoveredSkills); + const mergedSlashCommands = mergeSkillsIntoSlashCommands(dedupedSlashCommands, skills); if (!capabilities) { return buildServerProvider({ @@ -629,7 +639,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models, - slashCommands: dedupedSlashCommands, + slashCommands: mergedSlashCommands, + skills, probe: { installed: true, version: parsedVersion, @@ -649,7 +660,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 00000000000..d1078a7a5ca --- /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 618103883a2..e0a959619a0 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* ( @@ -428,6 +443,26 @@ 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, diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 7abe0be9816..a79d7b9d777 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 6431282d63b..0762317b8c7 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 b599a9d1f88..2526e3561a9 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, @@ -884,17 +890,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 +909,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 +938,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 +1030,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 +1056,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 +1437,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 +1461,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 +1549,57 @@ 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("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 00000000000..99a95e98618 --- /dev/null +++ b/apps/server/src/provider/SkillDiscovery.test.ts @@ -0,0 +1,214 @@ +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, + discoverCommonAgentSkills, + 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?.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 project .agents skills while walking to the git root", async () => { + const repo = makeTempDir("t3-skills-repo-"); + const home = makeTempDir("t3-skills-home-"); + NodeFS.mkdirSync(NodePath.join(repo, ".git")); + const cwd = NodePath.join(repo, "packages", "web"); + NodeFS.mkdirSync(cwd, { recursive: true }); + writeSkill( + NodePath.join(repo, ".agents", "skills"), + "ui-review", + ["---", "name: ui-review", "description: Review UI changes.", "---"].join("\n"), + ); + + const skills = await Effect.runPromise(discoverCommonAgentSkills({ cwd, homeDir: home })); + + assert.deepStrictEqual( + skills.map((skill) => [skill.name, skill.scope, skill.invocationPrefix]), + [["ui-review", "project", "$"]], + ); + }); + + 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 00000000000..0936bf90ce0 --- /dev/null +++ b/apps/server/src/provider/SkillDiscovery.ts @@ -0,0 +1,445 @@ +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 !== "---", + ); +} + +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 commonProjectRoots(cwd: string): Promise> { + const dirs = await projectSkillSearchDirs(cwd); + return dirs.map((dir) => ({ path: nodePath.join(dir, ".agents", "skills"), scope: "project" })); +} + +function commonUserRoots(homeDir: string): ReadonlyArray { + return [{ path: nodePath.join(homeDir, ".agents", "skills"), scope: "user" }]; +} + +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 const discoverCommonAgentSkills = ( + input: SkillDiscoveryInput & { readonly invocationPrefix?: SkillInvocationPrefix }, +): Effect.Effect> => + Effect.tryPromise({ + try: async () => { + const homeDir = resolveHomeDir(input); + return discoverSkillsFromRootsPromise({ + roots: [...(await commonProjectRoots(input.cwd)), ...commonUserRoots(homeDir)], + invocationPrefix: input.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 c9696b0c737..6e8e146e565 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -474,6 +474,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 d64d0316684..dd5c0e2cc24 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -109,7 +109,10 @@ import type { SessionPhase, Thread } from "../../types"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { PendingApproval, PendingUserInput } from "../../session-logic"; import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; -import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; +import { + formatProviderSkillDisplayName, + formatProviderSkillInvocation, +} from "../../providerSkillPresentation"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; @@ -147,6 +150,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.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 +908,37 @@ export const ChatComposer = memo( description: "Switch this thread back to normal build mode", }, ] satisfies ReadonlyArray>; - const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( - (command) => ({ + const providerSkills = selectedProviderStatus?.skills ?? []; + const providerSkillNames = new Set( + providerSkills + .filter((skill) => skill.enabled) + .map((skill) => skill.name.trim().toLowerCase()), + ); + const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []) + .filter((command) => !providerSkillNames.has(command.name.trim().toLowerCase())) + .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).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 +1016,7 @@ export const ChatComposer = memo( } return composerTriggerKind === "path" ? "No matching files or folders." - : "No matching command."; + : "No matching command or skill."; }, [composerTriggerKind]); // ------------------------------------------------------------------ @@ -1540,7 +1559,7 @@ export const ChatComposer = memo( return; } if (item.type === "skill") { - const replacement = `$${item.skill.name} `; + const replacement = `${formatProviderSkillInvocation(item.skill)} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, trigger.rangeEnd, diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index f687ec7ba23..e5c3f844006 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/providerSkillPresentation.test.ts b/apps/web/src/providerSkillPresentation.test.ts index ce94d88a6bd..11e145c838d 100644 --- a/apps/web/src/providerSkillPresentation.test.ts +++ b/apps/web/src/providerSkillPresentation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { formatProviderSkillDisplayName, + formatProviderSkillInvocation, formatProviderSkillInstallSource, } from "./providerSkillPresentation"; @@ -55,3 +56,15 @@ describe("formatProviderSkillInstallSource", () => { ).toBe("Project"); }); }); + +describe("formatProviderSkillInvocation", () => { + it("uses dollar invocation by default", () => { + expect(formatProviderSkillInvocation({ name: "review-follow-up" })).toBe("$review-follow-up"); + }); + + it("uses provider-specific invocation prefixes", () => { + expect( + formatProviderSkillInvocation({ name: "summarize-changes", invocationPrefix: "/" }), + ).toBe("/summarize-changes"); + }); +}); diff --git a/apps/web/src/providerSkillPresentation.ts b/apps/web/src/providerSkillPresentation.ts index fe077cbb191..0cf2a36d41d 100644 --- a/apps/web/src/providerSkillPresentation.ts +++ b/apps/web/src/providerSkillPresentation.ts @@ -50,3 +50,9 @@ export function formatProviderSkillInstallSource( return null; } + +export function formatProviderSkillInvocation( + skill: Pick, +): string { + return `${skill.invocationPrefix ?? "$"}${skill.name}`; +} diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index cae68e1f64b..ffe8d587fd1 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 15afea93ad9..947dfda03ea 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; From 83c436fabd1a1b6c52c4d35808634ec4d5bae603 Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Thu, 7 May 2026 22:56:06 +0300 Subject: [PATCH 2/9] Simplify provider skill handling --- .../src/provider/SkillDiscovery.test.ts | 21 ----------------- apps/server/src/provider/SkillDiscovery.ts | 23 ------------------- apps/web/src/components/chat/ChatComposer.tsx | 13 ++++------- 3 files changed, 4 insertions(+), 53 deletions(-) diff --git a/apps/server/src/provider/SkillDiscovery.test.ts b/apps/server/src/provider/SkillDiscovery.test.ts index 99a95e98618..b97307f38f4 100644 --- a/apps/server/src/provider/SkillDiscovery.test.ts +++ b/apps/server/src/provider/SkillDiscovery.test.ts @@ -8,7 +8,6 @@ import { afterEach, describe, it } from "vitest"; import { discoverClaudeSkills, - discoverCommonAgentSkills, discoverSkillsFromRoots, mergeProviderSkills, parseSkillMarkdown, @@ -112,26 +111,6 @@ describe("parseSkillMarkdown", () => { }); describe("skill discovery", () => { - it("discovers project .agents skills while walking to the git root", async () => { - const repo = makeTempDir("t3-skills-repo-"); - const home = makeTempDir("t3-skills-home-"); - NodeFS.mkdirSync(NodePath.join(repo, ".git")); - const cwd = NodePath.join(repo, "packages", "web"); - NodeFS.mkdirSync(cwd, { recursive: true }); - writeSkill( - NodePath.join(repo, ".agents", "skills"), - "ui-review", - ["---", "name: ui-review", "description: Review UI changes.", "---"].join("\n"), - ); - - const skills = await Effect.runPromise(discoverCommonAgentSkills({ cwd, homeDir: home })); - - assert.deepStrictEqual( - skills.map((skill) => [skill.name, skill.scope, skill.invocationPrefix]), - [["ui-review", "project", "$"]], - ); - }); - it("discovers Claude project and user skills", async () => { const repo = makeTempDir("t3-claude-skills-repo-"); const home = makeTempDir("t3-claude-skills-home-"); diff --git a/apps/server/src/provider/SkillDiscovery.ts b/apps/server/src/provider/SkillDiscovery.ts index 0936bf90ce0..82395f5eff1 100644 --- a/apps/server/src/provider/SkillDiscovery.ts +++ b/apps/server/src/provider/SkillDiscovery.ts @@ -317,15 +317,6 @@ export const discoverSkillsFromRoots = ( catch: () => undefined, }).pipe(Effect.catch(() => Effect.succeed([]))); -async function commonProjectRoots(cwd: string): Promise> { - const dirs = await projectSkillSearchDirs(cwd); - return dirs.map((dir) => ({ path: nodePath.join(dir, ".agents", "skills"), scope: "project" })); -} - -function commonUserRoots(homeDir: string): ReadonlyArray { - return [{ path: nodePath.join(homeDir, ".agents", "skills"), scope: "user" }]; -} - async function claudeProjectRoots(cwd: string): Promise> { const dirs = await projectSkillSearchDirs(cwd); return dirs.map((dir) => ({ path: nodePath.join(dir, ".claude", "skills"), scope: "project" })); @@ -385,20 +376,6 @@ export const discoverOpenCodeSkills = ( catch: () => undefined, }).pipe(Effect.catch(() => Effect.succeed([]))); -export const discoverCommonAgentSkills = ( - input: SkillDiscoveryInput & { readonly invocationPrefix?: SkillInvocationPrefix }, -): Effect.Effect> => - Effect.tryPromise({ - try: async () => { - const homeDir = resolveHomeDir(input); - return discoverSkillsFromRootsPromise({ - roots: [...(await commonProjectRoots(input.cwd)), ...commonUserRoots(homeDir)], - invocationPrefix: input.invocationPrefix ?? "$", - }); - }, - catch: () => undefined, - }).pipe(Effect.catch(() => Effect.succeed([]))); - export function mergeProviderSkills( primary: ReadonlyArray, secondary: ReadonlyArray, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 94a5142c02f..aa8181e959c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -909,21 +909,16 @@ export const ChatComposer = memo( }, ] satisfies ReadonlyArray>; const providerSkills = selectedProviderStatus?.skills ?? []; - const providerSkillNames = new Set( - providerSkills - .filter((skill) => skill.enabled) - .map((skill) => skill.name.trim().toLowerCase()), - ); - const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []) - .filter((command) => !providerSkillNames.has(command.name.trim().toLowerCase())) - .map((command) => ({ + 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 query = composerTrigger.query.trim().toLowerCase(); const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems]; const skillItems = searchProviderSkills(providerSkills, composerTrigger.query).map( From 0a3d59050bce84ef18d73a074133d59c3b0b7b23 Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Thu, 7 May 2026 23:16:03 +0300 Subject: [PATCH 3/9] Avoid duplicate skill slash menu entries --- apps/web/src/components/chat/ChatComposer.tsx | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index aa8181e959c..29575dabdca 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -909,21 +909,25 @@ export const ChatComposer = memo( }, ] satisfies ReadonlyArray>; const providerSkills = selectedProviderStatus?.skills ?? []; - 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 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]; - const skillItems = searchProviderSkills(providerSkills, composerTrigger.query).map( - (skill) => makeComposerSkillItem(selectedProvider, skill), - ); + 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, From f4152fab4da44dc98184e538212d435b8596babb Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Fri, 8 May 2026 02:09:32 +0300 Subject: [PATCH 4/9] Skip malformed skill frontmatter paragraphs --- apps/server/src/provider/SkillDiscovery.test.ts | 1 + apps/server/src/provider/SkillDiscovery.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/SkillDiscovery.test.ts b/apps/server/src/provider/SkillDiscovery.test.ts index b97307f38f4..1e1e29c1932 100644 --- a/apps/server/src/provider/SkillDiscovery.test.ts +++ b/apps/server/src/provider/SkillDiscovery.test.ts @@ -89,6 +89,7 @@ describe("parseSkillMarkdown", () => { }); assert.equal(skill?.name, "broken"); + assert.equal(skill?.description, "Body"); assert.equal(skill?.enabled, true); }); diff --git a/apps/server/src/provider/SkillDiscovery.ts b/apps/server/src/provider/SkillDiscovery.ts index 82395f5eff1..40369bf2139 100644 --- a/apps/server/src/provider/SkillDiscovery.ts +++ b/apps/server/src/provider/SkillDiscovery.ts @@ -146,7 +146,7 @@ function firstBodyParagraph(body: string): string | undefined { !paragraph.startsWith("#") && !paragraph.startsWith("```") && !paragraph.startsWith("!") && - paragraph !== "---", + !paragraph.startsWith("---"), ); } From cd338fc7be5abdf58b4b0b0f92cea86a2e4c3222 Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Fri, 8 May 2026 02:27:11 +0300 Subject: [PATCH 5/9] Scope skill composer item ids --- apps/web/src/components/chat/ChatComposer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 29575dabdca..298af80b4d4 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -155,7 +155,7 @@ function makeComposerSkillItem( skill: ServerProvider["skills"][number], ): Extract { return { - id: `skill:${provider}:${skill.name}`, + id: `skill:${provider}:${skill.scope ?? ""}:${skill.name}`, type: "skill", provider, skill, From d89d1dbe1af3a8829bac298ef3c74c73d5098bb7 Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Fri, 8 May 2026 02:54:12 +0300 Subject: [PATCH 6/9] Use canonical skill tokens in composer --- apps/web/src/components/chat/ChatComposer.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 298af80b4d4..1802f7b9f0e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -109,10 +109,7 @@ import type { SessionPhase, Thread } from "../../types"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { PendingApproval, PendingUserInput } from "../../session-logic"; import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; -import { - formatProviderSkillDisplayName, - formatProviderSkillInvocation, -} from "../../providerSkillPresentation"; +import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; @@ -1558,7 +1555,7 @@ export const ChatComposer = memo( return; } if (item.type === "skill") { - const replacement = `${formatProviderSkillInvocation(item.skill)} `; + const replacement = `$${item.skill.name} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, trigger.rangeEnd, From b71b40dab9baa0c8b0008acf4a7e90b648d46f6e Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Fri, 8 May 2026 03:09:26 +0300 Subject: [PATCH 7/9] Normalize slash skill search prefixes --- apps/web/src/providerSkillSearch.test.ts | 8 ++++++++ apps/web/src/providerSkillSearch.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/src/providerSkillSearch.test.ts b/apps/web/src/providerSkillSearch.test.ts index ede929c8d3b..f01667cee12 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 2391e81813f..89ac65f1ecf 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; From fa6c0ff50d16a888f12492499c0ab50923c278bc Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Fri, 8 May 2026 03:26:09 +0300 Subject: [PATCH 8/9] Tighten provider skill probe paths --- .../src/provider/Layers/ClaudeProvider.ts | 13 +++-- .../src/provider/Layers/CodexProvider.ts | 3 +- .../provider/Layers/ProviderRegistry.test.ts | 56 +++++++++++++++++++ 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 9dffa67eed2..62a84dd22c7 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -628,10 +628,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( : undefined; const slashCommands = capabilities?.slashCommands ?? []; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); - const claudeHome = yield* resolveClaudeHomePath(claudeSettings); - const discoveredSkills = yield* discoverClaudeSkills({ cwd, homeDir: claudeHome }); - const skills = mergeProviderSkills([], discoveredSkills); - const mergedSlashCommands = mergeSkillsIntoSlashCommands(dedupedSlashCommands, skills); if (!capabilities) { return buildServerProvider({ @@ -639,8 +635,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models, - slashCommands: mergedSlashCommands, - skills, + slashCommands: dedupedSlashCommands, + skills: [], probe: { installed: true, version: parsedVersion, @@ -651,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, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index e0a959619a0..8066a2c4c2b 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -418,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, @@ -466,7 +467,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu 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/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 2526e3561a9..1d9c1d1fea3 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -354,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, () => @@ -1600,6 +1619,43 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ); }); + 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( From 863904f2cb5fe7c9d3128fce8157cfa692c18192 Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Fri, 8 May 2026 03:39:13 +0300 Subject: [PATCH 9/9] Remove unused skill invocation formatter --- apps/web/src/providerSkillPresentation.test.ts | 13 ------------- apps/web/src/providerSkillPresentation.ts | 6 ------ 2 files changed, 19 deletions(-) diff --git a/apps/web/src/providerSkillPresentation.test.ts b/apps/web/src/providerSkillPresentation.test.ts index 11e145c838d..ce94d88a6bd 100644 --- a/apps/web/src/providerSkillPresentation.test.ts +++ b/apps/web/src/providerSkillPresentation.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { formatProviderSkillDisplayName, - formatProviderSkillInvocation, formatProviderSkillInstallSource, } from "./providerSkillPresentation"; @@ -56,15 +55,3 @@ describe("formatProviderSkillInstallSource", () => { ).toBe("Project"); }); }); - -describe("formatProviderSkillInvocation", () => { - it("uses dollar invocation by default", () => { - expect(formatProviderSkillInvocation({ name: "review-follow-up" })).toBe("$review-follow-up"); - }); - - it("uses provider-specific invocation prefixes", () => { - expect( - formatProviderSkillInvocation({ name: "summarize-changes", invocationPrefix: "/" }), - ).toBe("/summarize-changes"); - }); -}); diff --git a/apps/web/src/providerSkillPresentation.ts b/apps/web/src/providerSkillPresentation.ts index 0cf2a36d41d..fe077cbb191 100644 --- a/apps/web/src/providerSkillPresentation.ts +++ b/apps/web/src/providerSkillPresentation.ts @@ -50,9 +50,3 @@ export function formatProviderSkillInstallSource( return null; } - -export function formatProviderSkillInvocation( - skill: Pick, -): string { - return `${skill.invocationPrefix ?? "$"}${skill.name}`; -}