mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
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:
co-authored by
Claude Opus 4.6
parent
6a9abd2a48
commit
e3dbc67634
@@ -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({
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user