mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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
This commit is contained in:
parent
3d2b219eda
commit
333271b159
@ -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,
|
||||
|
||||
@ -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' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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.',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<Map<string, PluginSkillL
|
||||
|
||||
let enabledPlugins: LoadedPlugin[]
|
||||
try {
|
||||
resetSettingsCache()
|
||||
clearInstalledPluginsCache()
|
||||
clearPluginCache('skills-api-external-plugin-state')
|
||||
|
||||
const result = await loadAllPluginsCacheOnly()
|
||||
if (result.errors.some((error) => error.type === 'plugin-cache-miss')) {
|
||||
enabledPlugins = (await loadAllPlugins()).enabled
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user