fix: make portable plugin skills visible after CLI installs

Portable desktop installs can mutate CLAUDE_CONFIG_DIR from an embedded or external CLI while the desktop server is already running. The server skill surface now rereads plugin-related state before listing skills, and /reload-plugins refreshes plugin skills alongside plugin commands so terminal sessions see the same enabled skills without a restart.

Constraint: Portable mode expects CLI, desktop API, and terminal sessions to share the selected CLAUDE_CONFIG_DIR even when writes happen from separate processes
Rejected: Require a desktop restart after plugin install | leaves the portable install workflow stale and contradicts the existing /reload-plugins handoff
Confidence: high
Scope-risk: moderate
Directive: Keep skill discovery and reload summaries counting plugin skills separately from plugin slash commands
Tested: bun test src/server/__tests__/skills.test.ts
Tested: bun test src/server/__tests__/plugins.test.ts
Tested: bun test src/server/__tests__/sessions.test.ts
Tested: bun run check:server
Tested: CLAUDE_CONFIG_DIR=/tmp portable smoke with anthropics/skills, two cwd /api/skills checks, and DeepSeek skill slash-command calls
Tested: bun run quality:gate --mode baseline --allow-live --provider-model deepseek:main:deepseek-main --only provider-smoke:*
Not-tested: Packaged desktop UI visual smoke
This commit is contained in:
程序员阿江(Relakkes) 2026-05-25 17:32:15 +08:00
parent 3d2b219eda
commit 333271b159
6 changed files with 139 additions and 10 deletions

View File

