diff --git a/cli/src/modules/common/handlers/slashCommands.ts b/cli/src/modules/common/handlers/slashCommands.ts index f9a6be7b..1bad6823 100644 --- a/cli/src/modules/common/handlers/slashCommands.ts +++ b/cli/src/modules/common/handlers/slashCommands.ts @@ -3,12 +3,12 @@ import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' import { listSlashCommands, type ListSlashCommandsRequest, type ListSlashCommandsResponse } from '../slashCommands' import { getErrorMessage, rpcError } from '../rpcResponses' -export function registerSlashCommandHandlers(rpcHandlerManager: RpcHandlerManager): void { +export function registerSlashCommandHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { rpcHandlerManager.registerHandler('listSlashCommands', async (data) => { logger.debug('List slash commands request for agent:', data.agent) try { - const commands = await listSlashCommands(data.agent) + const commands = await listSlashCommands(data.agent, workingDirectory) return { success: true, commands } } catch (error) { logger.debug('Failed to list slash commands:', error) diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index 01d23cce..705c956c 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -15,7 +15,7 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor registerDirectoryHandlers(rpcHandlerManager, workingDirectory) registerRipgrepHandlers(rpcHandlerManager, workingDirectory) registerDifftasticHandlers(rpcHandlerManager, workingDirectory) - registerSlashCommandHandlers(rpcHandlerManager) + registerSlashCommandHandlers(rpcHandlerManager, workingDirectory) registerSkillsHandlers(rpcHandlerManager) registerGitHandlers(rpcHandlerManager, workingDirectory) registerUploadHandlers(rpcHandlerManager) diff --git a/cli/src/modules/common/slashCommands.test.ts b/cli/src/modules/common/slashCommands.test.ts new file mode 100644 index 00000000..2296d060 --- /dev/null +++ b/cli/src/modules/common/slashCommands.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { listSlashCommands } from './slashCommands' + +describe('listSlashCommands', () => { + const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR + let sandboxDir: string + let claudeConfigDir: string + let projectDir: string + + beforeEach(async () => { + sandboxDir = await mkdtemp(join(tmpdir(), 'hapi-slash-commands-')) + claudeConfigDir = join(sandboxDir, 'global-claude') + projectDir = join(sandboxDir, 'project') + + process.env.CLAUDE_CONFIG_DIR = claudeConfigDir + + await mkdir(join(claudeConfigDir, 'commands'), { recursive: true }) + await mkdir(join(projectDir, '.claude', 'commands'), { recursive: true }) + }) + + afterEach(async () => { + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir + } + + await rm(sandboxDir, { recursive: true, force: true }) + }) + + it('keeps backward-compatible behavior when projectDir is not provided', async () => { + await writeFile( + join(claudeConfigDir, 'commands', 'global-only.md'), + ['---', 'description: Global only', '---', '', 'Global command body'].join('\n') + ) + + const commands = await listSlashCommands('claude') + const command = commands.find(cmd => cmd.name === 'global-only') + + expect(command).toBeDefined() + expect(command?.source).toBe('user') + expect(command?.description).toBe('Global only') + }) + + it('loads project-level commands when projectDir is provided', async () => { + await writeFile( + join(projectDir, '.claude', 'commands', 'project-only.md'), + ['---', 'description: Project only', '---', '', 'Project command body'].join('\n') + ) + + const commands = await listSlashCommands('claude', projectDir) + const command = commands.find(cmd => cmd.name === 'project-only') + + expect(command).toBeDefined() + expect(command?.source).toBe('project') + expect(command?.description).toBe('Project only') + }) + + it('prefers project command when project and global have same name', async () => { + await writeFile( + join(claudeConfigDir, 'commands', 'shared.md'), + ['---', 'description: Global shared', '---', '', 'Global body'].join('\n') + ) + await writeFile( + join(projectDir, '.claude', 'commands', 'shared.md'), + ['---', 'description: Project shared', '---', '', 'Project body'].join('\n') + ) + + const commands = await listSlashCommands('claude', projectDir) + const sharedCommands = commands.filter(cmd => cmd.name === 'shared') + + expect(sharedCommands).toHaveLength(1) + expect(sharedCommands[0]?.source).toBe('project') + expect(sharedCommands[0]?.description).toBe('Project shared') + expect(sharedCommands[0]?.content).toBe('Project body') + }) + + it('loads nested project commands using colon-separated names', async () => { + await mkdir(join(projectDir, '.claude', 'commands', 'trellis'), { recursive: true }) + await writeFile( + join(projectDir, '.claude', 'commands', 'trellis', 'start.md'), + ['---', 'description: Trellis start', '---', '', 'Start flow'].join('\n') + ) + + const commands = await listSlashCommands('claude', projectDir) + const command = commands.find(cmd => cmd.name === 'trellis:start') + + expect(command).toBeDefined() + expect(command?.source).toBe('project') + expect(command?.description).toBe('Trellis start') + }) + + it('returns empty project commands when project directory does not exist', async () => { + const nonExistentProjectDir = join(sandboxDir, 'not-exists') + + await expect(listSlashCommands('claude', nonExistentProjectDir)).resolves.toBeDefined() + }) +}) diff --git a/cli/src/modules/common/slashCommands.ts b/cli/src/modules/common/slashCommands.ts index f2f31b7c..127cd78b 100644 --- a/cli/src/modules/common/slashCommands.ts +++ b/cli/src/modules/common/slashCommands.ts @@ -6,7 +6,7 @@ import { parse as parseYaml } from 'yaml'; export interface SlashCommand { name: string; description?: string; - source: 'builtin' | 'user' | 'plugin'; + source: 'builtin' | 'user' | 'plugin' | 'project'; content?: string; // Expanded content for Codex user prompts pluginName?: string; // Name of the plugin that provides this command } @@ -99,13 +99,29 @@ function getUserCommandsDir(agent: string): string | null { } } +/** + * Get the project commands directory for an agent type. + * Returns null if the agent doesn't support project commands. + */ +function getProjectCommandsDir(agent: string, projectDir: string): string | null { + switch (agent) { + case 'claude': + return join(projectDir, '.claude', 'commands'); + case 'codex': + return join(projectDir, '.codex', 'prompts'); + default: + // Gemini and other agents don't have project commands + return null; + } +} + /** * Scan a directory for commands (*.md files). * Returns commands with parsed frontmatter. */ async function scanCommandsDir( dir: string, - source: 'user' | 'plugin', + source: 'user' | 'plugin' | 'project', pluginName?: string ): Promise { async function scanRecursive(currentDir: string, segments: string[]): Promise { @@ -179,6 +195,22 @@ async function scanUserCommands(agent: string): Promise { return scanCommandsDir(dir, 'user'); } +/** + * Scan project-defined commands from /.claude/commands/ or equivalent. + */ +async function scanProjectCommands(agent: string, projectDir?: string): Promise { + if (!projectDir) { + return []; + } + + const dir = getProjectCommandsDir(agent, projectDir); + if (!dir) { + return []; + } + + return scanCommandsDir(dir, 'project'); +} + /** * Scan plugin commands from installed Claude plugins. * Reads ~/.claude/plugins/installed_plugins.json to find installed plugins, @@ -234,17 +266,31 @@ async function scanPluginCommands(agent: string): Promise { /** * List all available slash commands for an agent type. - * Returns built-in commands, user-defined commands, and plugin commands. + * Returns built-in commands, user-defined commands, plugin commands, and project commands. + * + * Merge order follows locality precedence for custom commands: + * built-in -> global user -> plugin -> project (project overrides same-name globals). */ -export async function listSlashCommands(agent: string): Promise { +export async function listSlashCommands(agent: string, projectDir?: string): Promise { const builtin = BUILTIN_COMMANDS[agent] ?? []; - // Scan user commands and plugin commands in parallel - const [user, plugin] = await Promise.all([ + // Scan all command sources in parallel + const [user, plugin, project] = await Promise.all([ scanUserCommands(agent), scanPluginCommands(agent), + scanProjectCommands(agent, projectDir), ]); - // Combine: built-in first, then user commands, then plugin commands - return [...builtin, ...user, ...plugin]; + 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()); } diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 85774dcd..3aca3e20 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -186,12 +186,12 @@ export class RpcGateway { async listSlashCommands(sessionId: string, agent: string): Promise<{ success: boolean - commands?: Array<{ name: string; description?: string; source: 'builtin' | 'user' }> + 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' }> + commands?: Array<{ name: string; description?: string; source: 'builtin' | 'user' | 'plugin' | 'project' }> error?: string } } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 23ae6376..da497556 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -450,7 +450,7 @@ export class SyncEngine { async listSlashCommands(sessionId: string, agent: string): Promise<{ success: boolean - commands?: Array<{ name: string; description?: string; source: 'builtin' | 'user' }> + commands?: Array<{ name: string; description?: string; source: 'builtin' | 'user' | 'plugin' | 'project' }> error?: string }> { return await this.rpcGateway.listSlashCommands(sessionId, agent) diff --git a/web/src/hooks/queries/useSlashCommands.ts b/web/src/hooks/queries/useSlashCommands.ts index 9af586ff..77ae8b57 100644 --- a/web/src/hooks/queries/useSlashCommands.ts +++ b/web/src/hooks/queries/useSlashCommands.ts @@ -87,7 +87,7 @@ export function useSlashCommands( // If API succeeded, add user-defined and plugin commands if (query.data?.success && query.data.commands) { const extraCommands = query.data.commands.filter( - cmd => cmd.source === 'user' || cmd.source === 'plugin' + cmd => cmd.source === 'user' || cmd.source === 'plugin' || cmd.source === 'project' ) return [...builtin, ...extraCommands] } diff --git a/web/src/hooks/useActiveSuggestions.ts b/web/src/hooks/useActiveSuggestions.ts index 7dc05bb8..7bd76df6 100644 --- a/web/src/hooks/useActiveSuggestions.ts +++ b/web/src/hooks/useActiveSuggestions.ts @@ -6,7 +6,7 @@ export interface Suggestion { label: string description?: string content?: string // Expanded content for Codex user prompts - source?: 'builtin' | 'user' | 'plugin' + source?: 'builtin' | 'user' | 'plugin' | 'project' } interface SuggestionOptions { diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 278a49bf..fe2c8667 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -151,7 +151,7 @@ export type GitStatusFiles = { export type SlashCommand = { name: string description?: string - source: 'builtin' | 'user' | 'plugin' + source: 'builtin' | 'user' | 'plugin' | 'project' content?: string // Expanded content for Codex user prompts pluginName?: string }