Initial release v1.0.0
Open-source marketplace for AI Agent skills. Features: - Next.js 15 web app with i18n (en/fa) - CLI tool for skill installation (npx skillhub) - GitHub crawler/indexer with multi-strategy discovery - Security scanning for all indexed skills - Self-hostable with Docker Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
79
apps/cli/src/commands/config.ts
Normal file
79
apps/cli/src/commands/config.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import chalk from 'chalk';
|
||||
import { loadConfig, saveConfig, getConfigPath } from '../utils/paths.js';
|
||||
|
||||
interface ConfigOptions {
|
||||
set?: string;
|
||||
get?: string;
|
||||
list?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage CLI configuration
|
||||
*/
|
||||
export async function config(options: ConfigOptions): Promise<void> {
|
||||
const currentConfig = await loadConfig();
|
||||
|
||||
// List all config
|
||||
if (options.list || (!options.set && !options.get)) {
|
||||
console.log(chalk.bold('SkillHub CLI Configuration:\n'));
|
||||
console.log(chalk.dim(`Config file: ${getConfigPath()}\n`));
|
||||
|
||||
if (Object.keys(currentConfig).length === 0) {
|
||||
console.log(chalk.yellow('No configuration set.'));
|
||||
console.log(chalk.dim('\nAvailable settings:'));
|
||||
console.log(chalk.dim(' defaultPlatform - Default platform for installations (claude, codex, copilot)'));
|
||||
console.log(chalk.dim(' apiUrl - SkillHub API URL'));
|
||||
console.log(chalk.dim(' githubToken - GitHub personal access token for private repos'));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(currentConfig)) {
|
||||
const displayValue = key.toLowerCase().includes('token')
|
||||
? maskSecret(String(value))
|
||||
: String(value);
|
||||
console.log(` ${chalk.cyan(key)}: ${displayValue}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get a config value
|
||||
if (options.get) {
|
||||
const value = currentConfig[options.get];
|
||||
if (value === undefined) {
|
||||
console.log(chalk.yellow(`Config '${options.get}' is not set.`));
|
||||
return;
|
||||
}
|
||||
|
||||
const displayValue = options.get.toLowerCase().includes('token')
|
||||
? maskSecret(String(value))
|
||||
: String(value);
|
||||
console.log(displayValue);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set a config value
|
||||
if (options.set) {
|
||||
const [key, ...valueParts] = options.set.split('=');
|
||||
const value = valueParts.join('=');
|
||||
|
||||
if (!key || value === undefined) {
|
||||
console.error(chalk.red('Invalid format. Use: --set key=value'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
currentConfig[key] = value;
|
||||
await saveConfig(currentConfig);
|
||||
|
||||
const displayValue = key.toLowerCase().includes('token')
|
||||
? maskSecret(value)
|
||||
: value;
|
||||
console.log(chalk.green(`Set ${chalk.cyan(key)} = ${displayValue}`));
|
||||
}
|
||||
}
|
||||
|
||||
function maskSecret(value: string): string {
|
||||
if (value.length <= 8) {
|
||||
return '*'.repeat(value.length);
|
||||
}
|
||||
return value.slice(0, 4) + '*'.repeat(value.length - 8) + value.slice(-4);
|
||||
}
|
||||
391
apps/cli/src/commands/install.ts
Normal file
391
apps/cli/src/commands/install.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
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) {
|
||||
spinner.succeed(`Found in registry: ${skillInfo.name}`);
|
||||
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) {
|
||||
// Use sourceFormat from API response if available
|
||||
if (cachedFiles.sourceFormat) {
|
||||
sourceFormat = cachedFiles.sourceFormat as SourceFormat;
|
||||
}
|
||||
// Convert API response to SkillContent format
|
||||
content = convertCachedFilesToSkillContent(cachedFiles, sourceFormat);
|
||||
spinner.text = cachedFiles.fromCache
|
||||
? `Using cached files (${cachedFiles.files.length} files)`
|
||||
: `Downloaded ${cachedFiles.files.length} files via API`;
|
||||
}
|
||||
} 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: [],
|
||||
};
|
||||
}
|
||||
177
apps/cli/src/commands/list.ts
Normal file
177
apps/cli/src/commands/list.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import { parseSkillMd } from 'skillhub-core';
|
||||
import { getSkillsPath, type Platform } from '../utils/paths.js';
|
||||
|
||||
interface ListOptions {
|
||||
platform?: Platform;
|
||||
project?: boolean;
|
||||
all?: boolean;
|
||||
}
|
||||
|
||||
const ALL_PLATFORMS: Platform[] = ['claude', 'codex', 'copilot', 'cursor', 'windsurf'];
|
||||
|
||||
/**
|
||||
* List installed skills
|
||||
*/
|
||||
export async function list(options: ListOptions): Promise<void> {
|
||||
const platforms = options.platform ? [options.platform] : ALL_PLATFORMS;
|
||||
let totalSkills = 0;
|
||||
|
||||
// Determine which locations to check
|
||||
const checkGlobal = options.all || !options.project;
|
||||
const checkProject = options.all || options.project;
|
||||
|
||||
// List global skills
|
||||
if (checkGlobal) {
|
||||
for (const platform of platforms) {
|
||||
const skills = await getInstalledSkills(platform, false);
|
||||
|
||||
if (skills.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
totalSkills += skills.length;
|
||||
|
||||
const header = options.all
|
||||
? `${getPlatformName(platform)} - Global (${skills.length} skills)`
|
||||
: `${getPlatformName(platform)} (${skills.length} skills)`;
|
||||
|
||||
console.log(chalk.bold(`\n${header}:`));
|
||||
console.log(chalk.dim('─'.repeat(60)));
|
||||
|
||||
for (const skill of skills) {
|
||||
const version = skill.version ? chalk.dim(`v${skill.version}`) : '';
|
||||
console.log(` ${chalk.cyan(skill.name.padEnd(25))} ${version}`);
|
||||
|
||||
if (skill.description) {
|
||||
console.log(` ${chalk.dim(skill.description.slice(0, 55))}${skill.description.length > 55 ? '...' : ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List project skills
|
||||
if (checkProject) {
|
||||
for (const platform of platforms) {
|
||||
const skills = await getInstalledSkills(platform, true);
|
||||
|
||||
if (skills.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
totalSkills += skills.length;
|
||||
|
||||
const header = options.all
|
||||
? `${getPlatformName(platform)} - Project (${skills.length} skills)`
|
||||
: `${getPlatformName(platform)} (${skills.length} skills)`;
|
||||
|
||||
console.log(chalk.bold(`\n${header}:`));
|
||||
console.log(chalk.dim('─'.repeat(60)));
|
||||
console.log(chalk.dim(` Path: ${getSkillsPath(platform, true)}`));
|
||||
|
||||
for (const skill of skills) {
|
||||
const version = skill.version ? chalk.dim(`v${skill.version}`) : '';
|
||||
console.log(` ${chalk.cyan(skill.name.padEnd(25))} ${version}`);
|
||||
|
||||
if (skill.description) {
|
||||
console.log(` ${chalk.dim(skill.description.slice(0, 55))}${skill.description.length > 55 ? '...' : ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalSkills === 0) {
|
||||
if (options.project) {
|
||||
console.log(chalk.yellow('\nNo skills installed in project.'));
|
||||
console.log(chalk.dim('Install a skill with: npx skillhub install <skill-id> --project'));
|
||||
} else if (options.all) {
|
||||
console.log(chalk.yellow('\nNo skills installed (global or project).'));
|
||||
} else {
|
||||
console.log(chalk.yellow('\nNo skills installed globally.'));
|
||||
console.log(chalk.dim('Use --project to list project-level skills'));
|
||||
console.log(chalk.dim('Use --all to list both global and project skills'));
|
||||
}
|
||||
console.log(chalk.dim('\nSearch for skills with: npx skillhub search <query>'));
|
||||
console.log(chalk.dim('Install a skill with: npx skillhub install <skill-id>'));
|
||||
} else {
|
||||
console.log(chalk.dim(`\n${totalSkills} total skill(s) installed.`));
|
||||
}
|
||||
}
|
||||
|
||||
interface InstalledSkill {
|
||||
name: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
async function getInstalledSkills(platform: Platform, project: boolean = false): Promise<InstalledSkill[]> {
|
||||
const skillsPath = getSkillsPath(platform, project);
|
||||
const skills: InstalledSkill[] = [];
|
||||
|
||||
if (!(await fs.pathExists(skillsPath))) {
|
||||
return skills;
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(skillsPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const skillPath = path.join(skillsPath, entry.name);
|
||||
const skillMdPath = path.join(skillPath, 'SKILL.md');
|
||||
const metadataPath = path.join(skillPath, '.skillhub.json');
|
||||
|
||||
const hasSkillMd = await fs.pathExists(skillMdPath);
|
||||
const hasMetadata = await fs.pathExists(metadataPath);
|
||||
|
||||
if (!hasSkillMd && !hasMetadata) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (hasSkillMd) {
|
||||
const content = await fs.readFile(skillMdPath, 'utf-8');
|
||||
const parsed = parseSkillMd(content);
|
||||
|
||||
skills.push({
|
||||
name: parsed.metadata.name || entry.name,
|
||||
description: parsed.metadata.description,
|
||||
version: parsed.metadata.version,
|
||||
path: skillPath,
|
||||
});
|
||||
} else if (hasMetadata) {
|
||||
const metadata = await fs.readJson(metadataPath);
|
||||
skills.push({
|
||||
name: entry.name,
|
||||
description: undefined,
|
||||
version: metadata.version || undefined,
|
||||
path: skillPath,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid skills
|
||||
skills.push({
|
||||
name: entry.name,
|
||||
path: skillPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
144
apps/cli/src/commands/search.test.ts
Normal file
144
apps/cli/src/commands/search.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('chalk', () => ({
|
||||
default: {
|
||||
bold: vi.fn((s) => s),
|
||||
dim: vi.fn((s) => s),
|
||||
cyan: vi.fn((s) => s),
|
||||
yellow: vi.fn((s) => s),
|
||||
red: vi.fn((s) => s),
|
||||
green: vi.fn((s) => s),
|
||||
white: vi.fn((s) => s),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('ora', () => ({
|
||||
default: vi.fn(() => ({
|
||||
start: vi.fn().mockReturnThis(),
|
||||
stop: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/api.js', () => ({
|
||||
searchSkills: vi.fn(),
|
||||
}));
|
||||
|
||||
import { search } from './search.js';
|
||||
import { searchSkills } from '../utils/api.js';
|
||||
|
||||
describe('search command', () => {
|
||||
let consoleLogSpy: MockInstance;
|
||||
let consoleErrorSpy: MockInstance;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let processExitSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should search skills via API', async () => {
|
||||
vi.mocked(searchSkills).mockResolvedValue({
|
||||
skills: [
|
||||
{
|
||||
id: 'test/skill',
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
githubOwner: 'test',
|
||||
githubRepo: 'skill',
|
||||
skillPath: 'skills/test',
|
||||
branch: 'main',
|
||||
githubStars: 100,
|
||||
downloadCount: 50,
|
||||
securityScore: 85,
|
||||
isVerified: true,
|
||||
compatibility: { platforms: ['claude', 'codex'] },
|
||||
},
|
||||
],
|
||||
pagination: { page: 1, limit: 10, total: 1, totalPages: 1 },
|
||||
});
|
||||
|
||||
await search('test', {});
|
||||
|
||||
expect(searchSkills).toHaveBeenCalledWith('test', { platform: undefined, limit: 10, page: 1, sort: 'downloads' });
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show "no skills found" message', async () => {
|
||||
vi.mocked(searchSkills).mockResolvedValue({
|
||||
skills: [],
|
||||
pagination: { page: 1, limit: 10, total: 0, totalPages: 0 },
|
||||
});
|
||||
|
||||
await search('nonexistent', {});
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('No skills found'));
|
||||
});
|
||||
|
||||
it('should filter by platform', async () => {
|
||||
vi.mocked(searchSkills).mockResolvedValue({
|
||||
skills: [],
|
||||
pagination: { page: 1, limit: 10, total: 0, totalPages: 0 },
|
||||
});
|
||||
|
||||
await search('test', { platform: 'claude' });
|
||||
|
||||
expect(searchSkills).toHaveBeenCalledWith('test', expect.objectContaining({ platform: 'claude' }));
|
||||
});
|
||||
|
||||
it('should respect limit option', async () => {
|
||||
vi.mocked(searchSkills).mockResolvedValue({
|
||||
skills: [],
|
||||
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
|
||||
});
|
||||
|
||||
await search('test', { limit: '20' });
|
||||
|
||||
expect(searchSkills).toHaveBeenCalledWith('test', expect.objectContaining({ limit: 20 }));
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
vi.mocked(searchSkills).mockRejectedValue(new Error('API error'));
|
||||
|
||||
await search('test', {});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('API error'));
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should display pagination info when more results available', async () => {
|
||||
vi.mocked(searchSkills).mockResolvedValue({
|
||||
skills: [
|
||||
{
|
||||
id: 'test/skill',
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
githubOwner: 'test',
|
||||
githubRepo: 'skill',
|
||||
skillPath: 'skills/test',
|
||||
branch: 'main',
|
||||
githubStars: 100,
|
||||
downloadCount: 50,
|
||||
securityScore: 85,
|
||||
isVerified: false,
|
||||
compatibility: { platforms: ['claude'] },
|
||||
},
|
||||
],
|
||||
pagination: { page: 1, limit: 10, total: 50, totalPages: 5 },
|
||||
});
|
||||
|
||||
await search('test', {});
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Page 1 of 5'));
|
||||
});
|
||||
});
|
||||
132
apps/cli/src/commands/search.ts
Normal file
132
apps/cli/src/commands/search.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { searchSkills } from '../utils/api.js';
|
||||
|
||||
interface SearchOptions {
|
||||
platform?: string;
|
||||
sort?: string;
|
||||
limit?: string;
|
||||
page?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for skills in the registry
|
||||
*/
|
||||
export async function search(query: string, options: SearchOptions): Promise<void> {
|
||||
const spinner = ora('Searching skills...').start();
|
||||
|
||||
try {
|
||||
const limit = parseInt(options.limit || '10');
|
||||
const page = parseInt(options.page || '1');
|
||||
const sort = options.sort || 'downloads';
|
||||
const result = await searchSkills(query, {
|
||||
platform: options.platform,
|
||||
limit,
|
||||
page,
|
||||
sort,
|
||||
});
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (result.skills.length === 0) {
|
||||
console.log(chalk.yellow('No skills found.'));
|
||||
console.log(chalk.dim('Try a different search term or check the spelling.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const sortLabel = { downloads: 'downloads', stars: 'GitHub stars', rating: 'rating', recent: 'recently updated' }[sort] || sort;
|
||||
console.log(chalk.bold(`Found ${result.pagination.total} skills (sorted by ${sortLabel}):\n`));
|
||||
|
||||
// Print results as a table
|
||||
console.log(
|
||||
chalk.dim('─'.repeat(80))
|
||||
);
|
||||
|
||||
const startIndex = (page - 1) * limit;
|
||||
for (let i = 0; i < result.skills.length; i++) {
|
||||
const skill = result.skills[i];
|
||||
const num = chalk.dim(`[${startIndex + i + 1}]`);
|
||||
const verified = skill.isVerified ? chalk.green('✓') : ' ';
|
||||
// Use securityStatus if available, otherwise fall back to score-based badge
|
||||
const security = skill.securityStatus
|
||||
? getSecurityStatusBadge(skill.securityStatus)
|
||||
: getSecurityBadge(skill.securityScore);
|
||||
|
||||
// First line: number, ID, security
|
||||
console.log(
|
||||
`${num} ${verified} ${chalk.cyan(skill.id.padEnd(38))} ${security}`
|
||||
);
|
||||
|
||||
// Second line: downloads, stars, description
|
||||
console.log(
|
||||
` ⬇ ${formatNumber(skill.downloadCount).padStart(6)} ⭐ ${formatNumber(skill.githubStars).padStart(6)} ${chalk.dim(skill.description.slice(0, 55))}${skill.description.length > 55 ? '...' : ''}`
|
||||
);
|
||||
|
||||
// Third line: Rating (only if ratingCount >= 3)
|
||||
const showRating = (skill.ratingCount ?? 0) >= 3;
|
||||
if (showRating && skill.rating) {
|
||||
console.log(
|
||||
` ${chalk.yellow('★')} ${skill.rating.toFixed(1)} ${chalk.dim(`(${skill.ratingCount} ratings)`)}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(chalk.dim('─'.repeat(80)));
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(chalk.dim(`Install with: ${chalk.white('npx skillhub install <skill-id>')}`));
|
||||
|
||||
const totalPages = result.pagination.totalPages;
|
||||
if (totalPages > 1) {
|
||||
console.log(
|
||||
chalk.dim(`Page ${page} of ${totalPages}. Use ${chalk.white(`--page ${page + 1}`)} for next page.`)
|
||||
);
|
||||
}
|
||||
|
||||
if (sort === 'downloads') {
|
||||
console.log(chalk.dim(`Sort options: ${chalk.white('--sort stars|rating|recent')}`));
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Search failed');
|
||||
const err = error as Error;
|
||||
console.error(chalk.red(err.message || 'Unknown error'));
|
||||
if (process.env.DEBUG) {
|
||||
console.error(chalk.dim('Stack:'), err.stack);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'k';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security badge from securityStatus (new system)
|
||||
*/
|
||||
function getSecurityStatusBadge(status: string): string {
|
||||
switch (status) {
|
||||
case 'pass':
|
||||
return chalk.green('🛡️ Pass');
|
||||
case 'warning':
|
||||
return chalk.yellow('⚠️ Warn');
|
||||
case 'fail':
|
||||
return chalk.red('❌ Fail');
|
||||
default:
|
||||
return chalk.dim('- N/A');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security badge from securityScore (legacy system)
|
||||
*/
|
||||
function getSecurityBadge(score: number): string {
|
||||
if (score >= 90) return chalk.green('●●●●●');
|
||||
if (score >= 70) return chalk.yellow('●●●●○');
|
||||
if (score >= 50) return chalk.yellow('●●●○○');
|
||||
if (score >= 30) return chalk.red('●●○○○');
|
||||
return chalk.red('●○○○○');
|
||||
}
|
||||
Reference in New Issue
Block a user