mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
fix(shared): support file-based agents and recursive commands
This commit is contained in:
+240
-54
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user