fix(shared): support file-based agents and recursive commands

This commit is contained in:
Tam Nhu Tran
2026-02-26 14:57:57 +07:00
parent 77a236490d
commit 346fa5fcda
3 changed files with 351 additions and 61 deletions
+240 -54
View File
@@ -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<string>([
const allowedRoots = new Set<string>([
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<string>): 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<string>,
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<string>): MarkdownFileEntry[] {
const directoriesToVisit = [sharedDir];
const visitedDirectories = new Set<string>();
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('<!--')) {
return trimDescription(trimmed);
}
}
return 'No description';
}
function readMarkdownDescription(markdownPath: string, allowedRoot: string): string | null {
function extractFrontmatterDescription(content: string): string | null {
const frontmatterMatch = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\s*\r?\n|$)/);
if (!frontmatterMatch) {
return null;
}
try {
const parsed = yaml.load(frontmatterMatch[1]) as Record<string, unknown> | null;
const description = parsed?.description;
if (typeof description !== 'string') {
return null;
}
const trimmed = description.trim();
return trimmed.length > 0 ? trimmed : null;
} catch {
return null;
}
}
function stripFrontmatter(content: string): string {
return content.replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*\r?\n?/, '');
}
function trimDescription(description: string): string {
const maxLength = 140;
if (description.length <= maxLength) {
return description;
}
return `${description.slice(0, maxLength - 3).trimEnd()}...`;
}
function readFirstMarkdownDescription(
markdownPaths: string[],
allowedRoots: Set<string>
): string | null {
for (const markdownPath of markdownPaths) {
const description = readMarkdownDescription(markdownPath, allowedRoots);
if (description) {
return description;
}
}
return null;
}
function readMarkdownDescription(markdownPath: string, allowedRoots: Set<string>): string | null {
try {
const resolvedMarkdownPath = safeRealPath(markdownPath);
if (!resolvedMarkdownPath || !isPathWithin(resolvedMarkdownPath, allowedRoot)) {
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
return null;
}
+78 -2
View File
@@ -114,7 +114,19 @@ describe('web-server shared-routes', () => {
fs.mkdirSync(sharedSkillsDir, { recursive: true });
const targetDir = path.join(ccsDir, '.claude', 'skills', 'my-skill');
fs.mkdirSync(targetDir, { recursive: true });
fs.writeFileSync(path.join(targetDir, 'SKILL.md'), 'name: my-skill\n\nMy test skill');
fs.writeFileSync(
path.join(targetDir, 'SKILL.md'),
[
'---',
'name: my-skill',
'description: This skill handles daily maintenance workflows.',
'---',
'',
'# My Skill',
'',
'My test skill body',
].join('\n')
);
const linkPath = path.join(sharedSkillsDir, 'my-skill');
createDirectorySymlink(targetDir, linkPath);
@@ -128,7 +140,7 @@ describe('web-server shared-routes', () => {
name: 'my-skill',
type: 'skill',
});
expect(payload.items[0].description.length).toBeGreaterThan(0);
expect(payload.items[0].description).toBe('This skill handles daily maintenance workflows.');
expect(payload.items[0].path).toBe(linkPath);
});
@@ -155,6 +167,70 @@ describe('web-server shared-routes', () => {
expect(payload.items[0].path).toBe(linkPath);
});
it('lists file-based agents from symlinked ~/.claude/agents directory', async () => {
const sharedAgentsDir = path.join(ccsDir, 'shared', 'agents');
const claudeAgentsDir = path.join(ccsDir, '.claude', 'agents');
fs.mkdirSync(claudeAgentsDir, { recursive: true });
fs.writeFileSync(
path.join(claudeAgentsDir, 'planner.md'),
[
'---',
'description: Plans implementation phases and dependencies.',
'---',
'',
'# Planner',
].join('\n')
);
createDirectorySymlink(claudeAgentsDir, sharedAgentsDir);
const payload = await getJson<{
items: Array<{ name: string; type: string; description: string; path: string }>;
}>(baseUrl, '/api/shared/agents');
expect(payload.items).toEqual([
{
name: 'planner',
type: 'agent',
description: 'Plans implementation phases and dependencies.',
path: path.join(sharedAgentsDir, 'planner.md'),
},
]);
});
it('lists command markdown files recursively', async () => {
const commandsDir = path.join(ccsDir, 'shared', 'commands');
fs.mkdirSync(path.join(commandsDir, 'mkt'), { recursive: true });
fs.mkdirSync(path.join(commandsDir, 'engineer'), { recursive: true });
fs.writeFileSync(
path.join(commandsDir, 'mkt', 'campaign.md'),
'# Campaign\n\nDraft campaign brief'
);
fs.writeFileSync(
path.join(commandsDir, 'engineer', 'review.md'),
['---', 'description: Code review command for engineering workflows.', '---'].join('\n')
);
const payload = await getJson<{
items: Array<{ name: string; type: string; description: string; path: string }>;
}>(baseUrl, '/api/shared/commands');
expect(payload.items).toEqual([
{
name: 'engineer/review',
type: 'command',
description: 'Code review command for engineering workflows.',
path: path.join(commandsDir, 'engineer', 'review.md'),
},
{
name: 'mkt/campaign',
type: 'command',
description: 'Draft campaign brief',
path: path.join(commandsDir, 'mkt', 'campaign.md'),
},
]);
});
it('ignores markdown files in shared skills root', async () => {
const sharedSkillsDir = path.join(ccsDir, 'shared', 'skills');
fs.mkdirSync(sharedSkillsDir, { recursive: true });
+33 -5
View File
@@ -1,16 +1,31 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { useSharedItems, useSharedSummary } from '@/hooks/use-shared';
import { FileText, Sparkles, Bot, AlertTriangle } from 'lucide-react';
import { FileText, Sparkles, Bot, AlertTriangle, Search } from 'lucide-react';
type TabType = 'commands' | 'skills' | 'agents';
export function SharedPage() {
const [tab, setTab] = useState<TabType>('commands');
const [query, setQuery] = useState('');
const { data: summary } = useSharedSummary();
const { data: items, isLoading } = useSharedItems(tab);
const normalizedQuery = query.trim().toLowerCase();
const filteredItems = useMemo(() => {
const allItems = items?.items ?? [];
if (!normalizedQuery) {
return allItems;
}
return allItems.filter((item) =>
[item.name, item.description, item.path].some((value) =>
value.toLowerCase().includes(normalizedQuery)
)
);
}, [items, normalizedQuery]);
const tabs: { id: TabType; label: string; icon: typeof FileText; count: number }[] = [
{ id: 'commands', label: 'Commands', icon: FileText, count: summary?.commands ?? 0 },
@@ -44,7 +59,10 @@ export function SharedPage() {
key={t.id}
variant={tab === t.id ? 'default' : 'ghost'}
size="sm"
onClick={() => setTab(t.id)}
onClick={() => {
setTab(t.id);
setQuery('');
}}
className="flex items-center gap-2"
>
<t.icon className="w-4 h-4" />
@@ -53,15 +71,25 @@ export function SharedPage() {
))}
</div>
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={`Filter ${tab} by name, description, or path`}
className="pl-9"
/>
</div>
{/* Content */}
<div className="mt-4">
{isLoading ? (
<div className="text-muted-foreground">Loading...</div>
) : items?.items.length === 0 ? (
) : filteredItems.length === 0 ? (
<div className="text-muted-foreground">No {tab} found</div>
) : (
<div className="grid gap-3">
{items?.items.map((item) => (
{filteredItems.map((item) => (
<Card key={item.name}>
<CardContent>
<div className="font-medium">{item.name}</div>