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 { 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) => {
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)
@@ -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)
@@ -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 {
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<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');
}
/**
* 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.
* 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.
* 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] ?? [];
// 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<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<{
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
}
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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]
}
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
}