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:
程序员阿江(Relakkes) 2026-05-16 00:03:44 +08:00
parent 62713f30de
commit b4a5c09b11
9 changed files with 302 additions and 45 deletions

View File

@ -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')
})
})
})

View File

@ -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(() => {

View File

@ -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' })

View File

@ -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

View File

@ -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 />)

View File

@ -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(() => {

View File

@ -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)

View File

@ -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

View File

@ -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 })
}