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