diff --git a/cli/src/modules/common/slashCommands.ts b/cli/src/modules/common/slashCommands.ts index 2bb02ce9..24eaa42d 100644 --- a/cli/src/modules/common/slashCommands.ts +++ b/cli/src/modules/common/slashCommands.ts @@ -2,58 +2,16 @@ import { access, readdir, readFile } from 'fs/promises'; import { dirname, join, resolve } from 'path'; import { homedir } from 'os'; import { parse as parseYaml } from 'yaml'; +import { getBuiltinSlashCommands, mergeSlashCommands } from '@hapi/protocol/slashCommands'; +import type { SlashCommand, SlashCommandsResponse } from '@hapi/protocol/apiTypes'; -export interface SlashCommand { - name: string; - description?: string; - source: 'builtin' | 'user' | 'plugin' | 'project'; - content?: string; // Expanded content for Codex user prompts - pluginName?: string; // Name of the plugin that provides this command -} +export type { SlashCommand } from '@hapi/protocol/apiTypes'; export interface ListSlashCommandsRequest { agent: string; } -export interface ListSlashCommandsResponse { - success: boolean; - commands?: SlashCommand[]; - error?: string; -} - -/** - * Built-in slash commands for each agent type. - */ -const BUILTIN_COMMANDS: Record = { - claude: [ - { name: 'clear', description: 'Clear conversation history', source: 'builtin' }, - { name: 'compact', description: 'Compact conversation context', source: 'builtin' }, - { name: 'context', description: 'Show context information', source: 'builtin' }, - { name: 'cost', description: 'Show session cost', source: 'builtin' }, - { name: 'plan', description: 'Toggle plan mode', source: 'builtin' }, - ], - codex: [ - { name: 'clear', description: 'Clear current Codex thread context', source: 'builtin' }, - { name: 'compact', description: 'Compact current Codex thread context', source: 'builtin' }, - { name: 'goal', description: 'Set, view, pause, resume, or clear a persistent Codex goal', source: 'builtin' }, - { name: 'help', description: 'Show supported HAPI Codex slash commands', source: 'builtin' }, - { name: 'plan', description: 'Enable plan mode; use /plan off to return to default', source: 'builtin' }, - { name: 'default', description: 'Return Codex collaboration mode to default', source: 'builtin' }, - { name: 'execute', description: 'Return Codex collaboration mode to default', source: 'builtin' }, - { name: 'status', description: 'Show current Codex session config', source: 'builtin' }, - { name: 'model', description: 'Show or set Codex model, e.g. /model gpt-5.5', source: 'builtin' }, - { name: 'reasoning', description: 'Show or set reasoning effort', source: 'builtin' }, - { name: 'effort', description: 'Alias for /reasoning', source: 'builtin' }, - { name: 'permissions', description: 'Show or set permission mode', source: 'builtin' }, - { name: 'permission', description: 'Alias for /permissions', source: 'builtin' }, - ], - gemini: [ - { name: 'about', description: 'About Gemini', source: 'builtin' }, - { name: 'clear', description: 'Clear conversation', source: 'builtin' }, - { name: 'compress', description: 'Compress context', source: 'builtin' }, - ], - opencode: [], -}; +export type ListSlashCommandsResponse = SlashCommandsResponse; /** * Interface for installed_plugins.json structure @@ -316,7 +274,7 @@ async function scanPluginCommands(agent: string): Promise { * built-in -> global user -> plugin -> project (project overrides same-name globals). */ export async function listSlashCommands(agent: string, projectDir?: string): Promise { - const builtin = BUILTIN_COMMANDS[agent] ?? []; + const builtin = getBuiltinSlashCommands(agent); // Scan all command sources in parallel const [user, plugin, project] = await Promise.all([ @@ -325,16 +283,5 @@ export async function listSlashCommands(agent: string, projectDir?: string): Pro scanProjectCommands(agent, projectDir), ]); - const allCommands = [...builtin, ...user, ...plugin, ...project]; - - // Keep insertion order while allowing latter commands to override prior ones. - const commandMap = new Map(); - for (const command of allCommands) { - if (commandMap.has(command.name)) { - commandMap.delete(command.name); - } - commandMap.set(command.name, command); - } - - return Array.from(commandMap.values()); + return mergeSlashCommands([...builtin, ...user, ...plugin, ...project]); } diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index f57b0cf9..67a463d8 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -11,6 +11,7 @@ import type { OpencodeModelsResponse, OpencodeModelSummary, PathExistsResponse, + SlashCommandsResponse, UploadFileResponse } from '@hapi/protocol/apiTypes' import type { Server } from 'socket.io' @@ -212,16 +213,8 @@ export class RpcGateway { return await this.sessionRpc(sessionId, 'ripgrep', { args, cwd }) as RpcCommandResponse } - async listSlashCommands(sessionId: string, agent: string): Promise<{ - success: boolean - commands?: Array<{ name: string; description?: string; source: 'builtin' | 'user' | 'plugin' | 'project' }> - error?: string - }> { - return await this.sessionRpc(sessionId, 'listSlashCommands', { agent }) as { - success: boolean - commands?: Array<{ name: string; description?: string; source: 'builtin' | 'user' | 'plugin' | 'project' }> - error?: string - } + async listSlashCommands(sessionId: string, agent: string): Promise { + return await this.sessionRpc(sessionId, 'listSlashCommands', { agent }) as SlashCommandsResponse } async listSkills(sessionId: string): Promise<{ diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 166691fc..07796c64 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -8,6 +8,7 @@ */ import { isKnownFlavor, type LocalResumeTarget, type ResumableSession } from '@hapi/protocol' +import type { SlashCommandsResponse } from '@hapi/protocol/apiTypes' import type { AgentFlavor, CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' import type { Server } from 'socket.io' @@ -883,11 +884,7 @@ export class SyncEngine { return await this.rpcGateway.runRipgrep(sessionId, args, cwd) } - async listSlashCommands(sessionId: string, agent: string): Promise<{ - success: boolean - commands?: Array<{ name: string; description?: string; source: 'builtin' | 'user' | 'plugin' | 'project' }> - error?: string - }> { + async listSlashCommands(sessionId: string, agent: string): Promise { return await this.rpcGateway.listSlashCommands(sessionId, agent) } diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index d8f489a6..672ad834 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -1,4 +1,5 @@ import { getPermissionModesForFlavor, isPermissionModeAllowedForFlavor, supportsModelChange, toSessionSummary } from '@hapi/protocol' +import type { SlashCommand } from '@hapi/protocol/apiTypes' import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas' import { Hono } from 'hono' import { z } from 'zod' @@ -46,15 +47,6 @@ const uploadDeleteSchema = z.object({ const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 - -type SlashCommand = { - name: string - description?: string - source: 'builtin' | 'user' | 'plugin' | 'project' - content?: string - pluginName?: string -} - function commandsFromMetadataSlashCommands(names: readonly string[] | undefined): SlashCommand[] { if (!names?.length) { return [] diff --git a/shared/package.json b/shared/package.json index 0254c0a1..54070ab7 100644 --- a/shared/package.json +++ b/shared/package.json @@ -9,6 +9,7 @@ ".": "./src/index.ts", "./apiTypes": "./src/apiTypes.ts", "./messages": "./src/messages.ts", + "./slashCommands": "./src/slashCommands.ts", "./buildInfo": "./src/buildInfo.ts", "./modes": "./src/modes.ts", "./schemas": "./src/schemas.ts", diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 4b9d9cc9..65587900 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -93,3 +93,17 @@ export type OpencodeModelsResponse = { } export type ListOpencodeModelsResponse = OpencodeModelsResponse + +export type SlashCommand = { + name: string + description?: string + source: 'builtin' | 'user' | 'plugin' | 'project' + content?: string + pluginName?: string +} + +export type SlashCommandsResponse = { + success: boolean + commands?: SlashCommand[] + error?: string +} diff --git a/shared/src/index.ts b/shared/src/index.ts index 9716e60d..09b7f18d 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -7,6 +7,7 @@ export * from './modes' export * from './resume' export * from './socket' export * from './sessionSummary' +export * from './slashCommands' export * from './utils' export * from './version' export type * from './types' diff --git a/shared/src/slashCommands.ts b/shared/src/slashCommands.ts new file mode 100644 index 00000000..b62c353f --- /dev/null +++ b/shared/src/slashCommands.ts @@ -0,0 +1,55 @@ +import type { SlashCommand } from './apiTypes' + +export const BUILTIN_SLASH_COMMANDS = { + claude: [ + { name: 'clear', description: 'Clear conversation history and free up context', source: 'builtin' }, + { name: 'compact', description: 'Clear conversation history but keep a summary in context', source: 'builtin' }, + { name: 'context', description: 'Visualize current context usage as a colored grid', source: 'builtin' }, + { name: 'cost', description: 'Show the total cost and duration of the current session', source: 'builtin' }, + { name: 'doctor', description: 'Diagnose and verify your Claude Code installation and settings', source: 'builtin' }, + { name: 'plan', description: 'View or open the current session plan', source: 'builtin' }, + { name: 'stats', description: 'Show your Claude Code usage statistics and activity', source: 'builtin' }, + { name: 'status', description: 'Show Claude Code status including version, model, account, and API connectivity', source: 'builtin' }, + ], + codex: [ + { name: 'clear', description: 'Clear current Codex thread context', source: 'builtin' }, + { name: 'compact', description: 'Compact current Codex thread context', source: 'builtin' }, + { name: 'goal', description: 'Set, view, pause, resume, or clear a persistent Codex goal', source: 'builtin' }, + { name: 'help', description: 'Show supported HAPI Codex slash commands', source: 'builtin' }, + { name: 'plan', description: 'Enable plan mode; use /plan off to return to default', source: 'builtin' }, + { name: 'default', description: 'Return Codex collaboration mode to default', source: 'builtin' }, + { name: 'execute', description: 'Return Codex collaboration mode to default', source: 'builtin' }, + { name: 'status', description: 'Show current Codex session config', source: 'builtin' }, + { name: 'model', description: 'Show or set Codex model, e.g. /model gpt-5.5', source: 'builtin' }, + { name: 'reasoning', description: 'Show or set reasoning effort', source: 'builtin' }, + { name: 'effort', description: 'Alias for /reasoning', source: 'builtin' }, + { name: 'permissions', description: 'Show or set permission mode', source: 'builtin' }, + { name: 'permission', description: 'Alias for /permissions', source: 'builtin' }, + ], + gemini: [ + { name: 'about', description: 'Show version info', source: 'builtin' }, + { name: 'clear', description: 'Clear the screen and conversation history', source: 'builtin' }, + { name: 'compress', description: 'Compress the context by replacing it with a summary', source: 'builtin' }, + { name: 'stats', description: 'Check session stats', source: 'builtin' }, + ], + opencode: [], + cursor: [], +} as const satisfies Record + +export function getBuiltinSlashCommands(agent: string): SlashCommand[] { + const commands = BUILTIN_SLASH_COMMANDS[agent as keyof typeof BUILTIN_SLASH_COMMANDS] + ?? BUILTIN_SLASH_COMMANDS.claude + return commands.map((command) => ({ ...command })) +} + +export function mergeSlashCommands(commands: readonly SlashCommand[]): SlashCommand[] { + const commandMap = new Map() + for (const command of commands) { + const key = command.name.toLowerCase() + if (commandMap.has(key)) { + commandMap.delete(key) + } + commandMap.set(key, command) + } + return Array.from(commandMap.values()) +} diff --git a/web/src/lib/codexSlashCommands.ts b/web/src/lib/codexSlashCommands.ts index 98c7d59a..bb02e133 100644 --- a/web/src/lib/codexSlashCommands.ts +++ b/web/src/lib/codexSlashCommands.ts @@ -1,39 +1,8 @@ -import type { SlashCommand } from '@/types/api' - -const BUILTIN_COMMANDS: Record = { - claude: [ - { name: 'clear', description: 'Clear conversation history and free up context', source: 'builtin' }, - { name: 'compact', description: 'Clear conversation history but keep a summary in context', source: 'builtin' }, - { name: 'context', description: 'Visualize current context usage as a colored grid', source: 'builtin' }, - { name: 'cost', description: 'Show the total cost and duration of the current session', source: 'builtin' }, - { name: 'doctor', description: 'Diagnose and verify your Claude Code installation and settings', source: 'builtin' }, - { name: 'plan', description: 'View or open the current session plan', source: 'builtin' }, - { name: 'stats', description: 'Show your Claude Code usage statistics and activity', source: 'builtin' }, - { name: 'status', description: 'Show Claude Code status including version, model, account, and API connectivity', source: 'builtin' }, - ], - codex: [ - { name: 'clear', description: 'Clear current Codex thread context', source: 'builtin' }, - { name: 'compact', description: 'Compact current Codex thread context', source: 'builtin' }, - { name: 'goal', description: 'Set, view, pause, resume, or clear a persistent Codex goal', source: 'builtin' }, - { name: 'help', description: 'Show supported HAPI Codex slash commands', source: 'builtin' }, - { name: 'plan', description: 'Enable plan mode; use /plan off to return to default', source: 'builtin' }, - { name: 'default', description: 'Return Codex collaboration mode to default', source: 'builtin' }, - { name: 'execute', description: 'Return Codex collaboration mode to default', source: 'builtin' }, - { name: 'status', description: 'Show current Codex session config', source: 'builtin' }, - { name: 'model', description: 'Show or set Codex model, e.g. /model gpt-5.5', source: 'builtin' }, - { name: 'reasoning', description: 'Show or set reasoning effort', source: 'builtin' }, - { name: 'effort', description: 'Alias for /reasoning', source: 'builtin' }, - { name: 'permissions', description: 'Show or set permission mode', source: 'builtin' }, - { name: 'permission', description: 'Alias for /permissions', source: 'builtin' }, - ], - gemini: [ - { name: 'about', description: 'Show version info', source: 'builtin' }, - { name: 'clear', description: 'Clear the screen and conversation history', source: 'builtin' }, - { name: 'compress', description: 'Compress the context by replacing it with a summary', source: 'builtin' }, - { name: 'stats', description: 'Check session stats', source: 'builtin' }, - ], - opencode: [], -} +import { + getBuiltinSlashCommands, + mergeSlashCommands +} from '@hapi/protocol/slashCommands' +import type { SlashCommand } from '@hapi/protocol/apiTypes' const UNSUPPORTED_CODEX_BUILTIN_COMMANDS = new Set([ 'review', @@ -43,21 +12,7 @@ const UNSUPPORTED_CODEX_BUILTIN_COMMANDS = new Set([ 'diff', ]) -export function getBuiltinSlashCommands(agentType: string): SlashCommand[] { - return BUILTIN_COMMANDS[agentType] ?? BUILTIN_COMMANDS.claude ?? [] -} - -export function mergeSlashCommands(commands: readonly SlashCommand[]): SlashCommand[] { - const commandMap = new Map() - for (const command of commands) { - const key = command.name.toLowerCase() - if (commandMap.has(key)) { - commandMap.delete(key) - } - commandMap.set(key, command) - } - return Array.from(commandMap.values()) -} +export { getBuiltinSlashCommands, mergeSlashCommands } export function findCodexCustomPromptExpansion( text: string, diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 8ae9c6f9..14d36172 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -21,6 +21,8 @@ export type { OpencodeModelsResponse, OpencodeModelSummary, PathExistsResponse, + SlashCommand, + SlashCommandsResponse, UploadFileResponse } from '@hapi/protocol/apiTypes' @@ -155,20 +157,6 @@ export type GitStatusFiles = { totalUnstaged: number } -export type SlashCommand = { - name: string - description?: string - source: 'builtin' | 'user' | 'plugin' | 'project' - content?: string // Expanded content for Codex user prompts - pluginName?: string -} - -export type SlashCommandsResponse = { - success: boolean - commands?: SlashCommand[] - error?: string -} - export type SkillSummary = { name: string description?: string