diff --git a/.plans/skills-system.md b/.plans/skills-system.md index 418913a58..79808aaad 100644 --- a/.plans/skills-system.md +++ b/.plans/skills-system.md @@ -2,7 +2,7 @@ ## Problem -Skills (slash commands backed by markdown definitions) currently exist as an external convention: markdown files placed in `~/.claude/skills//SKILL.md` that Claude discovers and surfaces as `/slash-commands`. There is no first-class support in OK Code for: +Skills (slash commands backed by markdown definitions) currently exist as an external convention: markdown files placed in `~/.okcode/skills//SKILL.md` that Claude discovers and surfaces as `/slash-commands`, with legacy `~/.claude/skills//SKILL.md` still readable during migration. There is no first-class support in OK Code for: 1. Creating new skills (scaffolding, validation, editing) 2. Storing skills at workspace vs global scope @@ -40,7 +40,7 @@ Body follows a loose convention: | Scope | Path | Purpose | |-------|------|---------| -| User/global | `~/.claude/skills//SKILL.md` | Available in all projects | +| User/global | `~/.okcode/skills//SKILL.md` | Available in all projects | | Shared agent | `~/.agents/skills//SKILL.md` | Shared across agent tools | | (missing) | `/.claude/skills//SKILL.md` | Project-scoped skills | @@ -48,7 +48,7 @@ Global skills can symlink to shared agent skills for deduplication. ### Current discovery -Claude Code discovers skills at startup by scanning `~/.claude/skills/` and presents them in the system prompt as available slash commands. There is no project-level discovery, no registry, no search. +OK Code should treat `~/.okcode/skills/` as the canonical global skill directory while preserving read compatibility with legacy `~/.claude/skills/` installs. There is no project-level discovery, no registry, no search. ### Current invocation @@ -58,7 +58,7 @@ Skills are invoked via the `Skill` tool, which takes `skill: ""` and optio ## Design goals -1. **Two-tier scoping**: skills live at global (`~/.claude/skills/`) or project (`.claude/skills/`) scope, with clear precedence rules. +1. **Two-tier scoping**: skills live at global (`~/.okcode/skills/`, with legacy fallback from `~/.claude/skills/`) or project (`.claude/skills/`) scope, with clear precedence rules. 2. **Scaffold-first authoring**: `okcode skill create` (or UI equivalent) generates valid skill structure with frontmatter, required sections, and optional supplementary files. 3. **Discoverability**: skills can be browsed, searched, and imported from a registry (local directory, git repo, or future remote registry). 4. **Zero-config invocation**: existing `/skill-name` slash command convention continues to work; new skills are immediately available after creation. @@ -117,7 +117,8 @@ Skills are resolved with project scope taking precedence over global scope: ``` Resolution order: 1. /.claude/skills//SKILL.md (project scope) - 2. ~/.claude/skills//SKILL.md (global scope) + 2. ~/.okcode/skills//SKILL.md (canonical global scope) + 3. ~/.claude/skills//SKILL.md (legacy global fallback) ``` If the same skill name exists in both scopes, the project-scoped version wins. This allows projects to override or customize global skills. @@ -423,7 +424,7 @@ Recommended phasing: 2. Skill versioning or dependency resolution between skills. 3. Skill permissions or access control (all installed skills are available). 4. Skill marketplace or monetization. -5. Breaking backwards compatibility with existing `~/.claude/skills/` layout. +5. Breaking backwards compatibility with existing `~/.claude/skills/` layout; legacy installs should remain readable while `.okcode` becomes the canonical write target. 6. Auto-updating skills from remote sources. --- diff --git a/apps/server/src/skills/SkillService.ts b/apps/server/src/skills/SkillService.ts index 53c952b19..d673f0229 100644 --- a/apps/server/src/skills/SkillService.ts +++ b/apps/server/src/skills/SkillService.ts @@ -7,19 +7,26 @@ * @module SkillService */ import type { + SkillCatalogResult, SkillCreateResult, + SkillImportResult, + SkillInstallResult, SkillListResult, SkillReadResult, SkillSearchResult, } from "@okcode/contracts"; import { Effect, Layer, Schema, ServiceMap } from "effect"; import { + ensureSystemSkillsInstalled, + importSkill, + installBundledSkill, listSkills, readSkill, searchSkills, createSkill, deleteSkill, } from "@okcode/shared/skill"; +import { listBundledSkills } from "@okcode/shared/skillCatalog"; /** * SkillServiceError - Tagged error for skill service failures. @@ -41,6 +48,10 @@ export class SkillServiceError extends Schema.TaggedErrorClass Effect.Effect; + /** * List all installed skills. */ @@ -64,6 +75,8 @@ export interface SkillServiceShape { readonly description: string; readonly scope: "global" | "project"; readonly cwd?: string | undefined; + readonly tags?: readonly string[] | undefined; + readonly template?: "blank" | "docs-helper" | "automation-helper" | "review-helper" | undefined; }) => Effect.Effect; /** @@ -75,6 +88,36 @@ export interface SkillServiceShape { readonly cwd?: string | undefined; }) => Effect.Effect; + readonly install: (input: { + readonly id: + | "pdf" + | "spreadsheet" + | "doc" + | "playwright" + | "github" + | "skill-creator" + | "image-gen" + | "plugin-creator" + | "skill-installer" + | "openclaw-docs" + | "openai-docs" + | "anthropic-docs"; + readonly scope: "global" | "project"; + readonly cwd?: string | undefined; + }) => Effect.Effect; + + readonly uninstall: (input: { + readonly name: string; + readonly scope: "global" | "project"; + readonly cwd?: string | undefined; + }) => Effect.Effect; + + readonly importSkill: (input: { + readonly path: string; + readonly scope: "global" | "project"; + readonly cwd?: string | undefined; + }) => Effect.Effect; + /** * Search skills by query. */ @@ -91,19 +134,60 @@ export class SkillService extends ServiceMap.Service[number]) { + return { + name: entry.name, + scope: entry.scope, + description: entry.description, + tags: entry.tags, + path: entry.path, + catalogId: entry.catalogId, + origin: entry.origin, + system: entry.system, + mutable: entry.mutable, + supplementaryFiles: entry.supplementaryFiles, + }; +} + +const catalogEntries = listBundledSkills(); +ensureSystemSkillsInstalled(); + export const SkillServiceLive = Layer.succeed(SkillService, { + catalog: (input) => + Effect.try({ + try: () => { + const installed = listSkills(input.cwd); + return { + skills: catalogEntries.map((catalogSkill) => { + const installedEntry = installed.find( + (entry) => + entry.catalogId === catalogSkill.entry.id || entry.name === catalogSkill.skillName, + ); + return Object.assign({}, catalogSkill.entry, { + installed: Boolean(installedEntry), + installedScope: installedEntry?.scope ?? null, + path: installedEntry?.path ?? null, + catalogId: installedEntry?.catalogId ?? catalogSkill.entry.id, + origin: installedEntry?.origin ?? null, + drifted: false, + }); + }), + }; + }, + catch: (cause) => + new SkillServiceError({ + operation: "catalog", + detail: cause instanceof Error ? cause.message : String(cause), + cause: cause instanceof Error ? cause : undefined, + }), + }), + 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, - })), + skills: entries.map(toSkillEntry), }; }, catch: (cause) => @@ -128,6 +212,11 @@ export const SkillServiceLive = Layer.succeed(SkillService, { content: result.content.raw, path: result.path, tags: result.tags, + catalogId: result.catalogId, + origin: result.origin, + system: result.system, + mutable: result.mutable, + supplementaryFiles: result.supplementaryFiles, }; }, catch: (cause) => @@ -140,7 +229,14 @@ export const SkillServiceLive = Layer.succeed(SkillService, { create: (input) => Effect.try({ - try: () => createSkill(input.name, input.description, input.scope, input.cwd), + try: () => + createSkill( + input.name, + input.description, + input.scope, + { tags: input.tags, template: input.template }, + input.cwd, + ), catch: (cause) => new SkillServiceError({ operation: "create", @@ -149,6 +245,39 @@ export const SkillServiceLive = Layer.succeed(SkillService, { }), }), + install: (input) => + Effect.try({ + try: () => installBundledSkill(input.id, input.scope, input.cwd), + catch: (cause) => + new SkillServiceError({ + operation: "install", + detail: cause instanceof Error ? cause.message : String(cause), + cause: cause instanceof Error ? cause : undefined, + }), + }), + + uninstall: (input) => + Effect.try({ + try: () => deleteSkill(input.name, input.scope, input.cwd), + catch: (cause) => + new SkillServiceError({ + operation: "uninstall", + detail: cause instanceof Error ? cause.message : String(cause), + cause: cause instanceof Error ? cause : undefined, + }), + }), + + importSkill: (input) => + Effect.try({ + try: () => importSkill(input.path, input.scope, input.cwd), + catch: (cause) => + new SkillServiceError({ + operation: "import", + 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), @@ -165,13 +294,7 @@ export const SkillServiceLive = Layer.succeed(SkillService, { 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, - })), + skills: entries.map(toSkillEntry), }; }, catch: (cause) => diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index f31e48f92..1f103240b 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -1316,6 +1316,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* skillService.list(body); } + case WS_METHODS.skillCatalog: { + const body = stripRequestTag(request.body); + return yield* skillService.catalog(body); + } + case WS_METHODS.skillRead: { const body = stripRequestTag(request.body); return yield* skillService.read(body); @@ -1331,6 +1336,21 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* skillService.delete(body); } + case WS_METHODS.skillInstall: { + const body = stripRequestTag(request.body); + return yield* skillService.install(body); + } + + case WS_METHODS.skillUninstall: { + const body = stripRequestTag(request.body); + return yield* skillService.uninstall(body); + } + + case WS_METHODS.skillImport: { + const body = stripRequestTag(request.body); + return yield* skillService.importSkill(body); + } + case WS_METHODS.skillSearch: { const body = stripRequestTag(request.body); return yield* skillService.search(body); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1a1cb2c01..76d1d3d43 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -34,9 +34,14 @@ 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 { + skillCatalogQueryOptions, + skillListQueryOptions, + skillQueryKeys, +} from "~/lib/skillReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; +import { openFileReference } from "../fileOpen"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { clampCollapsedComposerCursor, @@ -44,6 +49,7 @@ import { collapseExpandedComposerCursor, detectComposerTrigger, expandCollapsedComposerCursor, + parseSkillManagementCommand, parseStandaloneComposerSlashCommand, replaceTextRange, } from "../composer-logic"; @@ -119,6 +125,7 @@ import { } from "./ui/dialog"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; +import { ensureNativeApi } from "../nativeApi"; import { Separator } from "./ui/separator"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { cn, randomUUID } from "~/lib/utils"; @@ -228,12 +235,20 @@ const isAcceptedDragType = (dataTransfer: DataTransfer) => const isDragTreePath = (dataTransfer: DataTransfer) => dataTransfer.types.includes("application/x-okcode-tree-path"); const SKILL_SUBCOMMAND_ITEMS: Extract[] = [ + { + id: "skill-sub:browse", + type: "skill-subcommand" as const, + subcommand: "browse", + label: "/skill browse", + description: "Open the skills library", + usage: "/skill browse", + }, { id: "skill-sub:create", type: "skill-subcommand" as const, subcommand: "create", label: "/skill create", - description: "Create a new skill with scaffold template", + description: "Create a new skill with a guided scaffold", usage: "/skill create ", }, { @@ -261,12 +276,20 @@ const SKILL_SUBCOMMAND_ITEMS: Extract", }, { - id: "skill-sub:delete", + id: "skill-sub:install", + type: "skill-subcommand" as const, + subcommand: "install", + label: "/skill install", + description: "Install a recommended skill", + usage: "/skill install ", + }, + { + id: "skill-sub:uninstall", type: "skill-subcommand" as const, - subcommand: "delete", - label: "/skill delete", + subcommand: "uninstall", + label: "/skill uninstall", description: "Remove an installed skill", - usage: "/skill delete ", + usage: "/skill uninstall ", }, { id: "skill-sub:import", @@ -1216,7 +1239,17 @@ export default function ChatView({ threadId }: ChatViewProps) { enabled: composerTriggerKind === "slash-skill" || composerTriggerKind === "slash-command", }), ); + const skillCatalogQuery = useQuery( + skillCatalogQueryOptions({ + cwd: gitCwd, + enabled: composerTriggerKind === "slash-skill", + }), + ); const installedSkills = useMemo(() => skillsQuery.data?.skills ?? [], [skillsQuery.data?.skills]); + const catalogSkills = useMemo( + () => skillCatalogQuery.data?.skills ?? [], + [skillCatalogQuery.data?.skills], + ); const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { @@ -1238,14 +1271,24 @@ export default function ChatView({ threadId }: ChatViewProps) { const subcommandItems: ComposerCommandItem[] = SKILL_SUBCOMMAND_ITEMS; const skillItems: ComposerCommandItem[] = installedSkills.map((skill) => ({ id: `skill:${skill.scope}:${skill.name}`, - type: "skill" as const, + type: "skill-installed" as const, skillName: skill.name, scope: skill.scope as "global" | "project", label: `/${skill.name}`, description: skill.description, tags: skill.tags, })); - return [...subcommandItems, ...skillItems]; + const catalogItems: ComposerCommandItem[] = catalogSkills + .filter((skill) => !skill.installed && !skill.system) + .map((skill) => ({ + id: `skill-catalog:${skill.id}`, + type: "skill-catalog" as const, + skillId: skill.id, + label: `/skill install ${skill.name.toLowerCase()}`, + description: skill.description, + tags: skill.tags, + })); + return [...subcommandItems, ...skillItems, ...catalogItems]; } // Filter subcommands and skills by query @@ -1262,15 +1305,32 @@ export default function ChatView({ threadId }: ChatViewProps) { ) .map((skill) => ({ id: `skill:${skill.scope}:${skill.name}`, - type: "skill" as const, + type: "skill-installed" as const, skillName: skill.name, scope: skill.scope as "global" | "project", label: `/${skill.name}`, description: skill.description, tags: skill.tags, })); + const catalogItems: ComposerCommandItem[] = catalogSkills + .filter( + (skill) => + !skill.installed && + !skill.system && + (skill.name.toLowerCase().includes(query) || + skill.description.toLowerCase().includes(query) || + skill.tags.some((tag) => tag.toLowerCase().includes(query))), + ) + .map((skill) => ({ + id: `skill-catalog:${skill.id}`, + type: "skill-catalog" as const, + skillId: skill.id, + label: `/skill install ${skill.name.toLowerCase()}`, + description: skill.description, + tags: skill.tags, + })); - return [...subcommandItems, ...skillItems]; + return [...subcommandItems, ...skillItems, ...catalogItems]; } if (composerTrigger.kind === "slash-command") { @@ -1314,7 +1374,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const skillItems: ComposerCommandItem[] = installedSkills.map((skill) => ({ id: `skill:${skill.scope}:${skill.name}`, - type: "skill" as const, + type: "skill-installed" as const, skillName: skill.name, scope: skill.scope as "global" | "project", label: `/${skill.name}`, @@ -1331,7 +1391,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (item.type === "slash-command") { return item.command.includes(query) || item.label.slice(1).includes(query); } - if (item.type === "skill") { + if (item.type === "skill-installed") { return item.skillName.includes(query) || item.description.toLowerCase().includes(query); } return false; @@ -1354,7 +1414,7 @@ export default function ChatView({ threadId }: ChatViewProps) { label: name, description: `${providerLabel} · ${slug}`, })); - }, [composerTrigger, searchableModelOptions, workspaceEntries, installedSkills]); + }, [catalogSkills, composerTrigger, searchableModelOptions, workspaceEntries, installedSkills]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () => @@ -2966,6 +3026,152 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerTrigger(null); return; } + const skillManagementCommand = + composerImagesForSend.length === 0 && sendableComposerTerminalContexts.length === 0 + ? parseSkillManagementCommand(trimmed) + : null; + if (skillManagementCommand) { + const api = ensureNativeApi(); + try { + if (skillManagementCommand.subcommand === "browse") { + void navigate({ to: "/skills", search: { create: undefined, name: undefined } }); + } else if (skillManagementCommand.subcommand === "create") { + void navigate({ + to: "/skills", + search: { + create: "1", + name: skillManagementCommand.argument + ? skillManagementCommand.argument.split(/\s+/)[0] + : undefined, + }, + }); + } else if (skillManagementCommand.subcommand === "list") { + const result = await api.skills.list(gitCwd ? { cwd: gitCwd } : {}); + toastManager.add({ + type: "info", + title: `Installed skills: ${result.skills.length}`, + description: + result.skills.length > 0 + ? result.skills + .slice(0, 8) + .map((skill) => `/${skill.name}`) + .join(", ") + : "No skills are currently installed for this context.", + }); + } else if (skillManagementCommand.subcommand === "search") { + if (!skillManagementCommand.argument) { + throw new Error("Usage: /skill search "); + } + const result = await api.skills.search({ + query: skillManagementCommand.argument, + ...(gitCwd ? { cwd: gitCwd } : {}), + }); + toastManager.add({ + type: "info", + title: `Matching skills: ${result.skills.length}`, + description: + result.skills.length > 0 + ? result.skills + .slice(0, 8) + .map((skill) => `/${skill.name}`) + .join(", ") + : "No skills matched that query.", + }); + } else if (skillManagementCommand.subcommand === "read") { + if (!skillManagementCommand.argument) { + throw new Error("Usage: /skill read "); + } + const result = await api.skills.read({ + name: skillManagementCommand.argument, + ...(gitCwd ? { cwd: gitCwd } : {}), + }); + await openFileReference({ + api, + cwd: gitCwd ?? undefined, + targetPath: result.path, + preferExternal: true, + openInViewer: () => undefined, + }); + } else if (skillManagementCommand.subcommand === "install") { + if (!skillManagementCommand.argument) { + throw new Error("Usage: /skill install "); + } + const [skillName, scopeFlag] = skillManagementCommand.argument.split(/\s+--scope\s+/); + if (!skillName) { + throw new Error("Usage: /skill install "); + } + const scope = scopeFlag?.trim() === "project" && gitCwd ? "project" : "global"; + const catalog = await api.skills.catalog(gitCwd ? { cwd: gitCwd } : {}); + const target = catalog.skills.find( + (skill) => + skill.id === skillName || skill.name.toLowerCase() === skillName.toLowerCase(), + ); + if (!target) { + throw new Error(`Bundled skill "${skillName}" not found`); + } + await api.skills.install({ + id: target.id, + scope, + ...(scope === "project" && gitCwd ? { cwd: gitCwd } : {}), + }); + void queryClient.invalidateQueries({ queryKey: skillQueryKeys.all }); + toastManager.add({ + type: "success", + title: `Installed /${target.name.toLowerCase()}`, + }); + } else if (skillManagementCommand.subcommand === "uninstall") { + if (!skillManagementCommand.argument) { + throw new Error("Usage: /skill uninstall "); + } + const [skillName, scopeFlag] = skillManagementCommand.argument.split(/\s+--scope\s+/); + if (!skillName) { + throw new Error("Usage: /skill uninstall "); + } + const scope = scopeFlag?.trim() === "project" && gitCwd ? "project" : "global"; + await api.skills.uninstall({ + name: skillName, + scope, + ...(scope === "project" && gitCwd ? { cwd: gitCwd } : {}), + }); + void queryClient.invalidateQueries({ queryKey: skillQueryKeys.all }); + toastManager.add({ + type: "success", + title: `Removed /${skillName}`, + }); + } else if (skillManagementCommand.subcommand === "import") { + if (!skillManagementCommand.argument) { + throw new Error("Usage: /skill import "); + } + const [importPath, scopeFlag] = skillManagementCommand.argument.split(/\s+--scope\s+/); + if (!importPath) { + throw new Error("Usage: /skill import "); + } + const scope = scopeFlag?.trim() === "project" && gitCwd ? "project" : "global"; + const result = await api.skills.import({ + path: importPath, + scope, + ...(scope === "project" && gitCwd ? { cwd: gitCwd } : {}), + }); + void queryClient.invalidateQueries({ queryKey: skillQueryKeys.all }); + toastManager.add({ + type: "success", + title: `Imported /${result.name}`, + }); + } + } catch (error) { + toastManager.add({ + type: "error", + title: "Skill command failed", + description: error instanceof Error ? error.message : String(error), + }); + } + promptRef.current = ""; + clearComposerDraftContent(activeThread.id); + setComposerHighlightedItemId(null); + setComposerCursor(0); + setComposerTrigger(null); + return; + } if (!hasSendableContent) { if (expiredTerminalContextCount > 0) { const toastCopy = buildExpiredTerminalContextToastCopy( @@ -4014,7 +4220,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } return; } - if (item.type === "skill") { + if (item.type === "skill-installed") { const replacement = `/${item.skillName} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, @@ -4032,7 +4238,45 @@ export default function ChatView({ threadId }: ChatViewProps) { } return; } + if (item.type === "skill-catalog") { + const replacement = `/skill install ${item.skillId} `; + 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") { + if (item.subcommand === "browse") { + void navigate({ to: "/skills", search: { create: undefined, name: undefined } }); + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + if (item.subcommand === "create") { + void navigate({ to: "/skills", search: { create: "1", name: undefined } }); + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } const replacement = `/skill ${item.subcommand} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, @@ -4061,6 +4305,7 @@ export default function ChatView({ threadId }: ChatViewProps) { [ applyPromptReplacement, handleInteractionModeChange, + navigate, onProviderModelSelect, resolveActiveComposerTrigger, ], @@ -4092,7 +4337,8 @@ export default function ChatView({ threadId }: ChatViewProps) { workspaceEntriesQuery.isLoading || workspaceEntriesQuery.isFetching)) || ((composerTriggerKind === "slash-skill" || composerTriggerKind === "slash-command") && - skillsQuery.isLoading); + (skillsQuery.isLoading || + (composerTriggerKind === "slash-skill" && skillCatalogQuery.isLoading))); const onPromptChange = useCallback( ( diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index fe7f28112..c4f1877c0 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -206,6 +206,17 @@ function CommandsView() { void navigate({ to: "/settings" }); }, }); + cmds.push({ + id: "nav-skills", + label: "Open skills", + keywords: ["skills", "slash commands", "catalog", "library"], + icon: SearchIcon, + group: "Navigation", + onSelect: () => { + closePalette(); + void navigate({ to: "/skills", search: { create: undefined, name: undefined } }); + }, + }); // ── Project quick-switch (inline, first 5) ── for (const project of projects.slice(0, 5)) { diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 7371fb42c..c74c3d29f 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -33,13 +33,21 @@ export type ComposerCommandItem = } | { id: string; - type: "skill"; + type: "skill-installed"; skillName: string; scope: "global" | "project"; label: string; description: string; tags: readonly string[]; } + | { + id: string; + type: "skill-catalog"; + skillId: string; + label: string; + description: string; + tags: readonly string[]; + } | { id: string; type: "skill-subcommand"; @@ -132,7 +140,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { model ) : null} - {props.item.type === "skill" ? ( + {props.item.type === "skill-installed" ? (
@@ -140,6 +148,14 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
) : null} + {props.item.type === "skill-catalog" ? ( +
+ + + install + +
+ ) : null} {props.item.type === "skill-subcommand" ? ( ) : null} @@ -147,7 +163,8 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { {props.item.label} {props.item.description} - {props.item.type === "skill" && props.item.tags.length > 0 ? ( + {(props.item.type === "skill-installed" || props.item.type === "skill-catalog") && + props.item.tags.length > 0 ? ( {props.item.tags.slice(0, 2).map((tag) => ( diff --git a/apps/web/src/components/skills/CreateSkillDialog.tsx b/apps/web/src/components/skills/CreateSkillDialog.tsx new file mode 100644 index 000000000..53d53cb41 --- /dev/null +++ b/apps/web/src/components/skills/CreateSkillDialog.tsx @@ -0,0 +1,213 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { skillQueryKeys } from "~/lib/skillReactQuery"; +import { ensureNativeApi } from "~/nativeApi"; +import { openInPreferredEditor } from "~/editorPreferences"; +import { toastManager } from "~/components/ui/toast"; + +type SkillTemplateKind = "blank" | "docs-helper" | "automation-helper" | "review-helper"; +type SkillScope = "global" | "project"; + +function validateSkillName(name: string): string | null { + if (name.length === 0) return "Name is required."; + if (name.length > 64) return "Name must be 64 characters or fewer."; + if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(name)) { + return "Use lowercase letters, numbers, and hyphens only."; + } + return null; +} + +export function CreateSkillDialog(props: { + open: boolean; + onOpenChange: (open: boolean) => void; + cwd: string | null; + initialName?: string; + onCreated?: (name: string) => void; +}) { + const [name, setName] = useState(props.initialName ?? ""); + const [description, setDescription] = useState(""); + const [scope, setScope] = useState(props.cwd ? "project" : "global"); + const [tagsValue, setTagsValue] = useState(""); + const [template, setTemplate] = useState("blank"); + const queryClient = useQueryClient(); + + useEffect(() => { + if (!props.open) return; + setName(props.initialName ?? ""); + setDescription(""); + setTagsValue(""); + setTemplate("blank"); + setScope(props.cwd ? "project" : "global"); + }, [props.cwd, props.initialName, props.open]); + + const nameError = useMemo(() => validateSkillName(name.trim()), [name]); + const resolvedPath = useMemo(() => { + if (!name.trim()) + return scope === "project" && props.cwd + ? `${props.cwd}/.claude/skills//SKILL.md` + : "~/.okcode/skills//SKILL.md"; + if (scope === "project" && props.cwd) + return `${props.cwd}/.claude/skills/${name.trim()}/SKILL.md`; + return `~/.okcode/skills/${name.trim()}/SKILL.md`; + }, [name, props.cwd, scope]); + + const createMutation = useMutation({ + mutationFn: async () => { + const api = ensureNativeApi(); + const result = await api.skills.create({ + name: name.trim(), + description: description.trim(), + scope, + cwd: scope === "project" ? (props.cwd ?? undefined) : undefined, + tags: tagsValue + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean), + template, + }); + try { + await openInPreferredEditor(api, result.path); + } catch { + // Opening an editor is best effort. + } + return result; + }, + onSuccess: (result) => { + void queryClient.invalidateQueries({ queryKey: skillQueryKeys.all }); + toastManager.add({ + type: "success", + title: `Created /${result.name}`, + description: "The skill scaffold was created and opened in your editor if available.", + }); + props.onOpenChange(false); + props.onCreated?.(result.name); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Unable to create skill", + description: error instanceof Error ? error.message : String(error), + }); + }, + }); + + return ( + + + + Create Skill + + Scaffold a reusable skill and make it available as a slash command immediately. + + + +
+ + setName(event.target.value.trimStart())} + placeholder="my-skill" + /> +

+ {nameError ?? "Lowercase letters, numbers, and hyphens only."} +

+
+
+ + setDescription(event.target.value)} + placeholder="Explain what this skill helps with" + /> +
+
+
+ + +
+
+ + +
+
+
+ + setTagsValue(event.target.value)} + placeholder="docs, automation, review" + /> +
+
+

Install path

+

{resolvedPath}

+
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/skills/SkillsPage.tsx b/apps/web/src/components/skills/SkillsPage.tsx new file mode 100644 index 000000000..a7f3f2a2d --- /dev/null +++ b/apps/web/src/components/skills/SkillsPage.tsx @@ -0,0 +1,619 @@ +import type { + SkillCatalogCategory, + SkillCatalogAnnotatedEntry, + SkillEntry, +} from "@okcode/contracts"; +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { + BookOpenIcon, + CheckIcon, + FileSpreadsheetIcon, + FileTextIcon, + GithubIcon, + ImageIcon, + MoreHorizontalIcon, + PencilRulerIcon, + PlayIcon, + PlusIcon, + PlugIcon, + SearchIcon, + SparklesIcon, +} from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Badge } from "~/components/ui/badge"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { SidebarInset, SidebarTrigger } from "~/components/ui/sidebar"; +import { toastManager } from "~/components/ui/toast"; +import { + skillCatalogQueryOptions, + skillListQueryOptions, + skillQueryKeys, +} from "~/lib/skillReactQuery"; +import { ensureNativeApi } from "~/nativeApi"; +import { openInPreferredEditor } from "~/editorPreferences"; +import { CreateSkillDialog } from "./CreateSkillDialog"; + +const FILTER_OPTIONS: ReadonlyArray<{ + value: "all" | SkillCatalogCategory | "personal"; + label: string; +}> = [ + { value: "all", label: "All" }, + { value: "recommended", label: "Recommended" }, + { value: "system", label: "System" }, + { value: "docs", label: "Docs" }, + { value: "personal", label: "Personal" }, +] as const; + +function skillIcon(icon: string) { + switch (icon) { + case "sheet": + return FileSpreadsheetIcon; + case "github": + return GithubIcon; + case "play": + return PlayIcon; + case "image": + return ImageIcon; + case "plug": + return PlugIcon; + case "pencil": + return PencilRulerIcon; + case "book-open": + return BookOpenIcon; + default: + return FileTextIcon; + } +} + +function SectionHeader(props: { title: string; description: string }) { + return ( +
+

{props.title}

+

{props.description}

+
+ ); +} + +function isCatalogSkill( + skill: SkillCatalogAnnotatedEntry | SkillEntry, +): skill is SkillCatalogAnnotatedEntry { + return "installed" in skill; +} + +function SkillLibraryTabs(props: { current: "skills" | "plugins" }) { + const navigate = useNavigate(); + return ( +
+ + +
+ ); +} + +function SkillDetailDialog(props: { + open: boolean; + onOpenChange: (open: boolean) => void; + skill: SkillCatalogAnnotatedEntry | SkillEntry | null; + cwd: string | null; + onInstallGlobal: (id: SkillCatalogAnnotatedEntry["id"]) => void; + onInstallProject: (id: SkillCatalogAnnotatedEntry["id"]) => void; + onDelete: (skill: SkillEntry) => void; +}) { + if (!props.skill) return null; + const skill = props.skill; + const isCatalog = isCatalogSkill(skill); + const mutable = isCatalog ? !skill.immutable && skill.installed : skill.mutable; + const pathValue = skill.path; + const slashName = isCatalog ? skill.name.toLowerCase().replace(/\s+/g, "-") : skill.name; + return ( + + + + {skill.name} + {skill.description} + + +
+ {("tags" in skill ? skill.tags : []).map((tag) => ( + + {tag} + + ))} +
+
+

Slash commands

+

/{slashName}

+

/skill read {slashName}

+
+ {pathValue ? ( +
+

Path

+

{pathValue}

+
+ ) : null} +
+ + {isCatalog && !skill.installed ? ( + <> + {props.cwd ? ( + + ) : null} + + + ) : !isCatalog && mutable ? ( + + ) : null} + +
+
+ ); +} + +function SkillCard(props: { + title: string; + description: string; + tags: readonly string[]; + scopeLabel?: string | undefined; + icon: string; + installed: boolean; + mutable: boolean; + onPrimaryAction: () => void; + onOpenDetail: () => void; + onOpenInEditor?: (() => void) | undefined; + onDelete?: (() => void) | undefined; +}) { + const Icon = skillIcon(props.icon); + return ( +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + props.onOpenDetail(); + } + }} + > +
+
+ +
+
+
+
+
+

{props.title}

+ {props.scopeLabel ? {props.scopeLabel} : null} +
+

{props.description}

+
+
+ {props.installed ? ( +
+ +
+ ) : ( + + )} + {props.installed && props.mutable ? ( + + event.stopPropagation()} + > + + + + {props.onOpenInEditor ? ( + { + event.stopPropagation(); + props.onOpenInEditor?.(); + }} + > + Open in editor + + ) : null} + {props.onDelete ? ( + { + event.stopPropagation(); + props.onDelete?.(); + }} + > + Remove + + ) : null} + + + ) : null} +
+
+ {props.tags.length > 0 ? ( +
+ {props.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+ ) : null} +
+
+
+ ); +} + +export function SkillsPage(props: { + cwd: string | null; + initialCreateOpen?: boolean | undefined; + initialName?: string | undefined; +}) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [searchValue, setSearchValue] = useState(""); + const [filter, setFilter] = useState<"all" | SkillCatalogCategory | "personal">("all"); + const [createOpen, setCreateOpen] = useState(Boolean(props.initialCreateOpen)); + const [prefillName, setPrefillName] = useState(props.initialName ?? ""); + const [detailSkill, setDetailSkill] = useState( + null, + ); + + useEffect(() => { + setCreateOpen(Boolean(props.initialCreateOpen)); + setPrefillName(props.initialName ?? ""); + }, [props.initialCreateOpen, props.initialName]); + + const catalogQuery = useQuery(skillCatalogQueryOptions({ cwd: props.cwd })); + const installedSkillsQuery = useQuery(skillListQueryOptions({ cwd: props.cwd })); + + const installMutation = useMutation({ + mutationFn: async (input: { + id: SkillCatalogAnnotatedEntry["id"]; + scope: "global" | "project"; + }) => { + const api = ensureNativeApi(); + const result = await api.skills.install({ + id: input.id, + scope: input.scope, + cwd: input.scope === "project" ? (props.cwd ?? undefined) : undefined, + }); + try { + await openInPreferredEditor(api, result.path); + } catch { + // Best effort. + } + return result; + }, + onSuccess: (result) => { + void queryClient.invalidateQueries({ queryKey: skillQueryKeys.all }); + toastManager.add({ + type: "success", + title: `Installed /${result.name}`, + description: "The skill is now active and available from slash commands.", + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Unable to install skill", + description: error instanceof Error ? error.message : String(error), + }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (input: { name: string; scope: "global" | "project" }) => { + const api = ensureNativeApi(); + return api.skills.uninstall({ + name: input.name, + scope: input.scope, + cwd: input.scope === "project" ? (props.cwd ?? undefined) : undefined, + }); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: skillQueryKeys.all }); + toastManager.add({ + type: "success", + title: "Removed skill", + description: "The skill is no longer available from slash commands.", + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Unable to remove skill", + description: error instanceof Error ? error.message : String(error), + }); + }, + }); + + const recommended = useMemo(() => { + return (catalogQuery.data?.skills ?? []).filter((skill) => !skill.system); + }, [catalogQuery.data?.skills]); + const systemSkills = useMemo(() => { + return (catalogQuery.data?.skills ?? []).filter((skill) => skill.system); + }, [catalogQuery.data?.skills]); + const personal = useMemo(() => { + return (installedSkillsQuery.data?.skills ?? []).filter((skill) => !skill.system); + }, [installedSkillsQuery.data?.skills]); + + const matchesSearch = (name: string, description: string, tags: readonly string[]) => { + const query = searchValue.trim().toLowerCase(); + if (!query) return true; + return ( + name.toLowerCase().includes(query) || + description.toLowerCase().includes(query) || + tags.some((tag) => tag.toLowerCase().includes(query)) + ); + }; + + const filteredRecommended = recommended.filter( + (skill) => + (filter === "all" || filter === "recommended" || filter === skill.category) && + matchesSearch(skill.name, skill.description, skill.tags), + ); + const filteredSystem = systemSkills.filter( + (skill) => + (filter === "all" || filter === "system" || filter === skill.category) && + matchesSearch(skill.name, skill.description, skill.tags), + ); + const filteredPersonal = personal.filter( + (skill) => + (filter === "all" || filter === "personal") && + matchesSearch(skill.name, skill.description, skill.tags), + ); + + return ( + +
+
+
+ + +
+
+
+
+
+
+

+ Make OK Code work your way +

+

+ Install recommended skills, keep system skills ready, and create your own + slash-command workflows. +

+
+ +
+
+
+ + setSearchValue(event.target.value)} + placeholder="Search skills" + className="pl-9" + /> +
+ +
+
+ +
+ +
+ {filteredRecommended.map((skill) => + (() => { + const skillPath = skill.path; + const installedScope = skill.installedScope; + return ( + + installMutation.mutate({ id: skill.id, scope: "global" }) + } + onOpenDetail={() => setDetailSkill(skill)} + {...(skillPath + ? { + onOpenInEditor: () => { + const api = ensureNativeApi(); + void openInPreferredEditor(api, skillPath); + }, + } + : {})} + {...(skill.installed && installedScope + ? { + onDelete: () => + deleteMutation.mutate({ + name: skill.name.toLowerCase().replace(/\s+/g, "-"), + scope: installedScope, + }), + } + : {})} + /> + ); + })(), + )} +
+
+ +
+ +
+ {filteredSystem.map((skill) => + (() => { + const skillPath = skill.path; + return ( + undefined} + onOpenDetail={() => setDetailSkill(skill)} + {...(skillPath + ? { + onOpenInEditor: () => { + const api = ensureNativeApi(); + void openInPreferredEditor(api, skillPath); + }, + } + : {})} + /> + ); + })(), + )} +
+
+ +
+ +
+ {filteredPersonal.map((skill) => ( + undefined} + onOpenDetail={() => setDetailSkill(skill)} + onOpenInEditor={() => { + const api = ensureNativeApi(); + void openInPreferredEditor(api, skill.path); + }} + onDelete={() => deleteMutation.mutate({ name: skill.name, scope: skill.scope })} + /> + ))} + {filteredPersonal.length === 0 ? ( +
+ No personal skills yet. Use Create or run{" "} + /skill create. +
+ ) : null} +
+
+
+
+ + { + setCreateOpen(open); + if (!open) { + void navigate({ to: "/skills", search: { create: undefined, name: undefined } }); + } + }} + cwd={props.cwd} + initialName={prefillName} + /> + { + if (!open) setDetailSkill(null); + }} + skill={detailSkill} + cwd={props.cwd} + onInstallGlobal={(id) => installMutation.mutate({ id, scope: "global" })} + onInstallProject={(id) => installMutation.mutate({ id, scope: "project" })} + onDelete={(skill) => deleteMutation.mutate({ name: skill.name, scope: skill.scope })} + /> +
+ ); +} diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 0eda7a9fa..e90dce0a2 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -6,6 +6,7 @@ import { detectComposerTrigger, expandCollapsedComposerCursor, isCollapsedCursorAdjacentToInlineToken, + parseSkillManagementCommand, parseStandaloneComposerSlashCommand, replaceTextRange, } from "./composer-logic"; @@ -256,3 +257,23 @@ describe("parseStandaloneComposerSlashCommand", () => { expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull(); }); }); + +describe("parseSkillManagementCommand", () => { + it("defaults bare /skill to browse", () => { + expect(parseSkillManagementCommand("/skill")).toEqual({ + subcommand: "browse", + argument: "", + }); + }); + + it("parses create command with an argument", () => { + expect(parseSkillManagementCommand("/skill create my-skill")).toEqual({ + subcommand: "create", + argument: "my-skill", + }); + }); + + it("rejects unknown subcommands", () => { + expect(parseSkillManagementCommand("/skill nope")).toBeNull(); + }); +}); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 6e290784c..2ee4f2c11 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -3,6 +3,20 @@ import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; export type ComposerTriggerKind = "path" | "slash-command" | "slash-model" | "slash-skill"; export type ComposerSlashCommand = "model" | "plan" | "chat" | "code" | "skill"; +export type SkillManagementSubcommand = + | "browse" + | "create" + | "list" + | "search" + | "read" + | "install" + | "uninstall" + | "import"; + +export interface ParsedSkillManagementCommand { + subcommand: SkillManagementSubcommand; + argument: string; +} export interface ComposerTrigger { kind: ComposerTriggerKind; @@ -280,6 +294,31 @@ export function parseStandaloneComposerSlashCommand( return "chat"; } +const SKILL_MANAGEMENT_COMMANDS = new Set([ + "browse", + "create", + "list", + "search", + "read", + "install", + "uninstall", + "import", +]); + +export function parseSkillManagementCommand(text: string): ParsedSkillManagementCommand | null { + const match = /^\/skill(?:\s+([a-z-]+))?(?:\s+(.+))?$/i.exec(text.trim()); + if (!match) return null; + const rawSubcommand = match[1]?.toLowerCase(); + if (!rawSubcommand) return { subcommand: "browse", argument: "" }; + if (!SKILL_MANAGEMENT_COMMANDS.has(rawSubcommand as SkillManagementSubcommand)) { + return null; + } + return { + subcommand: rawSubcommand as SkillManagementSubcommand, + argument: (match[2] ?? "").trim(), + }; +} + export function replaceTextRange( text: string, rangeStart: number, diff --git a/apps/web/src/lib/skillReactQuery.ts b/apps/web/src/lib/skillReactQuery.ts index 956ae4f1d..624bf7a52 100644 --- a/apps/web/src/lib/skillReactQuery.ts +++ b/apps/web/src/lib/skillReactQuery.ts @@ -1,14 +1,33 @@ -import type { SkillListResult, SkillSearchResult } from "@okcode/contracts"; +import type { SkillCatalogResult, SkillListResult, SkillSearchResult } from "@okcode/contracts"; import { queryOptions } from "@tanstack/react-query"; import { ensureNativeApi } from "~/nativeApi"; export const skillQueryKeys = { all: ["skills"] as const, + catalog: (cwd: string | null) => ["skills", "catalog", cwd] 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: [] }; +const EMPTY_SKILL_CATALOG_RESULT: SkillCatalogResult = { skills: [] }; + +export function skillCatalogQueryOptions(input: { + cwd: string | null; + enabled?: boolean; + staleTime?: number; +}) { + return queryOptions({ + queryKey: skillQueryKeys.catalog(input.cwd), + queryFn: async () => { + const api = ensureNativeApi(); + return api.skills.catalog(input.cwd ? { cwd: input.cwd } : {}); + }, + enabled: input.enabled !== false, + staleTime: input.staleTime ?? 30_000, + placeholderData: EMPTY_SKILL_CATALOG_RESULT, + }); +} export function skillListQueryOptions(input: { cwd: string | null; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 7100dd56a..657ed743b 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,6 +11,8 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' +import { Route as ChatPluginsRouteImport } from './routes/_chat.plugins' +import { Route as ChatSkillsRouteImport } from './routes/_chat.skills' import { Route as ChatSettingsRouteImport } from './routes/_chat.settings' import { Route as ChatPrReviewRouteImport } from './routes/_chat.pr-review' import { Route as ChatMergeConflictsRouteImport } from './routes/_chat.merge-conflicts' @@ -26,6 +28,16 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ path: '/', getParentRoute: () => ChatRoute, } as any) +const ChatPluginsRoute = ChatPluginsRouteImport.update({ + id: '/plugins', + path: '/plugins', + getParentRoute: () => ChatRoute, +} as any) +const ChatSkillsRoute = ChatSkillsRouteImport.update({ + id: '/skills', + path: '/skills', + getParentRoute: () => ChatRoute, +} as any) const ChatSettingsRoute = ChatSettingsRouteImport.update({ id: '/settings', path: '/settings', @@ -57,15 +69,19 @@ export interface FileRoutesByFullPath { '/$threadId': typeof ChatThreadIdRoute '/file-view': typeof ChatFileViewRoute '/merge-conflicts': typeof ChatMergeConflictsRoute + '/plugins': typeof ChatPluginsRoute '/pr-review': typeof ChatPrReviewRoute '/settings': typeof ChatSettingsRoute + '/skills': typeof ChatSkillsRoute } export interface FileRoutesByTo { '/$threadId': typeof ChatThreadIdRoute '/file-view': typeof ChatFileViewRoute '/merge-conflicts': typeof ChatMergeConflictsRoute + '/plugins': typeof ChatPluginsRoute '/pr-review': typeof ChatPrReviewRoute '/settings': typeof ChatSettingsRoute + '/skills': typeof ChatSkillsRoute '/': typeof ChatIndexRoute } export interface FileRoutesById { @@ -74,8 +90,10 @@ export interface FileRoutesById { '/_chat/$threadId': typeof ChatThreadIdRoute '/_chat/file-view': typeof ChatFileViewRoute '/_chat/merge-conflicts': typeof ChatMergeConflictsRoute + '/_chat/plugins': typeof ChatPluginsRoute '/_chat/pr-review': typeof ChatPrReviewRoute '/_chat/settings': typeof ChatSettingsRoute + '/_chat/skills': typeof ChatSkillsRoute '/_chat/': typeof ChatIndexRoute } export interface FileRouteTypes { @@ -85,15 +103,19 @@ export interface FileRouteTypes { | '/$threadId' | '/file-view' | '/merge-conflicts' + | '/plugins' | '/pr-review' | '/settings' + | '/skills' fileRoutesByTo: FileRoutesByTo to: | '/$threadId' | '/file-view' | '/merge-conflicts' + | '/plugins' | '/pr-review' | '/settings' + | '/skills' | '/' id: | '__root__' @@ -101,8 +123,10 @@ export interface FileRouteTypes { | '/_chat/$threadId' | '/_chat/file-view' | '/_chat/merge-conflicts' + | '/_chat/plugins' | '/_chat/pr-review' | '/_chat/settings' + | '/_chat/skills' | '/_chat/' fileRoutesById: FileRoutesById } @@ -133,6 +157,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatSettingsRouteImport parentRoute: typeof ChatRoute } + '/_chat/plugins': { + id: '/_chat/plugins' + path: '/plugins' + fullPath: '/plugins' + preLoaderRoute: typeof ChatPluginsRouteImport + parentRoute: typeof ChatRoute + } + '/_chat/skills': { + id: '/_chat/skills' + path: '/skills' + fullPath: '/skills' + preLoaderRoute: typeof ChatSkillsRouteImport + parentRoute: typeof ChatRoute + } '/_chat/pr-review': { id: '/_chat/pr-review' path: '/pr-review' @@ -168,8 +206,10 @@ interface ChatRouteChildren { ChatThreadIdRoute: typeof ChatThreadIdRoute ChatFileViewRoute: typeof ChatFileViewRoute ChatMergeConflictsRoute: typeof ChatMergeConflictsRoute + ChatPluginsRoute: typeof ChatPluginsRoute ChatPrReviewRoute: typeof ChatPrReviewRoute ChatSettingsRoute: typeof ChatSettingsRoute + ChatSkillsRoute: typeof ChatSkillsRoute ChatIndexRoute: typeof ChatIndexRoute } @@ -177,8 +217,10 @@ const ChatRouteChildren: ChatRouteChildren = { ChatThreadIdRoute: ChatThreadIdRoute, ChatFileViewRoute: ChatFileViewRoute, ChatMergeConflictsRoute: ChatMergeConflictsRoute, + ChatPluginsRoute: ChatPluginsRoute, ChatPrReviewRoute: ChatPrReviewRoute, ChatSettingsRoute: ChatSettingsRoute, + ChatSkillsRoute: ChatSkillsRoute, ChatIndexRoute: ChatIndexRoute, } diff --git a/apps/web/src/routes/_chat.plugins.tsx b/apps/web/src/routes/_chat.plugins.tsx new file mode 100644 index 000000000..67ca404af --- /dev/null +++ b/apps/web/src/routes/_chat.plugins.tsx @@ -0,0 +1,58 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; +import { Button } from "~/components/ui/button"; +import { SidebarInset, SidebarTrigger } from "~/components/ui/sidebar"; + +function PluginsRouteView() { + const navigate = useNavigate(); + return ( + +
+
+
+ +
+ + +
+
+
+
+
+

Plugins are next

+

+ The plugin tab is reserved for a fuller plugin-management experience. The skills + library is complete now. +

+ +
+
+
+
+ ); +} + +export const Route = createFileRoute("/_chat/plugins")({ + component: PluginsRouteView, +}); diff --git a/apps/web/src/routes/_chat.skills.tsx b/apps/web/src/routes/_chat.skills.tsx new file mode 100644 index 000000000..8b410b34f --- /dev/null +++ b/apps/web/src/routes/_chat.skills.tsx @@ -0,0 +1,25 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { SkillsPage } from "~/components/skills/SkillsPage"; +import { useStore } from "~/store"; + +function SkillsRouteView() { + const projects = useStore((state) => state.projects); + const cwd = useMemo(() => projects[0]?.cwd ?? null, [projects]); + const search = Route.useSearch(); + return ( + + ); +} + +export const Route = createFileRoute("/_chat/skills")({ + validateSearch: (search: Record) => ({ + create: search.create === "1" ? "1" : undefined, + name: typeof search.name === "string" ? search.name : undefined, + }), + component: SkillsRouteView, +}); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index eedfa25da..94c0950cd 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -268,9 +268,13 @@ export function createWsNativeApi(): NativeApi { }, skills: { list: (input) => transport.request(WS_METHODS.skillList, input ?? {}), + catalog: (input) => transport.request(WS_METHODS.skillCatalog, 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), + install: (input) => transport.request(WS_METHODS.skillInstall, input), + uninstall: (input) => transport.request(WS_METHODS.skillUninstall, input), + import: (input) => transport.request(WS_METHODS.skillImport, input), search: (input) => transport.request(WS_METHODS.skillSearch, input), }, contextMenu: { diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 7c9e92e21..225479791 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -14,3 +14,4 @@ export * from "./editor"; export * from "./project"; export * from "./environment"; export * from "./skill"; +export * from "./skillCatalog"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 9e840392d..a708297d5 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -85,11 +85,18 @@ import type { SkillListResult, SkillReadInput, SkillReadResult, + SkillCatalogInput, + SkillCatalogResult, SkillCreateInput, SkillCreateResult, SkillDeleteInput, + SkillInstallInput, + SkillInstallResult, + SkillImportInput, + SkillImportResult, SkillSearchInput, SkillSearchResult, + SkillUninstallInput, } from "./skill"; import type { ClientOrchestrationCommand, @@ -328,9 +335,13 @@ export interface NativeApi { }; skills: { list: (input?: SkillListInput) => Promise; + catalog: (input?: SkillCatalogInput) => Promise; read: (input: SkillReadInput) => Promise; create: (input: SkillCreateInput) => Promise; delete: (input: SkillDeleteInput) => Promise; + install: (input: SkillInstallInput) => Promise; + uninstall: (input: SkillUninstallInput) => Promise; + import: (input: SkillImportInput) => Promise; search: (input: SkillSearchInput) => Promise; }; contextMenu: { diff --git a/packages/contracts/src/skill.ts b/packages/contracts/src/skill.ts index 7b6b7d3e9..481daf06c 100644 --- a/packages/contracts/src/skill.ts +++ b/packages/contracts/src/skill.ts @@ -1,5 +1,6 @@ import { Schema } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas"; +import { BundledSkillId, SkillCatalogAnnotatedEntry, SkillOrigin } from "./skillCatalog"; // ── Skill Manifest (parsed from SKILL.md frontmatter) ─────────────── @@ -15,6 +16,8 @@ export const SkillManifest = Schema.Struct({ tags: Schema.optional(Schema.Array(Schema.String)), tools: Schema.optional(Schema.Array(Schema.String)), author: Schema.optional(TrimmedNonEmptyString), + origin: Schema.optional(SkillOrigin), + catalog_id: Schema.optional(BundledSkillId), }); export type SkillManifest = typeof SkillManifest.Type; @@ -26,6 +29,11 @@ export const SkillEntry = Schema.Struct({ description: Schema.String, tags: Schema.Array(Schema.String), path: TrimmedNonEmptyString, + catalogId: Schema.NullOr(TrimmedNonEmptyString), + origin: SkillOrigin, + system: Schema.Boolean, + mutable: Schema.Boolean, + supplementaryFiles: Schema.Array(Schema.String), }); export type SkillEntry = typeof SkillEntry.Type; @@ -63,6 +71,11 @@ export const SkillReadResult = Schema.Struct({ content: Schema.String, path: TrimmedNonEmptyString, tags: Schema.Array(Schema.String), + catalogId: Schema.NullOr(TrimmedNonEmptyString), + origin: SkillOrigin, + system: Schema.Boolean, + mutable: Schema.Boolean, + supplementaryFiles: Schema.Array(Schema.String), }); export type SkillReadResult = typeof SkillReadResult.Type; @@ -71,6 +84,10 @@ export const SkillCreateInput = Schema.Struct({ description: Schema.String, scope: SkillScope, cwd: Schema.optional(TrimmedNonEmptyString), + tags: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + template: Schema.optional( + Schema.Literals(["blank", "docs-helper", "automation-helper", "review-helper"]), + ), }); export type SkillCreateInput = typeof SkillCreateInput.Type; @@ -97,3 +114,46 @@ export const SkillSearchResult = Schema.Struct({ skills: Schema.Array(SkillEntry), }); export type SkillSearchResult = typeof SkillSearchResult.Type; + +export const SkillCatalogInput = Schema.Struct({ + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type SkillCatalogInput = typeof SkillCatalogInput.Type; + +export const SkillCatalogResult = Schema.Struct({ + skills: Schema.Array(SkillCatalogAnnotatedEntry), +}); +export type SkillCatalogResult = typeof SkillCatalogResult.Type; + +export const SkillInstallInput = Schema.Struct({ + id: BundledSkillId, + scope: SkillScope, + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type SkillInstallInput = typeof SkillInstallInput.Type; + +export const SkillInstallResult = Schema.Struct({ + name: TrimmedNonEmptyString, + path: TrimmedNonEmptyString, +}); +export type SkillInstallResult = typeof SkillInstallResult.Type; + +export const SkillUninstallInput = Schema.Struct({ + name: TrimmedNonEmptyString, + scope: SkillScope, + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type SkillUninstallInput = typeof SkillUninstallInput.Type; + +export const SkillImportInput = Schema.Struct({ + path: TrimmedNonEmptyString, + scope: SkillScope, + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type SkillImportInput = typeof SkillImportInput.Type; + +export const SkillImportResult = Schema.Struct({ + name: TrimmedNonEmptyString, + path: TrimmedNonEmptyString, +}); +export type SkillImportResult = typeof SkillImportResult.Type; diff --git a/packages/contracts/src/skillCatalog.ts b/packages/contracts/src/skillCatalog.ts new file mode 100644 index 000000000..54a33b9b9 --- /dev/null +++ b/packages/contracts/src/skillCatalog.ts @@ -0,0 +1,72 @@ +import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas"; + +export const SkillCatalogCategory = Schema.Literals([ + "recommended", + "system", + "docs", + "automation", + "devtools", + "custom", +]); +export type SkillCatalogCategory = typeof SkillCatalogCategory.Type; + +export const BundledSkillId = Schema.Literals([ + "pdf", + "spreadsheet", + "doc", + "playwright", + "github", + "skill-creator", + "image-gen", + "plugin-creator", + "skill-installer", + "openclaw-docs", + "openai-docs", + "anthropic-docs", +]); +export type BundledSkillId = typeof BundledSkillId.Type; + +export const SkillOrigin = Schema.Literals(["bundled", "custom", "imported"]); +export type SkillOrigin = typeof SkillOrigin.Type; + +export const SkillCatalogInstallScope = Schema.Literals(["global", "project"]); +export type SkillCatalogInstallScope = typeof SkillCatalogInstallScope.Type; + +export const SkillCatalogEntry = Schema.Struct({ + id: BundledSkillId, + name: TrimmedNonEmptyString, + description: Schema.String, + category: SkillCatalogCategory, + tags: Schema.Array(Schema.String), + icon: TrimmedNonEmptyString, + installScopeDefault: SkillCatalogInstallScope, + system: Schema.Boolean, + recommended: Schema.Boolean, + immutable: Schema.Boolean, + sourceType: Schema.Literal("bundled"), + sourceRef: TrimmedNonEmptyString, +}); +export type SkillCatalogEntry = typeof SkillCatalogEntry.Type; + +export const SkillCatalogAnnotatedEntry = Schema.Struct({ + id: BundledSkillId, + name: TrimmedNonEmptyString, + description: Schema.String, + category: SkillCatalogCategory, + tags: Schema.Array(Schema.String), + icon: TrimmedNonEmptyString, + installScopeDefault: SkillCatalogInstallScope, + system: Schema.Boolean, + recommended: Schema.Boolean, + immutable: Schema.Boolean, + sourceType: Schema.Literal("bundled"), + sourceRef: TrimmedNonEmptyString, + installed: Schema.Boolean, + installedScope: Schema.NullOr(SkillCatalogInstallScope), + path: Schema.NullOr(Schema.String), + catalogId: Schema.NullOr(TrimmedNonEmptyString), + origin: Schema.NullOr(SkillOrigin), + drifted: Schema.Boolean, +}); +export type SkillCatalogAnnotatedEntry = typeof SkillCatalogAnnotatedEntry.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 439c5935d..a376357cd 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -67,10 +67,14 @@ import { OpenInEditorInput } from "./editor"; import { ServerConfigUpdatedPayload } from "./server"; import { SkillListInput, + SkillCatalogInput, SkillReadInput, SkillCreateInput, SkillDeleteInput, + SkillInstallInput, + SkillImportInput, SkillSearchInput, + SkillUninstallInput, } from "./skill"; // ── WebSocket RPC Method Names ─────────────────────────────────────── @@ -128,9 +132,13 @@ export const WS_METHODS = { // Skill methods skillList: "skill.list", + skillCatalog: "skill.catalog", skillRead: "skill.read", skillCreate: "skill.create", skillDelete: "skill.delete", + skillInstall: "skill.install", + skillUninstall: "skill.uninstall", + skillImport: "skill.import", skillSearch: "skill.search", // Server meta @@ -227,9 +235,13 @@ const WebSocketRequestBody = Schema.Union([ // Skill methods tagRequestBody(WS_METHODS.skillList, SkillListInput), + tagRequestBody(WS_METHODS.skillCatalog, SkillCatalogInput), tagRequestBody(WS_METHODS.skillRead, SkillReadInput), tagRequestBody(WS_METHODS.skillCreate, SkillCreateInput), tagRequestBody(WS_METHODS.skillDelete, SkillDeleteInput), + tagRequestBody(WS_METHODS.skillInstall, SkillInstallInput), + tagRequestBody(WS_METHODS.skillUninstall, SkillUninstallInput), + tagRequestBody(WS_METHODS.skillImport, SkillImportInput), tagRequestBody(WS_METHODS.skillSearch, SkillSearchInput), // Server meta diff --git a/packages/shared/package.json b/packages/shared/package.json index 9faf4d23b..2f0b4010f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -43,6 +43,10 @@ "./skill": { "types": "./src/skill.ts", "import": "./src/skill.ts" + }, + "./skillCatalog": { + "types": "./src/skillCatalog.ts", + "import": "./src/skillCatalog.ts" } }, "scripts": { diff --git a/packages/shared/src/skill.test.ts b/packages/shared/src/skill.test.ts new file mode 100644 index 000000000..2b7c79826 --- /dev/null +++ b/packages/shared/src/skill.test.ts @@ -0,0 +1,171 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + ensureSystemSkillsInstalled, + globalSkillsDir, + installBundledSkill, + legacyGlobalSkillsDir, + listSkills, + readSkill, + skillExists, +} from "./skill"; +import { listBundledSkillAssetPaths, readBundledSkillMarkdown } from "./skillCatalog"; + +function writeSkill(baseDir: string, name: string, description: string) { + const skillDir = path.join(baseDir, name); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + [ + "---", + `name: ${name}`, + `description: ${description}`, + "tags:", + " - test", + "---", + "", + `# ${name}`, + "", + "## When to use this skill", + "", + "- Use this in tests.", + "", + "## What this skill does", + "", + `- ${description}`, + "", + "## Implementation", + "", + "- Test helper implementation.", + "", + "## Best practices", + "", + "- Keep fixtures small.", + "", + ].join("\n"), + "utf-8", + ); +} + +describe("skill storage", () => { + let tempRoot = ""; + let tempHome = ""; + let projectRoot = ""; + const originalHome = process.env.HOME; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "okcode-skill-")); + tempHome = path.join(tempRoot, "home"); + projectRoot = path.join(tempRoot, "project"); + fs.mkdirSync(tempHome, { recursive: true }); + fs.mkdirSync(projectRoot, { recursive: true }); + process.env.HOME = tempHome; + }); + + afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it("uses ~/.okcode/skills as the canonical global directory", () => { + expect(globalSkillsDir()).toBe(path.join(tempHome, ".okcode", "skills")); + expect(legacyGlobalSkillsDir()).toBe(path.join(tempHome, ".claude", "skills")); + }); + + it("resolves precedence as project, then ~/.okcode, then ~/.claude", () => { + writeSkill(path.join(legacyGlobalSkillsDir()), "legacy-only", "Legacy skill"); + writeSkill(path.join(legacyGlobalSkillsDir()), "shared", "Legacy shared skill"); + writeSkill(path.join(globalSkillsDir()), "canonical-only", "Canonical skill"); + writeSkill(path.join(globalSkillsDir()), "shared", "Canonical shared skill"); + writeSkill(path.join(projectRoot, ".claude", "skills"), "project-only", "Project skill"); + writeSkill(path.join(projectRoot, ".claude", "skills"), "shared", "Project shared skill"); + + const skills = listSkills(projectRoot); + expect(skills.map((skill) => skill.name)).toEqual([ + "canonical-only", + "legacy-only", + "project-only", + "shared", + ]); + + expect(skills.find((skill) => skill.name === "shared")).toMatchObject({ + scope: "project", + description: "Project shared skill", + }); + expect(skills.find((skill) => skill.name === "canonical-only")).toMatchObject({ + scope: "global", + description: "Canonical skill", + }); + expect(skills.find((skill) => skill.name === "legacy-only")).toMatchObject({ + scope: "global", + description: "Legacy skill", + }); + }); + + it("prefers ~/.okcode over ~/.claude when reading a global skill", () => { + writeSkill(path.join(legacyGlobalSkillsDir()), "shared", "Legacy shared skill"); + writeSkill(path.join(globalSkillsDir()), "shared", "Canonical shared skill"); + + const skill = readSkill("shared"); + expect(skill).not.toBeNull(); + expect(skill?.description).toBe("Canonical shared skill"); + expect(skill?.path).toBe(path.join(globalSkillsDir(), "shared", "SKILL.md")); + }); + + it("does not silently fall back to ~/.claude when the canonical ~/.okcode directory exists", () => { + fs.mkdirSync(path.join(globalSkillsDir(), "broken"), { recursive: true }); + writeSkill(path.join(legacyGlobalSkillsDir()), "broken", "Legacy fallback skill"); + + expect(readSkill("broken")).toBeNull(); + expect(skillExists("broken")).toEqual({ exists: false }); + expect(listSkills().some((skill) => skill.name === "broken")).toBe(false); + }); + + it("installs bundled recommended skills into ~/.okcode/skills", () => { + const installed = installBundledSkill("pdf", "global"); + + expect(installed.path).toBe(path.join(globalSkillsDir(), "pdf", "SKILL.md")); + expect(fs.existsSync(installed.path)).toBe(true); + expect(fs.readFileSync(installed.path, "utf-8")).toContain("catalog_id: pdf"); + expect(fs.existsSync(path.join(legacyGlobalSkillsDir(), "pdf", "SKILL.md"))).toBe(false); + }); + + it("bootstraps system skills into ~/.okcode/skills only", () => { + ensureSystemSkillsInstalled(); + + expect(fs.existsSync(path.join(globalSkillsDir(), "skill-creator", "SKILL.md"))).toBe(true); + expect(fs.existsSync(path.join(legacyGlobalSkillsDir(), "skill-creator", "SKILL.md"))).toBe( + false, + ); + }); +}); + +describe("bundled recommended skill assets", () => { + const recommendedSkills = ["pdf", "spreadsheet", "doc", "playwright", "github"] as const; + + it("materializes each recommended skill as a file-backed asset", () => { + for (const assetPath of listBundledSkillAssetPaths()) { + expect(fs.existsSync(assetPath)).toBe(true); + } + }); + + it("ships full markdown for each recommended skill", () => { + for (const skillId of recommendedSkills) { + const markdown = readBundledSkillMarkdown(skillId); + expect(markdown).toContain(`catalog_id: ${skillId}`); + expect(markdown).toContain("origin: bundled"); + expect(markdown).toContain("# "); + expect(markdown).toContain("## When to use this skill"); + expect(markdown).toContain("## What this skill does"); + expect(markdown).toContain("## Implementation"); + expect(markdown).toContain("## Best practices"); + expect(markdown.trim().length).toBeGreaterThan(400); + } + }); +}); diff --git a/packages/shared/src/skill.ts b/packages/shared/src/skill.ts index f9ee631eb..cc83eca64 100644 --- a/packages/shared/src/skill.ts +++ b/packages/shared/src/skill.ts @@ -1,6 +1,11 @@ import * as path from "node:path"; import * as fs from "node:fs"; import * as os from "node:os"; +import type { BundledSkillId } from "@okcode/contracts"; +import { getBundledSkillById, listBundledSkills, readBundledSkillFiles } from "./skillCatalog"; + +export type SkillOrigin = "bundled" | "custom" | "imported"; +export type SkillTemplateKind = "blank" | "docs-helper" | "automation-helper" | "review-helper"; // ── Types ──────────────────────────────────────────────────────────── @@ -13,6 +18,8 @@ export interface SkillManifest { tags?: string[]; tools?: string[]; author?: string; + origin?: SkillOrigin; + catalog_id?: string; } export interface SkillEntry { @@ -23,6 +30,10 @@ export interface SkillEntry { path: string; dir: string; supplementaryFiles: string[]; + origin: SkillOrigin; + catalogId: string | null; + system: boolean; + mutable: boolean; } export interface SkillContent { @@ -37,6 +48,84 @@ export interface SkillValidationResult { warnings: string[]; } +function toSkillOrigin(value: unknown): SkillOrigin | undefined { + return value === "bundled" || value === "custom" || value === "imported" ? value : undefined; +} + +function serializeFrontmatter(manifest: SkillManifest): string { + const lines: string[] = ["---", `name: ${manifest.name}`, `description: ${manifest.description}`]; + if (manifest.catalog_id) lines.push(`catalog_id: ${manifest.catalog_id}`); + if (manifest.origin) lines.push(`origin: ${manifest.origin}`); + if (manifest.version) lines.push(`version: ${manifest.version}`); + if (manifest.author) lines.push(`author: ${manifest.author}`); + if (manifest.scope) lines.push(`scope: ${manifest.scope}`); + if (manifest.tags && manifest.tags.length > 0) { + lines.push("tags:"); + for (const tag of manifest.tags) lines.push(` - ${tag}`); + } else { + lines.push("tags: []"); + } + if (manifest.triggers && manifest.triggers.length > 0) { + lines.push("triggers:"); + for (const trigger of manifest.triggers) lines.push(` - ${trigger}`); + } + if (manifest.tools && manifest.tools.length > 0) { + lines.push("tools:"); + for (const tool of manifest.tools) lines.push(` - ${tool}`); + } + lines.push("---"); + return `${lines.join("\n")}\n`; +} + +function resolveOrigin(manifest: SkillManifest): SkillOrigin { + if (manifest.origin) return manifest.origin; + if (manifest.catalog_id) return "bundled"; + return "custom"; +} + +function resolveScopeBaseDir(scope: "global" | "project", projectRoot?: string): string { + return scope === "project" && projectRoot ? projectSkillsDir(projectRoot) : globalSkillsDir(); +} + +function getSupplementaryFiles(skillDir: string): string[] { + 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 + } + return supplementaryFiles; +} + +function buildSkillEntry(input: { + manifest: SkillManifest; + scope: "global" | "project"; + mdPath: string; + dir: string; +}): SkillEntry { + const bundled = input.manifest.catalog_id + ? getBundledSkillById(input.manifest.catalog_id as BundledSkillId) + : undefined; + return { + name: input.manifest.name, + scope: input.scope, + description: input.manifest.description, + tags: input.manifest.tags ?? [], + path: input.mdPath, + dir: input.dir, + supplementaryFiles: getSupplementaryFiles(input.dir), + origin: resolveOrigin(input.manifest), + catalogId: input.manifest.catalog_id ?? null, + system: bundled?.entry.system ?? false, + mutable: !(bundled?.entry.immutable ?? false), + }; +} + // ── Frontmatter parsing ────────────────────────────────────────────── /** @@ -133,6 +222,7 @@ export function parseSkillFrontmatter(raw: string): { */ export function parseSkillContent(raw: string, fallbackName: string): SkillContent { const { frontmatter, body } = parseSkillFrontmatter(raw); + const origin = toSkillOrigin(frontmatter.origin); const manifest: SkillManifest = { name: typeof frontmatter.name === "string" ? frontmatter.name : fallbackName, @@ -145,6 +235,8 @@ export function parseSkillContent(raw: string, fallbackName: string): SkillConte ...(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 } : {}), + ...(origin ? { origin } : {}), + ...(typeof frontmatter.catalog_id === "string" ? { catalog_id: frontmatter.catalog_id } : {}), }; return { manifest, body, raw }; @@ -199,6 +291,10 @@ export function validateSkillDirectory(skillDir: string): SkillValidationResult // ── Storage paths ──────────────────────────────────────────────────── export function globalSkillsDir(): string { + return path.join(os.homedir(), ".okcode", "skills"); +} + +export function legacyGlobalSkillsDir(): string { return path.join(os.homedir(), ".claude", "skills"); } @@ -230,29 +326,7 @@ function scanSkillsDirectory(baseDir: string, scope: "global" | "project"): Skil 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, - }); + entries.push(buildSkillEntry({ manifest, scope, mdPath, dir: skillDir })); } catch { // Skip skills that can't be parsed } @@ -269,13 +343,27 @@ function scanSkillsDirectory(baseDir: string, scope: "global" | "project"): Skil */ export function listSkills(projectRoot?: string): SkillEntry[] { const globalEntries = scanSkillsDirectory(globalSkillsDir(), "global"); + const legacyGlobalEntries = scanSkillsDirectory(legacyGlobalSkillsDir(), "global"); const projectEntries = projectRoot ? scanSkillsDirectory(projectSkillsDir(projectRoot), "project") : []; - // Project-scoped skills override global skills with the same name + const canonicalGlobalNames = new Set(); + try { + for (const entry of fs.readdirSync(globalSkillsDir(), { withFileTypes: true })) { + if (entry.isDirectory()) canonicalGlobalNames.add(entry.name); + } + } catch { + // Ignore unreadable canonical global directory. + } + + // Project-scoped skills override canonical global, which overrides legacy global. const nameSet = new Set(projectEntries.map((e) => e.name)); - const merged = [...projectEntries, ...globalEntries.filter((e) => !nameSet.has(e.name))]; + const merged = [ + ...projectEntries, + ...globalEntries.filter((e) => !nameSet.has(e.name)), + ...legacyGlobalEntries.filter((e) => !nameSet.has(e.name) && !canonicalGlobalNames.has(e.name)), + ]; return merged.toSorted((a, b) => a.name.localeCompare(b.name)); } @@ -290,52 +378,56 @@ export function readSkill( 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; - + const tryReadCandidate = (candidate: { + dir: string; + mdPath: string; + scope: "global" | "project"; + }): (SkillEntry & { content: SkillContent }) | null => { try { + if (!fs.existsSync(candidate.mdPath)) return null; 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, + ...buildSkillEntry({ + manifest: content.manifest, + scope: candidate.scope, + mdPath: candidate.mdPath, + dir: candidate.dir, + }), content, }; } catch { - continue; + return null; } + }; + + if (projectRoot) { + const projectDir = path.join(projectSkillsDir(projectRoot), name); + if (fs.existsSync(projectDir)) { + return tryReadCandidate({ + dir: projectDir, + mdPath: skillMdPath(projectDir), + scope: "project", + }); + } + } + + const canonicalGlobalDir = path.join(globalSkillsDir(), name); + if (fs.existsSync(canonicalGlobalDir)) { + return tryReadCandidate({ + dir: canonicalGlobalDir, + mdPath: skillMdPath(canonicalGlobalDir), + scope: "global", + }); + } + + const legacyGlobalDir = path.join(legacyGlobalSkillsDir(), name); + if (fs.existsSync(legacyGlobalDir)) { + return tryReadCandidate({ + dir: legacyGlobalDir, + mdPath: skillMdPath(legacyGlobalDir), + scope: "global", + }); } return null; @@ -380,13 +472,22 @@ export function skillExists( ): { exists: boolean; scope?: "global" | "project" } { if (projectRoot) { const projectDir = path.join(projectSkillsDir(projectRoot), name); - if (fs.existsSync(skillMdPath(projectDir))) { - return { exists: true, scope: "project" }; + if (fs.existsSync(projectDir)) { + return fs.existsSync(skillMdPath(projectDir)) + ? { exists: true, scope: "project" } + : { exists: false }; } } const globalDir = path.join(globalSkillsDir(), name); - if (fs.existsSync(skillMdPath(globalDir))) { + if (fs.existsSync(globalDir)) { + return fs.existsSync(skillMdPath(globalDir)) + ? { exists: true, scope: "global" } + : { exists: false }; + } + + const legacyGlobalDir = path.join(legacyGlobalSkillsDir(), name); + if (fs.existsSync(skillMdPath(legacyGlobalDir))) { return { exists: true, scope: "global" }; } @@ -395,19 +496,65 @@ export function skillExists( // ── Scaffold template ──────────────────────────────────────────────── -export function generateSkillTemplate(name: string, description: string): string { - const titleCase = name +function titleCaseName(name: string): string { + return name .split("-") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); +} - return `--- -name: ${name} -description: ${description} -tags: [] ---- - -# ${titleCase} — Claude Code Skill +function generateSkillTemplateContent( + name: string, + description: string, + options?: { + tags?: readonly string[]; + template?: SkillTemplateKind; + origin?: SkillOrigin; + catalogId?: string; + }, +): string { + const template = options?.template ?? "blank"; + const titleCase = titleCaseName(name); + const manifest: SkillManifest = { + name, + description, + tags: [...(options?.tags ?? [])], + origin: options?.origin ?? "custom", + ...(options?.catalogId ? { catalog_id: options.catalogId } : {}), + }; + const whatItDoes = + template === "docs-helper" + ? [ + "Summarize the relevant documentation accurately.", + "Link advice to source material when possible.", + ] + : template === "automation-helper" + ? [ + "Automate repetitive workflows with clear prerequisites.", + "Favor deterministic commands over manual steps.", + ] + : template === "review-helper" + ? [ + "Review changes with emphasis on bugs, regressions, and missing validation.", + "Present findings before summary.", + ] + : [description]; + const implementation = + template === "docs-helper" + ? ["Inspect the relevant docs first.", "Differentiate documented facts from inference."] + : template === "automation-helper" + ? ["Check available tools.", "Prefer repeatable scripts and safe defaults."] + : template === "review-helper" + ? ["Inspect the affected code paths.", "Call out severity and missing tests."] + : ["TODO: Add step-by-step instructions, commands, code examples."]; + const bestPractices = + template === "review-helper" + ? ["Keep findings specific and actionable.", "Avoid speculative issues without evidence."] + : ["TODO: Add dos and don'ts"]; + + return `${serializeFrontmatter(manifest)} + +# ${titleCase} ## When to use this skill @@ -415,18 +562,138 @@ tags: [] ## What this skill does -${description} +${whatItDoes.map((item) => `- ${item}`).join("\n")} ## Implementation -TODO: Add step-by-step instructions, commands, code examples. +${implementation.map((item) => `- ${item}`).join("\n")} ## Best practices -- TODO: Add dos and don'ts +${bestPractices.map((item) => `- ${item}`).join("\n")} `; } +function copyDirectory(sourceDir: string, targetDir: string) { + fs.mkdirSync(targetDir, { recursive: true }); + const entries = fs.readdirSync(sourceDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith(".")) continue; + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + if (entry.isDirectory()) { + copyDirectory(sourcePath, targetPath); + continue; + } + fs.copyFileSync(sourcePath, targetPath); + } +} + +function writeSkillDirectory(input: { + skillDir: string; + content: string; + supplementaryFiles?: Readonly> | undefined; +}) { + fs.mkdirSync(input.skillDir, { recursive: true }); + fs.writeFileSync(skillMdPath(input.skillDir), input.content, "utf-8"); + for (const [fileName, fileContent] of Object.entries(input.supplementaryFiles ?? {})) { + fs.writeFileSync(path.join(input.skillDir, fileName), fileContent, "utf-8"); + } +} + +export function installBundledSkill( + id: BundledSkillId, + scope: "global" | "project", + projectRoot?: string, +): { path: string; name: string } { + const bundledSkill = getBundledSkillById(id); + if (!bundledSkill) { + throw new Error(`Bundled skill "${id}" not found`); + } + const baseDir = resolveScopeBaseDir(scope, projectRoot); + const skillDir = path.join(baseDir, bundledSkill.skillName); + const mdPath = skillMdPath(skillDir); + if (fs.existsSync(mdPath)) { + const existing = readSkill(bundledSkill.skillName, projectRoot); + if (existing?.system) { + return { path: mdPath, name: bundledSkill.skillName }; + } + throw new Error(`Skill "${bundledSkill.skillName}" already exists at ${scope} scope`); + } + const bundledFiles = readBundledSkillFiles(id); + writeSkillDirectory({ + skillDir, + content: bundledFiles.skillMd, + supplementaryFiles: bundledFiles.supplementaryFiles, + }); + return { path: mdPath, name: bundledSkill.skillName }; +} + +export function ensureSystemSkillsInstalled(): void { + for (const bundledSkill of listBundledSkills()) { + if (!bundledSkill.entry.system) continue; + const targetDir = path.join(globalSkillsDir(), bundledSkill.skillName); + const targetMdPath = skillMdPath(targetDir); + if (!fs.existsSync(targetMdPath)) { + const bundledFiles = readBundledSkillFiles(bundledSkill.entry.id); + writeSkillDirectory({ + skillDir: targetDir, + content: bundledFiles.skillMd, + supplementaryFiles: bundledFiles.supplementaryFiles, + }); + continue; + } + try { + const currentRaw = fs.readFileSync(targetMdPath, "utf-8"); + const current = parseSkillContent(currentRaw, bundledSkill.skillName); + if ( + current.manifest.catalog_id === bundledSkill.entry.id && + resolveOrigin(current.manifest) === "bundled" + ) { + continue; + } + } catch { + // Fall through to preserve existing file. + } + } +} + +export function importSkill( + sourcePath: string, + scope: "global" | "project", + projectRoot?: string, +): { path: string; name: string } { + const sourceDir = path.resolve(sourcePath); + const validation = validateSkillDirectory(sourceDir); + if (!validation.valid) { + throw new Error(validation.errors.join(", ")); + } + const raw = fs.readFileSync(skillMdPath(sourceDir), "utf-8"); + const parsed = parseSkillContent(raw, path.basename(sourceDir)); + const nameValidation = validateSkillName(parsed.manifest.name); + if (!nameValidation.valid) { + throw new Error(nameValidation.reason ?? "Invalid skill name"); + } + const baseDir = resolveScopeBaseDir(scope, projectRoot); + const targetDir = path.join(baseDir, parsed.manifest.name); + const targetMdPath = skillMdPath(targetDir); + if (fs.existsSync(targetMdPath)) { + throw new Error(`Skill "${parsed.manifest.name}" already exists at ${scope} scope`); + } + copyDirectory(sourceDir, targetDir); + const importedManifest: SkillManifest = { + ...parsed.manifest, + origin: "imported", + }; + const updatedContent = `${serializeFrontmatter(importedManifest)}\n${parsed.body}`; + fs.writeFileSync(targetMdPath, updatedContent, "utf-8"); + return { path: targetMdPath, name: parsed.manifest.name }; +} + +export function generateSkillTemplate(name: string, description: string): string { + return generateSkillTemplateContent(name, description, { template: "blank" }); +} + /** * Create a new skill with scaffold template. */ @@ -434,6 +701,10 @@ export function createSkill( name: string, description: string, scope: "global" | "project", + options?: { + tags?: readonly string[]; + template?: SkillTemplateKind; + }, projectRoot?: string, ): { path: string; name: string } { const nameValidation = validateSkillName(name); @@ -441,9 +712,7 @@ export function createSkill( throw new Error(nameValidation.reason ?? "Invalid skill name"); } - const baseDir = - scope === "project" && projectRoot ? projectSkillsDir(projectRoot) : globalSkillsDir(); - + const baseDir = resolveScopeBaseDir(scope, projectRoot); const skillDir = path.join(baseDir, name); const mdPath = skillMdPath(skillDir); @@ -452,7 +721,15 @@ export function createSkill( } fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync(mdPath, generateSkillTemplate(name, description), "utf-8"); + fs.writeFileSync( + mdPath, + generateSkillTemplateContent(name, description, { + origin: "custom", + ...(options?.tags ? { tags: options.tags } : {}), + ...(options?.template ? { template: options.template } : {}), + }), + "utf-8", + ); return { path: mdPath, name }; } @@ -466,9 +743,11 @@ export function deleteSkill(name: string, scope: "global" | "project", projectRo throw new Error(nameValidation.reason ?? "Invalid skill name"); } - const baseDir = - scope === "project" && projectRoot ? projectSkillsDir(projectRoot) : globalSkillsDir(); - + const existing = readSkill(name, projectRoot); + if (existing && !existing.mutable) { + throw new Error(`Skill "${name}" is immutable and cannot be removed`); + } + const baseDir = resolveScopeBaseDir(scope, projectRoot); const skillDir = path.join(baseDir, name); if (!fs.existsSync(skillDir)) { @@ -487,9 +766,14 @@ export interface SkillSubcommandDef { } export const SKILL_MANAGEMENT_SUBCOMMANDS: readonly SkillSubcommandDef[] = [ + { + name: "browse", + description: "Open the skills library", + usage: "/skill browse", + }, { name: "create", - description: "Create a new skill with scaffold template", + description: "Create a new skill with a guided scaffold", usage: "/skill create [--scope global|project]", }, { @@ -507,6 +791,16 @@ export const SKILL_MANAGEMENT_SUBCOMMANDS: readonly SkillSubcommandDef[] = [ description: "View the full content of a skill", usage: "/skill read ", }, + { + name: "install", + description: "Install a recommended bundled skill", + usage: "/skill install [--scope global|project]", + }, + { + name: "uninstall", + description: "Remove an installed mutable skill", + usage: "/skill uninstall [--scope global|project]", + }, { name: "delete", description: "Remove an installed skill", diff --git a/packages/shared/src/skillCatalog.ts b/packages/shared/src/skillCatalog.ts new file mode 100644 index 000000000..8f7625652 --- /dev/null +++ b/packages/shared/src/skillCatalog.ts @@ -0,0 +1,295 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { BundledSkillId, SkillCatalogEntry } from "@okcode/contracts"; + +export interface BundledSkillAsset { + readonly entry: SkillCatalogEntry; + readonly skillName: string; + readonly sourcePath: string; +} + +const SKILL_CATALOG_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "skills-catalog"); + +export const BUNDLED_SKILLS: readonly BundledSkillAsset[] = [ + { + entry: { + id: "pdf", + name: "PDF", + description: "Create, edit, and review PDFs.", + category: "recommended", + tags: ["docs", "pdf", "review"], + icon: "file-text", + installScopeDefault: "global", + system: false, + recommended: true, + immutable: false, + sourceType: "bundled", + sourceRef: "bundled:pdf", + }, + skillName: "pdf", + sourcePath: "recommended/pdf/SKILL.md", + }, + { + entry: { + id: "spreadsheet", + name: "Spreadsheet", + description: "Create, edit, and analyze spreadsheets.", + category: "recommended", + tags: ["spreadsheet", "analysis", "csv"], + icon: "sheet", + installScopeDefault: "global", + system: false, + recommended: true, + immutable: false, + sourceType: "bundled", + sourceRef: "bundled:spreadsheet", + }, + skillName: "spreadsheet", + sourcePath: "recommended/spreadsheet/SKILL.md", + }, + { + entry: { + id: "doc", + name: "Doc", + description: "Edit and review docx-style documents.", + category: "recommended", + tags: ["docs", "docx", "writing"], + icon: "doc", + installScopeDefault: "global", + system: false, + recommended: true, + immutable: false, + sourceType: "bundled", + sourceRef: "bundled:doc", + }, + skillName: "doc", + sourcePath: "recommended/doc/SKILL.md", + }, + { + entry: { + id: "playwright", + name: "Playwright", + description: "Automate real browsers from the terminal.", + category: "recommended", + tags: ["browser", "testing", "automation"], + icon: "play", + installScopeDefault: "global", + system: false, + recommended: true, + immutable: false, + sourceType: "bundled", + sourceRef: "bundled:playwright", + }, + skillName: "playwright", + sourcePath: "recommended/playwright/SKILL.md", + }, + { + entry: { + id: "github", + name: "GitHub", + description: "Inspect repositories, PRs, issues, and CI workflows.", + category: "recommended", + tags: ["github", "pr", "issues"], + icon: "github", + installScopeDefault: "global", + system: false, + recommended: true, + immutable: false, + sourceType: "bundled", + sourceRef: "bundled:github", + }, + skillName: "github", + sourcePath: "recommended/github/SKILL.md", + }, + { + entry: { + id: "skill-creator", + name: "Skill Creator", + description: "Create or update a skill.", + category: "system", + tags: ["skills", "authoring"], + icon: "pencil", + installScopeDefault: "global", + system: true, + recommended: false, + immutable: true, + sourceType: "bundled", + sourceRef: "bundled:skill-creator", + }, + skillName: "skill-creator", + sourcePath: "system/skill-creator/SKILL.md", + }, + { + entry: { + id: "image-gen", + name: "Image Gen", + description: "Generate or edit images for product and content work.", + category: "system", + tags: ["images", "design"], + icon: "image", + installScopeDefault: "global", + system: true, + recommended: false, + immutable: true, + sourceType: "bundled", + sourceRef: "bundled:image-gen", + }, + skillName: "image-gen", + sourcePath: "system/image-gen/SKILL.md", + }, + { + entry: { + id: "plugin-creator", + name: "Plugin Creator", + description: "Scaffold plugins and marketplace entries.", + category: "system", + tags: ["plugins", "scaffold"], + icon: "plug", + installScopeDefault: "global", + system: true, + recommended: false, + immutable: true, + sourceType: "bundled", + sourceRef: "bundled:plugin-creator", + }, + skillName: "plugin-creator", + sourcePath: "system/plugin-creator/SKILL.md", + }, + { + entry: { + id: "skill-installer", + name: "Skill Installer", + description: "Install curated skills into the local environment.", + category: "system", + tags: ["skills", "install"], + icon: "download", + installScopeDefault: "global", + system: true, + recommended: false, + immutable: true, + sourceType: "bundled", + sourceRef: "bundled:skill-installer", + }, + skillName: "skill-installer", + sourcePath: "system/skill-installer/SKILL.md", + }, + { + entry: { + id: "openclaw-docs", + name: "OpenClaw Docs", + description: "Reference first-party OpenClaw and OK Code documentation.", + category: "docs", + tags: ["docs", "openclaw", "okcode"], + icon: "book-open", + installScopeDefault: "global", + system: true, + recommended: false, + immutable: true, + sourceType: "bundled", + sourceRef: "bundled:openclaw-docs", + }, + skillName: "openclaw-docs", + sourcePath: "docs/openclaw-docs/SKILL.md", + }, + { + entry: { + id: "openai-docs", + name: "OpenAI Docs", + description: "Reference official OpenAI docs and API guidance.", + category: "docs", + tags: ["docs", "openai", "api"], + icon: "book-open", + installScopeDefault: "global", + system: true, + recommended: false, + immutable: true, + sourceType: "bundled", + sourceRef: "bundled:openai-docs", + }, + skillName: "openai-docs", + sourcePath: "docs/openai-docs/SKILL.md", + }, + { + entry: { + id: "anthropic-docs", + name: "Anthropic Docs", + description: "Reference official Anthropic docs and Claude guidance.", + category: "docs", + tags: ["docs", "anthropic", "claude"], + icon: "book-open", + installScopeDefault: "global", + system: true, + recommended: false, + immutable: true, + sourceType: "bundled", + sourceRef: "bundled:anthropic-docs", + }, + skillName: "anthropic-docs", + sourcePath: "docs/anthropic-docs/SKILL.md", + }, +] as const; + +function resolveBundledSkillPath(relativePath: string): string { + return path.join(SKILL_CATALOG_DIR, relativePath); +} + +function readBundledSkillSupplementaryFiles(sourcePath: string): Readonly> { + const sourceDir = path.dirname(sourcePath); + const supplementaryFiles: Record = {}; + for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { + if (!entry.isFile()) continue; + if (entry.name === "SKILL.md" || entry.name.startsWith(".")) continue; + supplementaryFiles[entry.name] = fs.readFileSync(path.join(sourceDir, entry.name), "utf-8"); + } + return supplementaryFiles; +} + +export function listBundledSkills(): readonly BundledSkillAsset[] { + return BUNDLED_SKILLS; +} + +export function listBundledSkillAssetPaths(): readonly string[] { + return BUNDLED_SKILLS.map((skill) => resolveBundledSkillPath(skill.sourcePath)); +} + +export function getBundledSkillById(id: BundledSkillId): BundledSkillAsset | undefined { + return BUNDLED_SKILLS.find((skill) => skill.entry.id === id); +} + +export function getBundledSkillByName(name: string): BundledSkillAsset | undefined { + const lowerName = name.trim().toLowerCase(); + return BUNDLED_SKILLS.find( + (skill) => skill.skillName === lowerName || skill.entry.name.toLowerCase() === lowerName, + ); +} + +export function readBundledSkillMarkdown(id: BundledSkillId): string { + const skill = getBundledSkillById(id); + if (!skill) { + throw new Error(`Bundled skill "${id}" not found`); + } + const absolutePath = resolveBundledSkillPath(skill.sourcePath); + if (!fs.existsSync(absolutePath)) { + throw new Error(`Bundled skill source file not found: ${absolutePath}`); + } + return fs.readFileSync(absolutePath, "utf-8"); +} + +export function readBundledSkillFiles(id: BundledSkillId): { + readonly skillMd: string; + readonly supplementaryFiles: Readonly>; +} { + const skill = getBundledSkillById(id); + if (!skill) { + throw new Error(`Bundled skill "${id}" not found`); + } + const absolutePath = resolveBundledSkillPath(skill.sourcePath); + if (!fs.existsSync(absolutePath)) { + throw new Error(`Bundled skill source file not found: ${absolutePath}`); + } + return { + skillMd: fs.readFileSync(absolutePath, "utf-8"), + supplementaryFiles: readBundledSkillSupplementaryFiles(absolutePath), + }; +} diff --git a/packages/shared/src/skills-catalog/docs/anthropic-docs/SKILL.md b/packages/shared/src/skills-catalog/docs/anthropic-docs/SKILL.md new file mode 100644 index 000000000..dc05be4a4 --- /dev/null +++ b/packages/shared/src/skills-catalog/docs/anthropic-docs/SKILL.md @@ -0,0 +1,30 @@ +--- +name: anthropic-docs +description: Reference official Anthropic docs and Claude guidance. +catalog_id: anthropic-docs +origin: bundled +version: 1.0.0 +author: OK Code +tags: + - docs + - anthropic + - claude +--- + +# Anthropic Docs + +## When to use this skill + +- Use when the answer should be grounded in Anthropic and Claude docs. + +## What this skill does + +- Keeps provider-specific guidance tied to Anthropic documentation. + +## Implementation + +- Prefer current first-party docs and clarify provider-specific constraints. + +## Best practices + +- Separate Anthropic-specific guidance from generic coding-agent advice. diff --git a/packages/shared/src/skills-catalog/docs/openai-docs/SKILL.md b/packages/shared/src/skills-catalog/docs/openai-docs/SKILL.md new file mode 100644 index 000000000..5b050ef53 --- /dev/null +++ b/packages/shared/src/skills-catalog/docs/openai-docs/SKILL.md @@ -0,0 +1,30 @@ +--- +name: openai-docs +description: Reference official OpenAI docs and API guidance. +catalog_id: openai-docs +origin: bundled +version: 1.0.0 +author: OK Code +tags: + - docs + - openai + - api +--- + +# OpenAI Docs + +## When to use this skill + +- Use when the user asks how to build with OpenAI products or APIs. + +## What this skill does + +- Keeps answers aligned to official OpenAI documentation and upgrade guidance. + +## Implementation + +- Prefer current official docs over secondary summaries. + +## Best practices + +- Call out when a recommendation is an inference rather than a documented guarantee. diff --git a/packages/shared/src/skills-catalog/docs/openclaw-docs/SKILL.md b/packages/shared/src/skills-catalog/docs/openclaw-docs/SKILL.md new file mode 100644 index 000000000..7b7030f67 --- /dev/null +++ b/packages/shared/src/skills-catalog/docs/openclaw-docs/SKILL.md @@ -0,0 +1,30 @@ +--- +name: openclaw-docs +description: Reference first-party OpenClaw and OK Code documentation. +catalog_id: openclaw-docs +origin: bundled +version: 1.0.0 +author: OK Code +tags: + - docs + - openclaw + - okcode +--- + +# OpenClaw Docs + +## When to use this skill + +- Use when the answer should be grounded in product or internal docs. + +## What this skill does + +- Prefers first-party documentation and repository guidance over memory. + +## Implementation + +- Cite the most relevant local or first-party docs available in the environment. + +## Best practices + +- Be explicit about what is documented versus inferred. diff --git a/packages/shared/src/skills-catalog/recommended/doc/SKILL.md b/packages/shared/src/skills-catalog/recommended/doc/SKILL.md new file mode 100644 index 000000000..fa240afdb --- /dev/null +++ b/packages/shared/src/skills-catalog/recommended/doc/SKILL.md @@ -0,0 +1,49 @@ +--- +name: doc +description: Edit and review docx-style documents. +catalog_id: doc +origin: bundled +version: 1.0.0 +author: OK Code +tags: + - docs + - docx + - writing +tools: + - filesystem + - terminal +--- + +# Doc Skill + +## When to use this skill + +- Use when the task involves drafting, editing, reviewing, or converting rich-text documents such as DOCX files. +- Use when preserving writing quality and document structure matters more than raw plain-text extraction. +- Use when the user needs help balancing content edits against formatting preservation. +- Use when a document must be converted between editable and presentation-friendly formats. + +## What this skill does + +- Helps edit and review document content while being explicit about formatting-preservation limits. +- Separates writing changes from layout and formatting changes so the user can judge tradeoffs clearly. +- Prefers reversible workflows when converting between DOCX, Markdown, HTML, and plain text. +- Keeps tone, structure, and author intent central during review and rewrite tasks. + +## Implementation + +- Determine whether the user cares most about wording, formatting, comments, tracked changes, or export format. +- If the task is content-focused, use the most editable intermediate format available and summarize how fidelity may change. +- If the task is formatting-sensitive, warn early about limitations in round-tripping through simpler text formats. +- Preserve headings, lists, tables, and section boundaries where possible. +- When reviewing, summarize substantive content changes separately from formatting or layout issues. +- If a conversion path may drop tracked changes, comments, footnotes, or advanced formatting, state that explicitly. +- Keep rewrites concise and aligned to the intended audience unless the user requests a different tone. + +## Best practices + +- Do not silently flatten rich structure when the original formatting appears important. +- Prefer reversible edits and keep the original document available. +- Call out unsupported document features rather than approximating them as if they were preserved. +- Separate editorial guidance from file-format guidance. +- When rewriting text, keep the user's target audience and tone explicit. diff --git a/packages/shared/src/skills-catalog/recommended/github/SKILL.md b/packages/shared/src/skills-catalog/recommended/github/SKILL.md new file mode 100644 index 000000000..7c9b4af18 --- /dev/null +++ b/packages/shared/src/skills-catalog/recommended/github/SKILL.md @@ -0,0 +1,48 @@ +--- +name: github +description: Inspect repositories, PRs, issues, and CI workflows. +catalog_id: github +origin: bundled +version: 1.0.0 +author: OK Code +tags: + - github + - pr + - issues +tools: + - github +--- + +# GitHub Skill + +## When to use this skill + +- Use when the task involves GitHub repositories, pull requests, issues, review threads, or CI checks. +- Use when the answer should be grounded in live repository state rather than memory or inference alone. +- Use when the user needs repository context before making code or process decisions. +- Use when reviewing changes, triaging issues, or diagnosing failing GitHub Actions workflows. + +## What this skill does + +- Uses GitHub as the source of truth for repository, PR, issue, and CI state whenever connector data is available. +- Optimizes for actionable review, triage, and debugging rather than generic summaries. +- Prioritizes bugs, regressions, missing tests, and concrete follow-ups during review tasks. +- Keeps findings tied to exact files, checks, PR metadata, or issue context when possible. + +## Implementation + +- Start by identifying whether the task is about repository context, issue triage, PR review, CI debugging, or publishing changes. +- Pull the relevant GitHub metadata before making conclusions about state, authorship, status, or changed files. +- For PR review, focus first on behavioral regressions, correctness, risk, and validation gaps. +- For CI failures, inspect failing checks and logs before proposing a fix. +- For issue or project triage, summarize the operational state and the next concrete action. +- Ground conclusions in the observed GitHub data and clearly label any inference. +- When code changes are involved, connect review comments to precise file paths or changed surfaces. + +## Best practices + +- Do not rely on stale assumptions when current GitHub data is available. +- Keep findings specific, evidence-based, and prioritized by severity. +- Prefer exact file references, check names, issue numbers, and PR identifiers. +- Separate repository facts from interpretation. +- Avoid noisy summaries when the user needs a clear next action. diff --git a/packages/shared/src/skills-catalog/recommended/pdf/SKILL.md b/packages/shared/src/skills-catalog/recommended/pdf/SKILL.md new file mode 100644 index 000000000..849fc83ef --- /dev/null +++ b/packages/shared/src/skills-catalog/recommended/pdf/SKILL.md @@ -0,0 +1,49 @@ +--- +name: pdf +description: Create, edit, and review PDFs. +catalog_id: pdf +origin: bundled +version: 1.0.0 +author: OK Code +tags: + - docs + - pdf + - review +tools: + - filesystem + - terminal +--- + +# PDF Skill + +## When to use this skill + +- Use when the task involves creating, editing, reviewing, splitting, merging, or extracting content from PDF files. +- Use when the user needs to understand whether a PDF can be edited directly or must be converted first. +- Use when the task depends on preserving layout, images, pagination, or annotation fidelity. +- Use when OCR may be required because the PDF contains scanned pages rather than selectable text. + +## What this skill does + +- Chooses the safest PDF workflow based on the user's goal: review, extraction, transformation, or generation. +- Distinguishes between text-based PDFs and image-only scanned PDFs before proposing edits. +- Preserves fidelity when layout matters and explicitly calls out any tradeoff when converting to another format. +- Prefers repeatable tools and scripted operations over manual one-off desktop steps. + +## Implementation + +- Start by identifying the real goal: review content, extract data, annotate, merge, split, redact, or convert. +- Determine whether the PDF is text-based or scanned. If the content is image-only, state that OCR may be required. +- If the user needs edits that preserve layout, avoid lossy conversions unless they explicitly accept them. +- If extracting text, note whether the output should preserve reading order, tables, headings, or page boundaries. +- If generating a PDF from another source, preserve the source of truth in an editable format whenever possible. +- When tools are limited, explain the limitation clearly and offer the safest fallback rather than implying full fidelity. +- Summarize important tradeoffs before carrying out destructive or lossy transformations. + +## Best practices + +- Never imply that all PDFs are safely editable; many require conversion or specialized tooling. +- Call out OCR accuracy risk explicitly for scanned documents. +- Preserve the original file before transformations that may alter layout or metadata. +- Prefer deterministic, scriptable workflows for extraction and generation. +- Tell the user when tables, forms, annotations, or embedded fonts may not round-trip cleanly. diff --git a/packages/shared/src/skills-catalog/recommended/playwright/SKILL.md b/packages/shared/src/skills-catalog/recommended/playwright/SKILL.md new file mode 100644 index 000000000..0c7fded21 --- /dev/null +++ b/packages/shared/src/skills-catalog/recommended/playwright/SKILL.md @@ -0,0 +1,49 @@ +--- +name: playwright +description: Automate real browsers from the terminal. +catalog_id: playwright +origin: bundled +version: 1.0.0 +author: OK Code +tags: + - browser + - testing + - automation +tools: + - terminal + - browser +--- + +# Playwright Skill + +## When to use this skill + +- Use when the task needs browser automation, UI verification, end-to-end testing, or scripted web interactions. +- Use when the user needs reproducible validation of browser behavior rather than manual spot-checking. +- Use when screenshots, DOM assertions, navigation flows, or form automation are part of the task. +- Use when the user wants a reliable browser-based regression check from the terminal. + +## What this skill does + +- Designs Playwright workflows that are deterministic, inspectable, and resilient to minor UI changes. +- Prefers stable selectors and event-driven waits over brittle timing assumptions. +- Captures actionable failure context such as URLs, selectors, screenshots, and expectation mismatches. +- Keeps test intent focused on behavior, not incidental implementation details. + +## Implementation + +- Clarify whether the goal is one-off browser automation, a reusable test, or a debugging workflow. +- Prefer semantic selectors and stable locators over deep CSS selectors tied to styling structure. +- Use assertions that reflect user-observable behavior rather than implementation accidents. +- Prefer waits based on navigation, visible UI state, or network/DOM readiness rather than arbitrary sleep intervals. +- If the task is exploratory, keep scripts short and focused on reproducing the target behavior. +- If the task is test authoring, structure steps and assertions so failures are easy to diagnose. +- Capture enough context on failure for a human to reproduce and fix the issue quickly. + +## Best practices + +- Avoid timing-based waits unless no stronger signal exists. +- Keep selectors robust and understandable. +- Prefer behavior-level assertions over snapshotting everything. +- Include screenshots or other evidence when reporting a browser failure. +- Keep scripts and tests maintainable; do not overfit to current DOM noise. diff --git a/packages/shared/src/skills-catalog/recommended/spreadsheet/SKILL.md b/packages/shared/src/skills-catalog/recommended/spreadsheet/SKILL.md new file mode 100644 index 000000000..f5447264b --- /dev/null +++ b/packages/shared/src/skills-catalog/recommended/spreadsheet/SKILL.md @@ -0,0 +1,49 @@ +--- +name: spreadsheet +description: Create, edit, and analyze spreadsheets. +catalog_id: spreadsheet +origin: bundled +version: 1.0.0 +author: OK Code +tags: + - spreadsheet + - analysis + - csv +tools: + - filesystem + - terminal +--- + +# Spreadsheet Skill + +## When to use this skill + +- Use when the task involves spreadsheets, CSV/TSV files, tabular data cleanup, or workbook analysis. +- Use when the user needs formulas, summaries, transformations, or structured exports from spreadsheet data. +- Use when the task depends on preserving headers, column types, sheet boundaries, or formulas. +- Use when bulk edits must remain auditable and reversible. + +## What this skill does + +- Normalizes spreadsheet-like tasks into safe, repeatable tabular workflows. +- Treats headers, row counts, formulas, and delimiters as explicit assumptions rather than guessing silently. +- Prefers scripted transformations that can be reviewed and rerun. +- Helps separate data cleaning, calculation, and presentation concerns. + +## Implementation + +- First identify the source format: CSV, TSV, XLSX, multiple sheets, exported report, or ad hoc table. +- Confirm the shape of the data before editing: headers, unique keys, empty rows, encoding, delimiters, and date formats. +- If formulas are involved, clarify whether they should be preserved, recalculated, or replaced with values. +- For bulk updates, favor scriptable transforms that produce a clear before/after story. +- Preserve source data before rewrites, especially when changing delimiters, formulas, or workbook structure. +- If workbook layout matters, call out any gap between plain-text data processing and full spreadsheet fidelity. +- Summarize row-level and column-level impacts after non-trivial edits. + +## Best practices + +- Never assume the first row is a header without checking. +- Treat date, currency, and numeric coercion as high-risk transformations. +- Preserve formulas unless the user explicitly wants static values. +- Prefer small, auditable transformations over opaque bulk rewrites. +- State when workbook-only features such as formatting, filters, merged cells, or charts may not be preserved. diff --git a/packages/shared/src/skills-catalog/system/image-gen/SKILL.md b/packages/shared/src/skills-catalog/system/image-gen/SKILL.md new file mode 100644 index 000000000..d2c036273 --- /dev/null +++ b/packages/shared/src/skills-catalog/system/image-gen/SKILL.md @@ -0,0 +1,29 @@ +--- +name: image-gen +description: Generate or edit images for product and content work. +catalog_id: image-gen +origin: bundled +version: 1.0.0 +author: OK Code +tags: + - images + - design +--- + +# Image Gen + +## When to use this skill + +- Use when the task needs generated or edited raster imagery. + +## What this skill does + +- Chooses image generation or editing workflows that fit the requested output. + +## Implementation + +- Clarify the intended artifact, dimensions, and style before generating. + +## Best practices + +- Prefer code-native design changes over image generation when the asset should remain editable. diff --git a/packages/shared/src/skills-catalog/system/plugin-creator/SKILL.md b/packages/shared/src/skills-catalog/system/plugin-creator/SKILL.md new file mode 100644 index 000000000..52efdbc67 --- /dev/null +++ b/packages/shared/src/skills-catalog/system/plugin-creator/SKILL.md @@ -0,0 +1,29 @@ +--- +name: plugin-creator +description: Scaffold plugins and marketplace entries. +catalog_id: plugin-creator +origin: bundled +version: 1.0.0 +author: OK Code +tags: + - plugins + - scaffold +--- + +# Plugin Creator + +## When to use this skill + +- Use when the user wants to scaffold or update a Codex or OK Code plugin. + +## What this skill does + +- Creates the expected plugin skeleton and metadata structure. + +## Implementation + +- Prefer generated scaffolds that are easy to edit rather than opaque templates. + +## Best practices + +- Keep plugin structure explicit and minimal. diff --git a/packages/shared/src/skills-catalog/system/skill-creator/SKILL.md b/packages/shared/src/skills-catalog/system/skill-creator/SKILL.md new file mode 100644 index 000000000..72d0ab79c --- /dev/null +++ b/packages/shared/src/skills-catalog/system/skill-creator/SKILL.md @@ -0,0 +1,31 @@ +--- +name: skill-creator +description: Create or update a skill. +catalog_id: skill-creator +origin: bundled +version: 1.0.0 +author: OK Code +tags: + - skills + - authoring +--- + +# Skill Creator + +## When to use this skill + +- Use when the user wants to create a new skill or refine an existing one. + +## What this skill does + +- Guides structure, naming, frontmatter, and instruction quality for OK Code skills. + +## Implementation + +- Start by clarifying the skill's trigger conditions and output expectations. +- Prefer concise, enforceable instructions over vague guidance. + +## Best practices + +- Keep skills scoped and reusable. +- Document edge cases and tool assumptions explicitly. diff --git a/packages/shared/src/skills-catalog/system/skill-installer/SKILL.md b/packages/shared/src/skills-catalog/system/skill-installer/SKILL.md new file mode 100644 index 000000000..3f87ce52c --- /dev/null +++ b/packages/shared/src/skills-catalog/system/skill-installer/SKILL.md @@ -0,0 +1,29 @@ +--- +name: skill-installer +description: Install curated skills into the local environment. +catalog_id: skill-installer +origin: bundled +version: 1.0.0 +author: OK Code +tags: + - skills + - install +--- + +# Skill Installer + +## When to use this skill + +- Use when the user wants to browse or install curated skills. + +## What this skill does + +- Helps choose appropriate skills and install them safely. + +## Implementation + +- Prefer explicit install scopes and clear provenance. + +## Best practices + +- Do not overwrite custom skills silently.