mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-26 15:03:34 +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 = [
|
const parts = [
|
||||||
n(r.enabled_count, 'plugin'),
|
n(r.enabled_count, 'plugin'),
|
||||||
n(r.command_count, 'skill'),
|
n(r.skill_count, 'skill'),
|
||||||
n(r.agent_count, 'agent'),
|
n(r.agent_count, 'agent'),
|
||||||
n(r.hook_count, 'hook'),
|
n(r.hook_count, 'hook'),
|
||||||
// "plugin MCP/LSP" disambiguates from user-config/built-in servers,
|
// "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, '.claude-plugin'), { recursive: true })
|
||||||
await fs.mkdir(path.join(pluginRoot, 'commands'), { 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(path.dirname(marketplaceFile), { recursive: true })
|
||||||
await fs.mkdir(pluginsDir, { recursive: true })
|
await fs.mkdir(pluginsDir, { recursive: true })
|
||||||
|
|
||||||
@ -273,6 +274,11 @@ describe('Plugins API', () => {
|
|||||||
'---\ndescription: Render a drawing.\n---\nRender this drawing.',
|
'---\ndescription: Render a drawing.\n---\nRender this drawing.',
|
||||||
'utf-8',
|
'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(
|
await fs.writeFile(
|
||||||
marketplaceFile,
|
marketplaceFile,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@ -345,14 +351,24 @@ describe('Plugins API', () => {
|
|||||||
|
|
||||||
expect(result.enabled_count).toBe(1)
|
expect(result.enabled_count).toBe(1)
|
||||||
expect(result.command_count).toBe(1)
|
expect(result.command_count).toBe(1)
|
||||||
|
expect(result.skill_count).toBe(1)
|
||||||
expect(result.pluginCommands).toContainEqual(
|
expect(result.pluginCommands).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: 'draw:render',
|
name: 'draw:render',
|
||||||
description: 'Render a drawing.',
|
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(appState.plugins.commands).toContainEqual(
|
||||||
expect.objectContaining({ name: 'draw:render' }),
|
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 { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||||
import { getProjectDirsUpToHome } from '../../utils/markdownConfigLoader.js'
|
import { getProjectDirsUpToHome } from '../../utils/markdownConfigLoader.js'
|
||||||
import { getCwd } from '../../utils/cwd.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 { getSkillDirCommands } from '../../skills/loadSkillsDir.js'
|
||||||
|
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
|
||||||
import type { LoadedPlugin } from '../../types/plugin.js'
|
import type { LoadedPlugin } from '../../types/plugin.js'
|
||||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||||
|
|
||||||
@ -306,6 +308,10 @@ async function collectPluginSkillDirectories(): Promise<Map<string, PluginSkillL
|
|||||||
|
|
||||||
let enabledPlugins: LoadedPlugin[]
|
let enabledPlugins: LoadedPlugin[]
|
||||||
try {
|
try {
|
||||||
|
resetSettingsCache()
|
||||||
|
clearInstalledPluginsCache()
|
||||||
|
clearPluginCache('skills-api-external-plugin-state')
|
||||||
|
|
||||||
const result = await loadAllPluginsCacheOnly()
|
const result = await loadAllPluginsCacheOnly()
|
||||||
if (result.errors.some((error) => error.type === 'plugin-cache-miss')) {
|
if (result.errors.some((error) => error.type === 'plugin-cache-miss')) {
|
||||||
enabledPlugins = (await loadAllPlugins()).enabled
|
enabledPlugins = (await loadAllPlugins()).enabled
|
||||||
|
|||||||
@ -24,7 +24,7 @@ import { loadPluginMcpServers } from '../../utils/plugins/mcpPluginIntegration.j
|
|||||||
import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js'
|
import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js'
|
||||||
import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'
|
import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'
|
||||||
import { loadPluginHooks } from '../../utils/plugins/loadPluginHooks.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 { clearPluginCacheExclusions } from '../../utils/plugins/orphanedPluginFilter.js'
|
||||||
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
||||||
import { extractDescriptionFromMarkdown } from '../../utils/markdownConfigLoader.js'
|
import { extractDescriptionFromMarkdown } from '../../utils/markdownConfigLoader.js'
|
||||||
@ -238,8 +238,8 @@ export class PluginService {
|
|||||||
const pluginState = await this.loadPluginState()
|
const pluginState = await this.loadPluginState()
|
||||||
const { enabled, disabled, errors } = pluginState
|
const { enabled, disabled, errors } = pluginState
|
||||||
|
|
||||||
const [commands, agentDefinitions] = await Promise.all([
|
const [skills, agentDefinitions] = await Promise.all([
|
||||||
getPluginCommands(),
|
getPluginSkills(),
|
||||||
getAgentDefinitionsWithOverrides(cwd),
|
getAgentDefinitionsWithOverrides(cwd),
|
||||||
])
|
])
|
||||||
|
|
||||||
@ -262,7 +262,7 @@ export class PluginService {
|
|||||||
summary: {
|
summary: {
|
||||||
enabled: enabled.length,
|
enabled: enabled.length,
|
||||||
disabled: disabled.length,
|
disabled: disabled.length,
|
||||||
skills: commands.length,
|
skills: skills.length,
|
||||||
agents: agentDefinitions.allAgents.length,
|
agents: agentDefinitions.allAgents.length,
|
||||||
hooks: hookCount,
|
hooks: hookCount,
|
||||||
mcpServers: mcpCounts.reduce((sum, count) => sum + count, 0),
|
mcpServers: mcpCounts.reduce((sum, count) => sum + count, 0),
|
||||||
|
|||||||
@ -29,7 +29,7 @@ import { errorMessage } from '../errors.js'
|
|||||||
import { logError } from '../log.js'
|
import { logError } from '../log.js'
|
||||||
import { resetSettingsCache } from '../settings/settingsCache.js'
|
import { resetSettingsCache } from '../settings/settingsCache.js'
|
||||||
import { clearAllCaches } from './cacheUtils.js'
|
import { clearAllCaches } from './cacheUtils.js'
|
||||||
import { getPluginCommands } from './loadPluginCommands.js'
|
import { getPluginCommands, getPluginSkills } from './loadPluginCommands.js'
|
||||||
import { loadPluginHooks } from './loadPluginHooks.js'
|
import { loadPluginHooks } from './loadPluginHooks.js'
|
||||||
import { loadPluginLspServers } from './lspPluginIntegration.js'
|
import { loadPluginLspServers } from './lspPluginIntegration.js'
|
||||||
import { loadPluginMcpServers } from './mcpPluginIntegration.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
|
* is called unconditionally so the manager picks these up (no-op if
|
||||||
* manager was never initialized). */
|
* manager was never initialized). */
|
||||||
lsp_count: number
|
lsp_count: number
|
||||||
|
skill_count: number
|
||||||
error_count: number
|
error_count: number
|
||||||
/** The refreshed agent definitions, for callers (e.g. print.ts) that also
|
/** The refreshed agent definitions, for callers (e.g. print.ts) that also
|
||||||
* maintain a local mutable reference outside AppState. */
|
* maintain a local mutable reference outside AppState. */
|
||||||
agentDefinitions: AgentDefinitionsResult
|
agentDefinitions: AgentDefinitionsResult
|
||||||
/** The refreshed plugin commands, same rationale as agentDefinitions. */
|
/** The refreshed plugin commands, same rationale as agentDefinitions. */
|
||||||
pluginCommands: Command[]
|
pluginCommands: Command[]
|
||||||
|
pluginSkills: Command[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -91,10 +93,12 @@ export async function refreshActivePlugins(
|
|||||||
// the plugin, returning plugin-cache-miss. loadAllPlugins warms the
|
// the plugin, returning plugin-cache-miss. loadAllPlugins warms the
|
||||||
// cache-only memoize on completion, so the awaits below are ~free.
|
// cache-only memoize on completion, so the awaits below are ~free.
|
||||||
const pluginResult = await loadAllPlugins()
|
const pluginResult = await loadAllPlugins()
|
||||||
const [pluginCommands, agentDefinitions] = await Promise.all([
|
const [pluginCommands, pluginSkills, agentDefinitions] = await Promise.all([
|
||||||
getPluginCommands(),
|
getPluginCommands(),
|
||||||
|
getPluginSkills(),
|
||||||
getAgentDefinitionsWithOverrides(getOriginalCwd()),
|
getAgentDefinitionsWithOverrides(getOriginalCwd()),
|
||||||
])
|
])
|
||||||
|
const activePluginCommands = [...pluginCommands, ...pluginSkills]
|
||||||
|
|
||||||
const { enabled, disabled, errors } = pluginResult
|
const { enabled, disabled, errors } = pluginResult
|
||||||
|
|
||||||
@ -131,7 +135,7 @@ export async function refreshActivePlugins(
|
|||||||
...prev.plugins,
|
...prev.plugins,
|
||||||
enabled,
|
enabled,
|
||||||
disabled,
|
disabled,
|
||||||
commands: pluginCommands,
|
commands: activePluginCommands,
|
||||||
errors: mergePluginErrors(prev.plugins.errors, errors),
|
errors: mergePluginErrors(prev.plugins.errors, errors),
|
||||||
needsRefresh: false,
|
needsRefresh: false,
|
||||||
},
|
},
|
||||||
@ -185,13 +189,15 @@ export async function refreshActivePlugins(
|
|||||||
enabled_count: enabled.length,
|
enabled_count: enabled.length,
|
||||||
disabled_count: disabled.length,
|
disabled_count: disabled.length,
|
||||||
command_count: pluginCommands.length,
|
command_count: pluginCommands.length,
|
||||||
|
skill_count: pluginSkills.length,
|
||||||
agent_count: agentDefinitions.allAgents.length,
|
agent_count: agentDefinitions.allAgents.length,
|
||||||
hook_count,
|
hook_count,
|
||||||
mcp_count,
|
mcp_count,
|
||||||
lsp_count,
|
lsp_count,
|
||||||
error_count: errors.length + (hook_load_failed ? 1 : 0),
|
error_count: errors.length + (hook_load_failed ? 1 : 0),
|
||||||
agentDefinitions,
|
agentDefinitions,
|
||||||
pluginCommands,
|
pluginCommands: activePluginCommands,
|
||||||
|
pluginSkills,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user