From 81ac41e0891cf9318af641805e7b1c5af1194be4 Mon Sep 17 00:00:00 2001 From: Dax Date: Sat, 31 Jan 2026 00:41:55 -0500 Subject: [PATCH] feat: make skills invokable as slash commands in the TUI (#11390) --- .../cmd/tui/component/prompt/autocomplete.tsx | 3 ++- packages/opencode/src/command/index.ts | 20 +++++++++++++++++-- packages/opencode/src/skill/skill.ts | 2 ++ packages/opencode/src/tool/skill.ts | 3 +-- packages/sdk/js/src/v2/gen/types.gen.ts | 3 ++- 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 718929d445..bd000e2ab0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -345,8 +345,9 @@ export function Autocomplete(props: { const results: AutocompleteOption[] = [...command.slashes()] for (const serverCommand of sync.data.command) { + const label = serverCommand.source === "mcp" ? ":mcp" : serverCommand.source === "skill" ? ":skill" : "" results.push({ - display: "/" + serverCommand.name + (serverCommand.mcp ? " (MCP)" : ""), + display: "/" + serverCommand.name + label, description: serverCommand.description, onSelect: () => { const newText = "/" + serverCommand.name + " " diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 976f1cd51e..14dbeb6794 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -6,6 +6,7 @@ import { Identifier } from "../id/id" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" import { MCP } from "../mcp" +import { Skill } from "../skill" export namespace Command { export const Event = { @@ -26,7 +27,7 @@ export namespace Command { description: z.string().optional(), agent: z.string().optional(), model: z.string().optional(), - mcp: z.boolean().optional(), + source: z.enum(["command", "mcp", "skill"]).optional(), // workaround for zod not supporting async functions natively so we use getters // https://zod.dev/v4/changelog?id=zfunction template: z.promise(z.string()).or(z.string()), @@ -94,7 +95,7 @@ export namespace Command { for (const [name, prompt] of Object.entries(await MCP.prompts())) { result[name] = { name, - mcp: true, + source: "mcp", description: prompt.description, get template() { // since a getter can't be async we need to manually return a promise here @@ -118,6 +119,21 @@ export namespace Command { } } + // Add skills as invokable commands + for (const skill of await Skill.all()) { + // Skip if a command with this name already exists + if (result[skill.name]) continue + result[skill.name] = { + name: skill.name, + description: skill.description, + source: "skill", + get template() { + return skill.content + }, + hints: [], + } + } + return result }) diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 5b300a9287..6e05d013ae 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -18,6 +18,7 @@ export namespace Skill { name: z.string(), description: z.string(), location: z.string(), + content: z.string(), }) export type Info = z.infer @@ -74,6 +75,7 @@ export namespace Skill { name: parsed.data.name, description: parsed.data.description, location: match, + content: md.content, } } diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 76d9fd535e..8f285d5999 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -2,7 +2,6 @@ import path from "path" import z from "zod" import { Tool } from "./tool" import { Skill } from "../skill" -import { ConfigMarkdown } from "../config/markdown" import { PermissionNext } from "../permission/next" export const SkillTool = Tool.define("skill", async (ctx) => { @@ -62,7 +61,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => { always: [params.name], metadata: {}, }) - const content = (await ConfigMarkdown.parse(skill.location)).content + const content = skill.content const dir = path.dirname(skill.location) // Format output similar to plugin pattern diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a8c61c4daa..cb2f586775 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2116,7 +2116,7 @@ export type Command = { description?: string agent?: string model?: string - mcp?: boolean + source?: "command" | "mcp" | "skill" template: string subtask?: boolean hints: Array @@ -4913,6 +4913,7 @@ export type AppSkillsResponses = { name: string description: string location: string + content: string }> }