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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-15 21:39:24 +08:00
parent 70dc9ab111
commit 6ec8ae626b
2 changed files with 133 additions and 0 deletions

View File

@ -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<void> {
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.',
}),
)
})
})

View File

@ -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<ApiPluginReloadResponse> {
resetSettingsCache()
clearAllCaches()
clearPluginCacheExclusions()