fix(web-server): detect symlinked shared entries

Handle shared skills and agents when entries are symlinked directories.
Also avoid classifying root markdown files in skills/agents as commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
innovatias
2026-02-23 22:08:53 +07:00
co-authored by Claude Opus 4.6
parent 6a9abd2a48
commit e3dbc67634
2 changed files with 143 additions and 8 deletions
+10 -8
View File
@@ -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({
+133
View File
@@ -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<string, boolean> } }).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([]);
});
});