diff --git a/src/server/__tests__/plugins.test.ts b/src/server/__tests__/plugins.test.ts index 31694ce3..2288dd6e 100644 --- a/src/server/__tests__/plugins.test.ts +++ b/src/server/__tests__/plugins.test.ts @@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' +import type { AppState } from '../../state/AppStateStore.js' import { isEnabledPluginSettingValue } from '../../utils/plugins/dependencyResolver.js' import { clearInstalledPluginsCache } from '../../utils/plugins/installedPluginsManager.js' import { clearPluginCache, loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js' +import { refreshActivePlugins } from '../../utils/plugins/refresh.js' import { resetSettingsCache } from '../../utils/settings/settingsCache.js' import { handlePluginsApi } from '../api/plugins.js' import { conversationService } from '../services/conversationService.js' @@ -241,4 +243,116 @@ describe('Plugins API', () => { }, ]) }) + + it('refreshActivePlugins rereads settings after an external enable toggle', async () => { + const marketplaceRoot = path.join(tmpDir, 'marketplace-root') + const pluginRoot = path.join(marketplaceRoot, 'plugins', 'draw') + const pluginsDir = path.join(tmpDir, '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, 'commands'), { 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, 'commands', 'render.md'), + '---\ndescription: Render a drawing.\n---\nRender this drawing.', + '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(tmpDir, 'settings.json') + await fs.writeFile( + settingsPath, + JSON.stringify({ + enabledPlugins: { + 'draw@test-market': false, + }, + }), + 'utf-8', + ) + + const disabledResult = await loadAllPluginsCacheOnly() + expect(disabledResult.enabled).toEqual([]) + expect(disabledResult.disabled).toContainEqual( + expect.objectContaining({ source: 'draw@test-market', enabled: false }), + ) + + await fs.writeFile( + settingsPath, + JSON.stringify({ + enabledPlugins: { + 'draw@test-market': true, + }, + }), + 'utf-8', + ) + + let appState = { + plugins: { + enabled: [], + disabled: disabledResult.disabled, + commands: [], + errors: [], + needsRefresh: true, + }, + mcp: { pluginReconnectKey: 0 }, + agentDefinitions: { allAgents: [], errors: [] }, + } as unknown as AppState + + const result = await refreshActivePlugins(updater => { + appState = updater(appState) + }) + + expect(result.enabled_count).toBe(1) + expect(result.command_count).toBe(1) + expect(result.pluginCommands).toContainEqual( + expect.objectContaining({ + name: 'draw:render', + description: 'Render a drawing.', + }), + ) + expect(appState.plugins.commands).toContainEqual( + expect.objectContaining({ name: 'draw:render' }), + ) + }) }) diff --git a/src/utils/plugins/refresh.ts b/src/utils/plugins/refresh.ts index c31e8118..d02e1642 100644 --- a/src/utils/plugins/refresh.ts +++ b/src/utils/plugins/refresh.ts @@ -27,6 +27,7 @@ import type { PluginError } from '../../types/plugin.js' import { logForDebugging } from '../debug.js' 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 { loadPluginHooks } from './loadPluginHooks.js' @@ -72,7 +73,11 @@ export type RefreshActivePluginsResult = { export async function refreshActivePlugins( setAppState: SetAppState, ): Promise { - logForDebugging('refreshActivePlugins: clearing all plugin caches') + logForDebugging('refreshActivePlugins: clearing settings and plugin caches') + // Desktop plugin toggles are written by the server process while the active + // CLI keeps its own settings cache. /reload-plugins is the explicit handoff + // point, so re-read settings before resolving enabledPlugins. + resetSettingsCache() clearAllCaches() // Orphan exclusions are session-frozen by default, but /reload-plugins is // an explicit "disk changed, re-read it" signal — recompute them too.