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, } }