From 4e5b3566a2f0495da5168be8d0f0bb8efc129f1d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 5 Jan 2026 23:45:01 +0900 Subject: [PATCH] feat(tools): refactor slashcommand to support options and caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract createSlashcommandTool factory with SlashcommandToolOptions - Export discoverCommandsSync for external use - Move description building to lazy evaluation with caching - Support pre-warming cache with provided commands and skills - Simplify tool initialization in plugin with new factory approach This allows the slashcommand tool to be instantiated with custom options while maintaining backward compatibility through lazy loading. 🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- src/index.ts | 11 ++- src/tools/index.ts | 3 +- src/tools/slashcommand/index.ts | 2 +- src/tools/slashcommand/tools.ts | 150 ++++++++++++++++++-------------- src/tools/slashcommand/types.ts | 9 ++ 5 files changed, 107 insertions(+), 68 deletions(-) diff --git a/src/index.ts b/src/index.ts index 13b1818..91810ec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,8 @@ import { createLookAt, createSkillTool, createSkillMcpTool, + createSlashcommandTool, + discoverCommandsSync, sessionExists, interactive_bash, startTmuxCheck, @@ -231,6 +233,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { getSessionID: getSessionIDForMcp, }); + const commands = discoverCommandsSync(); + const slashcommandTool = createSlashcommandTool({ + commands, + skills: mergedSkills, + }); + const googleAuthHooks = pluginConfig.google_auth !== false ? await createGoogleAntigravityAuthPlugin(ctx) : null; @@ -251,7 +259,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { look_at: lookAt, skill: skillTool, skill_mcp: skillMcpTool, - interactive_bash, // Always included, handles missing tmux gracefully via getCachedTmuxPath() ?? "tmux" + slashcommand: slashcommandTool, + interactive_bash, }, "chat.message": async (input, output) => { diff --git a/src/tools/index.ts b/src/tools/index.ts index a45ff06..9ad4cea 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -19,7 +19,7 @@ import { import { grep } from "./grep" import { glob } from "./glob" -import { slashcommand } from "./slashcommand" +export { createSlashcommandTool, discoverCommandsSync } from "./slashcommand" import { session_list, @@ -73,7 +73,6 @@ export const builtinTools: Record = { ast_grep_replace, grep, glob, - slashcommand, session_list, session_read, session_search, diff --git a/src/tools/slashcommand/index.ts b/src/tools/slashcommand/index.ts index 0071c5f..d309202 100644 --- a/src/tools/slashcommand/index.ts +++ b/src/tools/slashcommand/index.ts @@ -1,2 +1,2 @@ export * from "./types" -export { slashcommand } from "./tools" +export { slashcommand, createSlashcommandTool, discoverCommandsSync } from "./tools" diff --git a/src/tools/slashcommand/tools.ts b/src/tools/slashcommand/tools.ts index 9a822bc..0cee32d 100644 --- a/src/tools/slashcommand/tools.ts +++ b/src/tools/slashcommand/tools.ts @@ -6,7 +6,7 @@ import type { CommandFrontmatter } from "../../features/claude-code-command-load import { isMarkdownFile } from "../../shared/file-utils" import { getClaudeConfigDir } from "../../shared" import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader" -import type { CommandScope, CommandMetadata, CommandInfo } from "./types" +import type { CommandScope, CommandMetadata, CommandInfo, SlashcommandToolOptions } from "./types" function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] { if (!existsSync(commandsDir)) { @@ -51,7 +51,7 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): Comm return commands } -function discoverCommandsSync(): CommandInfo[] { +export function discoverCommandsSync(): CommandInfo[] { const { homedir } = require("os") const userCommandsDir = join(getClaudeConfigDir(), "commands") const projectCommandsDir = join(process.cwd(), ".claude", "commands") @@ -138,87 +138,109 @@ function formatCommandList(items: CommandInfo[]): string { return lines.join("\n") } -async function buildDescription(): Promise { - const availableCommands = discoverCommandsSync() - const availableSkills = await discoverAllSkills() - const availableItems = [ - ...availableCommands, - ...availableSkills.map(skillToCommandInfo), - ] - const commandListForDescription = availableItems +const TOOL_DESCRIPTION_PREFIX = `Load a skill to get detailed instructions for a specific task. + +Skills provide specialized knowledge and step-by-step guidance. +Use this when a task matches an available skill's description. +` + +function buildDescriptionFromItems(items: CommandInfo[]): string { + const commandListForDescription = items .map((cmd) => { const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : "" return `- /${cmd.name}${hint}: ${cmd.metadata.description} (${cmd.scope})` }) .join("\n") - return `Load a skill to get detailed instructions for a specific task. - -Skills provide specialized knowledge and step-by-step guidance. -Use this when a task matches an available skill's description. - + return `${TOOL_DESCRIPTION_PREFIX} ${commandListForDescription} ` } -let cachedDescription: string | null = null +export function createSlashcommandTool(options: SlashcommandToolOptions = {}): ToolDefinition { + let cachedCommands: CommandInfo[] | null = options.commands ?? null + let cachedSkills: LoadedSkill[] | null = options.skills ?? null + let cachedDescription: string | null = null -export const slashcommand: ToolDefinition = tool({ - get description() { - if (!cachedDescription) { - cachedDescription = "Loading available commands and skills..." - buildDescription().then(desc => { cachedDescription = desc }) - } + const getCommands = (): CommandInfo[] => { + if (cachedCommands) return cachedCommands + cachedCommands = discoverCommandsSync() + return cachedCommands + } + + const getSkills = async (): Promise => { + if (cachedSkills) return cachedSkills + cachedSkills = await discoverAllSkills() + return cachedSkills + } + + const getAllItems = async (): Promise => { + const commands = getCommands() + const skills = await getSkills() + return [...commands, ...skills.map(skillToCommandInfo)] + } + + const buildDescription = async (): Promise => { + if (cachedDescription) return cachedDescription + const allItems = await getAllItems() + cachedDescription = buildDescriptionFromItems(allItems) return cachedDescription - }, + } - args: { - command: tool.schema - .string() - .describe( - "The slash command to execute (without the leading slash). E.g., 'commit', 'plan', 'execute'." - ), - }, + // Pre-warm the cache immediately + buildDescription() - async execute(args) { - const commands = discoverCommandsSync() - const skills = await discoverAllSkills() - const allItems = [ - ...commands, - ...skills.map(skillToCommandInfo), - ] + return tool({ + get description() { + return cachedDescription ?? TOOL_DESCRIPTION_PREFIX + }, - if (!args.command) { - return formatCommandList(allItems) + "\n\nProvide a command or skill name to execute." - } + args: { + command: tool.schema + .string() + .describe( + "The slash command to execute (without the leading slash). E.g., 'commit', 'plan', 'execute'." + ), + }, - const cmdName = args.command.replace(/^\//, "") + async execute(args) { + const allItems = await getAllItems() - const exactMatch = allItems.find( - (cmd) => cmd.name.toLowerCase() === cmdName.toLowerCase() - ) + if (!args.command) { + return formatCommandList(allItems) + "\n\nProvide a command or skill name to execute." + } - if (exactMatch) { - return await formatLoadedCommand(exactMatch) - } + const cmdName = args.command.replace(/^\//, "") - const partialMatches = allItems.filter((cmd) => - cmd.name.toLowerCase().includes(cmdName.toLowerCase()) - ) - - if (partialMatches.length > 0) { - const matchList = partialMatches.map((cmd) => `/${cmd.name}`).join(", ") - return ( - `No exact match for "/${cmdName}". Did you mean: ${matchList}?\n\n` + - formatCommandList(allItems) + const exactMatch = allItems.find( + (cmd) => cmd.name.toLowerCase() === cmdName.toLowerCase() ) - } - return ( - `Command or skill "/${cmdName}" not found.\n\n` + - formatCommandList(allItems) + - "\n\nTry a different name." - ) - }, -}) + if (exactMatch) { + return await formatLoadedCommand(exactMatch) + } + + const partialMatches = allItems.filter((cmd) => + cmd.name.toLowerCase().includes(cmdName.toLowerCase()) + ) + + if (partialMatches.length > 0) { + const matchList = partialMatches.map((cmd) => `/${cmd.name}`).join(", ") + return ( + `No exact match for "/${cmdName}". Did you mean: ${matchList}?\n\n` + + formatCommandList(allItems) + ) + } + + return ( + `Command or skill "/${cmdName}" not found.\n\n` + + formatCommandList(allItems) + + "\n\nTry a different name." + ) + }, + }) +} + +// Default instance for backward compatibility (lazy loading) +export const slashcommand = createSlashcommandTool() diff --git a/src/tools/slashcommand/types.ts b/src/tools/slashcommand/types.ts index 36437e2..fa51268 100644 --- a/src/tools/slashcommand/types.ts +++ b/src/tools/slashcommand/types.ts @@ -1,3 +1,5 @@ +import type { LoadedSkill } from "../../features/opencode-skill-loader" + export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project" export interface CommandMetadata { @@ -16,3 +18,10 @@ export interface CommandInfo { content?: string scope: CommandScope } + +export interface SlashcommandToolOptions { + /** Pre-loaded commands (skip discovery if provided) */ + commands?: CommandInfo[] + /** Pre-loaded skills (skip discovery if provided) */ + skills?: LoadedSkill[] +}