From 333271b1598c5551fa0875c4cc0ecfac837fa862 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: Mon, 25 May 2026 17:32:15 +0800 Subject: [PATCH] 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 --- src/commands/reload-plugins/reload-plugins.ts | 2 +- src/server/__tests__/plugins.test.ts | 16 +++ src/server/__tests__/skills.test.ts | 101 ++++++++++++++++++ src/server/api/skills.ts | 8 +- src/server/services/pluginService.ts | 8 +- src/utils/plugins/refresh.ts | 14 ++- 6 files changed, 139 insertions(+), 10 deletions(-) diff --git a/src/commands/reload-plugins/reload-plugins.ts b/src/commands/reload-plugins/reload-plugins.ts index 0789be43..facca09b 100644 --- a/src/commands/reload-plugins/reload-plugins.ts +++ b/src/commands/reload-plugins/reload-plugins.ts @@ -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, diff --git a/src/server/__tests__/plugins.test.ts b/src/server/__tests__/plugins.test.ts index 2288dd6e..614e7fc5 100644 --- a/src/server/__tests__/plugins.test.ts +++ b/src/server/__tests__/plugins.test.ts @@ -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' }), + ) }) }) diff --git a/src/server/__tests__/skills.test.ts b/src/server/__tests__/skills.test.ts index 1fccf602..5a6bec92 100644 --- a/src/server/__tests__/skills.test.ts +++ b/src/server/__tests__/skills.test.ts @@ -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.', + }), + ) + }) }) diff --git a/src/server/api/skills.ts b/src/server/api/skills.ts index 5de04159..2e743879 100644 --- a/src/server/api/skills.ts +++ b/src/server/api/skills.ts @@ -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 error.type === 'plugin-cache-miss')) { enabledPlugins = (await loadAllPlugins()).enabled diff --git a/src/server/services/pluginService.ts b/src/server/services/pluginService.ts index 6d6cc3f3..b97a1cd0 100644 --- a/src/server/services/pluginService.ts +++ b/src/server/services/pluginService.ts @@ -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), diff --git a/src/utils/plugins/refresh.ts b/src/utils/plugins/refresh.ts index d02e1642..c5dbc37e 100644 --- a/src/utils/plugins/refresh.ts +++ b/src/utils/plugins/refresh.ts @@ -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, } }