From 6ec8ae626b20a549ab6b5b213eda0f0728614c63 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: Fri, 15 May 2026 21:39:24 +0800 Subject: [PATCH] fix: refresh server plugin skills after enable toggles The previous hot-reload fix covered the running CLI process, but empty desktop sessions build their slash skill list through the server-side /api/skills path. If the server had already cached disabled plugin settings, /api/plugins/reload could still reload plugin state from stale settings and /api/skills stayed empty until a real chat spawned a fresh CLI. Reset the server settings cache at the plugin reload boundary before clearing plugin caches. This keeps the desktop server, active CLI reload, and empty-session skills API aligned on enable-after-disable transitions. Constraint: EmptySession uses /api/skills, not the active CLI slash-command cache Rejected: Rely on starting a real CLI session to repopulate skills | delays visibility and makes empty-session slash commands stale Confidence: high Scope-risk: narrow Directive: Plugin reload paths must invalidate settings before resolving enabledPlugins Tested: bun test src/server/__tests__/skills.test.ts src/server/__tests__/plugins.test.ts Tested: bun run check:server Tested: cd desktop && bun run test --run src/pages/EmptySession.test.tsx src/stores/pluginStore.test.ts --- src/server/__tests__/skills.test.ts | 131 +++++++++++++++++++++++++++ src/server/services/pluginService.ts | 2 + 2 files changed, 133 insertions(+) diff --git a/src/server/__tests__/skills.test.ts b/src/server/__tests__/skills.test.ts index 93ce2e5e..1fccf602 100644 --- a/src/server/__tests__/skills.test.ts +++ b/src/server/__tests__/skills.test.ts @@ -3,6 +3,10 @@ import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' import { getCwdState, setCwdState } from '../../bootstrap/state.js' +import { clearInstalledPluginsCache } from '../../utils/plugins/installedPluginsManager.js' +import { clearPluginCache } from '../../utils/plugins/pluginLoader.js' +import { resetSettingsCache } from '../../utils/settings/settingsCache.js' +import { handlePluginsApi } from '../api/plugins.js' import { handleSkillsApi } from '../api/skills.js' let tmpHome: string @@ -21,6 +25,16 @@ function makeRequest(urlStr: string): { req: Request; url: URL; segments: string } } +function makePluginReloadRequest(): { req: Request; url: URL; segments: string[] } { + const url = new URL('/api/plugins/reload', 'http://localhost:3456') + const req = new Request(url.toString(), { method: 'POST' }) + return { + req, + url, + segments: url.pathname.split('/').filter(Boolean), + } +} + async function writeSkill(root: string, skillName: string, content: string): Promise { const skillDir = path.join(root, skillName) await fs.mkdir(skillDir, { recursive: true }) @@ -39,9 +53,15 @@ describe('Skills API', () => { process.env.USERPROFILE = tmpHome process.env.CLAUDE_CONFIG_DIR = path.join(tmpHome, '.claude') setCwdState(tmpHome) + clearInstalledPluginsCache() + clearPluginCache('skills-api-test-setup') + resetSettingsCache() }) afterEach(async () => { + clearInstalledPluginsCache() + clearPluginCache('skills-api-test-teardown') + resetSettingsCache() if (originalHome === undefined) { delete process.env.HOME } else { @@ -148,4 +168,115 @@ describe('Skills API', () => { expect.objectContaining({ path: 'SKILL.md', body: 'child body' }), ) }) + + it('lists plugin skills after reload rereads an external enable toggle', 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.join(pluginRoot, 'skills', 'render'), { recursive: true }) + await fs.mkdir(path.dirname(marketplaceFile), { recursive: true }) + await fs.mkdir(pluginsDir, { recursive: true }) + + 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( + path.join(pluginRoot, 'skills', 'render', 'SKILL.md'), + [ + '---', + 'description: Render with the drawing plugin.', + '---', + '', + '# Render', + ].join('\n'), + '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' }), + ) + + await fs.writeFile( + settingsPath, + JSON.stringify({ + enabledPlugins: { + 'draw@test-market': true, + }, + }), + 'utf-8', + ) + + const reload = makePluginReloadRequest() + const reloadRes = await handlePluginsApi(reload.req, reload.url, reload.segments) + expect(reloadRes.status).toBe(200) + + 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/services/pluginService.ts b/src/server/services/pluginService.ts index 94371499..6d6cc3f3 100644 --- a/src/server/services/pluginService.ts +++ b/src/server/services/pluginService.ts @@ -28,6 +28,7 @@ import { getPluginCommands } from '../../utils/plugins/loadPluginCommands.js' import { clearPluginCacheExclusions } from '../../utils/plugins/orphanedPluginFilter.js' import { parseFrontmatter } from '../../utils/frontmatterParser.js' import { extractDescriptionFromMarkdown } from '../../utils/markdownConfigLoader.js' +import { resetSettingsCache } from '../../utils/settings/settingsCache.js' import type { PluginInstallationEntry, PluginScope, @@ -230,6 +231,7 @@ export class PluginService { } async reloadPlugins(cwd?: string): Promise { + resetSettingsCache() clearAllCaches() clearPluginCacheExclusions()