refactor(web-server): modularize shared-routes and Windows-aware symlink status

This commit is contained in:
Tam Nhu Tran
2026-05-10 22:15:33 -04:00
parent 248b6b735d
commit a983332016
11 changed files with 1348 additions and 926 deletions
+192
View File
@@ -0,0 +1,192 @@
/**
* Shared Routes — Collection item list builders
*
* Builds SharedItem arrays for commands, skills, and agents with
* a short-lived in-memory cache keyed by collection type.
*/
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../config/config-loader-facade';
import { safeRealPath, isPathWithinAny, resolveAllowedRoots } from './shared-routes-path-guards';
import {
readMarkdownDescription,
readFirstMarkdownDescription,
collectMarkdownFiles,
} from './shared-routes-markdown';
import { getPluginItems } from './shared-routes-plugins';
import type {
SharedItem,
SharedItemsCacheEntry,
SharedCollectionType,
} from './shared-routes-types';
const SHARED_ITEMS_CACHE_TTL_MS = 1000;
const sharedItemsCache = new Map<SharedCollectionType, SharedItemsCacheEntry>();
// ---------------------------------------------------------------------------
// Per-type item builders
// ---------------------------------------------------------------------------
export 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));
}
export 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' };
}
// agents
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' };
}
// ---------------------------------------------------------------------------
// Public cache-backed entry point
// ---------------------------------------------------------------------------
export function getSharedItems(type: SharedCollectionType): SharedItem[] {
const ccsDir = getCcsDir();
const sharedDir = path.join(ccsDir, 'shared', type);
const now = Date.now();
if (!fs.existsSync(sharedDir)) {
sharedItemsCache.delete(type);
return [];
}
const cached = sharedItemsCache.get(type);
if (cached && cached.sharedDir === sharedDir && cached.expiresAt > now) {
return cached.items;
}
const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir);
const allowedRoots = resolveAllowedRoots(type, ccsDir, sharedDirRoot);
if (type === 'commands') {
const commandItems = getCommandItems(sharedDir, allowedRoots);
sharedItemsCache.set(type, {
items: commandItems,
sharedDir,
expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS,
});
return commandItems;
}
if (type === 'plugins') {
const pluginItems = getPluginItems(sharedDir, allowedRoots);
sharedItemsCache.set(type, {
items: pluginItems,
sharedDir,
expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS,
});
return pluginItems;
}
// skills | agents
const items: SharedItem[] = [];
try {
const entries = fs.readdirSync(sharedDir, { withFileTypes: true });
for (const entry of entries) {
try {
const entryPath = path.join(sharedDir, entry.name);
const resolvedEntryPath = safeRealPath(entryPath);
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
continue;
}
const stats = fs.statSync(resolvedEntryPath);
const item = getSkillOrAgentItem(
type,
entry,
entryPath,
resolvedEntryPath,
allowedRoots,
stats
);
if (!item) {
continue;
}
items.push(item);
} catch {
// Fail soft per entry so one bad item does not hide valid results.
}
}
} catch {
// Directory read failed — return empty list
}
const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name));
sharedItemsCache.set(type, {
items: sortedItems,
sharedDir,
expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS,
});
return sortedItems;
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Shared Routes — Item content readers
*
* Resolves the full markdown content for a given shared item path,
* routing by collection type and enforcing allowed-root checks.
*/
import * as fs from 'fs';
import * as path from 'path';
import { safeRealPath, isPathWithinAny } from './shared-routes-path-guards';
import {
readMarkdownContent,
resolveReadableMarkdownPath,
MAX_CONTENT_FILE_BYTES,
} from './shared-routes-markdown';
import { isPluginInfrastructurePath } from './shared-routes-plugins';
import { getPluginRegistryContent } from './shared-routes-plugin-registry-content';
import type { SharedContentType } from './shared-routes-types';
// ---------------------------------------------------------------------------
// Settings
// ---------------------------------------------------------------------------
export function getSharedSettingsContent(
itemPath: string,
allowedRoots: Set<string>
): { content: string; contentPath: string } | null {
if (path.basename(itemPath) !== 'settings.json') {
return null;
}
const sharedRoot = Array.from(allowedRoots)[0];
if (!sharedRoot) {
return null;
}
const settingsPath = path.join(sharedRoot, 'settings.json');
const resolvedSettingsPath = safeRealPath(settingsPath);
if (!resolvedSettingsPath || !isPathWithinAny(resolvedSettingsPath, allowedRoots)) {
return null;
}
let stats: fs.Stats;
try {
stats = fs.statSync(resolvedSettingsPath);
} catch {
return null;
}
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
return null;
}
try {
const rawContent = fs.readFileSync(resolvedSettingsPath, 'utf8');
const parsed = JSON.parse(rawContent) as unknown;
return {
content: JSON.stringify(parsed, null, 2),
contentPath: resolvedSettingsPath,
};
} catch {
const content = readMarkdownContent(resolvedSettingsPath, allowedRoots);
return content ? { content, contentPath: resolvedSettingsPath } : null;
}
}
// ---------------------------------------------------------------------------
// Generic item content
// ---------------------------------------------------------------------------
export function getSharedItemContent(
type: SharedContentType,
itemPath: string,
allowedRoots: Set<string>,
sharedDir: string
): { content: string; contentPath: string } | null {
if (type === 'settings') {
return getSharedSettingsContent(itemPath, allowedRoots);
}
if (type === 'plugins') {
const pluginRegistryContent = getPluginRegistryContent(itemPath, sharedDir, allowedRoots);
if (pluginRegistryContent) {
return pluginRegistryContent;
}
}
const resolvedItemPath = safeRealPath(itemPath);
if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) {
return null;
}
let itemStats: fs.Stats;
try {
itemStats = fs.statSync(resolvedItemPath);
} catch {
return null;
}
let markdownPath: string | null = null;
if (type === 'commands') {
if (!itemStats.isFile() || !itemPath.toLowerCase().endsWith('.md')) {
return null;
}
markdownPath = resolvedItemPath;
} else if (type === 'skills') {
if (!itemStats.isDirectory()) {
return null;
}
markdownPath = resolveReadableMarkdownPath(
[path.join(resolvedItemPath, 'SKILL.md')],
allowedRoots
);
} else if (type === 'plugins') {
if (!itemStats.isDirectory() || isPluginInfrastructurePath(resolvedItemPath, sharedDir)) {
return null;
}
markdownPath = resolveReadableMarkdownPath(
[
path.join(resolvedItemPath, 'README.md'),
path.join(resolvedItemPath, 'readme.md'),
path.join(resolvedItemPath, 'PLUGIN.md'),
],
allowedRoots
);
if (!markdownPath) {
return null;
}
} else {
// agents
if (itemStats.isDirectory()) {
markdownPath = resolveReadableMarkdownPath(
[
path.join(resolvedItemPath, 'prompt.md'),
path.join(resolvedItemPath, 'AGENT.md'),
path.join(resolvedItemPath, 'agent.md'),
],
allowedRoots
);
} else if (itemStats.isFile() && itemPath.toLowerCase().endsWith('.md')) {
markdownPath = resolvedItemPath;
}
}
if (!markdownPath) {
return null;
}
const content = readMarkdownContent(markdownPath, allowedRoots);
if (!content) {
return null;
}
return { content, contentPath: markdownPath };
}
@@ -0,0 +1,90 @@
/**
* Shared Routes — Markdown directory walker
*
* Iterative BFS/DFS over a shared directory collecting all .md files
* within allowed roots and the configured traversal depth limit.
*/
import * as fs from 'fs';
import * as path from 'path';
import { safeRealPath, isPathWithinAny } from './shared-routes-path-guards';
const MAX_DIRECTORY_TRAVERSAL_DEPTH = 10;
export interface MarkdownFileEntry {
displayPath: string;
resolvedPath: string;
}
export function collectMarkdownFiles(
sharedDir: string,
allowedRoots: Set<string>
): MarkdownFileEntry[] {
const directoriesToVisit: Array<{ path: string; depth: number }> = [
{ path: sharedDir, depth: 0 },
];
const visitedDirectories = new Set<string>();
const markdownFiles: MarkdownFileEntry[] = [];
while (directoriesToVisit.length > 0) {
const current = directoriesToVisit.pop();
if (!current) {
continue;
}
const currentDir = current.path;
const resolvedCurrentDir = safeRealPath(currentDir);
if (!resolvedCurrentDir || !isPathWithinAny(resolvedCurrentDir, allowedRoots)) {
continue;
}
const normalizedDirPath =
process.platform === 'win32'
? path.resolve(resolvedCurrentDir).toLowerCase()
: path.resolve(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()) {
if (current.depth < MAX_DIRECTORY_TRAVERSAL_DEPTH) {
directoriesToVisit.push({ path: entryPath, depth: current.depth + 1 });
}
continue;
}
if (stats.isFile() && entry.name.toLowerCase().endsWith('.md')) {
markdownFiles.push({
displayPath: entryPath,
resolvedPath: resolvedEntryPath,
});
}
}
}
return markdownFiles;
}
+163
View File
@@ -0,0 +1,163 @@
/**
* Shared Routes — Markdown file reading and description extraction
*
* Handles YAML frontmatter parsing, body-line extraction, and safe
* bounded reads for both description snippets and full content.
*
* Directory walking is in shared-routes-markdown-walker.ts.
*/
import * as fs from 'fs';
import * as yaml from 'js-yaml';
import { safeRealPath, isPathWithinAny } from './shared-routes-path-guards';
const MAX_DESCRIPTION_LENGTH = 140;
const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB
/** Exported for content module which uses a larger limit. */
export const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB
// Re-export walker types so callers only need one import for markdown concerns.
export type { MarkdownFileEntry } from './shared-routes-markdown-walker';
export { collectMarkdownFiles } from './shared-routes-markdown-walker';
// ---------------------------------------------------------------------------
// Description extraction helpers
// ---------------------------------------------------------------------------
function isDescriptionBodyLine(line: string): boolean {
if (!line) {
return false;
}
if (line === '---' || line === '...') {
return false;
}
return !line.startsWith('#') && !line.startsWith('<!--');
}
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 {
if (description.length <= MAX_DESCRIPTION_LENGTH) {
return description;
}
return `${description.slice(0, MAX_DESCRIPTION_LENGTH - 3).trimEnd()}...`;
}
export function extractDescription(content: string): string {
const frontmatterDescription = extractFrontmatterDescription(content);
if (frontmatterDescription) {
return trimDescription(frontmatterDescription);
}
// Fall back to first non-empty, non-heading body line.
const lines = stripFrontmatter(content).split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (isDescriptionBodyLine(trimmed)) {
return trimDescription(trimmed);
}
}
return 'No description';
}
// ---------------------------------------------------------------------------
// File readers
// ---------------------------------------------------------------------------
export function readMarkdownDescription(
markdownPath: string,
allowedRoots: Set<string>
): string | null {
try {
const resolvedMarkdownPath = safeRealPath(markdownPath);
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
return null;
}
const stats = fs.statSync(resolvedMarkdownPath);
if (!stats.isFile() || stats.size > MAX_MARKDOWN_FILE_BYTES) {
return null;
}
const content = fs.readFileSync(resolvedMarkdownPath, 'utf8');
return extractDescription(content);
} catch {
return null;
}
}
export 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;
}
export function readMarkdownContent(
markdownPath: string,
allowedRoots: Set<string>
): string | null {
try {
const resolvedMarkdownPath = safeRealPath(markdownPath);
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
return null;
}
const stats = fs.statSync(resolvedMarkdownPath);
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
return null;
}
return fs.readFileSync(resolvedMarkdownPath, 'utf8');
} catch {
return null;
}
}
export function resolveReadableMarkdownPath(
markdownPaths: string[],
allowedRoots: Set<string>
): string | null {
for (const markdownPath of markdownPaths) {
const resolvedMarkdownPath = safeRealPath(markdownPath);
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
continue;
}
try {
const stats = fs.statSync(resolvedMarkdownPath);
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
continue;
}
return resolvedMarkdownPath;
} catch {
continue;
}
}
return null;
}
@@ -0,0 +1,77 @@
/**
* Shared Routes — Path safety helpers
*
* Prevents directory traversal by resolving real paths and checking
* containment within allowed roots before any file I/O.
*/
import * as fs from 'fs';
import * as path from 'path';
import { getClaudeConfigDir } from '../utils/claude-config-path';
import { getCcsDir } from '../config/config-loader-facade';
import type { SharedCollectionType } from './shared-routes-types';
export function safeRealPath(targetPath: string): string | null {
try {
return fs.realpathSync(targetPath);
} catch {
return null;
}
}
export function normalizeForPathComparison(targetPath: string): string {
const normalized = path.resolve(targetPath);
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
export function isPathWithin(candidatePath: string, basePath: string): boolean {
const normalizedCandidate = normalizeForPathComparison(candidatePath);
const normalizedBase = normalizeForPathComparison(basePath);
const relative = path.relative(normalizedBase, normalizedCandidate);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
export function isPathWithinAny(candidatePath: string, basePaths: Set<string>): boolean {
for (const basePath of basePaths) {
if (isPathWithin(candidatePath, basePath)) {
return true;
}
}
return false;
}
export function resolveAllowedRoots(
type: SharedCollectionType,
ccsDir: string,
sharedDirRoot: string
): Set<string> {
if (type === 'commands' || type === 'plugins') {
return new Set<string>([sharedDirRoot]);
}
return new Set<string>([
sharedDirRoot,
...[
path.join(getClaudeConfigDir(), type),
path.join(ccsDir, '.claude', type),
path.join(ccsDir, 'shared', type),
]
.map((dirPath) => safeRealPath(dirPath))
.filter((dirPath): dirPath is string => typeof dirPath === 'string'),
]);
}
export function resolveSettingsAllowedRoots(ccsDir: string, sharedDirRoot: string): Set<string> {
const claudeConfigDir = getClaudeConfigDir();
return new Set<string>(
[
sharedDirRoot,
safeRealPath(path.join(claudeConfigDir, 'settings.json')),
safeRealPath(path.join(ccsDir, '.claude', 'settings.json')),
].filter((dirPath): dirPath is string => typeof dirPath === 'string')
);
}
// Re-export getCcsDir so callers that need both path guards and ccsDir can import from one place
export { getCcsDir };
@@ -0,0 +1,103 @@
/**
* Shared Routes — Plugin registry content renderer
*
* Renders the markdown content for an installed plugin entry,
* pulling from installed_plugins.json, known_marketplaces.json,
* and blocklist.json within the allowed roots.
*/
import * as path from 'path';
import { safeRealPath, isPathWithinAny } from './shared-routes-path-guards';
import {
readJsonObject,
readPluginInstalledRegistry,
decodePluginRegistryPath,
extractMarketplaceFromPluginName,
stringifyJson,
} from './shared-routes-plugins';
function findPluginBlocklistEntry(
sharedDir: string,
allowedRoots: Set<string>,
pluginName: string
): unknown | null {
const blocklist = readJsonObject(path.join(sharedDir, 'blocklist.json'), allowedRoots);
const plugins = blocklist?.plugins;
if (!Array.isArray(plugins)) {
return null;
}
return (
plugins.find(
(entry) =>
entry &&
typeof entry === 'object' &&
'plugin' in entry &&
(entry as { plugin?: unknown }).plugin === pluginName
) ?? null
);
}
export function getPluginRegistryContent(
itemPath: string,
sharedDir: string,
allowedRoots: Set<string>
): { content: string; contentPath: string } | null {
const pluginName = decodePluginRegistryPath(itemPath);
if (!pluginName) {
return null;
}
const registryPath = path.join(sharedDir, 'installed_plugins.json');
const resolvedRegistryPath = safeRealPath(registryPath);
if (!resolvedRegistryPath || !isPathWithinAny(resolvedRegistryPath, allowedRoots)) {
return null;
}
const registry = readPluginInstalledRegistry(sharedDir, allowedRoots);
if (!registry || !Object.prototype.hasOwnProperty.call(registry.plugins, pluginName)) {
return null;
}
const metadata = registry.plugins[pluginName];
const marketplace = extractMarketplaceFromPluginName(pluginName);
const marketplaceEntry = marketplace
? readJsonObject(path.join(sharedDir, 'known_marketplaces.json'), allowedRoots)?.[marketplace]
: null;
const blocklistEntry = findPluginBlocklistEntry(sharedDir, allowedRoots, pluginName);
const lines = [
`# ${pluginName}`,
'',
`Registry: \`${resolvedRegistryPath}\``,
'',
'## Installed Plugin',
'',
`- Source: shared \`installed_plugins.json\` registry`,
`- Registry version: ${registry.version ?? 'unknown'}`,
`- Marketplace: ${marketplace ?? 'not recorded in plugin name'}`,
`- Install records: ${Array.isArray(metadata) ? metadata.length : 1}`,
];
if (marketplaceEntry) {
lines.push(
'',
'## Marketplace Registry Entry',
'',
'```json',
stringifyJson(marketplaceEntry),
'```'
);
}
if (blocklistEntry) {
lines.push('', '## Blocklist Notice', '', '```json', stringifyJson(blocklistEntry), '```');
}
lines.push('', '## Plugin Registry Entry', '', '```json', stringifyJson(metadata), '```');
return {
content: lines.join('\n'),
contentPath: resolvedRegistryPath,
};
}
+199
View File
@@ -0,0 +1,199 @@
/**
* Shared Routes — Plugin collection and registry helpers
*
* Handles installed_plugins.json registry reads, legacy plugin directory
* scanning, and plugin path encode/decode.
*
* Registry content rendering is in shared-routes-plugin-registry-content.ts.
*/
import * as fs from 'fs';
import * as path from 'path';
import { safeRealPath, isPathWithin, isPathWithinAny } from './shared-routes-path-guards';
import { readFirstMarkdownDescription, MAX_CONTENT_FILE_BYTES } from './shared-routes-markdown';
import type { SharedItem, InstalledPluginRegistry } from './shared-routes-types';
const PLUGIN_REGISTRY_PATH_PREFIX = 'plugin-registry:';
const PLUGIN_INFRASTRUCTURE_DIRECTORIES = new Set(['cache', 'data', 'marketplaces']);
// ---------------------------------------------------------------------------
// Path encode / decode
// ---------------------------------------------------------------------------
export function encodePluginRegistryPath(pluginName: string): string {
return `${PLUGIN_REGISTRY_PATH_PREFIX}${encodeURIComponent(pluginName)}`;
}
export function decodePluginRegistryPath(itemPath: string): string | null {
if (!itemPath.startsWith(PLUGIN_REGISTRY_PATH_PREFIX)) {
return null;
}
try {
const encodedName = itemPath.slice(PLUGIN_REGISTRY_PATH_PREFIX.length);
const pluginName = decodeURIComponent(encodedName);
return pluginName.length > 0 ? pluginName : null;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// JSON helpers
// ---------------------------------------------------------------------------
export function readJsonObject(
filePath: string,
allowedRoots: Set<string>
): Record<string, unknown> | null {
const resolvedPath = safeRealPath(filePath);
if (!resolvedPath || !isPathWithinAny(resolvedPath, allowedRoots)) {
return null;
}
try {
const stats = fs.statSync(resolvedPath);
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
return null;
}
const parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null;
}
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
export function readPluginInstalledRegistry(
sharedDir: string,
allowedRoots: Set<string>
): InstalledPluginRegistry | null {
const parsed = readJsonObject(path.join(sharedDir, 'installed_plugins.json'), allowedRoots);
if (
!parsed ||
!parsed.plugins ||
typeof parsed.plugins !== 'object' ||
Array.isArray(parsed.plugins)
) {
return null;
}
return {
version: typeof parsed.version === 'number' ? parsed.version : undefined,
plugins: parsed.plugins as Record<string, unknown>,
};
}
// ---------------------------------------------------------------------------
// Description helpers
// ---------------------------------------------------------------------------
export function extractMarketplaceFromPluginName(pluginName: string): string | null {
const atIndex = pluginName.lastIndexOf('@');
if (atIndex <= 0 || atIndex === pluginName.length - 1) {
return null;
}
return pluginName.slice(atIndex + 1);
}
export function describeInstalledPlugin(pluginName: string, metadata: unknown): string {
const recordCount = Array.isArray(metadata) ? metadata.length : 1;
const marketplace = extractMarketplaceFromPluginName(pluginName);
const recordLabel = `${recordCount} ${recordCount === 1 ? 'record' : 'records'}`;
return marketplace
? `Installed from ${marketplace}; ${recordLabel} in shared registry`
: `Installed plugin; ${recordLabel} in shared registry`;
}
// ---------------------------------------------------------------------------
// Infrastructure path guard
// ---------------------------------------------------------------------------
export function isPluginInfrastructurePath(candidatePath: string, sharedDir: string): boolean {
for (const directoryName of PLUGIN_INFRASTRUCTURE_DIRECTORIES) {
const resolvedInfrastructurePath = safeRealPath(path.join(sharedDir, directoryName));
if (resolvedInfrastructurePath && isPathWithin(candidatePath, resolvedInfrastructurePath)) {
return true;
}
}
return false;
}
// ---------------------------------------------------------------------------
// JSON serialization
// ---------------------------------------------------------------------------
export function stringifyJson(value: unknown): string {
return JSON.stringify(value, null, 2) ?? 'null';
}
// ---------------------------------------------------------------------------
// Item list builders
// ---------------------------------------------------------------------------
export function getPluginRegistryItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
const registry = readPluginInstalledRegistry(sharedDir, allowedRoots);
if (!registry) {
return [];
}
return Object.entries(registry.plugins).map(([pluginName, metadata]) => ({
name: pluginName,
description: describeInstalledPlugin(pluginName, metadata),
path: encodePluginRegistryPath(pluginName),
type: 'plugin' as const,
}));
}
export function getLegacyPluginDirectoryItems(
sharedDir: string,
allowedRoots: Set<string>
): SharedItem[] {
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(sharedDir, { withFileTypes: true });
} catch {
return [];
}
const items: SharedItem[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || PLUGIN_INFRASTRUCTURE_DIRECTORIES.has(entry.name)) {
continue;
}
const entryPath = path.join(sharedDir, entry.name);
const resolvedEntryPath = safeRealPath(entryPath);
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
continue;
}
const description = readFirstMarkdownDescription(
[
path.join(resolvedEntryPath, 'README.md'),
path.join(resolvedEntryPath, 'readme.md'),
path.join(resolvedEntryPath, 'PLUGIN.md'),
],
allowedRoots
);
if (!description) {
continue;
}
items.push({ name: entry.name, description, path: entryPath, type: 'plugin' });
}
return items;
}
export function getPluginItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
const registryItems = getPluginRegistryItems(sharedDir, allowedRoots);
const legacyDirectoryItems = getLegacyPluginDirectoryItems(sharedDir, allowedRoots);
const itemsByPath = new Map<string, SharedItem>();
for (const item of [...registryItems, ...legacyDirectoryItems]) {
itemsByPath.set(item.path, item);
}
return [...itemsByPath.values()].sort((a, b) => a.name.localeCompare(b.name));
}
@@ -0,0 +1,144 @@
/**
* Shared Routes — Symlink / copy status checker
*
* Reports whether shared resources (commands, skills, agents) under
* ~/.ccs/shared/ are configured as symlinks pointing to ~/.claude/,
* as directory copies (Windows fallback), or are missing.
*
* Return shape is a superset of the original { valid, message } so
* existing callers remain unaffected while new callers can inspect
* mode and per-link details.
*/
import * as fs from 'fs';
import * as path from 'path';
import { getClaudeConfigDir } from '../utils/claude-config-path';
import { getCcsDir } from '../config/config-loader-facade';
type LinkMode = 'symlink' | 'copy' | 'missing';
interface LinkDetail {
mode: LinkMode;
}
interface SymlinkStatusResult {
valid: boolean;
message: string;
/** Overall configuration mode derived from the three entries. */
mode: 'symlink' | 'copy' | 'mixed' | 'none';
/** Per-entry classification for diagnostics. */
links: {
commands: LinkMode;
skills: LinkMode;
agents: LinkMode;
};
}
const SHARED_ENTRY_TYPES = ['commands', 'skills', 'agents'] as const;
type SharedEntryType = (typeof SHARED_ENTRY_TYPES)[number];
function classifyEntry(linkPath: string, expectedTarget: string): LinkMode {
try {
const lstats = fs.lstatSync(linkPath);
if (lstats.isSymbolicLink()) {
// Validate it points to the expected Claude config sub-directory.
const target = fs.readlinkSync(linkPath);
const resolvedTarget = path.resolve(path.dirname(linkPath), target);
if (resolvedTarget === path.resolve(expectedTarget)) {
return 'symlink';
}
// Symlink exists but points elsewhere — treat as missing for validity.
return 'missing';
}
if (lstats.isDirectory()) {
// Best-effort non-empty check to distinguish a real copy from an
// accidental empty directory.
try {
const entries = fs.readdirSync(linkPath);
if (entries.length > 0) {
return 'copy';
}
} catch {
// readdirSync failed — fall through to missing
}
return 'missing';
}
} catch {
// lstatSync threw — path does not exist or is unreadable
}
return 'missing';
}
export function checkSymlinkStatus(): SymlinkStatusResult {
const ccsDir = getCcsDir();
const sharedDir = path.join(ccsDir, 'shared');
const claudeConfigDir = getClaudeConfigDir();
if (!fs.existsSync(sharedDir)) {
return {
valid: false,
message: 'Shared directory not found',
mode: 'none',
links: { commands: 'missing', skills: 'missing', agents: 'missing' },
};
}
const details: Record<SharedEntryType, LinkDetail> = {
commands: { mode: 'missing' },
skills: { mode: 'missing' },
agents: { mode: 'missing' },
};
for (const entryType of SHARED_ENTRY_TYPES) {
const linkPath = path.join(sharedDir, entryType);
const expectedTarget = path.join(claudeConfigDir, entryType);
if (!fs.existsSync(linkPath)) {
details[entryType] = { mode: 'missing' };
continue;
}
details[entryType] = { mode: classifyEntry(linkPath, expectedTarget) };
}
const modes = SHARED_ENTRY_TYPES.map((t) => details[t].mode);
const links = {
commands: details.commands.mode,
skills: details.skills.mode,
agents: details.agents.mode,
};
const allSymlink = modes.every((m) => m === 'symlink');
const allCopy = modes.every((m) => m === 'copy');
const allMissing = modes.every((m) => m === 'missing');
if (allSymlink) {
return { valid: true, message: 'Symlinks active', mode: 'symlink', links };
}
if (allCopy) {
return {
valid: true,
message: 'Copies active (Windows fallback)',
mode: 'copy',
links,
};
}
if (allMissing) {
return { valid: false, message: 'Shared resources not configured', mode: 'none', links };
}
// Mixed — at least one differs from the others.
const details_str = SHARED_ENTRY_TYPES.map((t) => `${t}:${details[t].mode}`).join(', ');
return {
valid: false,
message: `Mixed configuration: ${details_str}`,
mode: 'mixed',
links,
};
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Shared Routes — Type definitions and type guards
*/
export type SharedCollectionType = 'commands' | 'skills' | 'agents' | 'plugins';
export type SharedContentType = SharedCollectionType | 'settings';
export interface SharedItem {
name: string;
description: string;
path: string;
type: 'command' | 'skill' | 'agent' | 'plugin';
}
export interface SharedItemsCacheEntry {
items: SharedItem[];
sharedDir: string;
expiresAt: number;
}
export interface InstalledPluginRegistry {
version?: number;
plugins: Record<string, unknown>;
}
export function isSharedCollectionType(value: unknown): value is SharedCollectionType {
return value === 'commands' || value === 'skills' || value === 'agents' || value === 'plugins';
}
export function isSharedContentType(value: unknown): value is SharedContentType {
return isSharedCollectionType(value) || value === 'settings';
}
+21 -926
View File
@@ -1,17 +1,32 @@
/**
* Shared Data Routes (Phase 07)
*
* Thin Express router — delegates all logic to focused sub-modules.
* API routes for commands, skills, agents, and plugins from ~/.ccs/shared/
*/
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';
import { getClaudeConfigDir } from '../utils/claude-config-path';
import { requireLocalAccessWhenAuthDisabled } from './middleware/auth-middleware';
import { getCcsDir } from '../config/config-loader-facade';
import { requireLocalAccessWhenAuthDisabled } from './middleware/auth-middleware';
import { isSharedContentType } from './shared-routes-types';
import {
safeRealPath,
resolveAllowedRoots,
resolveSettingsAllowedRoots,
isPathWithinAny,
} from './shared-routes-path-guards';
import { getSharedItems } from './shared-routes-collections';
import { getSharedItemContent } from './shared-routes-content';
import { checkSymlinkStatus } from './shared-routes-symlink-status';
/** Strips extended fields so the summary endpoint stays wire-compatible. */
function symlinkStatusForSummary(): { valid: boolean; message: string } {
const { valid, message } = checkSymlinkStatus();
return { valid, message };
}
export const sharedRoutes = Router();
@@ -27,37 +42,6 @@ sharedRoutes.use((req: Request, res: Response, next) => {
}
});
const MAX_DIRECTORY_TRAVERSAL_DEPTH = 10;
const MAX_DESCRIPTION_LENGTH = 140;
const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB
const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB
const SHARED_ITEMS_CACHE_TTL_MS = 1000;
const PLUGIN_REGISTRY_PATH_PREFIX = 'plugin-registry:';
const PLUGIN_INFRASTRUCTURE_DIRECTORIES = new Set(['cache', 'data', 'marketplaces']);
type SharedCollectionType = 'commands' | 'skills' | 'agents' | 'plugins';
type SharedContentType = SharedCollectionType | 'settings';
interface SharedItem {
name: string;
description: string;
path: string;
type: 'command' | 'skill' | 'agent' | 'plugin';
}
interface SharedItemsCacheEntry {
items: SharedItem[];
sharedDir: string;
expiresAt: number;
}
interface InstalledPluginRegistry {
version?: number;
plugins: Record<string, unknown>;
}
const sharedItemsCache = new Map<SharedCollectionType, SharedItemsCacheEntry>();
/**
* GET /api/shared/commands
*/
@@ -109,6 +93,7 @@ sharedRoutes.get('/content', (req: Request, res: Response) => {
const ccsDir = getCcsDir();
const sharedDir =
typeParam === 'settings' ? path.join(ccsDir, 'shared') : path.join(ccsDir, 'shared', typeParam);
if (!fs.existsSync(sharedDir)) {
res.status(404).json({ error: 'Shared directory not found' });
return;
@@ -119,6 +104,7 @@ sharedRoutes.get('/content', (req: Request, res: Response) => {
typeParam === 'settings'
? resolveSettingsAllowedRoots(ccsDir, sharedDirRoot)
: resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot);
const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots, sharedDirRoot);
if (!contentResult) {
@@ -157,897 +143,6 @@ sharedRoutes.get('/summary', (_req: Request, res: Response) => {
plugins,
settings: hasSettings,
total: commands + skills + agents + plugins + (hasSettings ? 1 : 0),
symlinkStatus: checkSymlinkStatus(),
symlinkStatus: symlinkStatusForSummary(),
});
});
function isSharedCollectionType(value: unknown): value is SharedCollectionType {
return value === 'commands' || value === 'skills' || value === 'agents' || value === 'plugins';
}
function isSharedContentType(value: unknown): value is SharedContentType {
return isSharedCollectionType(value) || value === 'settings';
}
function resolveAllowedRoots(
type: SharedCollectionType,
ccsDir: string,
sharedDirRoot: string
): Set<string> {
if (type === 'commands' || type === 'plugins') {
return new Set<string>([sharedDirRoot]);
}
return new Set<string>([
sharedDirRoot,
...[
path.join(getClaudeConfigDir(), type),
path.join(ccsDir, '.claude', type),
path.join(ccsDir, 'shared', type),
]
.map((dirPath) => safeRealPath(dirPath))
.filter((dirPath): dirPath is string => typeof dirPath === 'string'),
]);
}
function resolveSettingsAllowedRoots(ccsDir: string, sharedDirRoot: string): Set<string> {
const claudeConfigDir = getClaudeConfigDir();
return new Set<string>(
[
sharedDirRoot,
safeRealPath(path.join(claudeConfigDir, 'settings.json')),
safeRealPath(path.join(ccsDir, '.claude', 'settings.json')),
].filter((dirPath): dirPath is string => typeof dirPath === 'string')
);
}
function getSharedItems(type: SharedCollectionType): SharedItem[] {
const ccsDir = getCcsDir();
const sharedDir = path.join(ccsDir, 'shared', type);
const now = Date.now();
if (!fs.existsSync(sharedDir)) {
sharedItemsCache.delete(type);
return [];
}
const cached = sharedItemsCache.get(type);
if (cached && cached.sharedDir === sharedDir && cached.expiresAt > now) {
return cached.items;
}
const items: SharedItem[] = [];
const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir);
const allowedRoots = resolveAllowedRoots(type, ccsDir, sharedDirRoot);
if (type === 'commands') {
const commandItems = getCommandItems(sharedDir, allowedRoots);
sharedItemsCache.set(type, {
items: commandItems,
sharedDir,
expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS,
});
return commandItems;
}
if (type === 'plugins') {
const pluginItems = getPluginItems(sharedDir, allowedRoots);
sharedItemsCache.set(type, {
items: pluginItems,
sharedDir,
expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS,
});
return pluginItems;
}
try {
const entries = fs.readdirSync(sharedDir, { withFileTypes: true });
for (const entry of entries) {
try {
const entryPath = path.join(sharedDir, entry.name);
const resolvedEntryPath = safeRealPath(entryPath);
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
continue;
}
const stats = fs.statSync(resolvedEntryPath);
const item = getSkillOrAgentItem(
type,
entry,
entryPath,
resolvedEntryPath,
allowedRoots,
stats
);
if (!item) {
continue;
}
items.push(item);
} catch {
// Fail soft per entry so one bad item does not hide valid results.
}
}
} catch {
// Directory read failed
}
const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name));
sharedItemsCache.set(type, {
items: sortedItems,
sharedDir,
expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS,
});
return sortedItems;
}
function getPluginItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
const registryItems = getPluginRegistryItems(sharedDir, allowedRoots);
const legacyDirectoryItems = getLegacyPluginDirectoryItems(sharedDir, allowedRoots);
const itemsByPath = new Map<string, SharedItem>();
for (const item of [...registryItems, ...legacyDirectoryItems]) {
itemsByPath.set(item.path, item);
}
return [...itemsByPath.values()].sort((a, b) => a.name.localeCompare(b.name));
}
function getPluginRegistryItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
const registry = readPluginInstalledRegistry(sharedDir, allowedRoots);
if (!registry) {
return [];
}
return Object.entries(registry.plugins).map(([pluginName, metadata]) => ({
name: pluginName,
description: describeInstalledPlugin(pluginName, metadata),
path: encodePluginRegistryPath(pluginName),
type: 'plugin' as const,
}));
}
function getLegacyPluginDirectoryItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(sharedDir, { withFileTypes: true });
} catch {
return [];
}
const items: SharedItem[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || PLUGIN_INFRASTRUCTURE_DIRECTORIES.has(entry.name)) {
continue;
}
const entryPath = path.join(sharedDir, entry.name);
const resolvedEntryPath = safeRealPath(entryPath);
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
continue;
}
const description = readFirstMarkdownDescription(
[
path.join(resolvedEntryPath, 'README.md'),
path.join(resolvedEntryPath, 'readme.md'),
path.join(resolvedEntryPath, 'PLUGIN.md'),
],
allowedRoots
);
if (!description) {
continue;
}
items.push({
name: entry.name,
description,
path: entryPath,
type: 'plugin',
});
}
return items;
}
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: Array<{ path: string; depth: number }> = [
{ path: sharedDir, depth: 0 },
];
const visitedDirectories = new Set<string>();
const markdownFiles: MarkdownFileEntry[] = [];
while (directoriesToVisit.length > 0) {
const current = directoriesToVisit.pop();
if (!current) {
continue;
}
const currentDir = current.path;
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()) {
if (current.depth < MAX_DIRECTORY_TRAVERSAL_DEPTH) {
directoriesToVisit.push({ path: entryPath, depth: current.depth + 1 });
}
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 (isDescriptionBodyLine(trimmed)) {
return trimDescription(trimmed);
}
}
return 'No description';
}
function isDescriptionBodyLine(line: string): boolean {
if (!line) {
return false;
}
if (line === '---' || line === '...') {
return false;
}
return !line.startsWith('#') && !line.startsWith('<!--');
}
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 {
if (description.length <= MAX_DESCRIPTION_LENGTH) {
return description;
}
return `${description.slice(0, MAX_DESCRIPTION_LENGTH - 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 || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
return null;
}
const stats = fs.statSync(resolvedMarkdownPath);
if (!stats.isFile()) {
return null;
}
if (stats.size > MAX_MARKDOWN_FILE_BYTES) {
return null;
}
const content = fs.readFileSync(resolvedMarkdownPath, 'utf8');
return extractDescription(content);
} catch {
return null;
}
}
function readMarkdownContent(markdownPath: string, allowedRoots: Set<string>): string | null {
try {
const resolvedMarkdownPath = safeRealPath(markdownPath);
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
return null;
}
const stats = fs.statSync(resolvedMarkdownPath);
if (!stats.isFile()) {
return null;
}
if (stats.size > MAX_CONTENT_FILE_BYTES) {
return null;
}
return fs.readFileSync(resolvedMarkdownPath, 'utf8');
} catch {
return null;
}
}
function encodePluginRegistryPath(pluginName: string): string {
return `${PLUGIN_REGISTRY_PATH_PREFIX}${encodeURIComponent(pluginName)}`;
}
function decodePluginRegistryPath(itemPath: string): string | null {
if (!itemPath.startsWith(PLUGIN_REGISTRY_PATH_PREFIX)) {
return null;
}
try {
const encodedName = itemPath.slice(PLUGIN_REGISTRY_PATH_PREFIX.length);
const pluginName = decodeURIComponent(encodedName);
return pluginName.length > 0 ? pluginName : null;
} catch {
return null;
}
}
function readJsonObject(
filePath: string,
allowedRoots: Set<string>
): Record<string, unknown> | null {
const resolvedPath = safeRealPath(filePath);
if (!resolvedPath || !isPathWithinAny(resolvedPath, allowedRoots)) {
return null;
}
try {
const stats = fs.statSync(resolvedPath);
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
return null;
}
const parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null;
}
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
function readPluginInstalledRegistry(
sharedDir: string,
allowedRoots: Set<string>
): InstalledPluginRegistry | null {
const parsed = readJsonObject(path.join(sharedDir, 'installed_plugins.json'), allowedRoots);
if (
!parsed ||
!parsed.plugins ||
typeof parsed.plugins !== 'object' ||
Array.isArray(parsed.plugins)
) {
return null;
}
return {
version: typeof parsed.version === 'number' ? parsed.version : undefined,
plugins: parsed.plugins as Record<string, unknown>,
};
}
function describeInstalledPlugin(pluginName: string, metadata: unknown): string {
const recordCount = Array.isArray(metadata) ? metadata.length : 1;
const marketplace = extractMarketplaceFromPluginName(pluginName);
const recordLabel = `${recordCount} ${recordCount === 1 ? 'record' : 'records'}`;
return marketplace
? `Installed from ${marketplace}; ${recordLabel} in shared registry`
: `Installed plugin; ${recordLabel} in shared registry`;
}
function extractMarketplaceFromPluginName(pluginName: string): string | null {
const atIndex = pluginName.lastIndexOf('@');
if (atIndex <= 0 || atIndex === pluginName.length - 1) {
return null;
}
return pluginName.slice(atIndex + 1);
}
function resolveReadableMarkdownPath(
markdownPaths: string[],
allowedRoots: Set<string>
): string | null {
for (const markdownPath of markdownPaths) {
const resolvedMarkdownPath = safeRealPath(markdownPath);
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
continue;
}
try {
const stats = fs.statSync(resolvedMarkdownPath);
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
continue;
}
return resolvedMarkdownPath;
} catch {
continue;
}
}
return null;
}
function getSharedItemContent(
type: SharedContentType,
itemPath: string,
allowedRoots: Set<string>,
sharedDir: string
): { content: string; contentPath: string } | null {
if (type === 'settings') {
return getSharedSettingsContent(itemPath, allowedRoots);
}
if (type === 'plugins') {
const pluginRegistryContent = getPluginRegistryContent(itemPath, sharedDir, allowedRoots);
if (pluginRegistryContent) {
return pluginRegistryContent;
}
}
const resolvedItemPath = safeRealPath(itemPath);
if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) {
return null;
}
let itemStats: fs.Stats;
try {
itemStats = fs.statSync(resolvedItemPath);
} catch {
return null;
}
let markdownPath: string | null = null;
if (type === 'commands') {
if (!itemStats.isFile() || !itemPath.toLowerCase().endsWith('.md')) {
return null;
}
markdownPath = resolvedItemPath;
} else if (type === 'skills') {
if (!itemStats.isDirectory()) {
return null;
}
markdownPath = resolveReadableMarkdownPath(
[path.join(resolvedItemPath, 'SKILL.md')],
allowedRoots
);
} else if (type === 'plugins') {
if (!itemStats.isDirectory() || isPluginInfrastructurePath(resolvedItemPath, sharedDir)) {
return null;
}
markdownPath = resolveReadableMarkdownPath(
[
path.join(resolvedItemPath, 'README.md'),
path.join(resolvedItemPath, 'readme.md'),
path.join(resolvedItemPath, 'PLUGIN.md'),
],
allowedRoots
);
if (!markdownPath) {
return null;
}
} else {
if (itemStats.isDirectory()) {
markdownPath = resolveReadableMarkdownPath(
[
path.join(resolvedItemPath, 'prompt.md'),
path.join(resolvedItemPath, 'AGENT.md'),
path.join(resolvedItemPath, 'agent.md'),
],
allowedRoots
);
} else if (itemStats.isFile() && itemPath.toLowerCase().endsWith('.md')) {
markdownPath = resolvedItemPath;
}
}
if (!markdownPath) {
return null;
}
const content = readMarkdownContent(markdownPath, allowedRoots);
if (!content) {
return null;
}
return {
content,
contentPath: markdownPath,
};
}
function isPluginInfrastructurePath(candidatePath: string, sharedDir: string): boolean {
for (const directoryName of PLUGIN_INFRASTRUCTURE_DIRECTORIES) {
const resolvedInfrastructurePath = safeRealPath(path.join(sharedDir, directoryName));
if (resolvedInfrastructurePath && isPathWithin(candidatePath, resolvedInfrastructurePath)) {
return true;
}
}
return false;
}
function getPluginRegistryContent(
itemPath: string,
sharedDir: string,
allowedRoots: Set<string>
): { content: string; contentPath: string } | null {
const pluginName = decodePluginRegistryPath(itemPath);
if (!pluginName) {
return null;
}
const registryPath = path.join(sharedDir, 'installed_plugins.json');
const resolvedRegistryPath = safeRealPath(registryPath);
if (!resolvedRegistryPath || !isPathWithinAny(resolvedRegistryPath, allowedRoots)) {
return null;
}
const registry = readPluginInstalledRegistry(sharedDir, allowedRoots);
if (!registry || !Object.prototype.hasOwnProperty.call(registry.plugins, pluginName)) {
return null;
}
const metadata = registry.plugins[pluginName];
const marketplace = extractMarketplaceFromPluginName(pluginName);
const marketplaceEntry = marketplace
? readJsonObject(path.join(sharedDir, 'known_marketplaces.json'), allowedRoots)?.[marketplace]
: null;
const blocklistEntry = findPluginBlocklistEntry(sharedDir, allowedRoots, pluginName);
const lines = [
`# ${pluginName}`,
'',
`Registry: \`${resolvedRegistryPath}\``,
'',
'## Installed Plugin',
'',
`- Source: shared \`installed_plugins.json\` registry`,
`- Registry version: ${registry.version ?? 'unknown'}`,
`- Marketplace: ${marketplace ?? 'not recorded in plugin name'}`,
`- Install records: ${Array.isArray(metadata) ? metadata.length : 1}`,
];
if (marketplaceEntry) {
lines.push(
'',
'## Marketplace Registry Entry',
'',
'```json',
stringifyJson(marketplaceEntry),
'```'
);
}
if (blocklistEntry) {
lines.push('', '## Blocklist Notice', '', '```json', stringifyJson(blocklistEntry), '```');
}
lines.push('', '## Plugin Registry Entry', '', '```json', stringifyJson(metadata), '```');
return {
content: lines.join('\n'),
contentPath: resolvedRegistryPath,
};
}
function findPluginBlocklistEntry(
sharedDir: string,
allowedRoots: Set<string>,
pluginName: string
): unknown | null {
const blocklist = readJsonObject(path.join(sharedDir, 'blocklist.json'), allowedRoots);
const plugins = blocklist?.plugins;
if (!Array.isArray(plugins)) {
return null;
}
return (
plugins.find(
(entry) =>
entry &&
typeof entry === 'object' &&
'plugin' in entry &&
(entry as { plugin?: unknown }).plugin === pluginName
) ?? null
);
}
function stringifyJson(value: unknown): string {
return JSON.stringify(value, null, 2) ?? 'null';
}
function safeRealPath(targetPath: string): string | null {
try {
return fs.realpathSync(targetPath);
} catch {
return null;
}
}
function getSharedSettingsContent(
itemPath: string,
allowedRoots: Set<string>
): { content: string; contentPath: string } | null {
if (path.basename(itemPath) !== 'settings.json') {
return null;
}
const sharedRoot = Array.from(allowedRoots)[0];
if (!sharedRoot) {
return null;
}
const settingsPath = path.join(sharedRoot, 'settings.json');
const resolvedSettingsPath = safeRealPath(settingsPath);
if (!resolvedSettingsPath || !isPathWithinAny(resolvedSettingsPath, allowedRoots)) {
return null;
}
let stats: fs.Stats;
try {
stats = fs.statSync(resolvedSettingsPath);
} catch {
return null;
}
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
return null;
}
try {
const rawContent = fs.readFileSync(resolvedSettingsPath, 'utf8');
const parsed = JSON.parse(rawContent) as unknown;
return {
content: JSON.stringify(parsed, null, 2),
contentPath: resolvedSettingsPath,
};
} catch {
const content = readMarkdownContent(resolvedSettingsPath, allowedRoots);
return content
? {
content,
contentPath: resolvedSettingsPath,
}
: null;
}
}
function isPathWithin(candidatePath: string, basePath: string): boolean {
const normalizedCandidate = normalizeForPathComparison(candidatePath);
const normalizedBase = normalizeForPathComparison(basePath);
const relative = path.relative(normalizedBase, normalizedCandidate);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
function isPathWithinAny(candidatePath: string, basePaths: Set<string>): boolean {
for (const basePath of basePaths) {
if (isPathWithin(candidatePath, basePath)) {
return true;
}
}
return false;
}
function normalizeForPathComparison(targetPath: string): string {
const normalized = path.resolve(targetPath);
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function checkSymlinkStatus(): { valid: boolean; message: string } {
const ccsDir = getCcsDir();
const sharedDir = path.join(ccsDir, 'shared');
if (!fs.existsSync(sharedDir)) {
return { valid: false, message: 'Shared directory not found' };
}
// Check all three symlinks: commands, skills, agents
const linkTypes = ['commands', 'skills', 'agents'];
let validLinks = 0;
for (const linkType of linkTypes) {
const linkPath = path.join(sharedDir, linkType);
try {
if (fs.existsSync(linkPath)) {
const stats = fs.lstatSync(linkPath);
if (stats.isSymbolicLink()) {
const target = fs.readlinkSync(linkPath);
// Check if it points to Claude config dir.
const expectedTarget = path.join(getClaudeConfigDir(), linkType);
if (path.resolve(path.dirname(linkPath), target) === path.resolve(expectedTarget)) {
validLinks++;
}
}
}
} catch {
// Not a symlink or read error
}
}
if (validLinks === linkTypes.length) {
return { valid: true, message: 'Symlinks active' };
} else if (validLinks > 0) {
return {
valid: false,
message: `Symlinks partially configured (${validLinks}/${linkTypes.length})`,
};
}
return { valid: false, message: 'Symlinks not configured' };
}
@@ -0,0 +1,170 @@
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 { checkSymlinkStatus } from '../../../src/web-server/shared-routes-symlink-status';
/**
* All tests set process.env.CCS_HOME to a temp directory so getCcsDir()
* never touches the user's real ~/.ccs/. CLAUDE_CONFIG_DIR is also
* overridden so checkSymlinkStatus() does not reference ~/.claude/.
*/
describe('checkSymlinkStatus', () => {
let tempHome: string;
let ccsDir: string;
let sharedDir: string;
let originalCcsHome: string | undefined;
let originalClaudeConfigDir: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-symlink-status-test-'));
originalCcsHome = process.env.CCS_HOME;
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CCS_HOME = tempHome;
// Point CLAUDE_CONFIG_DIR to a dir we control so expected targets resolve correctly.
process.env.CLAUDE_CONFIG_DIR = path.join(tempHome, 'claude-config');
ccsDir = path.join(tempHome, '.ccs');
sharedDir = path.join(ccsDir, 'shared');
fs.mkdirSync(sharedDir, { recursive: true });
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (originalClaudeConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
} else {
delete process.env.CLAUDE_CONFIG_DIR;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
// ---------------------------------------------------------------------------
// Missing shared directory
// ---------------------------------------------------------------------------
it('reports none when shared directory does not exist', () => {
fs.rmSync(sharedDir, { recursive: true, force: true });
const result = checkSymlinkStatus();
expect(result.valid).toBe(false);
expect(result.mode).toBe('none');
expect(result.message).toContain('not found');
});
// ---------------------------------------------------------------------------
// All-missing (no entries at all)
// ---------------------------------------------------------------------------
it('reports none when no entries exist under shared/', () => {
const result = checkSymlinkStatus();
expect(result.valid).toBe(false);
expect(result.mode).toBe('none');
expect(result.message).toBe('Shared resources not configured');
expect(result.links).toEqual({ commands: 'missing', skills: 'missing', agents: 'missing' });
});
// ---------------------------------------------------------------------------
// Windows copy-fallback mode: all three are non-empty directories
// ---------------------------------------------------------------------------
it('reports valid copy mode when all three entries are non-empty directories (Windows fallback)', () => {
for (const entryType of ['commands', 'skills', 'agents']) {
const dir = path.join(sharedDir, entryType);
fs.mkdirSync(dir, { recursive: true });
// Place a file so the directory is non-empty.
fs.writeFileSync(path.join(dir, 'placeholder.md'), `# ${entryType}`);
}
const result = checkSymlinkStatus();
expect(result.valid).toBe(true);
expect(result.mode).toBe('copy');
expect(result.message).toBe('Copies active (Windows fallback)');
expect(result.links).toEqual({ commands: 'copy', skills: 'copy', agents: 'copy' });
});
// ---------------------------------------------------------------------------
// Symlink mode: all three are valid symlinks to CLAUDE_CONFIG_DIR/<type>
// ---------------------------------------------------------------------------
it('reports valid symlink mode when all three entries are correct symlinks', () => {
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR as string;
for (const entryType of ['commands', 'skills', 'agents']) {
const target = path.join(claudeConfigDir, entryType);
fs.mkdirSync(target, { recursive: true });
const symlinkType = process.platform === 'win32' ? 'junction' : 'dir';
fs.symlinkSync(target, path.join(sharedDir, entryType), symlinkType as fs.symlink.Type);
}
const result = checkSymlinkStatus();
expect(result.valid).toBe(true);
expect(result.mode).toBe('symlink');
expect(result.message).toBe('Symlinks active');
expect(result.links).toEqual({ commands: 'symlink', skills: 'symlink', agents: 'symlink' });
});
// ---------------------------------------------------------------------------
// Mixed / partial: only commands exists as a copy
// ---------------------------------------------------------------------------
it('reports invalid mixed mode when only commands exists as a copy', () => {
const commandsDir = path.join(sharedDir, 'commands');
fs.mkdirSync(commandsDir, { recursive: true });
fs.writeFileSync(path.join(commandsDir, 'build.md'), 'Run build');
const result = checkSymlinkStatus();
expect(result.valid).toBe(false);
// mode must reflect a non-uniform state
expect(['mixed', 'none']).toContain(result.mode);
// message must reference the mixed/partial state
expect(result.message.toLowerCase()).toMatch(/mixed|not configured/);
expect(result.links.commands).toBe('copy');
expect(result.links.skills).toBe('missing');
expect(result.links.agents).toBe('missing');
});
// ---------------------------------------------------------------------------
// Mixed: two copies, one missing
// ---------------------------------------------------------------------------
it('reports invalid mixed mode when two entries are copies and one is missing', () => {
for (const entryType of ['commands', 'skills']) {
const dir = path.join(sharedDir, entryType);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'placeholder.md'), `# ${entryType}`);
}
// agents intentionally absent
const result = checkSymlinkStatus();
expect(result.valid).toBe(false);
expect(result.mode).toBe('mixed');
expect(result.message).toContain('Mixed');
expect(result.links.commands).toBe('copy');
expect(result.links.skills).toBe('copy');
expect(result.links.agents).toBe('missing');
});
// ---------------------------------------------------------------------------
// Empty directory is treated as missing (not a valid copy)
// ---------------------------------------------------------------------------
it('treats an empty directory as missing, not a valid copy', () => {
for (const entryType of ['commands', 'skills', 'agents']) {
fs.mkdirSync(path.join(sharedDir, entryType), { recursive: true });
// intentionally empty
}
const result = checkSymlinkStatus();
// All entries empty -> all missing -> mode none
expect(result.valid).toBe(false);
expect(result.mode).toBe('none');
expect(result.links).toEqual({ commands: 'missing', skills: 'missing', agents: 'missing' });
});
});