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:
airano
2026-02-12 06:08:51 +03:30
commit 97b427831a
227 changed files with 48411 additions and 0 deletions

View 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);
}

View 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: [],
};
}

View 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];
}

View 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'));
});
});

View 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('●○○○○');
}