fix(cli): support project slash command completion (#245)

Add project-level slash command discovery with recursive nested command scanning, pass workingDirectory through slash-command handlers, and align hub/web source unions to include project commands.
This commit is contained in:
Jlovec
2026-03-05 12:50:59 +08:00
committed by GitHub
parent 55f08bd562
commit ef3328f48f
9 changed files with 164 additions and 17 deletions
@@ -3,12 +3,12 @@ import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'
import { listSlashCommands, type ListSlashCommandsRequest, type ListSlashCommandsResponse } from '../slashCommands' import { listSlashCommands, type ListSlashCommandsRequest, type ListSlashCommandsResponse } from '../slashCommands'
import { getErrorMessage, rpcError } from '../rpcResponses' import { getErrorMessage, rpcError } from '../rpcResponses'
export function registerSlashCommandHandlers(rpcHandlerManager: RpcHandlerManager): void { export function registerSlashCommandHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void {
rpcHandlerManager.registerHandler<ListSlashCommandsRequest, ListSlashCommandsResponse>('listSlashCommands', async (data) => { rpcHandlerManager.registerHandler<ListSlashCommandsRequest, ListSlashCommandsResponse>('listSlashCommands', async (data) => {
logger.debug('List slash commands request for agent:', data.agent) logger.debug('List slash commands request for agent:', data.agent)
try { try {
const commands = await listSlashCommands(data.agent) const commands = await listSlashCommands(data.agent, workingDirectory)
return { success: true, commands } return { success: true, commands }
} catch (error) { } catch (error) {
logger.debug('Failed to list slash commands:', error) logger.debug('Failed to list slash commands:', error)
@@ -15,7 +15,7 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor
registerDirectoryHandlers(rpcHandlerManager, workingDirectory) registerDirectoryHandlers(rpcHandlerManager, workingDirectory)
registerRipgrepHandlers(rpcHandlerManager, workingDirectory) registerRipgrepHandlers(rpcHandlerManager, workingDirectory)
registerDifftasticHandlers(rpcHandlerManager, workingDirectory) registerDifftasticHandlers(rpcHandlerManager, workingDirectory)
registerSlashCommandHandlers(rpcHandlerManager) registerSlashCommandHandlers(rpcHandlerManager, workingDirectory)
registerSkillsHandlers(rpcHandlerManager) registerSkillsHandlers(rpcHandlerManager)
registerGitHandlers(rpcHandlerManager, workingDirectory) registerGitHandlers(rpcHandlerManager, workingDirectory)
registerUploadHandlers(rpcHandlerManager) registerUploadHandlers(rpcHandlerManager)
@@ -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()
})
})
+54 -8
View File
@@ -6,7 +6,7 @@ import { parse as parseYaml } from 'yaml';
export interface SlashCommand { export interface SlashCommand {
name: string; name: string;
description?: string; description?: string;
source: 'builtin' | 'user' | 'plugin'; source: 'builtin' | 'user' | 'plugin' | 'project';
content?: string; // Expanded content for Codex user prompts content?: string; // Expanded content for Codex user prompts
pluginName?: string; // Name of the plugin that provides this command 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). * Scan a directory for commands (*.md files).
* Returns commands with parsed frontmatter. * Returns commands with parsed frontmatter.
*/ */
async function scanCommandsDir( async function scanCommandsDir(
dir: string, dir: string,
source: 'user' | 'plugin', source: 'user' | 'plugin' | 'project',
pluginName?: string pluginName?: string
): Promise<SlashCommand[]> { ): Promise<SlashCommand[]> {
async function scanRecursive(currentDir: string, segments: string[]): Promise<SlashCommand[]> { async function scanRecursive(currentDir: string, segments: string[]): Promise<SlashCommand[]> {
@@ -179,6 +195,22 @@ async function scanUserCommands(agent: string): Promise<SlashCommand[]> {
return scanCommandsDir(dir, 'user'); return scanCommandsDir(dir, 'user');
} }
/**
* Scan project-defined commands from <projectDir>/.claude/commands/ or equivalent.
*/
async function scanProjectCommands(agent: string, projectDir?: string): Promise<SlashCommand[]> {
if (!projectDir) {
return [];
}
const dir = getProjectCommandsDir(agent, projectDir);
if (!dir) {
return [];
}
return scanCommandsDir(dir, 'project');
}
/** /**
* Scan plugin commands from installed Claude plugins. * Scan plugin commands from installed Claude plugins.
* Reads ~/.claude/plugins/installed_plugins.json to find installed plugins, * Reads ~/.claude/plugins/installed_plugins.json to find installed plugins,
@@ -234,17 +266,31 @@ async function scanPluginCommands(agent: string): Promise<SlashCommand[]> {
/** /**
* List all available slash commands for an agent type. * 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<SlashCommand[]> { export async function listSlashCommands(agent: string, projectDir?: string): Promise<SlashCommand[]> {
const builtin = BUILTIN_COMMANDS[agent] ?? []; const builtin = BUILTIN_COMMANDS[agent] ?? [];
// Scan user commands and plugin commands in parallel // Scan all command sources in parallel
const [user, plugin] = await Promise.all([ const [user, plugin, project] = await Promise.all([
scanUserCommands(agent), scanUserCommands(agent),
scanPluginCommands(agent), scanPluginCommands(agent),
scanProjectCommands(agent, projectDir),
]); ]);
// Combine: built-in first, then user commands, then plugin commands const allCommands = [...builtin, ...user, ...plugin, ...project];
return [...builtin, ...user, ...plugin];
// Keep insertion order while allowing latter commands to override prior ones.
const commandMap = new Map<string, SlashCommand>();
for (const command of allCommands) {
if (commandMap.has(command.name)) {
commandMap.delete(command.name);
}
commandMap.set(command.name, command);
}
return Array.from(commandMap.values());
} }
+2 -2
View File
@@ -186,12 +186,12 @@ export class RpcGateway {
async listSlashCommands(sessionId: string, agent: string): Promise<{ async listSlashCommands(sessionId: string, agent: string): Promise<{
success: boolean success: boolean
commands?: Array<{ name: string; description?: string; source: 'builtin' | 'user' }> commands?: Array<{ name: string; description?: string; source: 'builtin' | 'user' | 'plugin' | 'project' }>
error?: string error?: string
}> { }> {
return await this.sessionRpc(sessionId, 'listSlashCommands', { agent }) as { return await this.sessionRpc(sessionId, 'listSlashCommands', { agent }) as {
success: boolean success: boolean
commands?: Array<{ name: string; description?: string; source: 'builtin' | 'user' }> commands?: Array<{ name: string; description?: string; source: 'builtin' | 'user' | 'plugin' | 'project' }>
error?: string error?: string
} }
} }
+1 -1
View File
@@ -450,7 +450,7 @@ export class SyncEngine {
async listSlashCommands(sessionId: string, agent: string): Promise<{ async listSlashCommands(sessionId: string, agent: string): Promise<{
success: boolean success: boolean
commands?: Array<{ name: string; description?: string; source: 'builtin' | 'user' }> commands?: Array<{ name: string; description?: string; source: 'builtin' | 'user' | 'plugin' | 'project' }>
error?: string error?: string
}> { }> {
return await this.rpcGateway.listSlashCommands(sessionId, agent) return await this.rpcGateway.listSlashCommands(sessionId, agent)
+1 -1
View File
@@ -87,7 +87,7 @@ export function useSlashCommands(
// If API succeeded, add user-defined and plugin commands // If API succeeded, add user-defined and plugin commands
if (query.data?.success && query.data.commands) { if (query.data?.success && query.data.commands) {
const extraCommands = query.data.commands.filter( 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] return [...builtin, ...extraCommands]
} }
+1 -1
View File
@@ -6,7 +6,7 @@ export interface Suggestion {
label: string label: string
description?: string description?: string
content?: string // Expanded content for Codex user prompts content?: string // Expanded content for Codex user prompts
source?: 'builtin' | 'user' | 'plugin' source?: 'builtin' | 'user' | 'plugin' | 'project'
} }
interface SuggestionOptions { interface SuggestionOptions {
+1 -1
View File
@@ -151,7 +151,7 @@ export type GitStatusFiles = {
export type SlashCommand = { export type SlashCommand = {
name: string name: string
description?: string description?: string
source: 'builtin' | 'user' | 'plugin' source: 'builtin' | 'user' | 'plugin' | 'project'
content?: string // Expanded content for Codex user prompts content?: string // Expanded content for Codex user prompts
pluginName?: string pluginName?: string
} }