From 9dfd7395f9be87044956a972ffa7c96feda2402b Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 29 Mar 2026 02:16:45 -0500 Subject: [PATCH] Add skill CRUD and slash-command support - Wire skill RPCs through server and web native API - Surface installed skills and /skill subcommands in composer menus - Add shared skill contracts and utilities --- apps/server/src/serverLayers.ts | 2 + apps/server/src/skills/SkillService.ts | 184 +++++++ apps/server/src/wsServer.ts | 28 + apps/web/src/components/ChatView.tsx | 204 ++++++- .../components/chat/ComposerCommandMenu.tsx | 47 +- apps/web/src/composer-logic.ts | 37 +- apps/web/src/lib/skillReactQuery.ts | 48 ++ apps/web/src/wsNativeApi.ts | 7 + packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 18 + packages/contracts/src/skill.ts | 99 ++++ packages/contracts/src/ws.ts | 21 + packages/shared/package.json | 4 + packages/shared/src/skill.ts | 520 ++++++++++++++++++ 14 files changed, 1203 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/skills/SkillService.ts create mode 100644 apps/web/src/lib/skillReactQuery.ts create mode 100644 packages/contracts/src/skill.ts create mode 100644 packages/shared/src/skill.ts diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index fc7a570b..24577931 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -28,6 +28,7 @@ import { EnvironmentVariablesLive } from "./persistence/Services/EnvironmentVari import { TerminalManagerLive } from "./terminal/Layers/Manager"; import { KeybindingsLive } from "./keybindings"; +import { SkillServiceLive } from "./skills/SkillService"; import { GitManagerLive } from "./git/Layers/GitManager"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; @@ -159,5 +160,6 @@ export function makeServerRuntimeServicesLayer() { prReviewLayer, terminalLayer, KeybindingsLive, + SkillServiceLive, ).pipe(Layer.provideMerge(NodeServices.layer)); } diff --git a/apps/server/src/skills/SkillService.ts b/apps/server/src/skills/SkillService.ts new file mode 100644 index 00000000..53c952b1 --- /dev/null +++ b/apps/server/src/skills/SkillService.ts @@ -0,0 +1,184 @@ +/** + * SkillService - Effect service for skill CRUD and search operations. + * + * Wraps the shared skill utilities from `@okcode/shared/skill` as an Effect + * service using the project's `ServiceMap.Service` pattern. + * + * @module SkillService + */ +import type { + SkillCreateResult, + SkillListResult, + SkillReadResult, + SkillSearchResult, +} from "@okcode/contracts"; +import { Effect, Layer, Schema, ServiceMap } from "effect"; +import { + listSkills, + readSkill, + searchSkills, + createSkill, + deleteSkill, +} from "@okcode/shared/skill"; + +/** + * SkillServiceError - Tagged error for skill service failures. + */ +export class SkillServiceError extends Schema.TaggedErrorClass()( + "SkillServiceError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Skill service error (${this.operation}): ${this.detail}`; + } +} + +/** + * SkillServiceShape - Service API for skill CRUD and search operations. + */ +export interface SkillServiceShape { + /** + * List all installed skills. + */ + readonly list: (input: { + readonly cwd?: string | undefined; + }) => Effect.Effect; + + /** + * Read a skill by name. + */ + readonly read: (input: { + readonly name: string; + readonly cwd?: string | undefined; + }) => Effect.Effect; + + /** + * Create a new skill with scaffold template. + */ + readonly create: (input: { + readonly name: string; + readonly description: string; + readonly scope: "global" | "project"; + readonly cwd?: string | undefined; + }) => Effect.Effect; + + /** + * Delete a skill. + */ + readonly delete: (input: { + readonly name: string; + readonly scope: "global" | "project"; + readonly cwd?: string | undefined; + }) => Effect.Effect; + + /** + * Search skills by query. + */ + readonly search: (input: { + readonly query: string; + readonly cwd?: string | undefined; + }) => Effect.Effect; +} + +/** + * SkillService - Service tag for skill CRUD and search operations. + */ +export class SkillService extends ServiceMap.Service()( + "okcode/skills/SkillService", +) {} + +export const SkillServiceLive = Layer.succeed(SkillService, { + list: (input) => + Effect.try({ + try: () => { + const entries = listSkills(input.cwd); + return { + skills: entries.map((e) => ({ + name: e.name, + scope: e.scope, + description: e.description, + tags: e.tags, + path: e.path, + })), + }; + }, + catch: (cause) => + new SkillServiceError({ + operation: "list", + detail: cause instanceof Error ? cause.message : String(cause), + cause: cause instanceof Error ? cause : undefined, + }), + }), + + read: (input) => + Effect.try({ + try: () => { + const result = readSkill(input.name, input.cwd); + if (!result) { + throw new Error(`Skill "${input.name}" not found`); + } + return { + name: result.name, + scope: result.scope, + description: result.description, + content: result.content.raw, + path: result.path, + tags: result.tags, + }; + }, + catch: (cause) => + new SkillServiceError({ + operation: "read", + detail: cause instanceof Error ? cause.message : String(cause), + cause: cause instanceof Error ? cause : undefined, + }), + }), + + create: (input) => + Effect.try({ + try: () => createSkill(input.name, input.description, input.scope, input.cwd), + catch: (cause) => + new SkillServiceError({ + operation: "create", + detail: cause instanceof Error ? cause.message : String(cause), + cause: cause instanceof Error ? cause : undefined, + }), + }), + + delete: (input) => + Effect.try({ + try: () => deleteSkill(input.name, input.scope, input.cwd), + catch: (cause) => + new SkillServiceError({ + operation: "delete", + detail: cause instanceof Error ? cause.message : String(cause), + cause: cause instanceof Error ? cause : undefined, + }), + }), + + search: (input) => + Effect.try({ + try: () => { + const entries = searchSkills(input.query, input.cwd); + return { + skills: entries.map((e) => ({ + name: e.name, + scope: e.scope, + description: e.description, + tags: e.tags, + path: e.path, + })), + }; + }, + catch: (cause) => + new SkillServiceError({ + operation: "search", + detail: cause instanceof Error ? cause.message : String(cause), + cause: cause instanceof Error ? cause : undefined, + }), + }), +} satisfies SkillServiceShape); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index a23170f2..e3f6adfd 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -83,6 +83,7 @@ import { decodeJsonResult, formatSchemaError } from "@okcode/shared/schemaJson"; import { PrReview } from "./prReview/Services/PrReview.ts"; import { GitActionExecutionError } from "./git/Errors.ts"; import { EnvironmentVariables } from "./persistence/Services/EnvironmentVariables.ts"; +import { SkillService } from "./skills/SkillService.ts"; import { resolveRuntimeEnvironment } from "./runtimeEnvironment.ts"; /** @@ -263,6 +264,7 @@ export type ServerRuntimeServices = | PrReview | TerminalManager | Keybindings + | SkillService | Open | AnalyticsService | EnvironmentVariables; @@ -653,6 +655,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const prReview = yield* PrReview; const { openInEditor } = yield* Open; const environmentVariables = yield* EnvironmentVariables; + const skillService = yield* SkillService; const subscriptionsScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); @@ -1177,6 +1180,31 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { path: pickedPath }; } + case WS_METHODS.skillList: { + const body = stripRequestTag(request.body); + return yield* skillService.list(body); + } + + case WS_METHODS.skillRead: { + const body = stripRequestTag(request.body); + return yield* skillService.read(body); + } + + case WS_METHODS.skillCreate: { + const body = stripRequestTag(request.body); + return yield* skillService.create(body); + } + + case WS_METHODS.skillDelete: { + const body = stripRequestTag(request.body); + return yield* skillService.delete(body); + } + + case WS_METHODS.skillSearch: { + const body = stripRequestTag(request.body); + return yield* skillService.search(body); + } + default: { const _exhaustiveCheck: never = request.body; return yield* new RouteRequestError({ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a1fd31fc..1b4dd1b6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -32,6 +32,7 @@ import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; +import { skillListQueryOptions } from "~/lib/skillReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; @@ -1104,6 +1105,63 @@ export default function ChatView({ threadId }: ChatViewProps) { }), ); const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; + const skillsQuery = useQuery( + skillListQueryOptions({ + cwd: gitCwd, + enabled: composerTriggerKind === "slash-skill" || composerTriggerKind === "slash-command", + }), + ); + const installedSkills = skillsQuery.data?.skills ?? []; + const SKILL_SUBCOMMAND_ITEMS: Extract[] = [ + { + id: "skill-sub:create", + type: "skill-subcommand" as const, + subcommand: "create", + label: "/skill create", + description: "Create a new skill with scaffold template", + usage: "/skill create ", + }, + { + id: "skill-sub:list", + type: "skill-subcommand" as const, + subcommand: "list", + label: "/skill list", + description: "List all installed skills", + usage: "/skill list", + }, + { + id: "skill-sub:search", + type: "skill-subcommand" as const, + subcommand: "search", + label: "/skill search", + description: "Search installed skills by keyword", + usage: "/skill search ", + }, + { + id: "skill-sub:read", + type: "skill-subcommand" as const, + subcommand: "read", + label: "/skill read", + description: "View the full content of a skill", + usage: "/skill read ", + }, + { + id: "skill-sub:delete", + type: "skill-subcommand" as const, + subcommand: "delete", + label: "/skill delete", + description: "Remove an installed skill", + usage: "/skill delete ", + }, + { + id: "skill-sub:import", + type: "skill-subcommand" as const, + subcommand: "import", + label: "/skill import", + description: "Import a skill from a local path", + usage: "/skill import ", + }, + ]; const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { @@ -1117,6 +1175,49 @@ export default function ChatView({ threadId }: ChatViewProps) { })); } + if (composerTrigger.kind === "slash-skill") { + const query = composerTrigger.query.trim().toLowerCase(); + + // If query is empty, show skill management subcommands + installed skills + if (!query) { + const subcommandItems: ComposerCommandItem[] = SKILL_SUBCOMMAND_ITEMS; + const skillItems: ComposerCommandItem[] = installedSkills.map((skill) => ({ + id: `skill:${skill.scope}:${skill.name}`, + type: "skill" as const, + skillName: skill.name, + scope: skill.scope as "global" | "project", + label: `/${skill.name}`, + description: skill.description, + tags: skill.tags, + })); + return [...subcommandItems, ...skillItems]; + } + + // Filter subcommands and skills by query + const subcommandItems: ComposerCommandItem[] = SKILL_SUBCOMMAND_ITEMS.filter( + (item) => item.subcommand.includes(query) || item.label.includes(query), + ); + + const skillItems: ComposerCommandItem[] = installedSkills + .filter( + (skill) => + skill.name.includes(query) || + skill.description.toLowerCase().includes(query) || + skill.tags.some((tag) => tag.toLowerCase().includes(query)), + ) + .map((skill) => ({ + id: `skill:${skill.scope}:${skill.name}`, + type: "skill" as const, + skillName: skill.name, + scope: skill.scope as "global" | "project", + label: `/${skill.name}`, + description: skill.description, + tags: skill.tags, + })); + + return [...subcommandItems, ...skillItems]; + } + if (composerTrigger.kind === "slash-command") { const slashCommandItems = [ { @@ -1147,14 +1248,39 @@ export default function ChatView({ threadId }: ChatViewProps) { label: "/code", description: "Switch this thread into code mode", }, + { + id: "slash:skill", + type: "slash-command", + command: "skill", + label: "/skill", + description: "Manage skills and plugins", + }, ] satisfies ReadonlyArray>; + + const skillItems: ComposerCommandItem[] = installedSkills.map((skill) => ({ + id: `skill:${skill.scope}:${skill.name}`, + type: "skill" as const, + skillName: skill.name, + scope: skill.scope as "global" | "project", + label: `/${skill.name}`, + description: skill.description, + tags: skill.tags, + })); + const query = composerTrigger.query.trim().toLowerCase(); + const allItems: ComposerCommandItem[] = [...slashCommandItems, ...skillItems]; if (!query) { - return [...slashCommandItems]; + return allItems; } - return slashCommandItems.filter( - (item) => item.command.includes(query) || item.label.slice(1).includes(query), - ); + return allItems.filter((item) => { + if (item.type === "slash-command") { + return item.command.includes(query) || item.label.slice(1).includes(query); + } + if (item.type === "skill") { + return item.skillName.includes(query) || item.description.toLowerCase().includes(query); + } + return false; + }); } return searchableModelOptions @@ -1173,7 +1299,7 @@ export default function ChatView({ threadId }: ChatViewProps) { label: name, description: `${providerLabel} · ${slug}`, })); - }, [composerTrigger, searchableModelOptions, workspaceEntries]); + }, [composerTrigger, searchableModelOptions, workspaceEntries, installedSkills]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () => @@ -2758,7 +2884,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { - handleInteractionModeChange(standaloneSlashCommand); + if (standaloneSlashCommand !== "skill") { + handleInteractionModeChange(standaloneSlashCommand); + } promptRef.current = ""; clearComposerDraftContent(activeThread.id); setComposerHighlightedItemId(null); @@ -3731,6 +3859,24 @@ export default function ChatView({ threadId }: ChatViewProps) { } return; } + if (item.command === "skill") { + const replacement = "/skill "; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } void handleInteractionModeChange( item.command === "plan" ? "plan" : item.command === "code" ? "code" : "chat", ); @@ -3742,6 +3888,42 @@ export default function ChatView({ threadId }: ChatViewProps) { } return; } + if (item.type === "skill") { + const replacement = `/${item.skillName} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + if (item.type === "skill-subcommand") { + const replacement = `/skill ${item.subcommand} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } onProviderModelSelect(item.provider, item.model); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), @@ -3779,10 +3961,12 @@ export default function ChatView({ threadId }: ChatViewProps) { [composerHighlightedItemId, composerMenuItems], ); const isComposerMenuLoading = - composerTriggerKind === "path" && - ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || - workspaceEntriesQuery.isLoading || - workspaceEntriesQuery.isFetching); + (composerTriggerKind === "path" && + ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || + workspaceEntriesQuery.isLoading || + workspaceEntriesQuery.isFetching)) || + ((composerTriggerKind === "slash-skill" || composerTriggerKind === "slash-command") && + skillsQuery.isLoading); const onPromptChange = useCallback( ( diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 8ff83425..7371fb42 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,7 +1,7 @@ import { type ProjectEntry, type ModelSlug, type ProviderKind } from "@okcode/contracts"; import { memo } from "react"; import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; -import { BotIcon } from "lucide-react"; +import { BotIcon, PuzzleIcon, TerminalSquareIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; import { Command, CommandItem, CommandList } from "../ui/command"; @@ -30,6 +30,23 @@ export type ComposerCommandItem = model: ModelSlug; label: string; description: string; + } + | { + id: string; + type: "skill"; + skillName: string; + scope: "global" | "project"; + label: string; + description: string; + tags: readonly string[]; + } + | { + id: string; + type: "skill-subcommand"; + subcommand: string; + label: string; + description: string; + usage: string; }; export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { @@ -65,10 +82,14 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { {props.items.length === 0 && (

{props.isLoading - ? "Searching workspace files..." + ? props.triggerKind === "slash-skill" + ? "Loading skills..." + : "Searching workspace files..." : props.triggerKind === "path" ? "No matching files or folders." - : "No matching command."} + : props.triggerKind === "slash-skill" + ? "No matching skills." + : "No matching command."}

)} @@ -111,10 +132,30 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { model ) : null} + {props.item.type === "skill" ? ( +
+ + + {props.item.scope} + +
+ ) : null} + {props.item.type === "skill-subcommand" ? ( + + ) : null} {props.item.label} {props.item.description} + {props.item.type === "skill" && props.item.tags.length > 0 ? ( + + {props.item.tags.slice(0, 2).map((tag) => ( + + {tag} + + ))} + + ) : null} ); }); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index a803706a..6e290784 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,8 +1,8 @@ import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; -export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; -export type ComposerSlashCommand = "model" | "plan" | "chat" | "code"; +export type ComposerTriggerKind = "path" | "slash-command" | "slash-model" | "slash-skill"; +export type ComposerSlashCommand = "model" | "plan" | "chat" | "code" | "skill"; export interface ComposerTrigger { kind: ComposerTriggerKind; @@ -11,7 +11,7 @@ export interface ComposerTrigger { rangeEnd: number; } -const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "chat", "code"]; +const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "chat", "code", "skill"]; const isInlineTokenSegment = ( segment: { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" }, ): boolean => segment.type !== "text"; @@ -201,6 +201,14 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } + if (commandQuery.toLowerCase() === "skill") { + return { + kind: "slash-skill", + query: "", + rangeStart: lineStart, + rangeEnd: cursor, + }; + } if (SLASH_COMMANDS.some((command) => command.startsWith(commandQuery.toLowerCase()))) { return { kind: "slash-command", @@ -209,6 +217,15 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } + // Check if this matches a dynamic skill name pattern (e.g., /my-custom-skill) + if (/^[a-z0-9-]+$/.test(commandQuery.toLowerCase()) && commandQuery.length > 0) { + return { + kind: "slash-skill", + query: commandQuery, + rangeStart: lineStart, + rangeEnd: cursor, + }; + } return null; } @@ -221,6 +238,17 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } + + // Handle /skill pattern + const skillMatch = /^\/skill(?:\s+(.*))?$/.exec(linePrefix); + if (skillMatch) { + return { + kind: "slash-skill", + query: (skillMatch[1] ?? "").trim(), + rangeStart: lineStart, + rangeEnd: cursor, + }; + } } const tokenStart = tokenStartForCursor(text, cursor); @@ -240,13 +268,14 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos export function parseStandaloneComposerSlashCommand( text: string, ): Exclude | null { - const match = /^\/(plan|chat|code|default)\s*$/i.exec(text.trim()); + const match = /^\/(plan|chat|code|default|skill)\s*$/i.exec(text.trim()); if (!match) { return null; } const command = match[1]?.toLowerCase(); if (command === "plan") return "plan"; if (command === "code") return "code"; + if (command === "skill") return "skill"; // `/default` is a legacy alias for chat mode return "chat"; } diff --git a/apps/web/src/lib/skillReactQuery.ts b/apps/web/src/lib/skillReactQuery.ts new file mode 100644 index 00000000..956ae4f1 --- /dev/null +++ b/apps/web/src/lib/skillReactQuery.ts @@ -0,0 +1,48 @@ +import type { SkillListResult, SkillSearchResult } from "@okcode/contracts"; +import { queryOptions } from "@tanstack/react-query"; +import { ensureNativeApi } from "~/nativeApi"; + +export const skillQueryKeys = { + all: ["skills"] as const, + list: (cwd: string | null) => ["skills", "list", cwd] as const, + search: (cwd: string | null, query: string) => ["skills", "search", cwd, query] as const, +}; + +const EMPTY_SKILL_LIST_RESULT: SkillListResult = { skills: [] }; + +export function skillListQueryOptions(input: { + cwd: string | null; + enabled?: boolean; + staleTime?: number; +}) { + return queryOptions({ + queryKey: skillQueryKeys.list(input.cwd), + queryFn: async () => { + const api = ensureNativeApi(); + if (!input.cwd) return EMPTY_SKILL_LIST_RESULT; + return api.skills.list({ cwd: input.cwd }); + }, + enabled: input.enabled !== false, + staleTime: input.staleTime ?? 30_000, + placeholderData: EMPTY_SKILL_LIST_RESULT, + }); +} + +export function skillSearchQueryOptions(input: { + cwd: string | null; + query: string; + enabled?: boolean; +}) { + const EMPTY_SEARCH_RESULT: SkillSearchResult = { skills: [] }; + return queryOptions({ + queryKey: skillQueryKeys.search(input.cwd, input.query), + queryFn: async () => { + const api = ensureNativeApi(); + if (!input.cwd || !input.query.trim()) return EMPTY_SEARCH_RESULT; + return api.skills.search({ query: input.query, cwd: input.cwd }); + }, + enabled: input.enabled !== false && input.query.trim().length > 0, + staleTime: 15_000, + placeholderData: EMPTY_SEARCH_RESULT, + }); +} diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 4c706d36..73e8188f 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -264,6 +264,13 @@ export function createWsNativeApi(): NativeApi { }; }, }, + skills: { + list: (input) => transport.request(WS_METHODS.skillList, input ?? {}), + read: (input) => transport.request(WS_METHODS.skillRead, input), + create: (input) => transport.request(WS_METHODS.skillCreate, input), + delete: (input) => transport.request(WS_METHODS.skillDelete, input), + search: (input) => transport.request(WS_METHODS.skillSearch, input), + }, contextMenu: { show: async ( items: readonly ContextMenuItem[], diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 06f69f96..7c9e92e2 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -13,3 +13,4 @@ export * from "./orchestration"; export * from "./editor"; export * from "./project"; export * from "./environment"; +export * from "./skill"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e776622d..fb99daa1 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -74,6 +74,17 @@ import type { TerminalWriteInput, } from "./terminal"; import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; +import type { + SkillListInput, + SkillListResult, + SkillReadInput, + SkillReadResult, + SkillCreateInput, + SkillCreateResult, + SkillDeleteInput, + SkillSearchInput, + SkillSearchResult, +} from "./skill"; import type { ClientOrchestrationCommand, OrchestrationGetFullThreadDiffInput, @@ -307,6 +318,13 @@ export interface NativeApi { callback: (payload: PrReviewRepoConfigUpdatedPayload) => void, ) => () => void; }; + skills: { + list: (input?: SkillListInput) => Promise; + read: (input: SkillReadInput) => Promise; + create: (input: SkillCreateInput) => Promise; + delete: (input: SkillDeleteInput) => Promise; + search: (input: SkillSearchInput) => Promise; + }; contextMenu: { show: ( items: readonly ContextMenuItem[], diff --git a/packages/contracts/src/skill.ts b/packages/contracts/src/skill.ts new file mode 100644 index 00000000..7b6b7d3e --- /dev/null +++ b/packages/contracts/src/skill.ts @@ -0,0 +1,99 @@ +import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas"; + +// ── Skill Manifest (parsed from SKILL.md frontmatter) ─────────────── + +export const SkillScope = Schema.Literals(["global", "project"]); +export type SkillScope = typeof SkillScope.Type; + +export const SkillManifest = Schema.Struct({ + name: TrimmedNonEmptyString, + description: Schema.String, + version: Schema.optional(TrimmedNonEmptyString), + scope: Schema.optional(SkillScope), + triggers: Schema.optional(Schema.Array(Schema.String)), + tags: Schema.optional(Schema.Array(Schema.String)), + tools: Schema.optional(Schema.Array(Schema.String)), + author: Schema.optional(TrimmedNonEmptyString), +}); +export type SkillManifest = typeof SkillManifest.Type; + +// ── Skill Entry (lightweight listing item) ─────────────────────────── + +export const SkillEntry = Schema.Struct({ + name: TrimmedNonEmptyString, + scope: SkillScope, + description: Schema.String, + tags: Schema.Array(Schema.String), + path: TrimmedNonEmptyString, +}); +export type SkillEntry = typeof SkillEntry.Type; + +// ── Skill Subcommand (for hierarchical slash commands) ─────────────── + +export const SkillSubcommand = Schema.Struct({ + name: TrimmedNonEmptyString, + description: Schema.String, + usage: Schema.optional(Schema.String), +}); +export type SkillSubcommand = typeof SkillSubcommand.Type; + +// ── WS API Input/Result Schemas ────────────────────────────────────── + +export const SkillListInput = Schema.Struct({ + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type SkillListInput = typeof SkillListInput.Type; + +export const SkillListResult = Schema.Struct({ + skills: Schema.Array(SkillEntry), +}); +export type SkillListResult = typeof SkillListResult.Type; + +export const SkillReadInput = Schema.Struct({ + name: TrimmedNonEmptyString, + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type SkillReadInput = typeof SkillReadInput.Type; + +export const SkillReadResult = Schema.Struct({ + name: TrimmedNonEmptyString, + scope: SkillScope, + description: Schema.String, + content: Schema.String, + path: TrimmedNonEmptyString, + tags: Schema.Array(Schema.String), +}); +export type SkillReadResult = typeof SkillReadResult.Type; + +export const SkillCreateInput = Schema.Struct({ + name: TrimmedNonEmptyString, + description: Schema.String, + scope: SkillScope, + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type SkillCreateInput = typeof SkillCreateInput.Type; + +export const SkillCreateResult = Schema.Struct({ + path: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, +}); +export type SkillCreateResult = typeof SkillCreateResult.Type; + +export const SkillDeleteInput = Schema.Struct({ + name: TrimmedNonEmptyString, + scope: SkillScope, + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type SkillDeleteInput = typeof SkillDeleteInput.Type; + +export const SkillSearchInput = Schema.Struct({ + query: TrimmedNonEmptyString, + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type SkillSearchInput = typeof SkillSearchInput.Type; + +export const SkillSearchResult = Schema.Struct({ + skills: Schema.Array(SkillEntry), +}); +export type SkillSearchResult = typeof SkillSearchResult.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 5ae01262..f95a1039 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -73,6 +73,13 @@ import { } from "./project"; import { OpenInEditorInput } from "./editor"; import { ServerConfigUpdatedPayload } from "./server"; +import { + SkillListInput, + SkillReadInput, + SkillCreateInput, + SkillDeleteInput, + SkillSearchInput, +} from "./skill"; // ── WebSocket RPC Method Names ─────────────────────────────────────── @@ -126,6 +133,13 @@ export const WS_METHODS = { terminalRestart: "terminal.restart", terminalClose: "terminal.close", + // Skill methods + skillList: "skill.list", + skillRead: "skill.read", + skillCreate: "skill.create", + skillDelete: "skill.delete", + skillSearch: "skill.search", + // Server meta serverGetConfig: "server.getConfig", serverGetGlobalEnvironmentVariables: "server.getGlobalEnvironmentVariables", @@ -216,6 +230,13 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.terminalRestart, TerminalRestartInput), tagRequestBody(WS_METHODS.terminalClose, TerminalCloseInput), + // Skill methods + tagRequestBody(WS_METHODS.skillList, SkillListInput), + tagRequestBody(WS_METHODS.skillRead, SkillReadInput), + tagRequestBody(WS_METHODS.skillCreate, SkillCreateInput), + tagRequestBody(WS_METHODS.skillDelete, SkillDeleteInput), + tagRequestBody(WS_METHODS.skillSearch, SkillSearchInput), + // Server meta tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), tagRequestBody(WS_METHODS.serverGetGlobalEnvironmentVariables, Schema.Struct({})), diff --git a/packages/shared/package.json b/packages/shared/package.json index 24ec8adc..a492b829 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -39,6 +39,10 @@ "./environment": { "types": "./src/environment.ts", "import": "./src/environment.ts" + }, + "./skill": { + "types": "./src/skill.ts", + "import": "./src/skill.ts" } }, "scripts": { diff --git a/packages/shared/src/skill.ts b/packages/shared/src/skill.ts new file mode 100644 index 00000000..68a634e7 --- /dev/null +++ b/packages/shared/src/skill.ts @@ -0,0 +1,520 @@ +import * as path from "node:path"; +import * as fs from "node:fs"; +import * as os from "node:os"; + +// ── Types ──────────────────────────────────────────────────────────── + +export interface SkillManifest { + name: string; + description: string; + version?: string; + scope?: "global" | "project"; + triggers?: string[]; + tags?: string[]; + tools?: string[]; + author?: string; +} + +export interface SkillEntry { + name: string; + scope: "global" | "project"; + description: string; + tags: string[]; + path: string; + dir: string; + supplementaryFiles: string[]; +} + +export interface SkillContent { + manifest: SkillManifest; + body: string; + raw: string; +} + +export interface SkillValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +// ── Frontmatter parsing ────────────────────────────────────────────── + +/** + * Parse YAML frontmatter from a SKILL.md string. + * Returns the frontmatter as key-value pairs and the markdown body. + * Uses a simple parser to avoid external YAML dependency. + */ +export function parseSkillFrontmatter(raw: string): { + frontmatter: Record; + body: string; +} { + const trimmed = raw.trimStart(); + if (!trimmed.startsWith("---")) { + return { frontmatter: {}, body: raw }; + } + + const endIndex = trimmed.indexOf("---", 3); + if (endIndex === -1) { + return { frontmatter: {}, body: raw }; + } + + const frontmatterBlock = trimmed.slice(3, endIndex).trim(); + const body = trimmed.slice(endIndex + 3).trimStart(); + const frontmatter: Record = {}; + const lines = frontmatterBlock.split("\n"); + + let currentKey: string | null = null; + let currentList: string[] | null = null; + + for (const line of lines) { + // Check for block-list item ( - value) + const listItemMatch = /^\s+-\s+(.*)$/.exec(line); + if (listItemMatch && currentKey !== null && currentList !== null) { + const itemValue = listItemMatch[1]?.trim().replace(/^["']|["']$/g, "") ?? ""; + if (itemValue.length > 0) { + currentList.push(itemValue); + } + continue; + } + + // If we were building a list, commit it + if (currentKey !== null && currentList !== null) { + frontmatter[currentKey] = currentList; + currentKey = null; + currentList = null; + } + + const colonIndex = line.indexOf(":"); + if (colonIndex === -1) continue; + const key = line.slice(0, colonIndex).trim(); + const rawValue = line.slice(colonIndex + 1).trim(); + + if (key.length === 0) continue; + + // Empty value after colon means potential block-list follows + if (rawValue.length === 0) { + currentKey = key; + currentList = []; + continue; + } + + // Handle inline array syntax: [item1, item2] + if (rawValue.startsWith("[") && rawValue.endsWith("]")) { + frontmatter[key] = rawValue + .slice(1, -1) + .split(",") + .map((item) => item.trim().replace(/^["']|["']$/g, "")) + .filter((item) => item.length > 0); + continue; + } + + // Handle quoted strings + if ( + (rawValue.startsWith('"') && rawValue.endsWith('"')) || + (rawValue.startsWith("'") && rawValue.endsWith("'")) + ) { + frontmatter[key] = rawValue.slice(1, -1); + continue; + } + + frontmatter[key] = rawValue; + } + + // Commit any trailing list + if (currentKey !== null && currentList !== null) { + frontmatter[currentKey] = currentList; + } + + return { frontmatter, body }; +} + +/** + * Parse a SKILL.md file into a typed SkillContent. + */ +export function parseSkillContent(raw: string, fallbackName: string): SkillContent { + const { frontmatter, body } = parseSkillFrontmatter(raw); + + const manifest: SkillManifest = { + name: typeof frontmatter.name === "string" ? frontmatter.name : fallbackName, + description: typeof frontmatter.description === "string" ? frontmatter.description : "", + ...(typeof frontmatter.version === "string" ? { version: frontmatter.version } : {}), + ...(frontmatter.scope === "global" || frontmatter.scope === "project" + ? { scope: frontmatter.scope } + : {}), + ...(Array.isArray(frontmatter.triggers) ? { triggers: frontmatter.triggers.map(String) } : {}), + ...(Array.isArray(frontmatter.tags) ? { tags: frontmatter.tags.map(String) } : {}), + ...(Array.isArray(frontmatter.tools) ? { tools: frontmatter.tools.map(String) } : {}), + ...(typeof frontmatter.author === "string" ? { author: frontmatter.author } : {}), + }; + + return { manifest, body, raw }; +} + +// ── Validation ─────────────────────────────────────────────────────── + +const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; + +export function validateSkillName(name: string): { valid: boolean; reason?: string } { + if (name.length === 0) return { valid: false, reason: "Skill name cannot be empty" }; + if (name.length > 64) + return { valid: false, reason: "Skill name must be 64 characters or fewer" }; + if (!SKILL_NAME_PATTERN.test(name)) { + return { + valid: false, + reason: "Skill name must be lowercase alphanumeric with hyphens (e.g., 'my-skill')", + }; + } + return { valid: true }; +} + +export function validateSkillDirectory(skillDir: string): SkillValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + const skillMdPath = path.join(skillDir, "SKILL.md"); + if (!fs.existsSync(skillMdPath)) { + errors.push("Missing SKILL.md file"); + return { valid: false, errors, warnings }; + } + + const raw = fs.readFileSync(skillMdPath, "utf-8"); + if (raw.trim().length === 0) { + errors.push("SKILL.md is empty"); + return { valid: false, errors, warnings }; + } + + const { manifest } = parseSkillContent(raw, path.basename(skillDir)); + + if (!manifest.description || manifest.description.length === 0) { + warnings.push("Missing description in frontmatter"); + } + + if (!manifest.tags || manifest.tags.length === 0) { + warnings.push("No tags specified"); + } + + return { valid: errors.length === 0, errors, warnings }; +} + +// ── Storage paths ──────────────────────────────────────────────────── + +export function globalSkillsDir(): string { + return path.join(os.homedir(), ".claude", "skills"); +} + +export function projectSkillsDir(projectRoot: string): string { + return path.join(projectRoot, ".claude", "skills"); +} + +export function skillMdPath(skillDir: string): string { + return path.join(skillDir, "SKILL.md"); +} + +// ── Listing ────────────────────────────────────────────────────────── + +function scanSkillsDirectory(baseDir: string, scope: "global" | "project"): SkillEntry[] { + if (!fs.existsSync(baseDir)) return []; + + const entries: SkillEntry[] = []; + + try { + const items = fs.readdirSync(baseDir, { withFileTypes: true }); + for (const item of items) { + if (!item.isDirectory()) continue; + + const skillDir = path.join(baseDir, item.name); + const mdPath = skillMdPath(skillDir); + + if (!fs.existsSync(mdPath)) continue; + + try { + const raw = fs.readFileSync(mdPath, "utf-8"); + const { manifest } = parseSkillContent(raw, item.name); + + // Find supplementary files + const supplementaryFiles: string[] = []; + try { + const dirContents = fs.readdirSync(skillDir); + for (const file of dirContents) { + if (file !== "SKILL.md" && !file.startsWith(".")) { + supplementaryFiles.push(file); + } + } + } catch { + // Ignore errors reading supplementary files + } + + entries.push({ + name: manifest.name, + scope, + description: manifest.description, + tags: manifest.tags ?? [], + path: mdPath, + dir: skillDir, + supplementaryFiles, + }); + } catch { + // Skip skills that can't be parsed + } + } + } catch { + // Directory doesn't exist or is unreadable + } + + return entries; +} + +/** + * List all installed skills, with project scope taking precedence over global. + */ +export function listSkills(projectRoot?: string): SkillEntry[] { + const globalEntries = scanSkillsDirectory(globalSkillsDir(), "global"); + const projectEntries = projectRoot + ? scanSkillsDirectory(projectSkillsDir(projectRoot), "project") + : []; + + // Project-scoped skills override global skills with the same name + const nameSet = new Set(projectEntries.map((e) => e.name)); + const merged = [...projectEntries, ...globalEntries.filter((e) => !nameSet.has(e.name))]; + + return merged.sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Read a skill by name, resolving from project scope first then global. + */ +export function readSkill( + name: string, + projectRoot?: string, +): (SkillEntry & { content: SkillContent }) | null { + const nameValidation = validateSkillName(name); + if (!nameValidation.valid) return null; + + // Direct path lookup instead of scanning all directories + const candidates: Array<{ dir: string; mdPath: string; scope: "global" | "project" }> = []; + + if (projectRoot) { + const dir = path.join(projectSkillsDir(projectRoot), name); + candidates.push({ dir, mdPath: skillMdPath(dir), scope: "project" }); + } + candidates.push({ + dir: path.join(globalSkillsDir(), name), + mdPath: skillMdPath(path.join(globalSkillsDir(), name)), + scope: "global", + }); + + for (const candidate of candidates) { + if (!fs.existsSync(candidate.mdPath)) continue; + + try { + const raw = fs.readFileSync(candidate.mdPath, "utf-8"); + const content = parseSkillContent(raw, name); + + // Find supplementary files + const supplementaryFiles: string[] = []; + try { + const dirContents = fs.readdirSync(candidate.dir); + for (const file of dirContents) { + if (file !== "SKILL.md" && !file.startsWith(".")) { + supplementaryFiles.push(file); + } + } + } catch { + // Ignore + } + + return { + name: content.manifest.name, + scope: candidate.scope, + description: content.manifest.description, + tags: content.manifest.tags ?? [], + path: candidate.mdPath, + dir: candidate.dir, + supplementaryFiles, + content, + }; + } catch { + continue; + } + } + + return null; +} + +/** + * Search skills by query (fuzzy match against name, description, and tags). + */ +export function searchSkills(query: string, projectRoot?: string): SkillEntry[] { + const allSkills = listSkills(projectRoot); + const lowerQuery = query.toLowerCase(); + + return allSkills + .map((skill) => { + let score = 0; + const lowerName = skill.name.toLowerCase(); + const lowerDesc = skill.description.toLowerCase(); + + if (lowerName === lowerQuery) score += 100; + else if (lowerName.startsWith(lowerQuery)) score += 80; + else if (lowerName.includes(lowerQuery)) score += 60; + + if (lowerDesc.includes(lowerQuery)) score += 40; + + for (const tag of skill.tags) { + if (tag.toLowerCase().includes(lowerQuery)) score += 30; + } + + return { skill, score }; + }) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .map(({ skill }) => skill); +} + +/** + * Check if a skill exists. + */ +export function skillExists( + name: string, + projectRoot?: string, +): { exists: boolean; scope?: "global" | "project" } { + if (projectRoot) { + const projectDir = path.join(projectSkillsDir(projectRoot), name); + if (fs.existsSync(skillMdPath(projectDir))) { + return { exists: true, scope: "project" }; + } + } + + const globalDir = path.join(globalSkillsDir(), name); + if (fs.existsSync(skillMdPath(globalDir))) { + return { exists: true, scope: "global" }; + } + + return { exists: false }; +} + +// ── Scaffold template ──────────────────────────────────────────────── + +export function generateSkillTemplate(name: string, description: string): string { + const titleCase = name + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + + return `--- +name: ${name} +description: ${description} +tags: [] +--- + +# ${titleCase} — Claude Code Skill + +## When to use this skill + +- TODO: Describe when Claude should invoke this skill + +## What this skill does + +${description} + +## Implementation + +TODO: Add step-by-step instructions, commands, code examples. + +## Best practices + +- TODO: Add dos and don'ts +`; +} + +/** + * Create a new skill with scaffold template. + */ +export function createSkill( + name: string, + description: string, + scope: "global" | "project", + projectRoot?: string, +): { path: string; name: string } { + const nameValidation = validateSkillName(name); + if (!nameValidation.valid) { + throw new Error(nameValidation.reason ?? "Invalid skill name"); + } + + const baseDir = + scope === "project" && projectRoot ? projectSkillsDir(projectRoot) : globalSkillsDir(); + + const skillDir = path.join(baseDir, name); + const mdPath = skillMdPath(skillDir); + + if (fs.existsSync(mdPath)) { + throw new Error(`Skill "${name}" already exists at ${scope} scope`); + } + + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(mdPath, generateSkillTemplate(name, description), "utf-8"); + + return { path: mdPath, name }; +} + +/** + * Delete a skill. + */ +export function deleteSkill(name: string, scope: "global" | "project", projectRoot?: string): void { + const nameValidation = validateSkillName(name); + if (!nameValidation.valid) { + throw new Error(nameValidation.reason ?? "Invalid skill name"); + } + + const baseDir = + scope === "project" && projectRoot ? projectSkillsDir(projectRoot) : globalSkillsDir(); + + const skillDir = path.join(baseDir, name); + + if (!fs.existsSync(skillDir)) { + throw new Error(`Skill "${name}" not found at ${scope} scope`); + } + + fs.rmSync(skillDir, { recursive: true, force: true }); +} + +// ── Built-in skill management subcommands ──────────────────────────── + +export interface SkillSubcommandDef { + name: string; + description: string; + usage: string; +} + +export const SKILL_MANAGEMENT_SUBCOMMANDS: readonly SkillSubcommandDef[] = [ + { + name: "create", + description: "Create a new skill with scaffold template", + usage: "/skill create [--scope global|project]", + }, + { + name: "list", + description: "List all installed skills", + usage: "/skill list", + }, + { + name: "search", + description: "Search installed skills by keyword", + usage: "/skill search ", + }, + { + name: "read", + description: "View the full content of a skill", + usage: "/skill read ", + }, + { + name: "delete", + description: "Remove an installed skill", + usage: "/skill delete [--scope global|project]", + }, + { + name: "import", + description: "Import a skill from a local path", + usage: "/skill import [--scope global|project]", + }, +] as const;