@ -38,7 +38,7 @@ export const call: LocalCommandCall = async (_args, context) => {
const parts = [
n(r.enabled_count, 'plugin'),
n(r.command_count, 'skill'),
n(r.skill_count, 'skill'),
n(r.agent_count, 'agent'),
n(r.hook_count, 'hook'),
// "plugin MCP/LSP" disambiguates from user-config/built-in servers,

View File

@ -256,6 +256,7 @@ describe('Plugins API', () => {
await fs.mkdir(path.join(pluginRoot, '.claude-plugin'), { recursive: true })
await fs.mkdir(path.join(pluginRoot, 'commands'), { recursive: true })
await fs.mkdir(path.join(pluginRoot, 'skills', 'paint'), { recursive: true })
await fs.mkdir(path.dirname(marketplaceFile), { recursive: true })
await fs.mkdir(pluginsDir, { recursive: true })
@ -273,6 +274,11 @@ describe('Plugins API', () => {
'---\ndescription: Render a drawing.\n---\nRender this drawing.',
'utf-8',
)
await fs.writeFile(
path.join(pluginRoot, 'skills', 'paint', 'SKILL.md'),
'---\ndescription: Paint with the drawing plugin.\n---\nPaint this drawing.',
'utf-8',
)
await fs.writeFile(
marketplaceFile,
JSON.stringify({
@ -345,14 +351,24 @@ describe('Plugins API', () => {
expect(result.enabled_count).toBe(1)
expect(result.command_count).toBe(1)
expect(result.skill_count).toBe(1)
expect(result.pluginCommands).toContainEqual(
expect.objectContaining({
name: 'draw:render',
description: 'Render a drawing.',
}),
)
expect(result.pluginSkills).toContainEqual(
expect.objectContaining({
name: 'draw:paint',
description: 'Paint with the drawing plugin.',
}),
)
expect(appState.plugins.commands).toContainEqual(
expect.objectContaining({ name: 'draw:render' }),
)
expect(appState.plugins.commands).toContainEqual(
expect.objectContaining({ name: 'draw:paint' }),
)
})
})

View File

@ -279,4 +279,105 @@ describe('Skills API', () => {
}),
)
})
it('lists plugin skills after an external CLI install updates portable config on disk', async () => {
const marketplaceRoot = path.join(tmpHome, 'marketplace-root')
const pluginRoot = path.join(marketplaceRoot, 'plugins', 'draw')
const pluginsDir = path.join(tmpHome, '.claude', '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 writeSkill(
path.join(pluginRoot, 'skills'),
'render',
['---', 'description: Render with the drawing plugin.', '---', '', '# Render'].join('\n'),
)
await fs.writeFile(
path.join(pluginRoot, '.claude-plugin', 'plugin.json'),
JSON.stringify({
name: 'draw',
version: '1.0.0',
description: 'Drawing plugin',
}),
'utf-8',
)
await fs.writeFile(
marketplaceFile,
JSON.stringify({
name: 'test-market',
owner: { name: 'Test' },
plugins: [
{
name: 'draw',
source: './plugins/draw',
version: '1.0.0',
},
],
}),
'utf-8',
)
await fs.writeFile(
path.join(pluginsDir, 'known_marketplaces.json'),
JSON.stringify({
'test-market': {
source: { source: 'directory', path: marketplaceRoot },
installLocation: marketplaceRoot,
lastUpdated: new Date(0).toISOString(),
},
}),
'utf-8',
)
const settingsPath = path.join(tmpHome, '.claude', 'settings.json')
await fs.writeFile(
settingsPath,
JSON.stringify({
enabledPlugins: {
'draw@test-market': false,
},
}),
'utf-8',
)
const initial = makeRequest('/api/skills')
const initialRes = await handleSkillsApi(initial.req, initial.url, initial.segments)
const initialBody = await initialRes.json() as {
skills: Array<{ name: string; source: string }>
}
expect(initialBody.skills).not.toContainEqual(
expect.objectContaining({ name: 'draw:render', source: 'plugin' }),
)
// Simulates the embedded terminal running the CLI against the same
// CLAUDE_CONFIG_DIR while the desktop server process stays alive.
await fs.writeFile(
settingsPath,
JSON.stringify({
enabledPlugins: {
'draw@test-market': true,
},
}),
'utf-8',
)
const after = makeRequest('/api/skills')
const afterRes = await handleSkillsApi(after.req, after.url, after.segments)
const afterBody = await afterRes.json() as {
skills: Array<{ name: string; source: string; description: string }>
}
expect(afterBody.skills).toContainEqual(
expect.objectContaining({
name: 'draw:render',
source: 'plugin',
description: 'Render with the drawing plugin.',
}),
)
})
})

View File

@ -12,8 +12,10 @@ 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 { loadAllPlugins, loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js'
import { clearInstalledPluginsCache } from '../../utils/plugins/installedPluginsManager.js'
import { clearPluginCache, loadAllPlugins, loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js'
import { getSkillDirCommands } from '../../skills/loadSkillsDir.js'
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
import type { LoadedPlugin } from '../../types/plugin.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
@ -306,6 +308,10 @@ async function collectPluginSkillDirectories(): Promise<Map<string, PluginSkillL
let enabledPlugins: LoadedPlugin[]
try {
resetSettingsCache()
clearInstalledPluginsCache()
clearPluginCache('skills-api-external-plugin-state')
const result = await loadAllPluginsCacheOnly()
if (result.errors.some((error) => error.type === 'plugin-cache-miss')) {
enabledPlugins = (await loadAllPlugins()).enabled

View File

@ -24,7 +24,7 @@ import { loadPluginMcpServers } from '../../utils/plugins/mcpPluginIntegration.j
import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js'
import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'
import { loadPluginHooks } from '../../utils/plugins/loadPluginHooks.js'
import { getPluginCommands } from '../../utils/plugins/loadPluginCommands.js'
import { getPluginSkills } from '../../utils/plugins/loadPluginCommands.js'
import { clearPluginCacheExclusions } from '../../utils/plugins/orphanedPluginFilter.js'
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
import { extractDescriptionFromMarkdown } from '../../utils/markdownConfigLoader.js'
@ -238,8 +238,8 @@ export class PluginService {
const pluginState = await this.loadPluginState()
const { enabled, disabled, errors } = pluginState
const [commands, agentDefinitions] = await Promise.all([
getPluginCommands(),
const [skills, agentDefinitions] = await Promise.all([
getPluginSkills(),
getAgentDefinitionsWithOverrides(cwd),
])
@ -262,7 +262,7 @@ export class PluginService {
summary: {
enabled: enabled.length,
disabled: disabled.length,
skills: commands.length,
skills: skills.length,
agents: agentDefinitions.allAgents.length,
hooks: hookCount,
mcpServers: mcpCounts.reduce((sum, count) => sum + count, 0),

View File

@ -29,7 +29,7 @@ import { errorMessage } from '../errors.js'
import { logError } from '../log.js'
import { resetSettingsCache } from '../settings/settingsCache.js'
import { clearAllCaches } from './cacheUtils.js'
import { getPluginCommands } from './loadPluginCommands.js'
import { getPluginCommands, getPluginSkills } from './loadPluginCommands.js'
import { loadPluginHooks } from './loadPluginHooks.js'
import { loadPluginLspServers } from './lspPluginIntegration.js'
import { loadPluginMcpServers } from './mcpPluginIntegration.js'
@ -49,12 +49,14 @@ export type RefreshActivePluginsResult = {
* is called unconditionally so the manager picks these up (no-op if
* manager was never initialized). */
lsp_count: number
skill_count: number
error_count: number
/** The refreshed agent definitions, for callers (e.g. print.ts) that also
* maintain a local mutable reference outside AppState. */
agentDefinitions: AgentDefinitionsResult
/** The refreshed plugin commands, same rationale as agentDefinitions. */
pluginCommands: Command[]
pluginSkills: Command[]
}
/**
@ -91,10 +93,12 @@ export async function refreshActivePlugins(
// the plugin, returning plugin-cache-miss. loadAllPlugins warms the
// cache-only memoize on completion, so the awaits below are ~free.
const pluginResult = await loadAllPlugins()
const [pluginCommands, agentDefinitions] = await Promise.all([
const [pluginCommands, pluginSkills, agentDefinitions] = await Promise.all([
getPluginCommands(),
getPluginSkills(),
getAgentDefinitionsWithOverrides(getOriginalCwd()),
])
const activePluginCommands = [...pluginCommands, ...pluginSkills]
const { enabled, disabled, errors } = pluginResult
@ -131,7 +135,7 @@ export async function refreshActivePlugins(
...prev.plugins,
enabled,
disabled,
commands: pluginCommands,
commands: activePluginCommands,
errors: mergePluginErrors(prev.plugins.errors, errors),
needsRefresh: false,
},
@ -185,13 +189,15 @@ export async function refreshActivePlugins(
enabled_count: enabled.length,
disabled_count: disabled.length,
command_count: pluginCommands.length,
skill_count: pluginSkills.length,
agent_count: agentDefinitions.allAgents.length,
hook_count,
mcp_count,
lsp_count,
error_count: errors.length + (hook_load_failed ? 1 : 0),
agentDefinitions,
pluginCommands,
pluginCommands: activePluginCommands,
pluginSkills,
}
}