mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
1187 lines
34 KiB
TypeScript
1187 lines
34 KiB
TypeScript
/**
|
|
* Skill Hub Routes Module
|
|
* Handles shared skill repository management endpoints
|
|
*
|
|
* Endpoints:
|
|
* - GET /api/skill-hub/remote - Fetch remote skill index
|
|
* - GET /api/skill-hub/local - List local shared skills
|
|
* - GET /api/skill-hub/installed - List installed skills from hub
|
|
* - POST /api/skill-hub/install - Install skill to claude/codex
|
|
* - POST /api/skill-hub/cache - Cache remote skill locally
|
|
* - GET /api/skill-hub/updates - Check for available updates
|
|
*/
|
|
|
|
import { readFileSync, existsSync, readdirSync, statSync, mkdirSync, cpSync, rmSync, writeFileSync } from 'fs';
|
|
import { join, dirname } from 'path';
|
|
import { homedir } from 'os';
|
|
import { fileURLToPath } from 'url';
|
|
import { validatePath as validateAllowedPath } from '../../utils/path-validator.js';
|
|
import type { RouteContext } from './types.js';
|
|
|
|
// ES Module __dirname equivalent
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
type CliType = 'claude' | 'codex';
|
|
|
|
// ============================================================================
|
|
// Security Helpers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Allowed domains for remote skill downloads (SSRF protection)
|
|
*/
|
|
const ALLOWED_REMOTE_DOMAINS = [
|
|
'raw.githubusercontent.com',
|
|
'github.com',
|
|
'gist.githubusercontent.com',
|
|
];
|
|
|
|
/**
|
|
* Validate that a URL is from an allowed domain (SSRF protection)
|
|
*/
|
|
function isUrlAllowed(url: string): boolean {
|
|
try {
|
|
const parsed = new URL(url);
|
|
// Only allow HTTPS
|
|
if (parsed.protocol !== 'https:') {
|
|
return false;
|
|
}
|
|
// Check against whitelist
|
|
return ALLOWED_REMOTE_DOMAINS.some(domain =>
|
|
parsed.hostname === domain || parsed.hostname.endsWith('.' + domain)
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate skill name for filesystem safety
|
|
* Only allows alphanumeric, dash, and underscore characters
|
|
*/
|
|
function isValidSkillName(name: string): boolean {
|
|
if (!name || name.length === 0 || name.length > 100) {
|
|
return false;
|
|
}
|
|
// Only allow safe characters: a-z, A-Z, 0-9, -, _
|
|
const safeNameRegex = /^[a-zA-Z0-9_-]+$/;
|
|
return safeNameRegex.test(name);
|
|
}
|
|
|
|
/**
|
|
* Sanitize error message for client response
|
|
* Returns a generic message while logging the actual error
|
|
*/
|
|
function sanitizeErrorMessage(error: unknown, operation: string): string {
|
|
// Log the actual error for debugging
|
|
console.error(`[SkillHub] ${operation} failed:`, error instanceof Error ? error.message : String(error));
|
|
// Return generic message to client
|
|
return `${operation} failed. Please try again later.`;
|
|
}
|
|
|
|
// ============================================================================
|
|
// TypeScript Interfaces
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Remote skill index entry (from GitHub or HTTP source)
|
|
*/
|
|
export interface RemoteSkillEntry {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
version: string;
|
|
author: string;
|
|
category: string;
|
|
tags: string[];
|
|
downloadUrl?: string;
|
|
path?: string; // Relative path to skill directory in repo
|
|
readmeUrl?: string;
|
|
homepage?: string;
|
|
license?: string;
|
|
updatedAt?: string;
|
|
}
|
|
|
|
/**
|
|
* Remote skill index response
|
|
*/
|
|
export interface RemoteSkillIndex {
|
|
version: string;
|
|
updated_at: string;
|
|
source: 'github' | 'http' | 'local';
|
|
skills: RemoteSkillEntry[];
|
|
}
|
|
|
|
/**
|
|
* Local shared skill info
|
|
*/
|
|
export interface LocalSkillInfo {
|
|
id: string;
|
|
name: string;
|
|
folderName: string;
|
|
description: string;
|
|
version: string;
|
|
author?: string;
|
|
category?: string;
|
|
tags?: string[];
|
|
path: string;
|
|
source: 'local';
|
|
updatedAt: string;
|
|
}
|
|
|
|
/**
|
|
* Installed skill info (from hub)
|
|
*/
|
|
export interface InstalledSkillInfo {
|
|
id: string;
|
|
name: string;
|
|
folderName: string;
|
|
version: string;
|
|
installedAt: string;
|
|
installedTo: 'claude' | 'codex';
|
|
source: 'remote' | 'local';
|
|
originalId: string;
|
|
updatesAvailable?: boolean;
|
|
latestVersion?: string;
|
|
}
|
|
|
|
/**
|
|
* Skill install request
|
|
*/
|
|
interface SkillInstallRequest {
|
|
skillId: string;
|
|
cliType: CliType;
|
|
source: 'remote' | 'local';
|
|
customName?: string;
|
|
}
|
|
|
|
/**
|
|
* Skill cache request
|
|
*/
|
|
interface SkillCacheRequest {
|
|
skillId: string;
|
|
downloadUrl: string;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Configuration
|
|
// ============================================================================
|
|
|
|
/**
|
|
* GitHub repository configuration for remote skills
|
|
*/
|
|
const GITHUB_CONFIG = {
|
|
owner: 'catlog22',
|
|
repo: 'skill-hub',
|
|
branch: 'main',
|
|
skillIndexPath: 'skill-hub/index.json'
|
|
};
|
|
|
|
/**
|
|
* Remote skills cache with TTL (10 minutes)
|
|
*/
|
|
let remoteSkillsCache: {
|
|
data: RemoteSkillIndex | null;
|
|
timestamp: number;
|
|
} = { data: null, timestamp: 0 };
|
|
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
|
|
// ============================================================================
|
|
// Storage Helpers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get the skill-hub directory path
|
|
*/
|
|
function getSkillHubDir(): string {
|
|
return join(homedir(), '.ccw', 'skill-hub');
|
|
}
|
|
|
|
/**
|
|
* Get the cached skills directory
|
|
*/
|
|
function getCachedSkillsDir(): string {
|
|
return join(getSkillHubDir(), 'cached');
|
|
}
|
|
|
|
/**
|
|
* Get the local skills directory
|
|
*/
|
|
function getLocalSkillsDir(): string {
|
|
return join(getSkillHubDir(), 'local');
|
|
}
|
|
|
|
/**
|
|
* Get the installed skills tracking file path
|
|
*/
|
|
function getInstalledSkillsFile(): string {
|
|
return join(getSkillHubDir(), 'installed.json');
|
|
}
|
|
|
|
/**
|
|
* Ensure skill-hub directories exist
|
|
*/
|
|
function ensureSkillHubDirs(): void {
|
|
const dirs = [getSkillHubDir(), getCachedSkillsDir(), getLocalSkillsDir()];
|
|
for (const dir of dirs) {
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get CLI skills directory based on cliType
|
|
*/
|
|
function getCliSkillsDir(cliType: CliType): string {
|
|
const cliDir = cliType === 'codex' ? '.codex' : '.claude';
|
|
return join(homedir(), cliDir, 'skills');
|
|
}
|
|
|
|
// ============================================================================
|
|
// Skill Parsing Helpers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Parse skill frontmatter (YAML header)
|
|
*/
|
|
function parseSkillFrontmatter(content: string): {
|
|
name: string;
|
|
description: string;
|
|
version: string;
|
|
author?: string;
|
|
category?: string;
|
|
tags?: string[];
|
|
} {
|
|
const result = {
|
|
name: '',
|
|
description: '',
|
|
version: '1.0.0',
|
|
author: undefined as string | undefined,
|
|
category: undefined as string | undefined,
|
|
tags: undefined as string[] | undefined,
|
|
};
|
|
|
|
if (content.startsWith('---')) {
|
|
const endIndex = content.indexOf('---', 3);
|
|
if (endIndex > 0) {
|
|
const frontmatter = content.substring(3, endIndex).trim();
|
|
const lines = frontmatter.split('\n');
|
|
|
|
for (const line of lines) {
|
|
const colonIndex = line.indexOf(':');
|
|
if (colonIndex > 0) {
|
|
const key = line.substring(0, colonIndex).trim().toLowerCase();
|
|
const value = line.substring(colonIndex + 1).trim();
|
|
|
|
switch (key) {
|
|
case 'name':
|
|
result.name = value.replace(/^["']|["']$/g, '');
|
|
break;
|
|
case 'description':
|
|
result.description = value.replace(/^["']|["']$/g, '');
|
|
break;
|
|
case 'version':
|
|
result.version = value.replace(/^["']|["']$/g, '');
|
|
break;
|
|
case 'author':
|
|
result.author = value.replace(/^["']|["']$/g, '');
|
|
break;
|
|
case 'category':
|
|
result.category = value.replace(/^["']|["']$/g, '');
|
|
break;
|
|
case 'tags':
|
|
result.tags = value
|
|
.replace(/^\[|\]$/g, '')
|
|
.split(',')
|
|
.map(t => t.trim())
|
|
.filter(Boolean);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Remote Skills Helpers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Fetch remote skill index from GitHub
|
|
*/
|
|
async function fetchRemoteSkillIndex(): Promise<RemoteSkillIndex> {
|
|
// Check cache
|
|
const now = Date.now();
|
|
if (remoteSkillsCache.data && (now - remoteSkillsCache.timestamp) < CACHE_TTL_MS) {
|
|
return remoteSkillsCache.data;
|
|
}
|
|
|
|
const indexUrl = `https://raw.githubusercontent.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/${GITHUB_CONFIG.branch}/${GITHUB_CONFIG.skillIndexPath}`;
|
|
|
|
try {
|
|
const response = await fetch(indexUrl);
|
|
if (!response.ok) {
|
|
// Try local fallback
|
|
const localIndex = loadLocalIndex();
|
|
if (localIndex) {
|
|
return localIndex;
|
|
}
|
|
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const index = await response.json() as RemoteSkillIndex;
|
|
index.source = 'github';
|
|
|
|
// Update cache
|
|
remoteSkillsCache = { data: index, timestamp: now };
|
|
|
|
// Persist to local cache file
|
|
saveCachedIndex(index);
|
|
|
|
return index;
|
|
} catch (error) {
|
|
// Return cached data if available, even if expired
|
|
if (remoteSkillsCache.data) {
|
|
return remoteSkillsCache.data;
|
|
}
|
|
|
|
// Try local fallback
|
|
const localIndex = loadLocalIndex();
|
|
if (localIndex) {
|
|
return localIndex;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load cached index from local file
|
|
*/
|
|
function loadLocalIndex(): RemoteSkillIndex | null {
|
|
try {
|
|
const cachedPath = join(getSkillHubDir(), 'index.json');
|
|
if (existsSync(cachedPath)) {
|
|
const content = readFileSync(cachedPath, 'utf8');
|
|
const index = JSON.parse(content) as RemoteSkillIndex;
|
|
index.source = 'local';
|
|
return index;
|
|
}
|
|
} catch (error) {
|
|
console.error('[SkillHub] Failed to load cached index:', error instanceof Error ? error.message : String(error));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Save index to local cache
|
|
*/
|
|
function saveCachedIndex(index: RemoteSkillIndex): void {
|
|
try {
|
|
ensureSkillHubDirs();
|
|
const cachedPath = join(getSkillHubDir(), 'index.json');
|
|
writeFileSync(cachedPath, JSON.stringify(index, null, 2), 'utf8');
|
|
} catch (error) {
|
|
console.error('[SkillHub] Failed to save cached index:', error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch a single skill from remote URL
|
|
*/
|
|
async function fetchRemoteSkill(downloadUrl: string): Promise<string> {
|
|
const response = await fetch(downloadUrl);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch skill: ${response.status} ${response.statusText}`);
|
|
}
|
|
return response.text();
|
|
}
|
|
|
|
/**
|
|
* Build download URL from skill path
|
|
*/
|
|
function buildDownloadUrlFromPath(skillPath: string): string {
|
|
return `https://raw.githubusercontent.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/${GITHUB_CONFIG.branch}/${skillPath}/SKILL.md`;
|
|
}
|
|
|
|
/**
|
|
* Fetch skill directory contents from GitHub API
|
|
* Returns list of files in the directory
|
|
*/
|
|
interface GitHubTreeEntry {
|
|
path: string;
|
|
mode: string;
|
|
type: 'blob' | 'tree';
|
|
sha: string;
|
|
size?: number;
|
|
url: string;
|
|
}
|
|
|
|
async function fetchSkillDirectoryContents(skillPath: string): Promise<GitHubTreeEntry[]> {
|
|
// Use GitHub API to get tree contents
|
|
const apiUrl = `https://api.github.com/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/contents/${skillPath}?ref=${GITHUB_CONFIG.branch}`;
|
|
|
|
const response = await fetch(apiUrl, {
|
|
headers: {
|
|
'Accept': 'application/vnd.github.v3+json',
|
|
'User-Agent': 'CCW-SkillHub/1.0',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
/**
|
|
* Download all files from a skill directory
|
|
*/
|
|
async function downloadSkillDirectory(
|
|
skillPath: string,
|
|
targetDir: string
|
|
): Promise<{ success: boolean; files: string[] }> {
|
|
const files: string[] = [];
|
|
|
|
try {
|
|
const contents = await fetchSkillDirectoryContents(skillPath);
|
|
|
|
for (const entry of contents) {
|
|
if (entry.type === 'blob') {
|
|
// Download file
|
|
const fileUrl = `https://raw.githubusercontent.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/${GITHUB_CONFIG.branch}/${entry.path}`;
|
|
const response = await fetch(fileUrl);
|
|
|
|
if (response.ok) {
|
|
const content = await response.text();
|
|
const filePath = join(targetDir, entry.path.replace(skillPath + '/', ''));
|
|
const fileDir = dirname(filePath);
|
|
|
|
// Ensure directory exists
|
|
if (!existsSync(fileDir)) {
|
|
mkdirSync(fileDir, { recursive: true });
|
|
}
|
|
|
|
writeFileSync(filePath, content, 'utf8');
|
|
files.push(entry.path);
|
|
}
|
|
} else if (entry.type === 'tree') {
|
|
// Recursively download subdirectory
|
|
const subDir = join(targetDir, entry.path.replace(skillPath + '/', ''));
|
|
if (!existsSync(subDir)) {
|
|
mkdirSync(subDir, { recursive: true });
|
|
}
|
|
const subResult = await downloadSkillDirectory(entry.path, targetDir);
|
|
files.push(...subResult.files);
|
|
}
|
|
}
|
|
|
|
return { success: true, files };
|
|
} catch (error) {
|
|
console.error('[SkillHub] Failed to download directory:', error);
|
|
return { success: false, files };
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Local Skills Helpers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* List local shared skills from ~/.ccw/skill-hub/local/
|
|
*/
|
|
function listLocalSkills(): LocalSkillInfo[] {
|
|
const result: LocalSkillInfo[] = [];
|
|
const localDir = getLocalSkillsDir();
|
|
|
|
if (!existsSync(localDir)) {
|
|
return result;
|
|
}
|
|
|
|
try {
|
|
const entries = readdirSync(localDir, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
|
|
const skillDir = join(localDir, entry.name);
|
|
const skillMdPath = join(skillDir, 'SKILL.md');
|
|
|
|
if (!existsSync(skillMdPath)) continue;
|
|
|
|
try {
|
|
const content = readFileSync(skillMdPath, 'utf8');
|
|
const parsed = parseSkillFrontmatter(content);
|
|
const stat = statSync(skillMdPath);
|
|
|
|
result.push({
|
|
id: `local-${entry.name}`,
|
|
name: parsed.name || entry.name,
|
|
folderName: entry.name,
|
|
description: parsed.description || '',
|
|
version: parsed.version || '1.0.0',
|
|
author: parsed.author,
|
|
category: parsed.category,
|
|
tags: parsed.tags,
|
|
path: skillDir,
|
|
source: 'local',
|
|
updatedAt: stat.mtime.toISOString(),
|
|
});
|
|
} catch {
|
|
// Skip invalid skills
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[SkillHub] Failed to list local skills:', error);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Installed Skills Tracking
|
|
// ============================================================================
|
|
|
|
interface InstalledSkillsRegistry {
|
|
version: string;
|
|
updatedAt: string;
|
|
installed: InstalledSkillInfo[];
|
|
}
|
|
|
|
/**
|
|
* Load installed skills registry
|
|
*/
|
|
function loadInstalledSkills(): InstalledSkillsRegistry {
|
|
try {
|
|
const filePath = getInstalledSkillsFile();
|
|
if (existsSync(filePath)) {
|
|
const content = readFileSync(filePath, 'utf8');
|
|
return JSON.parse(content) as InstalledSkillsRegistry;
|
|
}
|
|
} catch {
|
|
// Ignore errors
|
|
}
|
|
|
|
return {
|
|
version: '1.0.0',
|
|
updatedAt: new Date().toISOString(),
|
|
installed: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Save installed skills registry
|
|
*/
|
|
function saveInstalledSkills(registry: InstalledSkillsRegistry): void {
|
|
try {
|
|
ensureSkillHubDirs();
|
|
registry.updatedAt = new Date().toISOString();
|
|
const filePath = getInstalledSkillsFile();
|
|
writeFileSync(filePath, JSON.stringify(registry, null, 2), 'utf8');
|
|
} catch (error) {
|
|
console.error('[SkillHub] Failed to save installed skills:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add installed skill to registry
|
|
*/
|
|
function addInstalledSkill(info: InstalledSkillInfo): void {
|
|
const registry = loadInstalledSkills();
|
|
|
|
// Remove existing entry with same id
|
|
registry.installed = registry.installed.filter(
|
|
s => !(s.originalId === info.originalId && s.installedTo === info.installedTo)
|
|
);
|
|
|
|
registry.installed.push(info);
|
|
saveInstalledSkills(registry);
|
|
}
|
|
|
|
/**
|
|
* Remove installed skill from registry
|
|
*/
|
|
function removeInstalledSkill(skillId: string, cliType: CliType): void {
|
|
const registry = loadInstalledSkills();
|
|
registry.installed = registry.installed.filter(
|
|
s => !(s.originalId === skillId && s.installedTo === cliType)
|
|
);
|
|
saveInstalledSkills(registry);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Skill Installation Helpers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Install skill from local path
|
|
*/
|
|
async function installSkillFromLocal(
|
|
localPath: string,
|
|
cliType: CliType,
|
|
customName?: string
|
|
): Promise<{ success: boolean; message: string; installedPath?: string }> {
|
|
try {
|
|
// Validate source path exists
|
|
if (!existsSync(localPath)) {
|
|
return { success: false, message: 'Source skill path not found' };
|
|
}
|
|
|
|
// Validate source path is within allowed skill-hub directory
|
|
const localSkillsDir = getLocalSkillsDir();
|
|
const resolvedLocalPath = localPath;
|
|
if (!resolvedLocalPath.startsWith(localSkillsDir)) {
|
|
console.error('[SkillHub] Path traversal attempt blocked:', localPath);
|
|
return { success: false, message: 'Invalid source path' };
|
|
}
|
|
|
|
// Read skill metadata
|
|
const skillMdPath = join(localPath, 'SKILL.md');
|
|
if (!existsSync(skillMdPath)) {
|
|
return { success: false, message: 'SKILL.md not found in source' };
|
|
}
|
|
|
|
const content = readFileSync(skillMdPath, 'utf8');
|
|
const parsed = parseSkillFrontmatter(content);
|
|
const skillName = customName || parsed.name;
|
|
|
|
if (!skillName) {
|
|
return { success: false, message: 'Skill name is required' };
|
|
}
|
|
|
|
// Validate skill name with strict whitelist
|
|
if (!isValidSkillName(skillName)) {
|
|
console.error('[SkillHub] Invalid skill name rejected:', skillName);
|
|
return { success: false, message: 'Invalid skill name. Only letters, numbers, dash and underscore allowed.' };
|
|
}
|
|
|
|
// Get target directory
|
|
const targetDir = getCliSkillsDir(cliType);
|
|
const targetSkillDir = join(targetDir, skillName);
|
|
|
|
// Create target directory if needed
|
|
if (!existsSync(targetDir)) {
|
|
mkdirSync(targetDir, { recursive: true });
|
|
}
|
|
|
|
// Check if already exists
|
|
if (existsSync(targetSkillDir)) {
|
|
return { success: false, message: `Skill '${skillName}' already exists in ${cliType}` };
|
|
}
|
|
|
|
// Copy skill directory
|
|
cpSync(localPath, targetSkillDir, { recursive: true });
|
|
|
|
return {
|
|
success: true,
|
|
message: `Skill '${skillName}' installed to ${cliType}`,
|
|
installedPath: targetSkillDir,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
message: sanitizeErrorMessage(error, 'Skill installation'),
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Install skill from remote URL
|
|
*/
|
|
async function installSkillFromRemote(
|
|
downloadUrl: string,
|
|
cliType: CliType,
|
|
skillId: string,
|
|
customName?: string
|
|
): Promise<{ success: boolean; message: string; installedPath?: string }> {
|
|
try {
|
|
// Validate URL for SSRF protection
|
|
if (!isUrlAllowed(downloadUrl)) {
|
|
console.error('[SkillHub] Blocked download from unauthorized URL:', downloadUrl);
|
|
return { success: false, message: 'Download URL is not from an allowed source' };
|
|
}
|
|
|
|
// Fetch skill content
|
|
const skillContent = await fetchRemoteSkill(downloadUrl);
|
|
|
|
// Parse metadata
|
|
const parsed = parseSkillFrontmatter(skillContent);
|
|
const skillName = customName || parsed.name;
|
|
|
|
if (!skillName) {
|
|
return { success: false, message: 'Skill name is required' };
|
|
}
|
|
|
|
// Validate skill name with strict whitelist
|
|
if (!isValidSkillName(skillName)) {
|
|
console.error('[SkillHub] Invalid skill name rejected:', skillName);
|
|
return { success: false, message: 'Invalid skill name. Only letters, numbers, dash and underscore allowed.' };
|
|
}
|
|
|
|
// Validate skillId for caching path safety
|
|
if (!isValidSkillName(skillId.replace('remote-', '').replace('local-', ''))) {
|
|
console.error('[SkillHub] Invalid skill ID rejected:', skillId);
|
|
return { success: false, message: 'Invalid skill ID' };
|
|
}
|
|
|
|
// Get target directory
|
|
const targetDir = getCliSkillsDir(cliType);
|
|
const targetSkillDir = join(targetDir, skillName);
|
|
|
|
// Create target directory if needed
|
|
if (!existsSync(targetDir)) {
|
|
mkdirSync(targetDir, { recursive: true });
|
|
}
|
|
|
|
// Check if already exists
|
|
if (existsSync(targetSkillDir)) {
|
|
return { success: false, message: `Skill '${skillName}' already exists in ${cliType}` };
|
|
}
|
|
|
|
// Create skill directory and write SKILL.md
|
|
mkdirSync(targetSkillDir, { recursive: true });
|
|
writeFileSync(join(targetSkillDir, 'SKILL.md'), skillContent, 'utf8');
|
|
|
|
// Cache the skill locally
|
|
try {
|
|
ensureSkillHubDirs();
|
|
const cachedDir = join(getCachedSkillsDir(), skillId);
|
|
if (!existsSync(cachedDir)) {
|
|
mkdirSync(cachedDir, { recursive: true });
|
|
}
|
|
writeFileSync(join(cachedDir, 'SKILL.md'), skillContent, 'utf8');
|
|
} catch (cacheError) {
|
|
// Log but don't fail - caching is optional
|
|
console.error('[SkillHub] Failed to cache skill:', cacheError instanceof Error ? cacheError.message : String(cacheError));
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: `Skill '${skillName}' installed to ${cliType}`,
|
|
installedPath: targetSkillDir,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
message: sanitizeErrorMessage(error, 'Skill installation'),
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Install skill from remote path (downloads entire directory)
|
|
*/
|
|
async function installSkillFromRemotePath(
|
|
skillPath: string,
|
|
cliType: CliType,
|
|
skillId: string,
|
|
customName?: string
|
|
): Promise<{ success: boolean; message: string; installedPath?: string }> {
|
|
try {
|
|
// Validate skillId for path safety
|
|
if (!isValidSkillName(skillId.replace('remote-', '').replace('local-', ''))) {
|
|
console.error('[SkillHub] Invalid skill ID rejected:', skillId);
|
|
return { success: false, message: 'Invalid skill ID' };
|
|
}
|
|
|
|
// Get target directory
|
|
const targetDir = getCliSkillsDir(cliType);
|
|
const skillName = customName || skillId;
|
|
const targetSkillDir = join(targetDir, skillName);
|
|
|
|
// Create target directory if needed
|
|
if (!existsSync(targetDir)) {
|
|
mkdirSync(targetDir, { recursive: true });
|
|
}
|
|
|
|
// Check if already exists
|
|
if (existsSync(targetSkillDir)) {
|
|
return { success: false, message: `Skill '${skillName}' already exists in ${cliType}` };
|
|
}
|
|
|
|
// Create skill directory
|
|
mkdirSync(targetSkillDir, { recursive: true });
|
|
|
|
// Download entire skill directory
|
|
console.log(`[SkillHub] Downloading skill directory: ${skillPath}`);
|
|
const result = await downloadSkillDirectory(skillPath, targetSkillDir);
|
|
|
|
if (!result.success || result.files.length === 0) {
|
|
// Fallback: download only SKILL.md
|
|
console.log('[SkillHub] Directory download failed, falling back to SKILL.md only');
|
|
const skillMdUrl = buildDownloadUrlFromPath(skillPath);
|
|
const skillContent = await fetchRemoteSkill(skillMdUrl);
|
|
writeFileSync(join(targetSkillDir, 'SKILL.md'), skillContent, 'utf8');
|
|
}
|
|
|
|
// Cache the skill locally
|
|
try {
|
|
ensureSkillHubDirs();
|
|
const cachedDir = join(getCachedSkillsDir(), skillId);
|
|
if (!existsSync(cachedDir)) {
|
|
mkdirSync(cachedDir, { recursive: true });
|
|
}
|
|
// Copy entire skill directory to cache
|
|
cpSync(targetSkillDir, cachedDir, { recursive: true });
|
|
} catch (cacheError) {
|
|
console.error('[SkillHub] Failed to cache skill:', cacheError instanceof Error ? cacheError.message : String(cacheError));
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: `Skill '${skillName}' installed to ${cliType} (${result.files.length} files)`,
|
|
installedPath: targetSkillDir,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
message: sanitizeErrorMessage(error, 'Skill installation'),
|
|
};
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Updates Check Helpers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Check for available updates
|
|
*/
|
|
async function checkForUpdates(
|
|
installedSkills: InstalledSkillInfo[],
|
|
remoteSkills: RemoteSkillEntry[]
|
|
): Promise<InstalledSkillInfo[]> {
|
|
const remoteMap = new Map(remoteSkills.map(s => [s.id, s]));
|
|
|
|
return installedSkills.map(skill => {
|
|
const remote = remoteMap.get(skill.originalId);
|
|
if (remote && remote.version !== skill.version) {
|
|
return {
|
|
...skill,
|
|
updatesAvailable: true,
|
|
latestVersion: remote.version,
|
|
};
|
|
}
|
|
return {
|
|
...skill,
|
|
updatesAvailable: false,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Route Handler
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Handle skill hub routes
|
|
* @returns true if route was handled, false otherwise
|
|
*/
|
|
export async function handleSkillHubRoutes(ctx: RouteContext): Promise<boolean> {
|
|
const { pathname, req, res, handlePostRequest } = ctx;
|
|
|
|
// Ensure skill-hub directories exist
|
|
ensureSkillHubDirs();
|
|
|
|
// ==== LIST REMOTE SKILLS ====
|
|
// GET /api/skill-hub/remote
|
|
if (pathname === '/api/skill-hub/remote' && req.method === 'GET') {
|
|
try {
|
|
// Check for refresh parameter to bypass cache
|
|
const refresh = ctx.url.searchParams.get('refresh') === 'true';
|
|
if (refresh) {
|
|
// Clear memory cache
|
|
remoteSkillsCache = { data: null, timestamp: 0 };
|
|
// Clear file cache
|
|
const cachedPath = join(getSkillHubDir(), 'index.json');
|
|
if (existsSync(cachedPath)) {
|
|
rmSync(cachedPath, { force: true });
|
|
}
|
|
}
|
|
|
|
const index = await fetchRemoteSkillIndex();
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
success: true,
|
|
data: index.skills,
|
|
meta: {
|
|
version: index.version,
|
|
updated_at: index.updated_at,
|
|
source: index.source,
|
|
},
|
|
total: index.skills.length,
|
|
timestamp: new Date().toISOString(),
|
|
}));
|
|
return true;
|
|
} catch (error) {
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
success: false,
|
|
error: (error as Error).message,
|
|
message: 'Failed to fetch remote skills. Check network connectivity.',
|
|
}));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// ==== LIST LOCAL SKILLS ====
|
|
// GET /api/skill-hub/local
|
|
if (pathname === '/api/skill-hub/local' && req.method === 'GET') {
|
|
try {
|
|
const skills = listLocalSkills();
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
success: true,
|
|
data: skills,
|
|
total: skills.length,
|
|
timestamp: new Date().toISOString(),
|
|
}));
|
|
return true;
|
|
} catch (error) {
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
success: false,
|
|
error: (error as Error).message,
|
|
}));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// ==== LIST INSTALLED SKILLS ====
|
|
// GET /api/skill-hub/installed
|
|
if (pathname === '/api/skill-hub/installed' && req.method === 'GET') {
|
|
try {
|
|
const registry = loadInstalledSkills();
|
|
|
|
// Optionally check for updates
|
|
const checkUpdates = ctx.url.searchParams.get('checkUpdates') === 'true';
|
|
let installed = registry.installed;
|
|
|
|
if (checkUpdates) {
|
|
try {
|
|
const remoteIndex = await fetchRemoteSkillIndex();
|
|
installed = await checkForUpdates(installed, remoteIndex.skills);
|
|
} catch {
|
|
// Ignore update check errors
|
|
}
|
|
}
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
success: true,
|
|
data: installed,
|
|
total: installed.length,
|
|
timestamp: new Date().toISOString(),
|
|
}));
|
|
return true;
|
|
} catch (error) {
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
success: false,
|
|
error: (error as Error).message,
|
|
}));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// ==== INSTALL SKILL ====
|
|
// POST /api/skill-hub/install
|
|
if (pathname === '/api/skill-hub/install' && req.method === 'POST') {
|
|
handlePostRequest(req, res, async (body) => {
|
|
const { skillId, cliType, source, customName, downloadUrl } = body as SkillInstallRequest & { downloadUrl?: string };
|
|
|
|
// Validation
|
|
if (!skillId) {
|
|
return { success: false, error: 'skillId is required', status: 400 };
|
|
}
|
|
|
|
if (cliType !== 'claude' && cliType !== 'codex') {
|
|
return { success: false, error: 'cliType must be "claude" or "codex"', status: 400 };
|
|
}
|
|
|
|
if (source !== 'remote' && source !== 'local') {
|
|
return { success: false, error: 'source must be "remote" or "local"', status: 400 };
|
|
}
|
|
|
|
try {
|
|
let result;
|
|
|
|
if (source === 'local') {
|
|
// Install from local
|
|
const localSkills = listLocalSkills();
|
|
const localSkill = localSkills.find(s => s.id === skillId);
|
|
|
|
if (!localSkill) {
|
|
return { success: false, error: 'Local skill not found', status: 404 };
|
|
}
|
|
|
|
result = await installSkillFromLocal(localSkill.path, cliType, customName);
|
|
} else {
|
|
// Install from remote
|
|
let url = downloadUrl;
|
|
let skillPath: string | undefined;
|
|
|
|
if (!url) {
|
|
// Fetch from remote index
|
|
const index = await fetchRemoteSkillIndex();
|
|
const remoteSkill = index.skills.find(s => s.id === skillId);
|
|
|
|
if (!remoteSkill) {
|
|
return { success: false, error: 'Remote skill not found', status: 404 };
|
|
}
|
|
|
|
url = remoteSkill.downloadUrl;
|
|
skillPath = remoteSkill.path;
|
|
}
|
|
|
|
// Prefer path-based installation for full directory download
|
|
if (skillPath && !url) {
|
|
result = await installSkillFromRemotePath(skillPath, cliType, skillId, customName);
|
|
} else if (url) {
|
|
result = await installSkillFromRemote(url, cliType, skillId, customName);
|
|
} else {
|
|
return { success: false, error: 'No downloadUrl or path available for skill', status: 400 };
|
|
}
|
|
}
|
|
|
|
if (result.success) {
|
|
// Track installation
|
|
addInstalledSkill({
|
|
id: `${skillId}-${cliType}`,
|
|
name: customName || skillId,
|
|
folderName: customName || skillId,
|
|
version: '1.0.0', // Would need to parse from installed skill
|
|
installedAt: new Date().toISOString(),
|
|
installedTo: cliType,
|
|
source: source,
|
|
originalId: skillId,
|
|
});
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: (error as Error).message,
|
|
status: 500,
|
|
};
|
|
}
|
|
});
|
|
return true;
|
|
}
|
|
|
|
// ==== CACHE REMOTE SKILL ====
|
|
// POST /api/skill-hub/cache
|
|
if (pathname === '/api/skill-hub/cache' && req.method === 'POST') {
|
|
handlePostRequest(req, res, async (body) => {
|
|
const { skillId, downloadUrl } = body as SkillCacheRequest;
|
|
|
|
if (!skillId || !downloadUrl) {
|
|
return { success: false, error: 'skillId and downloadUrl are required', status: 400 };
|
|
}
|
|
|
|
try {
|
|
const content = await fetchRemoteSkill(downloadUrl);
|
|
|
|
ensureSkillHubDirs();
|
|
const cachedDir = join(getCachedSkillsDir(), skillId);
|
|
mkdirSync(cachedDir, { recursive: true });
|
|
writeFileSync(join(cachedDir, 'SKILL.md'), content, 'utf8');
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Skill cached successfully',
|
|
path: cachedDir,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: (error as Error).message,
|
|
status: 500,
|
|
};
|
|
}
|
|
});
|
|
return true;
|
|
}
|
|
|
|
// ==== CHECK UPDATES ====
|
|
// GET /api/skill-hub/updates
|
|
if (pathname === '/api/skill-hub/updates' && req.method === 'GET') {
|
|
try {
|
|
const registry = loadInstalledSkills();
|
|
const remoteIndex = await fetchRemoteSkillIndex();
|
|
const updated = await checkForUpdates(registry.installed, remoteIndex.skills);
|
|
const updatesAvailable = updated.filter(s => s.updatesAvailable);
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
success: true,
|
|
data: updatesAvailable,
|
|
total: updatesAvailable.length,
|
|
timestamp: new Date().toISOString(),
|
|
}));
|
|
return true;
|
|
} catch (error) {
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
success: false,
|
|
error: (error as Error).message,
|
|
}));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// ==== UNINSTALL SKILL ====
|
|
// DELETE /api/skill-hub/installed/:id
|
|
if (pathname.match(/^\/api\/skill-hub\/installed\/[^/]+$/) && req.method === 'DELETE') {
|
|
const skillId = pathname.split('/').pop();
|
|
|
|
handlePostRequest(req, res, async (body) => {
|
|
const { cliType } = body as { cliType: CliType };
|
|
|
|
if (!cliType) {
|
|
return { success: false, error: 'cliType is required', status: 400 };
|
|
}
|
|
|
|
try {
|
|
const registry = loadInstalledSkills();
|
|
const installed = registry.installed.find(
|
|
s => s.id === skillId && s.installedTo === cliType
|
|
);
|
|
|
|
if (!installed) {
|
|
return { success: false, error: 'Installed skill not found', status: 404 };
|
|
}
|
|
|
|
// Remove skill directory
|
|
const skillDir = join(getCliSkillsDir(cliType), installed.folderName);
|
|
if (existsSync(skillDir)) {
|
|
rmSync(skillDir, { recursive: true, force: true });
|
|
}
|
|
|
|
// Remove from registry
|
|
removeInstalledSkill(installed.originalId, cliType);
|
|
|
|
return { success: true, message: 'Skill uninstalled' };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: (error as Error).message,
|
|
status: 500,
|
|
};
|
|
}
|
|
});
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|