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 { 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user