mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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
384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
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 { 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
|
|
let originalHome: string | undefined
|
|
let originalUserProfile: string | undefined
|
|
let originalClaudeConfigDir: string | undefined
|
|
let originalCwdState: string
|
|
|
|
function makeRequest(urlStr: string): { req: Request; url: URL; segments: string[] } {
|
|
const url = new URL(urlStr, 'http://localhost:3456')
|
|
const req = new Request(url.toString(), { method: 'GET' })
|
|
return {
|
|
req,
|
|
url,
|
|
segments: url.pathname.split('/').filter(Boolean),
|
|
}
|
|
}
|
|
|
|
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 })
|
|
await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8')
|
|
}
|
|
|
|
describe('Skills API', () => {
|
|
beforeEach(async () => {
|
|
tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-skills-test-'))
|
|
originalHome = process.env.HOME
|
|
originalUserProfile = process.env.USERPROFILE
|
|
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
|
|
originalCwdState = getCwdState()
|
|
|
|
process.env.HOME = tmpHome
|
|
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 {
|
|
process.env.HOME = originalHome
|
|
}
|
|
|
|
if (originalUserProfile === undefined) {
|
|
delete process.env.USERPROFILE
|
|
} else {
|
|
process.env.USERPROFILE = originalUserProfile
|
|
}
|
|
|
|
if (originalClaudeConfigDir === undefined) {
|
|
delete process.env.CLAUDE_CONFIG_DIR
|
|
} else {
|
|
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
|
|
}
|
|
|
|
setCwdState(originalCwdState)
|
|
await fs.rm(tmpHome, { recursive: true, force: true })
|
|
})
|
|
|
|
it('lists user and project skills for the requested cwd', async () => {
|
|
const userSkillsRoot = path.join(tmpHome, '.claude', 'skills')
|
|
const projectRoot = path.join(tmpHome, 'workspace')
|
|
const cwd = path.join(projectRoot, 'packages', 'app')
|
|
|
|
await writeSkill(
|
|
userSkillsRoot,
|
|
'user-skill',
|
|
['---', 'description: User scope', '---', '', '# User skill'].join('\n'),
|
|
)
|
|
await writeSkill(
|
|
path.join(projectRoot, '.claude', 'skills'),
|
|
'project-skill',
|
|
['---', 'description: Project scope', '---', '', '# Project skill'].join('\n'),
|
|
)
|
|
|
|
const { req, url, segments } = makeRequest(`/api/skills?cwd=${encodeURIComponent(cwd)}`)
|
|
const res = await handleSkillsApi(req, url, segments)
|
|
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as { skills: Array<{ name: string; source: string }> }
|
|
expect(body.skills).toContainEqual(expect.objectContaining({ name: 'user-skill', source: 'user' }))
|
|
expect(body.skills).toContainEqual(expect.objectContaining({ name: 'project-skill', source: 'project' }))
|
|
})
|
|
|
|
it('lists user skills installed through a directory symlink or junction', async () => {
|
|
const linkedSkillsRoot = path.join(tmpHome, '.agents', 'skills')
|
|
const userSkillsRoot = path.join(tmpHome, '.claude', 'skills')
|
|
const projectRoot = path.join(tmpHome, 'workspace')
|
|
const cwd = path.join(projectRoot, 'packages', 'app')
|
|
|
|
await writeSkill(
|
|
linkedSkillsRoot,
|
|
'linked-skill',
|
|
['---', 'description: Linked skill', '---', '', '# Linked skill'].join('\n'),
|
|
)
|
|
await fs.mkdir(userSkillsRoot, { recursive: true })
|
|
await fs.symlink(
|
|
path.join(linkedSkillsRoot, 'linked-skill'),
|
|
path.join(userSkillsRoot, 'linked-skill'),
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
)
|
|
|
|
const { req, url, segments } = makeRequest(`/api/skills?cwd=${encodeURIComponent(cwd)}`)
|
|
const res = await handleSkillsApi(req, url, segments)
|
|
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as { skills: Array<{ name: string; source: string }> }
|
|
expect(body.skills).toContainEqual(expect.objectContaining({ name: 'linked-skill', source: 'user' }))
|
|
})
|
|
|
|
it('resolves project skill details from the nearest project skills directory', async () => {
|
|
const projectRoot = path.join(tmpHome, 'workspace')
|
|
const nestedRoot = path.join(projectRoot, 'packages', 'app')
|
|
const nestedSkillsRoot = path.join(nestedRoot, '.claude', 'skills')
|
|
const parentSkillsRoot = path.join(projectRoot, '.claude', 'skills')
|
|
|
|
await writeSkill(
|
|
parentSkillsRoot,
|
|
'shared-skill',
|
|
['---', 'description: Parent version', '---', '', 'parent body'].join('\n'),
|
|
)
|
|
await writeSkill(
|
|
nestedSkillsRoot,
|
|
'shared-skill',
|
|
['---', 'description: Child version', '---', '', 'child body'].join('\n'),
|
|
)
|
|
|
|
const { req, url, segments } = makeRequest(
|
|
`/api/skills/detail?source=project&name=shared-skill&cwd=${encodeURIComponent(nestedRoot)}`,
|
|
)
|
|
const res = await handleSkillsApi(req, url, segments)
|
|
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as {
|
|
detail: { meta: { description: string }; skillRoot: string; files: Array<{ path: string; body?: string }> }
|
|
}
|
|
|
|
expect(body.detail.meta.description).toBe('Child version')
|
|
expect(body.detail.skillRoot).toBe(path.join(nestedSkillsRoot, 'shared-skill'))
|
|
expect(body.detail.files).toContainEqual(
|
|
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.',
|
|
}),
|
|
)
|
|
})
|
|
|
|
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.',
|
|
}),
|
|
)
|
|
})
|
|
})
|