- Add malware security advisory page and SecurityAlertBanner component - Add review diagnostics API and reviewed stats page - Improve skill detail page with better scoring display and metadata - Update review API endpoints with enhanced filtering and stats - Add skill file serving improvements and cache enhancements - Remove legacy curation scripts (moved to internal tooling) - Update CLI search with score filtering support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
import fs from 'fs-extra';
|
|
import * as path from 'path';
|
|
import chalk from 'chalk';
|
|
import ora from 'ora';
|
|
import { parseSkillMd, parseGenericInstructionFile, INSTRUCTION_FILE_PATTERNS, type SourceFormat } from 'skillhub-core';
|
|
import { getSkillPath, ensureSkillsDir, isSkillInstalled, isFlatFilePlatform, getPlatformFilePath, type Platform } from '../utils/paths.js';
|
|
import { getSkill, trackInstall, getSkillFiles, type SkillFilesResponse } from '../utils/api.js';
|
|
import { fetchSkillContent, getDefaultBranch, type SkillContent } from '../utils/github.js';
|
|
import { getPlatformFileName, transformForPlatform, shouldKeepOriginal } from '../utils/transform.js';
|
|
|
|
interface InstallOptions {
|
|
platform: Platform;
|
|
project?: boolean;
|
|
force?: boolean;
|
|
noApi?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Install a skill from the registry
|
|
*/
|
|
export async function install(skillId: string, options: InstallOptions): Promise<void> {
|
|
const spinner = ora('Parsing skill ID...').start();
|
|
|
|
try {
|
|
// Parse skill ID
|
|
const parts = skillId.split('/');
|
|
if (parts.length < 2) {
|
|
spinner.fail('Invalid skill ID format. Use: owner/repo or owner/repo/skill-name');
|
|
process.exit(1);
|
|
}
|
|
|
|
const [owner, repo, ...rest] = parts;
|
|
let skillPath = rest.join('/');
|
|
|
|
// Try to get skill info from API (unless --no-api)
|
|
let skillInfo;
|
|
if (!options.noApi) {
|
|
spinner.text = `Connecting to ${process.env.SKILLHUB_API_URL || 'https://skills.palebluedot.live'}...`;
|
|
try {
|
|
skillInfo = await getSkill(skillId);
|
|
if (skillInfo) {
|
|
// Block installation of malicious skills
|
|
if (skillInfo.isMalicious) {
|
|
spinner.fail(chalk.red('This skill has been flagged as malicious (contains malware).'));
|
|
console.log(chalk.red('Installation blocked for your safety.'));
|
|
console.log(chalk.dim(`See: https://skills.palebluedot.live/en/skill/${skillId}`));
|
|
process.exit(1);
|
|
}
|
|
|
|
const reviewBadge = skillInfo.aiScore && skillInfo.reviewStatus === 'ai-reviewed'
|
|
? chalk.dim(` | AI: ${skillInfo.aiScore}/100`)
|
|
: skillInfo.reviewStatus === 'verified'
|
|
? chalk.green(` | AI: ${skillInfo.aiScore}/100 ✓`)
|
|
: '';
|
|
spinner.succeed(`Found in registry: ${skillInfo.name}${reviewBadge}`);
|
|
spinner.start('Preparing installation...');
|
|
}
|
|
} catch (error) {
|
|
spinner.warn(`API unavailable: ${(error as Error).message}`);
|
|
spinner.start('Falling back to direct GitHub fetch...');
|
|
}
|
|
} else {
|
|
spinner.text = 'Skipping API lookup (--no-api flag)';
|
|
}
|
|
|
|
let skillName: string;
|
|
let branch = 'main';
|
|
let sourceFormat: SourceFormat = 'skill.md';
|
|
|
|
if (skillInfo) {
|
|
skillName = skillInfo.name;
|
|
// Use the actual skillPath from database (e.g., 'skills/nuxt-ui' not 'nuxt-ui')
|
|
skillPath = skillInfo.skillPath;
|
|
branch = skillInfo.branch || 'main';
|
|
sourceFormat = (skillInfo.sourceFormat as SourceFormat) || 'skill.md';
|
|
spinner.text = `Found skill: ${chalk.cyan(skillName)}`;
|
|
} else {
|
|
// Fall back to fetching directly from GitHub
|
|
spinner.text = 'Skill not in registry, fetching from GitHub...';
|
|
try {
|
|
branch = await getDefaultBranch(owner, repo);
|
|
skillName = skillPath || repo;
|
|
} catch (error) {
|
|
spinner.fail('Failed to connect to GitHub');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Check if already installed
|
|
const installed = await isSkillInstalled(options.platform, skillName, options.project);
|
|
if (installed && !options.force) {
|
|
spinner.fail(
|
|
`Skill ${chalk.cyan(skillName)} is already installed. Use ${chalk.yellow('--force')} to overwrite.`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Ensure skills directory exists
|
|
await ensureSkillsDir(options.platform, options.project);
|
|
|
|
// Fetch skill content - try API first, fall back to GitHub
|
|
spinner.text = `Downloading ${skillInfo?.name || skillId}...`;
|
|
let content: SkillContent | undefined;
|
|
let apiWasReachable = false;
|
|
|
|
// Try API first (unless --no-api)
|
|
if (!options.noApi && skillInfo) {
|
|
apiWasReachable = true; // API responded to getSkill, so it's reachable
|
|
try {
|
|
spinner.text = 'Downloading skill files...';
|
|
const cachedFiles = await getSkillFiles(skillInfo.id);
|
|
|
|
if (cachedFiles && cachedFiles.files.length > 0) {
|
|
// Warn if skill is stale (served from cache because GitHub returned 404)
|
|
if (cachedFiles.isStale) {
|
|
spinner.warn(chalk.yellow(
|
|
'This skill may have been removed from its GitHub repository.\n' +
|
|
' Files were served from the SkillHub cache and may be outdated.'
|
|
));
|
|
spinner.start('Installing from cached files...');
|
|
}
|
|
// Use sourceFormat from API response if available
|
|
if (cachedFiles.sourceFormat) {
|
|
sourceFormat = cachedFiles.sourceFormat as SourceFormat;
|
|
}
|
|
// Convert API response to SkillContent format
|
|
const converted = convertCachedFilesToSkillContent(cachedFiles, sourceFormat);
|
|
// Only use API result if the main instruction file was found
|
|
if (converted.skillMd) {
|
|
content = converted;
|
|
spinner.text = cachedFiles.fromCache
|
|
? `Using cached files (${cachedFiles.files.length} files)`
|
|
: `Downloaded ${cachedFiles.files.length} files via API`;
|
|
} else {
|
|
spinner.text = 'API returned files but main instruction file missing, falling back...';
|
|
}
|
|
}
|
|
} catch {
|
|
// API was reachable but file fetch failed (timeout, server error, etc.)
|
|
spinner.text = 'API file fetch failed, falling back to GitHub...';
|
|
}
|
|
}
|
|
|
|
// Fall back to direct GitHub fetch only if:
|
|
// - API was not used (--no-api or skill not in registry)
|
|
// - OR API file fetch returned empty/null (not a timeout - timeout means server is working on it)
|
|
if (!content) {
|
|
if (apiWasReachable) {
|
|
// API was reachable but couldn't provide files - still try GitHub as last resort
|
|
spinner.text = `Falling back to GitHub: ${owner}/${repo}/${skillPath || ''}...`;
|
|
} else {
|
|
spinner.text = `Downloading from GitHub: ${owner}/${repo}/${skillPath || ''}...`;
|
|
}
|
|
try {
|
|
content = await fetchSkillContent(owner, repo, skillPath, branch, sourceFormat);
|
|
spinner.text = `Downloaded ${content.scripts.length} scripts, ${content.references.length} references`;
|
|
} catch (error) {
|
|
spinner.fail('Failed to download skill files');
|
|
console.error(chalk.red((error as Error).message));
|
|
console.log();
|
|
console.log(chalk.yellow('Troubleshooting tips:'));
|
|
console.log(chalk.dim(' 1. Check your internet connection'));
|
|
if (apiWasReachable) {
|
|
console.log(chalk.dim(' 2. The API server could not fetch files either - try again in a minute'));
|
|
console.log(chalk.dim(' 3. The server may be caching the files now - retry shortly'));
|
|
} else {
|
|
console.log(chalk.dim(' 2. If behind a proxy, configure HTTP_PROXY/HTTPS_PROXY environment variables'));
|
|
}
|
|
console.log(chalk.dim(` ${apiWasReachable ? '4' : '3'}. Set GITHUB_TOKEN environment variable for higher rate limits`));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Ensure content is available (TypeScript narrowing)
|
|
if (!content) {
|
|
spinner.fail('Failed to download skill content');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Parse and validate (format-aware)
|
|
const parsed = sourceFormat === 'skill.md'
|
|
? parseSkillMd(content.skillMd)
|
|
: parseGenericInstructionFile(content.skillMd, sourceFormat, {
|
|
name: skillName,
|
|
description: skillInfo?.description || null,
|
|
owner,
|
|
});
|
|
if (!parsed.validation.isValid) {
|
|
spinner.warn('Skill has validation issues:');
|
|
for (const error of parsed.validation.errors) {
|
|
console.log(chalk.yellow(` - ${error.message}`));
|
|
}
|
|
}
|
|
|
|
// Get the actual skill name from metadata
|
|
const actualName = parsed.metadata.name || skillName;
|
|
const installPath = getSkillPath(options.platform, actualName, options.project);
|
|
|
|
// Check for name collision with different skill
|
|
const metadataPath = path.join(installPath, '.skillhub.json');
|
|
if (await fs.pathExists(metadataPath)) {
|
|
try {
|
|
const existingMetadata = await fs.readJson(metadataPath);
|
|
if (existingMetadata.skillId && existingMetadata.skillId !== skillId) {
|
|
spinner.warn(`Name collision detected!`);
|
|
console.log(chalk.yellow(`\nA different skill is already installed with the name "${actualName}":`));
|
|
console.log(chalk.dim(` Existing: ${existingMetadata.skillId}`));
|
|
console.log(chalk.dim(` New: ${skillId}`));
|
|
console.log();
|
|
|
|
if (!options.force) {
|
|
console.log(chalk.red('Installation cancelled to prevent overwriting.'));
|
|
console.log(chalk.dim('Use --force to overwrite the existing skill.'));
|
|
process.exit(1);
|
|
} else {
|
|
console.log(chalk.yellow('Overwriting existing skill (--force flag used).\n'));
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore metadata read errors
|
|
}
|
|
}
|
|
|
|
// Remove existing if force
|
|
if (installed && options.force) {
|
|
await fs.remove(installPath);
|
|
}
|
|
|
|
// Create skill directory and write files
|
|
spinner.text = 'Installing skill...';
|
|
await fs.ensureDir(installPath);
|
|
|
|
// Transform content for target platform
|
|
const platformFileName = getPlatformFileName(options.platform, actualName);
|
|
const { content: transformedContent, warnings: transformWarnings } =
|
|
transformForPlatform(options.platform, content.skillMd, parsed);
|
|
|
|
for (const warning of transformWarnings) {
|
|
console.log(chalk.yellow(` Warning: ${warning}`));
|
|
}
|
|
|
|
// Write the platform-specific file
|
|
if (isFlatFilePlatform(options.platform)) {
|
|
const platformFilePath = getPlatformFilePath(
|
|
options.platform, actualName, platformFileName, options.project
|
|
);
|
|
await fs.writeFile(platformFilePath, transformedContent);
|
|
} else {
|
|
await fs.writeFile(path.join(installPath, platformFileName), transformedContent);
|
|
}
|
|
|
|
// Keep original SKILL.md in tracking directory for re-transformation
|
|
if (shouldKeepOriginal(options.platform)) {
|
|
await fs.writeFile(path.join(installPath, 'SKILL.md'), content.skillMd);
|
|
}
|
|
|
|
// Write metadata file for update tracking
|
|
// Use canonical ID from registry if available for proper tracking
|
|
const canonicalId = skillInfo?.id || skillId;
|
|
const platformFilePath = isFlatFilePlatform(options.platform)
|
|
? getPlatformFilePath(options.platform, actualName, platformFileName, options.project)
|
|
: null;
|
|
await fs.writeJson(path.join(installPath, '.skillhub.json'), {
|
|
skillId: canonicalId,
|
|
installedAt: new Date().toISOString(),
|
|
platform: options.platform,
|
|
version: parsed.metadata.version || null,
|
|
platformFileName,
|
|
platformFilePath,
|
|
});
|
|
|
|
// Write scripts
|
|
if (content.scripts.length > 0) {
|
|
const scriptsDir = path.join(installPath, 'scripts');
|
|
await fs.ensureDir(scriptsDir);
|
|
|
|
for (const script of content.scripts) {
|
|
const scriptPath = path.join(scriptsDir, script.name);
|
|
await fs.writeFile(scriptPath, script.content);
|
|
await fs.chmod(scriptPath, '755');
|
|
}
|
|
}
|
|
|
|
// Write references
|
|
if (content.references.length > 0) {
|
|
const refsDir = path.join(installPath, 'references');
|
|
await fs.ensureDir(refsDir);
|
|
|
|
for (const ref of content.references) {
|
|
await fs.writeFile(path.join(refsDir, ref.name), ref.content);
|
|
}
|
|
}
|
|
|
|
// Track installation using canonical skill ID from registry (if available)
|
|
// This ensures the tracking matches the database record
|
|
const trackingId = skillInfo?.id || skillId;
|
|
await trackInstall(trackingId, options.platform, 'cli');
|
|
|
|
spinner.succeed(`Skill ${chalk.green(actualName)} installed successfully!`);
|
|
|
|
// Print info
|
|
console.log();
|
|
console.log(chalk.dim(`Path: ${installPath}`));
|
|
console.log();
|
|
|
|
if (parsed.metadata.description) {
|
|
console.log(chalk.dim(parsed.metadata.description));
|
|
console.log();
|
|
}
|
|
|
|
console.log(chalk.yellow('Usage:'));
|
|
console.log(
|
|
` This skill will be automatically activated when your ${getPlatformName(options.platform)} agent recognizes it's relevant.`
|
|
);
|
|
|
|
const setupInstructions = getPlatformSetupInstructions(options.platform, installPath);
|
|
if (setupInstructions) {
|
|
console.log();
|
|
console.log(chalk.cyan('Next Steps:'));
|
|
console.log(setupInstructions);
|
|
}
|
|
|
|
if (content.scripts.length > 0) {
|
|
console.log();
|
|
console.log(chalk.dim(`Scripts: ${content.scripts.map((s) => s.name).join(', ')}`));
|
|
}
|
|
} catch (error) {
|
|
spinner.fail('Installation failed');
|
|
console.error(chalk.red((error as Error).message));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
function getPlatformName(platform: Platform): string {
|
|
const names: Record<Platform, string> = {
|
|
claude: 'Claude',
|
|
codex: 'OpenAI Codex',
|
|
copilot: 'GitHub Copilot',
|
|
cursor: 'Cursor',
|
|
windsurf: 'Windsurf',
|
|
};
|
|
return names[platform];
|
|
}
|
|
|
|
function getPlatformSetupInstructions(platform: Platform, installPath: string): string | null {
|
|
switch (platform) {
|
|
case 'claude':
|
|
return chalk.dim(' Skills in .claude/skills/ are automatically discovered by Claude Code.');
|
|
case 'codex':
|
|
return chalk.dim(` Reference this skill in your AGENTS.md:\n @import ${installPath}/SKILL.md`);
|
|
case 'copilot':
|
|
return chalk.dim(' Instructions in .github/instructions/ are automatically loaded by GitHub Copilot.');
|
|
case 'cursor':
|
|
return chalk.dim(' Rules in .cursor/rules/ are automatically loaded by Cursor.');
|
|
case 'windsurf':
|
|
return chalk.dim(' Rules in .windsurf/rules/ are automatically loaded by Windsurf.');
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* All known main instruction file names across platforms
|
|
*/
|
|
const MAIN_FILE_NAMES = INSTRUCTION_FILE_PATTERNS.map(p => p.filename);
|
|
|
|
/**
|
|
* Convert cached files API response to SkillContent format.
|
|
* Detects the main instruction file by name (SKILL.md, AGENTS.md, .cursorrules, etc.)
|
|
*/
|
|
function convertCachedFilesToSkillContent(
|
|
response: SkillFilesResponse,
|
|
sourceFormat: SourceFormat = 'skill.md'
|
|
): SkillContent {
|
|
let skillMd = '';
|
|
const scripts: SkillContent['scripts'] = [];
|
|
const references: SkillContent['references'] = [];
|
|
|
|
// Find the expected main filename for this format
|
|
const expectedPattern = INSTRUCTION_FILE_PATTERNS.find(p => p.format === sourceFormat);
|
|
const expectedFilename = expectedPattern?.filename || 'SKILL.md';
|
|
|
|
for (const file of response.files) {
|
|
// Skip files without content (binary files)
|
|
if (!file.content) continue;
|
|
|
|
// Main instruction file: match expected filename or any known instruction file
|
|
if (!skillMd && (file.name === expectedFilename || MAIN_FILE_NAMES.includes(file.name)) &&
|
|
file.path === file.name) {
|
|
skillMd = file.content;
|
|
continue;
|
|
}
|
|
|
|
// Scripts folder
|
|
if (file.path.startsWith('scripts/')) {
|
|
scripts.push({
|
|
name: file.name,
|
|
content: file.content,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// References folder
|
|
if (file.path.startsWith('references/')) {
|
|
references.push({
|
|
name: file.name,
|
|
content: file.content,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
skillMd,
|
|
scripts,
|
|
references,
|
|
assets: [],
|
|
};
|
|
}
|