mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user