From b4a5c09b11dfe76af232bc875802c7ac23b64728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 16 May 2026 00:03:44 +0800 Subject: [PATCH] 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. --- .../src/components/chat/ChatInput.test.tsx | 41 +++++++ desktop/src/components/chat/ChatInput.tsx | 10 +- .../src/components/chat/composerUtils.test.ts | 17 +++ desktop/src/components/chat/composerUtils.ts | 33 ++++++ desktop/src/pages/EmptySession.test.tsx | 39 +++++++ desktop/src/pages/EmptySession.tsx | 9 +- src/server/__tests__/sessions.test.ts | 101 ++++++++++++++++++ src/server/api/sessions.ts | 54 ++++++---- src/server/api/skills.ts | 43 ++++++-- 9 files changed, 302 insertions(+), 45 deletions(-) diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index e12c8d12..e51ca5e2 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -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() + + 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') + }) + }) }) diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 7b0b89dd..579e7b26 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -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(() => { diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index 763b2f4b..2d9b569a 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -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' }) diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index 94ba1f0f..d56f9f9d 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -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, + 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 diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index dfa90160..0d7e0799 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -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() + + 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() diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index 64e02fd1..a2ba8154 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -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(() => { diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index f8bfb1d0..8300c88b 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -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) diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index c8b8e48b..4fe19747 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -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 { - 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() + + 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 { + 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 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 diff --git a/src/server/api/skills.ts b/src/server/api/skills.ts index 59233791..1d268588 100644 --- a/src/server/api/skills.ts +++ b/src/server/api/skills.ts @@ -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 error.type === 'plugin-cache-miss')) { + enabledPlugins = (await loadAllPlugins()).enabled + } else { + enabledPlugins = result.enabled + } } catch { return locations } @@ -359,6 +368,27 @@ async function collectPluginSkills(): Promise { return skills } +async function collectAllSkills(cwd?: string): Promise { + 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 { + 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 { 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 }) }