diff --git a/src/web-server/shared-routes.ts b/src/web-server/shared-routes.ts index 6c56c993..6e1d965a 100644 --- a/src/web-server/shared-routes.ts +++ b/src/web-server/shared-routes.ts @@ -74,23 +74,25 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] { const entries = fs.readdirSync(sharedDir, { withFileTypes: true }); for (const entry of entries) { - if (entry.isDirectory()) { - // Skill/Agent: look for SKILL.md for skills, prompt.md for agents + const entryPath = path.join(sharedDir, entry.name); + + // Skills/Agents are directory-based and may be symlinked directories + if (type !== 'commands' && (entry.isDirectory() || entry.isSymbolicLink())) { const markdownFile = type === 'skills' ? 'SKILL.md' : 'prompt.md'; - const promptPath = path.join(sharedDir, entry.name, markdownFile); + const promptPath = path.join(entryPath, markdownFile); if (fs.existsSync(promptPath)) { const content = fs.readFileSync(promptPath, 'utf8'); const description = extractDescription(content); items.push({ name: entry.name, description, - path: path.join(sharedDir, entry.name), - type: type === 'commands' ? 'command' : (type.slice(0, -1) as 'skill' | 'agent'), + path: entryPath, + type: type === 'skills' ? 'skill' : 'agent', }); } - } else if (entry.name.endsWith('.md')) { - // Command: .md file - const filePath = path.join(sharedDir, entry.name); + } else if (type === 'commands' && entry.name.endsWith('.md')) { + // Commands are markdown files + const filePath = entryPath; const content = fs.readFileSync(filePath, 'utf8'); const description = extractDescription(content); items.push({ diff --git a/tests/unit/web-server/shared-routes.test.ts b/tests/unit/web-server/shared-routes.test.ts new file mode 100644 index 00000000..0fc0352d --- /dev/null +++ b/tests/unit/web-server/shared-routes.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { sharedRoutes } from '../../../src/web-server/shared-routes'; + +function getGetRouteHandler(routePath: string): (req: unknown, res: unknown) => void { + const layer = (sharedRoutes as { stack?: unknown[] }).stack?.find((entry) => { + const route = (entry as { route?: { path?: string; methods?: Record } }).route; + return route?.path === routePath && route.methods?.get; + }) as + | { + route?: { + stack?: Array<{ handle: (req: unknown, res: unknown) => void }>; + }; + } + | undefined; + + const handler = layer?.route?.stack?.[0]?.handle; + if (!handler) { + throw new Error(`GET handler not found for route: ${routePath}`); + } + + return handler; +} + +describe('web-server shared-routes', () => { + let tempHome: string; + let ccsDir: string; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-routes-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + + ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(path.join(ccsDir, 'shared', 'skills'), { recursive: true }); + fs.mkdirSync(path.join(ccsDir, 'shared', 'agents'), { recursive: true }); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('lists symlinked skill directories', () => { + const sharedSkillsDir = path.join(ccsDir, 'shared', 'skills'); + const targetDir = path.join(tempHome, 'skill-targets', 'my-skill'); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(path.join(targetDir, 'SKILL.md'), 'name: my-skill\n\nMy test skill'); + + const linkPath = path.join(sharedSkillsDir, 'my-skill'); + const symlinkType = process.platform === 'win32' ? 'junction' : 'dir'; + fs.symlinkSync(targetDir, linkPath, symlinkType as fs.symlink.Type); + + const handler = getGetRouteHandler('/skills'); + let payload: { items: Array<{ name: string; type: string }> } | undefined; + + handler( + {}, + { + json: (data: { items: Array<{ name: string; type: string }> }) => { + payload = data; + }, + } + ); + + expect(payload).toBeDefined(); + expect(payload?.items).toHaveLength(1); + expect(payload?.items[0]).toMatchObject({ + name: 'my-skill', + type: 'skill', + }); + }); + + it('lists symlinked agent directories', () => { + const sharedAgentsDir = path.join(ccsDir, 'shared', 'agents'); + const targetDir = path.join(tempHome, 'agent-targets', 'my-agent'); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(path.join(targetDir, 'prompt.md'), 'My test agent prompt'); + + const linkPath = path.join(sharedAgentsDir, 'my-agent'); + const symlinkType = process.platform === 'win32' ? 'junction' : 'dir'; + fs.symlinkSync(targetDir, linkPath, symlinkType as fs.symlink.Type); + + const handler = getGetRouteHandler('/agents'); + let payload: { items: Array<{ name: string; type: string }> } | undefined; + + handler( + {}, + { + json: (data: { items: Array<{ name: string; type: string }> }) => { + payload = data; + }, + } + ); + + expect(payload).toBeDefined(); + expect(payload?.items).toHaveLength(1); + expect(payload?.items[0]).toMatchObject({ + name: 'my-agent', + type: 'agent', + }); + }); + + it('ignores markdown files in shared skills root', () => { + const sharedSkillsDir = path.join(ccsDir, 'shared', 'skills'); + fs.writeFileSync(path.join(sharedSkillsDir, 'CLAUDE.md'), 'not a skill directory'); + + const handler = getGetRouteHandler('/skills'); + let payload: { items: Array<{ name: string }> } | undefined; + + handler( + {}, + { + json: (data: { items: Array<{ name: string }> }) => { + payload = data; + }, + } + ); + + expect(payload).toBeDefined(); + expect(payload?.items).toEqual([]); + }); +});