From 346fa5fcda7f740b01a219575f466452adbfdfb2 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 14:11:43 +0700 Subject: [PATCH] fix(shared): support file-based agents and recursive commands --- src/web-server/shared-routes.ts | 294 ++++++++++++++++---- tests/unit/web-server/shared-routes.test.ts | 80 +++++- ui/src/pages/shared.tsx | 38 ++- 3 files changed, 351 insertions(+), 61 deletions(-) diff --git a/src/web-server/shared-routes.ts b/src/web-server/shared-routes.ts index 1682cab0..4dcf1a5b 100644 --- a/src/web-server/shared-routes.ts +++ b/src/web-server/shared-routes.ts @@ -7,6 +7,7 @@ import { Router, Request, Response } from 'express'; import * as fs from 'fs'; import * as path from 'path'; +import * as yaml from 'js-yaml'; import { getCcsDir } from '../utils/config-manager'; import { getClaudeConfigDir } from '../utils/claude-config-path'; @@ -70,7 +71,7 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] { const items: SharedItem[] = []; const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir); - const allowedSkillAgentRoots = new Set([ + const allowedRoots = new Set([ sharedDirRoot, ...[ path.join(getClaudeConfigDir(), type), @@ -81,62 +82,35 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] { .filter((dirPath): dirPath is string => typeof dirPath === 'string'), ]); + if (type === 'commands') { + return getCommandItems(sharedDir, allowedRoots); + } + try { const entries = fs.readdirSync(sharedDir, { withFileTypes: true }); for (const entry of entries) { try { const entryPath = path.join(sharedDir, entry.name); - - if (type === 'commands') { - if (!entry.name.endsWith('.md')) { - continue; - } - if (!entry.isFile() && !entry.isSymbolicLink()) { - continue; - } - - const commandPath = safeRealPath(entryPath); - if (!commandPath || !isPathWithin(commandPath, sharedDirRoot)) { - continue; - } - - const description = readMarkdownDescription(commandPath, sharedDirRoot); - if (!description) { - continue; - } - - items.push({ - name: entry.name.replace('.md', ''), - description, - path: entryPath, - type: 'command', - }); + const resolvedEntryPath = safeRealPath(entryPath); + if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) { continue; } - // Skills/agents are directory-based and may be symlinked directories. - if (!entry.isDirectory() && !entry.isSymbolicLink()) { + const stats = fs.statSync(resolvedEntryPath); + const item = getSkillOrAgentItem( + type, + entry, + entryPath, + resolvedEntryPath, + allowedRoots, + stats + ); + if (!item) { continue; } - const entryRoot = safeRealPath(entryPath); - if (!entryRoot || !isPathWithinAny(entryRoot, allowedSkillAgentRoots)) { - continue; - } - - const markdownFile = type === 'skills' ? 'SKILL.md' : 'prompt.md'; - const description = readMarkdownDescription(path.join(entryRoot, markdownFile), entryRoot); - if (!description) { - continue; - } - - items.push({ - name: entry.name, - description, - path: entryPath, - type: type === 'skills' ? 'skill' : 'agent', - }); + items.push(item); } catch { // Fail soft per entry so one bad item does not hide valid results. } @@ -148,22 +122,234 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] { return items.sort((a, b) => a.name.localeCompare(b.name)); } -function extractDescription(content: string): string { - // Extract first non-empty, non-heading line - const lines = content.split('\n'); - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('---')) { - return trimmed.slice(0, 100); +function getCommandItems(sharedDir: string, allowedRoots: Set): SharedItem[] { + const markdownFiles = collectMarkdownFiles(sharedDir, allowedRoots); + const items: SharedItem[] = []; + + for (const markdownFile of markdownFiles) { + const description = readMarkdownDescription(markdownFile.resolvedPath, allowedRoots); + if (!description) { + continue; + } + + const relativePath = path.relative(sharedDir, markdownFile.displayPath); + const normalizedName = relativePath.split(path.sep).join('/').replace(/\.md$/i, ''); + if (!normalizedName) { + continue; + } + + items.push({ + name: normalizedName, + description, + path: markdownFile.displayPath, + type: 'command', + }); + } + + return items.sort((a, b) => a.name.localeCompare(b.name)); +} + +function getSkillOrAgentItem( + type: 'skills' | 'agents', + entry: fs.Dirent, + entryPath: string, + resolvedEntryPath: string, + allowedRoots: Set, + stats: fs.Stats +): SharedItem | null { + if (type === 'skills') { + if (!stats.isDirectory()) { + return null; + } + + const description = readMarkdownDescription( + path.join(resolvedEntryPath, 'SKILL.md'), + allowedRoots + ); + if (!description) { + return null; + } + + return { + name: entry.name, + description, + path: entryPath, + type: 'skill', + }; + } + + if (stats.isDirectory()) { + const description = readFirstMarkdownDescription( + [ + path.join(resolvedEntryPath, 'prompt.md'), + path.join(resolvedEntryPath, 'AGENT.md'), + path.join(resolvedEntryPath, 'agent.md'), + ], + allowedRoots + ); + if (!description) { + return null; + } + + return { + name: entry.name, + description, + path: entryPath, + type: 'agent', + }; + } + + if (!stats.isFile() || !entry.name.toLowerCase().endsWith('.md')) { + return null; + } + + const description = readMarkdownDescription(resolvedEntryPath, allowedRoots); + if (!description) { + return null; + } + + return { + name: entry.name.replace(/\.md$/i, ''), + description, + path: entryPath, + type: 'agent', + }; +} + +interface MarkdownFileEntry { + displayPath: string; + resolvedPath: string; +} + +function collectMarkdownFiles(sharedDir: string, allowedRoots: Set): MarkdownFileEntry[] { + const directoriesToVisit = [sharedDir]; + const visitedDirectories = new Set(); + const markdownFiles: MarkdownFileEntry[] = []; + + while (directoriesToVisit.length > 0) { + const currentDir = directoriesToVisit.pop(); + if (!currentDir) { + continue; + } + + const resolvedCurrentDir = safeRealPath(currentDir); + if (!resolvedCurrentDir || !isPathWithinAny(resolvedCurrentDir, allowedRoots)) { + continue; + } + + const normalizedDirPath = normalizeForPathComparison(resolvedCurrentDir); + if (visitedDirectories.has(normalizedDirPath)) { + continue; + } + visitedDirectories.add(normalizedDirPath); + + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(currentDir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const entryPath = path.join(currentDir, entry.name); + const resolvedEntryPath = safeRealPath(entryPath); + if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) { + continue; + } + + let stats: fs.Stats; + try { + stats = fs.statSync(resolvedEntryPath); + } catch { + continue; + } + + if (stats.isDirectory()) { + directoriesToVisit.push(entryPath); + continue; + } + + if (stats.isFile() && entry.name.toLowerCase().endsWith('.md')) { + markdownFiles.push({ + displayPath: entryPath, + resolvedPath: resolvedEntryPath, + }); + } } } + + return markdownFiles; +} + +function extractDescription(content: string): string { + const frontmatterDescription = extractFrontmatterDescription(content); + if (frontmatterDescription) { + return trimDescription(frontmatterDescription); + } + + // Extract first non-empty, non-heading line from the markdown body. + const lines = stripFrontmatter(content).split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('