mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix: expose plugin skills in new session slash commands
New desktop sessions populated slash suggestions from the session endpoint before the CLI had emitted init metadata. That endpoint only scanned user and project skill directories, while the plugin settings view and global skills API already saw enabled plugin skills such as superpowers. The session endpoint now reuses the global skill listing and merges it with any cached CLI slash commands, and both composer surfaces rank command-name matches before broad description matches so /su surfaces superpowers first. Constraint: New sessions need plugin skills before the first real user turn starts the CLI. Rejected: Start or restart a hidden CLI process on plugin enable | heavier than needed and still misses the REST slash-command fallback path. Confidence: high Scope-risk: moderate Directive: Keep session slash commands and /api/skills on the same skill discovery path when changing plugin skill loading. Tested: bun test src/server/__tests__/skills.test.ts src/server/__tests__/plugins.test.ts src/server/__tests__/sessions.test.ts Tested: cd desktop && bun run test --run src/components/chat/composerUtils.test.ts src/pages/EmptySession.test.tsx src/components/chat/ChatInput.test.tsx Tested: bun run check:server Tested: cd desktop && bun run lint Not-tested: Manual desktop click-through in the packaged app.
This commit is contained in:
parent
62713f30de
commit
b4a5c09b11
@ -704,4 +704,45 @@ describe('ChatInput file mentions', () => {
|
||||
expect(fileSearchMenu).not.toHaveClass('min-w-[480px]')
|
||||
expect(fileSearchMenu).not.toHaveTextContent('Navigate')
|
||||
})
|
||||
|
||||
it('prioritizes active-session slash commands by command name when filtering', async () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[sessionId]: {
|
||||
...useChatStore.getState().sessions[sessionId]!,
|
||||
slashCommands: [
|
||||
{
|
||||
name: 'agent-team-orchestrator',
|
||||
description: 'Agent Teams can use Subagent orchestration.',
|
||||
},
|
||||
{
|
||||
name: 'lark-calendar',
|
||||
description: 'Includes suggestion helpers.',
|
||||
},
|
||||
{
|
||||
name: 'superpowers:brainstorming',
|
||||
description: 'Creative work planning.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(<ChatInput />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.getGitInfo).toHaveBeenCalledWith(sessionId)
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: { value: '/su', selectionStart: 3 },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const commandButtons = screen
|
||||
.getAllByRole('button')
|
||||
.filter((button) => button.textContent?.startsWith('/'))
|
||||
expect(commandButtons[0]).toHaveTextContent('/superpowers:brainstorming')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -24,6 +24,7 @@ import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlash
|
||||
import { ContextUsageIndicator } from './ContextUsageIndicator'
|
||||
import {
|
||||
FALLBACK_SLASH_COMMANDS,
|
||||
filterSlashCommands,
|
||||
findSlashTrigger,
|
||||
mergeSlashCommands,
|
||||
replaceSlashToken,
|
||||
@ -343,14 +344,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
)
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
const source = allSlashCommands
|
||||
if (!slashFilter) return source
|
||||
const lower = slashFilter.toLowerCase()
|
||||
return source.filter((command) => (
|
||||
command.name.toLowerCase().includes(lower) ||
|
||||
command.description.toLowerCase().includes(lower) ||
|
||||
command.argumentHint?.toLowerCase().includes(lower)
|
||||
))
|
||||
return filterSlashCommands(allSlashCommands, slashFilter)
|
||||
}, [allSlashCommands, slashFilter])
|
||||
|
||||
const exactSlashCommand = useMemo(() => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
filterSlashCommands,
|
||||
findSlashToken,
|
||||
insertSlashTrigger,
|
||||
mergeSlashCommands,
|
||||
@ -75,6 +76,22 @@ describe('composerUtils', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('ranks slash command name matches before broad description matches', () => {
|
||||
expect(
|
||||
filterSlashCommands([
|
||||
{ name: 'lark-calendar', description: 'Includes shortcuts and suggestion helpers' },
|
||||
{ name: 'agent-team-orchestrator', description: 'Uses Subagent orchestration' },
|
||||
{ name: 'superpowers:brainstorming', description: 'Creative work planning' },
|
||||
{ name: 'superpowers:systematic-debugging', description: 'Debug unexpected behavior' },
|
||||
], 'su').map((command) => command.name),
|
||||
).toEqual([
|
||||
'superpowers:brainstorming',
|
||||
'superpowers:systematic-debugging',
|
||||
'lark-calendar',
|
||||
'agent-team-orchestrator',
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves hidden settings aliases without displaying duplicate fallback rows', () => {
|
||||
expect(resolveSlashUiAction('plugins')).toEqual({ type: 'settings', tab: 'plugins' })
|
||||
expect(resolveSlashUiAction('memory')).toEqual({ type: 'settings', tab: 'memory' })
|
||||
|
||||
@ -108,6 +108,39 @@ export function mergeSlashCommands(
|
||||
return [...merged.values()]
|
||||
}
|
||||
|
||||
function getSlashCommandMatchRank(command: SlashCommandOption, filter: string): number {
|
||||
const name = command.name.toLowerCase()
|
||||
const description = command.description.toLowerCase()
|
||||
const argumentHint = command.argumentHint?.toLowerCase() ?? ''
|
||||
const nameParts = name.split(/[:/._-]+/).filter(Boolean)
|
||||
|
||||
if (name === filter) return 0
|
||||
if (name.startsWith(filter)) return 1
|
||||
if (nameParts.some((part) => part.startsWith(filter))) return 2
|
||||
if (name.includes(filter)) return 3
|
||||
if (description.includes(filter)) return 4
|
||||
if (argumentHint.includes(filter)) return 5
|
||||
return Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
export function filterSlashCommands(
|
||||
commands: ReadonlyArray<SlashCommandOption>,
|
||||
filter: string,
|
||||
): SlashCommandOption[] {
|
||||
const normalized = filter.trim().toLowerCase()
|
||||
if (!normalized) return [...commands]
|
||||
|
||||
return commands
|
||||
.map((command, index) => ({
|
||||
command,
|
||||
index,
|
||||
rank: getSlashCommandMatchRank(command, normalized),
|
||||
}))
|
||||
.filter((item) => Number.isFinite(item.rank))
|
||||
.sort((a, b) => a.rank - b.rank || a.index - b.index)
|
||||
.map((item) => item.command)
|
||||
}
|
||||
|
||||
export type SlashTrigger = {
|
||||
slashPos: number
|
||||
filter: string
|
||||
|
||||
@ -246,6 +246,45 @@ describe('EmptySession', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('prioritizes enabled plugin slash commands by command name when filtering', async () => {
|
||||
mocks.listSkills.mockResolvedValueOnce({
|
||||
skills: [
|
||||
{
|
||||
name: 'agent-team-orchestrator',
|
||||
description: 'Agent Teams can use Subagent orchestration.',
|
||||
userInvocable: true,
|
||||
},
|
||||
{
|
||||
name: 'lark-calendar',
|
||||
description: 'Includes suggestion helpers.',
|
||||
userInvocable: true,
|
||||
},
|
||||
{
|
||||
name: 'superpowers:brainstorming',
|
||||
description: 'Creative work planning.',
|
||||
userInvocable: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
render(<EmptySession />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.listSkills).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: { value: '/su', selectionStart: 3 },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const commandButtons = screen
|
||||
.getAllByRole('button')
|
||||
.filter((button) => button.textContent?.startsWith('/'))
|
||||
expect(commandButtons[0]).toHaveTextContent('/superpowers:brainstorming')
|
||||
})
|
||||
})
|
||||
|
||||
it('integrates repository launch controls into the desktop composer panel', async () => {
|
||||
render(<EmptySession />)
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ import {
|
||||
} from '../lib/composerAttachments'
|
||||
import {
|
||||
FALLBACK_SLASH_COMMANDS,
|
||||
filterSlashCommands,
|
||||
findSlashToken,
|
||||
insertSlashTrigger,
|
||||
mergeSlashCommands,
|
||||
@ -215,13 +216,7 @@ export function EmptySession() {
|
||||
}
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
const source = allSlashCommands
|
||||
if (!slashFilter) return source
|
||||
const lower = slashFilter.toLowerCase()
|
||||
return source.filter((command) => (
|
||||
command.name.toLowerCase().includes(lower) ||
|
||||
command.description.toLowerCase().includes(lower)
|
||||
))
|
||||
return filterSlashCommands(allSlashCommands, slashFilter)
|
||||
}, [allSlashCommands, slashFilter])
|
||||
|
||||
const exactSlashCommand = useMemo(() => {
|
||||
|
||||
@ -15,6 +15,9 @@ import {
|
||||
import { conversationService } from '../services/conversationService.js'
|
||||
import { clearCommandsCache } from '../../commands.js'
|
||||
import { sanitizePath } from '../../utils/sessionStoragePortable.js'
|
||||
import { clearInstalledPluginsCache } from '../../utils/plugins/installedPluginsManager.js'
|
||||
import { clearPluginCache } from '../../utils/plugins/pluginLoader.js'
|
||||
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
|
||||
|
||||
// ============================================================================
|
||||
// Test helpers
|
||||
@ -397,10 +400,16 @@ describe('SessionService', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTmpConfigDir()
|
||||
service = new SessionService()
|
||||
clearInstalledPluginsCache()
|
||||
clearPluginCache('sessions-api-test-setup')
|
||||
resetSettingsCache()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearCommandsCache()
|
||||
clearInstalledPluginsCache()
|
||||
clearPluginCache('session-service-test-teardown')
|
||||
resetSettingsCache()
|
||||
await cleanupTmpDir()
|
||||
})
|
||||
|
||||
@ -1481,6 +1490,9 @@ describe('Sessions API', () => {
|
||||
server.stop(true)
|
||||
server = null
|
||||
}
|
||||
clearInstalledPluginsCache()
|
||||
clearPluginCache('sessions-api-test-teardown')
|
||||
resetSettingsCache()
|
||||
await cleanupTmpDir()
|
||||
})
|
||||
|
||||
@ -2026,6 +2038,95 @@ describe('Sessions API', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/slash-commands should include enabled plugin skills before CLI init', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-ffffffffffff'
|
||||
const workDir = path.join(tmpDir, 'workspace', 'app')
|
||||
const marketplaceRoot = path.join(tmpDir, 'marketplace-root')
|
||||
const pluginRoot = path.join(marketplaceRoot, 'plugins', 'superpowers')
|
||||
const pluginsDir = path.join(tmpDir, 'plugins')
|
||||
const marketplaceFile = path.join(
|
||||
marketplaceRoot,
|
||||
'.claude-plugin',
|
||||
'marketplace.json',
|
||||
)
|
||||
|
||||
await fs.mkdir(path.join(pluginRoot, '.claude-plugin'), { recursive: true })
|
||||
await fs.mkdir(path.dirname(marketplaceFile), { recursive: true })
|
||||
await fs.mkdir(pluginsDir, { recursive: true })
|
||||
await fs.mkdir(workDir, { recursive: true })
|
||||
await writeSkill(
|
||||
path.join(pluginRoot, 'skills'),
|
||||
'brainstorming',
|
||||
'Superpowers brainstorming skill',
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, '.claude-plugin', 'plugin.json'),
|
||||
JSON.stringify({
|
||||
name: 'superpowers',
|
||||
version: '5.0.7',
|
||||
description: 'Core skills library',
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
await fs.writeFile(
|
||||
marketplaceFile,
|
||||
JSON.stringify({
|
||||
name: 'claude-plugins-official',
|
||||
owner: { name: 'Test' },
|
||||
plugins: [
|
||||
{
|
||||
name: 'superpowers',
|
||||
source: './plugins/superpowers',
|
||||
version: '5.0.7',
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(pluginsDir, 'known_marketplaces.json'),
|
||||
JSON.stringify({
|
||||
'claude-plugins-official': {
|
||||
source: { source: 'directory', path: marketplaceRoot },
|
||||
installLocation: marketplaceRoot,
|
||||
lastUpdated: new Date(0).toISOString(),
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
enabledPlugins: {
|
||||
'superpowers@claude-plugins-official': true,
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
resetSettingsCache()
|
||||
clearPluginCache('sessions-api-plugin-skills')
|
||||
clearCommandsCache()
|
||||
await writeSessionFile('-tmp-api-test', sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
makeSessionMetaEntry(workDir),
|
||||
])
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/slash-commands`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as {
|
||||
commands: Array<{ name: string; description: string }>
|
||||
}
|
||||
|
||||
expect(body.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'superpowers:brainstorming',
|
||||
description: 'Superpowers brainstorming skill',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/workspace/status|tree|file|diff should return workspace data', async () => {
|
||||
const workDir = await createWorkspaceApiGitRepo(tmpDir)
|
||||
const { sessionId } = await service.createSession(workDir)
|
||||
|
||||
@ -19,8 +19,7 @@ import { sessionService } from '../services/sessionService.js'
|
||||
import { conversationService } from '../services/conversationService.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
import { closeSessionConnection, getSlashCommands } from '../ws/handler.js'
|
||||
import { getCommandName } from '../../commands.js'
|
||||
import { getSkillDirCommands } from '../../skills/loadSkillsDir.js'
|
||||
import { listSkillSlashCommands, type SkillSlashCommand } from './skills.js'
|
||||
import { WorkspaceService } from '../services/workspaceService.js'
|
||||
import {
|
||||
getRepositoryContext,
|
||||
@ -426,24 +425,43 @@ function cleanupAdapterSessionMappings(sessionId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function getSessionSlashCommands(sessionId: string): Promise<Response> {
|
||||
const cachedCommands = getSlashCommands(sessionId)
|
||||
if (cachedCommands.length > 0) {
|
||||
return Response.json({ commands: cachedCommands })
|
||||
function mergeSessionSlashCommands(
|
||||
preferred: Array<{ name: string; description?: string; argumentHint?: string }>,
|
||||
fallback: SkillSlashCommand[],
|
||||
): Array<{ name: string; description: string; argumentHint?: string }> {
|
||||
const merged = new Map<string, { name: string; description: string; argumentHint?: string }>()
|
||||
|
||||
for (const command of preferred) {
|
||||
if (!command.name) continue
|
||||
merged.set(command.name, {
|
||||
name: command.name,
|
||||
description: command.description || '',
|
||||
...(command.argumentHint ? { argumentHint: command.argumentHint } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
for (const command of fallback) {
|
||||
if (!command.name || merged.has(command.name)) continue
|
||||
merged.set(command.name, {
|
||||
name: command.name,
|
||||
description: command.description || '',
|
||||
})
|
||||
}
|
||||
|
||||
return [...merged.values()]
|
||||
}
|
||||
|
||||
async function getSessionSlashCommands(sessionId: string): Promise<Response> {
|
||||
const cachedCommands = getSlashCommands(sessionId)
|
||||
const workDir = await sessionService.getSessionWorkDir(sessionId)
|
||||
if (!workDir) {
|
||||
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
const commands = await getSkillDirCommands(workDir)
|
||||
const slashCommands = commands
|
||||
.filter((command) => command.userInvocable !== false)
|
||||
.map((command) => ({
|
||||
name: getCommandName(command),
|
||||
description: command.description || '',
|
||||
}))
|
||||
const skillCommands = await listSkillSlashCommands(workDir)
|
||||
const slashCommands = cachedCommands.length > 0
|
||||
? mergeSessionSlashCommands(cachedCommands, skillCommands)
|
||||
: skillCommands
|
||||
|
||||
return Response.json({ commands: slashCommands })
|
||||
}
|
||||
@ -466,14 +484,10 @@ async function getSessionInspection(sessionId: string, url: URL): Promise<Respon
|
||||
.find((message) => message?.type === 'system' && message.subtype === 'init')
|
||||
const transcriptMetadata = await sessionService.getTranscriptMetadata(sessionId)
|
||||
const cachedSlashCommands = getSlashCommands(sessionId)
|
||||
const skillSlashCommands = await listSkillSlashCommands(workDir)
|
||||
const fallbackSlashCommands = cachedSlashCommands.length > 0
|
||||
? cachedSlashCommands
|
||||
: (await getSkillDirCommands(workDir))
|
||||
.filter((command) => command.userInvocable !== false)
|
||||
.map((command) => ({
|
||||
name: getCommandName(command),
|
||||
description: command.description || '',
|
||||
}))
|
||||
? mergeSessionSlashCommands(cachedSlashCommands, skillSlashCommands)
|
||||
: skillSlashCommands
|
||||
const slashCommandCount = Array.isArray(initMessage?.slash_commands)
|
||||
? initMessage.slash_commands.length
|
||||
: fallbackSlashCommands.length
|
||||
|
||||
@ -12,7 +12,7 @@ import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import { getProjectDirsUpToHome } from '../../utils/markdownConfigLoader.js'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js'
|
||||
import { loadAllPlugins, loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js'
|
||||
import type { LoadedPlugin } from '../../types/plugin.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
@ -275,6 +275,11 @@ type PluginSkillLocation = {
|
||||
pluginName: string
|
||||
}
|
||||
|
||||
export type SkillSlashCommand = {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
function buildPluginSkillName(pluginName: string, skillDir: string): string {
|
||||
return `${pluginName}:${path.basename(skillDir)}`
|
||||
}
|
||||
@ -285,7 +290,11 @@ async function collectPluginSkillDirectories(): Promise<Map<string, PluginSkillL
|
||||
let enabledPlugins: LoadedPlugin[]
|
||||
try {
|
||||
const result = await loadAllPluginsCacheOnly()
|
||||
enabledPlugins = result.enabled
|
||||
if (result.errors.some((error) => error.type === 'plugin-cache-miss')) {
|
||||
enabledPlugins = (await loadAllPlugins()).enabled
|
||||
} else {
|
||||
enabledPlugins = result.enabled
|
||||
}
|
||||
} catch {
|
||||
return locations
|
||||
}
|
||||
@ -359,6 +368,27 @@ async function collectPluginSkills(): Promise<SkillMeta[]> {
|
||||
return skills
|
||||
}
|
||||
|
||||
async function collectAllSkills(cwd?: string): Promise<SkillMeta[]> {
|
||||
const [userSkills, projectSkills, pluginSkills] = await Promise.all([
|
||||
collectSkillsFromRoots([getUserSkillsDir()], 'user'),
|
||||
collectSkillsFromRoots(getProjectSkillsDirs(cwd), 'project'),
|
||||
collectPluginSkills(),
|
||||
])
|
||||
|
||||
const skills = [...userSkills, ...projectSkills, ...pluginSkills]
|
||||
skills.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return skills
|
||||
}
|
||||
|
||||
export async function listSkillSlashCommands(cwd?: string): Promise<SkillSlashCommand[]> {
|
||||
return (await collectAllSkills(cwd))
|
||||
.filter((skill) => skill.userInvocable)
|
||||
.map((skill) => ({
|
||||
name: skill.name,
|
||||
description: skill.description || '',
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── Router ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleSkillsApi(
|
||||
@ -390,14 +420,7 @@ export async function handleSkillsApi(
|
||||
|
||||
async function listSkills(url: URL): Promise<Response> {
|
||||
const cwd = getRequestedCwd(url)
|
||||
const [userSkills, projectSkills, pluginSkills] = await Promise.all([
|
||||
collectSkillsFromRoots([getUserSkillsDir()], 'user'),
|
||||
collectSkillsFromRoots(getProjectSkillsDirs(cwd), 'project'),
|
||||
collectPluginSkills(),
|
||||
])
|
||||
|
||||
const skills = [...userSkills, ...projectSkills, ...pluginSkills]
|
||||
skills.sort((a, b) => a.name.localeCompare(b.name))
|
||||
const skills = await collectAllSkills(cwd)
|
||||
return Response.json({ skills })
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user