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

121
apps/cli/README.md Normal file
View File

@@ -0,0 +1,121 @@
# @skillhub/cli
Command-line tool for installing and managing AI Agent skills from [SkillHub](https://skills.palebluedot.live).
## Installation
```bash
# Install globally
npm install -g @skillhub/cli
# Or use directly with npx
npx @skillhub/cli
```
## Usage
### Search for skills
```bash
skillhub search pdf
skillhub search "code review" --platform claude --limit 20
```
### Install a skill
```bash
skillhub install anthropics/skills/pdf
skillhub install obra/superpowers/brainstorming --platform codex
skillhub install anthropics/skills/docx --project # Install in current project
```
### List installed skills
```bash
skillhub list
skillhub list --platform claude
```
### Update skills
```bash
skillhub update anthropics/skills/pdf # Update specific skill
skillhub update --all # Update all installed skills
```
### Uninstall a skill
```bash
skillhub uninstall pdf
skillhub uninstall brainstorming --platform codex
```
### Configuration
```bash
skillhub config --list # Show all config
skillhub config --get defaultPlatform # Get specific value
skillhub config --set defaultPlatform=claude # Set value
```
## Platform Support
SkillHub CLI supports multiple AI agent platforms:
| Platform | Flag | Install Path |
|----------|------|--------------|
| Claude | `--platform claude` | `~/.claude/skills/` |
| OpenAI Codex | `--platform codex` | `~/.codex/skills/` |
| GitHub Copilot | `--platform copilot` | `~/.github/skills/` |
| Cursor | `--platform cursor` | `~/.cursor/skills/` |
| Windsurf | `--platform windsurf` | `~/.windsurf/skills/` |
Default platform: `claude`
## Options
### Global Options
- `--platform <name>` - Target platform (claude, codex, copilot, cursor, windsurf)
- `--project` - Install in current project instead of user directory
- `--force` - Overwrite existing installation
- `--help` - Show help information
- `--version` - Show version number
### Environment Variables
- `SKILLHUB_API_URL` - Override API endpoint (default: https://skills.palebluedot.live/api)
- `GITHUB_TOKEN` - GitHub token for API rate limits (optional)
## Configuration File
Configuration is stored in `~/.skillhub/config.json`:
```json
{
"defaultPlatform": "claude",
"apiUrl": "https://skills.palebluedot.live/api",
"githubToken": "ghp_..."
}
```
## Examples
```bash
# Search and install a skill
skillhub search "document processing"
skillhub install anthropics/skills/pdf
# Install skill for Codex
skillhub install obra/superpowers/brainstorming --platform codex
# Update all installed skills
skillhub update --all
# Check what's installed
skillhub list
```
## License
MIT

61
apps/cli/package.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "skillhub",
"version": "0.2.4",
"description": "CLI tool for managing AI Agent skills - search, install, and update skills for Claude, Codex, Copilot, and more",
"author": "SkillHub Contributors",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/airano-ir/skillhub.git",
"directory": "apps/cli"
},
"homepage": "https://skills.palebluedot.live",
"type": "module",
"bin": {
"skillhub": "./dist/index.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsx src/index.ts",
"start": "node dist/index.js",
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"@octokit/rest": "^20.0.2",
"chalk": "^5.3.0",
"commander": "^11.1.0",
"fs-extra": "^11.2.0",
"ora": "^8.0.1",
"prompts": "^2.4.2"
},
"devDependencies": {
"skillhub-core": "workspace:*",
"@types/fs-extra": "^11.0.4",
"@types/node": "^20.10.0",
"@types/prompts": "^2.4.9",
"tsup": "^8.0.1",
"tsx": "^4.7.0",
"typescript": "^5.3.0",
"vitest": "^1.2.0"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"ai",
"agent",
"skills",
"cli",
"claude",
"codex",
"copilot",
"cursor",
"windsurf"
]
}

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

239
apps/cli/src/index.ts Normal file
View File

@@ -0,0 +1,239 @@
#!/usr/bin/env node
import { Command } from 'commander';
import chalk from 'chalk';
import { createRequire } from 'module';
import { install } from './commands/install.js';
import { search } from './commands/search.js';
import { list } from './commands/list.js';
import { config } from './commands/config.js';
import { loadConfig, type Platform } from './utils/paths.js';
const require = createRequire(import.meta.url);
const pkg = require('../package.json');
const VERSION = pkg.version;
const program = new Command();
program
.name('skillhub')
.description('CLI for managing AI Agent skills')
.version(VERSION);
// Install command
program
.command('install <skill-id>')
.description('Install a skill from the registry')
.option('-p, --platform <platform>', 'Target platform (claude, codex, copilot, cursor, windsurf)')
.option('--project', 'Install in the current project instead of globally')
.option('-f, --force', 'Overwrite existing skill')
.option('--no-api', 'Skip API lookup and fetch directly from GitHub')
.action(async (skillId: string, options) => {
// Load config to get default platform
const userConfig = await loadConfig();
const platform = options.platform || (userConfig.defaultPlatform as Platform) || 'claude';
await install(skillId, {
platform,
project: options.project,
force: options.force,
noApi: !options.api, // Commander converts --no-api to api: false
});
});
// Search command
program
.command('search <query>')
.description('Search for skills in the registry')
.option('-p, --platform <platform>', 'Filter by platform')
.option('-s, --sort <sort>', 'Sort by: downloads, stars, rating, recent', 'downloads')
.option('-l, --limit <number>', 'Number of results', '10')
.option('--page <number>', 'Page number', '1')
.action(async (query: string, options) => {
await search(query, options);
});
// List command
program
.command('list')
.description('List installed skills')
.option('-p, --platform <platform>', 'Filter by platform')
.option('--project', 'List skills in the current project')
.option('--all', 'List both global and project skills')
.action(async (options) => {
await list(options);
});
// Config command
program
.command('config')
.description('Manage CLI configuration')
.option('--set <key=value>', 'Set a configuration value')
.option('--get <key>', 'Get a configuration value')
.option('--list', 'List all configuration values')
.action(async (options) => {
await config(options);
});
// Uninstall command
program
.command('uninstall <skill-name>')
.description('Uninstall a skill')
.option('-p, --platform <platform>', 'Target platform')
.option('--project', 'Uninstall from project instead of globally')
.action(async (skillName: string, options) => {
const fs = await import('fs-extra');
const pathModule = await import('path');
const { getSkillPath, isSkillInstalled } = await import('./utils/paths.js');
// Load config to get default platform
const userConfig = await loadConfig();
const platform = options.platform || (userConfig.defaultPlatform as Platform) || 'claude';
const installed = await isSkillInstalled(platform, skillName, options.project);
if (!installed) {
console.log(chalk.yellow(`Skill ${skillName} is not installed.`));
process.exit(1);
}
const skillPath = getSkillPath(platform, skillName, options.project);
// Clean up flat platform file if present
const metadataPath = pathModule.join(skillPath, '.skillhub.json');
if (await fs.default.pathExists(metadataPath)) {
try {
const metadata = await fs.default.readJson(metadataPath);
if (metadata.platformFilePath) {
await fs.default.remove(metadata.platformFilePath);
}
} catch {
// Ignore metadata read errors
}
}
await fs.default.remove(skillPath);
console.log(chalk.green(`Skill ${skillName} uninstalled successfully.`));
});
// Update command
program
.command('update [skill-name]')
.description('Update installed skills')
.option('-p, --platform <platform>', 'Target platform')
.option('--all', 'Update all installed skills')
.action(async (skillName: string | undefined, options) => {
const fsExtra = await import('fs-extra');
const pathModule = await import('path');
const { getSkillsPath, getSkillPath } = await import('./utils/paths.js');
// Load config to get default platform
const userConfig = await loadConfig();
const platform = options.platform || (userConfig.defaultPlatform as Platform) || 'claude';
const ALL_PLATFORMS: Platform[] = ['claude', 'codex', 'copilot', 'cursor', 'windsurf'];
if (options.all) {
console.log(chalk.cyan('\nUpdating all installed skills...\n'));
const platforms = options.platform ? [platform] : ALL_PLATFORMS;
let updated = 0;
let failed = 0;
let skipped = 0;
for (const p of platforms) {
const skillsPath = getSkillsPath(p);
if (!(await fsExtra.default.pathExists(skillsPath))) {
continue;
}
const entries = await fsExtra.default.readdir(skillsPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillPath = pathModule.join(skillsPath, entry.name);
const metadataPath = pathModule.join(skillPath, '.skillhub.json');
// Check if skill has metadata with skillId
if (!(await fsExtra.default.pathExists(metadataPath))) {
console.log(chalk.yellow(` Skipping ${entry.name}: No metadata (reinstall with CLI to enable updates)`));
skipped++;
continue;
}
try {
const metadata = await fsExtra.default.readJson(metadataPath);
const skillId = metadata.skillId;
if (!skillId) {
console.log(chalk.yellow(` Skipping ${entry.name}: No skill ID in metadata`));
skipped++;
continue;
}
console.log(chalk.dim(` Updating ${entry.name}...`));
// Re-install with force
await install(skillId, {
platform: p,
force: true,
});
updated++;
} catch (error) {
console.log(chalk.red(` Failed to update ${entry.name}: ${(error as Error).message}`));
failed++;
}
}
}
console.log();
console.log(chalk.green(`Updated: ${updated}`));
if (failed > 0) console.log(chalk.red(`Failed: ${failed}`));
if (skipped > 0) console.log(chalk.yellow(`Skipped: ${skipped}`));
return;
}
if (!skillName) {
console.log(chalk.red('Please specify a skill name or use --all.'));
process.exit(1);
}
// Update single skill by name
const skillPath = getSkillPath(platform, skillName);
const metadataPath = pathModule.join(skillPath, '.skillhub.json');
if (!(await fsExtra.default.pathExists(metadataPath))) {
console.log(chalk.yellow(`Skill ${skillName} was not installed via CLI.`));
console.log(chalk.dim('To update, reinstall with: npx skillhub install <skill-id> --force'));
process.exit(1);
}
try {
const metadata = await fsExtra.default.readJson(metadataPath);
const skillId = metadata.skillId;
if (!skillId) {
console.log(chalk.red('No skill ID found in metadata.'));
process.exit(1);
}
console.log(chalk.dim(`Updating ${skillName}...`));
await install(skillId, {
platform,
force: true,
});
} catch (error) {
console.log(chalk.red(`Failed to update: ${(error as Error).message}`));
process.exit(1);
}
});
// Parse arguments
program.parse();
// Show help if no command
if (!process.argv.slice(2).length) {
program.help();
}

View File

@@ -0,0 +1,176 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PassThrough } from 'stream';
// Use vi.hoisted to make mock state available to hoisted vi.mock calls
const mockState = vi.hoisted(() => ({
responseData: {
skills: [{ id: 'test-skill', name: 'Test Skill' }],
pagination: { page: 1, limit: 10, total: 1, totalPages: 1 },
} as unknown,
statusCode: 200,
shouldError: false,
}));
// Mock https module
vi.mock('https', () => {
return {
default: {
request: vi.fn().mockImplementation((_options: unknown, callback: (res: unknown) => void) => {
const req = new PassThrough();
if (mockState.shouldError) {
Object.assign(req, {
on: vi.fn().mockImplementation((event: string, handler: (err?: Error) => void) => {
if (event === 'error') {
setImmediate(() => handler(new Error('Network error')));
}
return req;
}),
write: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
});
return req;
}
const res = new PassThrough();
Object.assign(res, {
statusCode: mockState.statusCode,
setTimeout: vi.fn(),
});
setImmediate(() => {
callback(res);
res.push(JSON.stringify(mockState.responseData));
res.push(null);
});
Object.assign(req, {
on: vi.fn().mockReturnThis(),
write: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
});
return req;
}),
},
request: vi.fn().mockImplementation((_options: unknown, callback: (res: unknown) => void) => {
const req = new PassThrough();
if (mockState.shouldError) {
Object.assign(req, {
on: vi.fn().mockImplementation((event: string, handler: (err?: Error) => void) => {
if (event === 'error') {
setImmediate(() => handler(new Error('Network error')));
}
return req;
}),
write: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
});
return req;
}
const res = new PassThrough();
Object.assign(res, {
statusCode: mockState.statusCode,
setTimeout: vi.fn(),
});
setImmediate(() => {
callback(res);
res.push(JSON.stringify(mockState.responseData));
res.push(null);
});
Object.assign(req, {
on: vi.fn().mockReturnThis(),
write: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
});
return req;
}),
};
});
// Mock http module
vi.mock('http', () => ({
default: { request: vi.fn() },
request: vi.fn(),
}));
// Import after mocking
import { searchSkills, getSkill, trackInstall } from './api.js';
describe('API Utils', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset to default
mockState.responseData = {
skills: [{ id: 'test-skill', name: 'Test Skill' }],
pagination: { page: 1, limit: 10, total: 1, totalPages: 1 },
};
mockState.statusCode = 200;
mockState.shouldError = false;
});
describe('searchSkills', () => {
it('should search skills with query', async () => {
const result = await searchSkills('test');
expect(result.skills).toBeDefined();
expect(result.pagination).toBeDefined();
});
it('should return skills array', async () => {
const result = await searchSkills('test');
expect(Array.isArray(result.skills)).toBe(true);
});
it('should return pagination info', async () => {
const result = await searchSkills('test');
expect(result.pagination.page).toBeDefined();
expect(result.pagination.limit).toBeDefined();
});
});
describe('getSkill', () => {
it('should return skill data when found', async () => {
mockState.responseData = { id: 'test/repo/skill', name: 'Test Skill' };
const result = await getSkill('test/repo/skill');
expect(result).toBeDefined();
expect(result?.id).toBe('test/repo/skill');
});
it('should return null for 404', async () => {
mockState.statusCode = 404;
mockState.responseData = { error: 'Not found' };
const result = await getSkill('nonexistent');
expect(result).toBeNull();
});
});
describe('trackInstall', () => {
it('should not throw on success', async () => {
mockState.responseData = { success: true };
// Should not throw
await expect(trackInstall('test/skill', 'claude', 'cli')).resolves.not.toThrow();
});
it('should not throw on failure', async () => {
mockState.shouldError = true;
// Should not throw - tracking failures are silently ignored
await expect(trackInstall('test/skill', 'claude')).resolves.not.toThrow();
});
});
});

260
apps/cli/src/utils/api.ts Normal file
View File

@@ -0,0 +1,260 @@
import https from 'https';
import http from 'http';
const API_BASE_URL = process.env.SKILLHUB_API_URL || 'https://skills.palebluedot.live/api';
const API_TIMEOUT = parseInt(process.env.SKILLHUB_API_TIMEOUT || '20000'); // 20 seconds default
const API_FILES_TIMEOUT = parseInt(process.env.SKILLHUB_API_FILES_TIMEOUT || '45000'); // 45 seconds for file fetching (server may need to fetch from GitHub on cache miss)
interface HttpResponse {
statusCode: number;
data: string;
}
/**
* Make an HTTPS request using native Node.js module
* Handles Cloudflare chunked encoding issues by detecting complete JSON responses
*/
function httpsRequest(
url: string,
options: {
method?: string;
headers?: Record<string, string>;
body?: string;
timeout?: number;
} = {}
): Promise<HttpResponse> {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const isHttps = parsedUrl.protocol === 'https:';
const lib = isHttps ? https : http;
const requestTimeout = options.timeout || API_TIMEOUT;
const reqOptions = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (isHttps ? 443 : 80),
path: parsedUrl.pathname + parsedUrl.search,
method: options.method || 'GET',
headers: {
'User-Agent': 'skillhub-cli',
'Accept': 'application/json',
...options.headers,
},
timeout: requestTimeout,
};
const req = lib.request(reqOptions, (res) => {
const chunks: Buffer[] = [];
let resolved = false;
const tryResolve = () => {
if (resolved) return;
const data = Buffer.concat(chunks).toString();
// For JSON responses, detect complete objects
// This works around Cloudflare chunked encoding issues
if (data.startsWith('{') || data.startsWith('[')) {
try {
JSON.parse(data); // Validate complete JSON
resolved = true;
res.destroy(); // Force close connection
resolve({
statusCode: res.statusCode || 0,
data,
});
} catch {
// JSON not complete yet, continue waiting
}
}
};
res.on('data', (chunk) => {
chunks.push(chunk);
tryResolve();
});
res.on('end', () => {
if (!resolved) {
resolve({
statusCode: res.statusCode || 0,
data: Buffer.concat(chunks).toString(),
});
}
});
// Set a per-request timeout for receiving data
res.setTimeout(requestTimeout, () => {
if (!resolved) {
res.destroy();
reject(new Error(`Response timeout after ${requestTimeout / 1000}s`));
}
});
});
req.on('error', (err) => {
// Ignore stream destroyed errors (we caused them intentionally)
if ((err as NodeJS.ErrnoException).code === 'ERR_STREAM_DESTROYED') return;
reject(new Error(`Network error: ${err.message}`));
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`Request timeout after ${requestTimeout / 1000}s`));
});
if (options.body) {
req.write(options.body);
}
req.end();
});
}
export interface SkillInfo {
id: string;
name: string;
description: string;
githubOwner: string;
githubRepo: string;
skillPath: string;
branch: string;
version?: string;
license?: string;
githubStars: number;
downloadCount: number;
securityScore: number;
securityStatus?: 'pass' | 'warning' | 'fail' | null;
sourceFormat?: string;
rating?: number | null;
ratingCount?: number | null;
isVerified: boolean;
compatibility: {
platforms: string[];
};
}
export interface SearchResult {
skills: SkillInfo[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
/**
* Search for skills
*/
export async function searchSkills(
query: string,
options: {
platform?: string;
limit?: number;
page?: number;
sort?: string;
} = {}
): Promise<SearchResult> {
const params = new URLSearchParams({
q: query,
limit: String(options.limit || 10),
page: String(options.page || 1),
sort: options.sort || 'downloads',
});
if (options.platform) {
params.set('platform', options.platform);
}
const response = await httpsRequest(`${API_BASE_URL}/skills?${params}`);
if (response.statusCode !== 200) {
throw new Error(`API error ${response.statusCode}: ${response.data || 'Unknown error'}`);
}
return JSON.parse(response.data);
}
/**
* Get skill details
*/
export async function getSkill(id: string): Promise<SkillInfo | null> {
// Encode each segment separately to preserve slashes in URL path
const encodedPath = id.split('/').map(encodeURIComponent).join('/');
const response = await httpsRequest(`${API_BASE_URL}/skills/${encodedPath}`);
if (response.statusCode === 404) {
return null;
}
if (response.statusCode !== 200) {
throw new Error(`API error ${response.statusCode}: ${response.data || 'Unknown error'}`);
}
return JSON.parse(response.data);
}
/**
* Track an installation
*/
export async function trackInstall(
skillId: string,
platform: string,
method = 'cli'
): Promise<void> {
try {
await httpsRequest(`${API_BASE_URL}/skills/install`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ skillId, platform, method }),
});
} catch {
// Silently fail - tracking is not critical
}
}
export interface SkillFile {
name: string;
path: string;
type: 'file';
size: number;
content?: string; // Text file content
downloadUrl?: string; // For binary files
}
export interface SkillFilesResponse {
skillId: string;
githubOwner: string;
githubRepo: string;
skillPath: string;
branch: string;
sourceFormat?: string;
files: SkillFile[];
fromCache: boolean;
cachedAt?: string;
}
/**
* Get skill files from API (uses server-side cache)
* Returns null if API unavailable or skill not found
*/
export async function getSkillFiles(id: string): Promise<SkillFilesResponse | null> {
try {
const response = await httpsRequest(
`${API_BASE_URL}/skill-files?id=${encodeURIComponent(id)}`,
{ timeout: API_FILES_TIMEOUT }
);
if (response.statusCode === 404) {
return null;
}
if (response.statusCode !== 200) {
return null;
}
return JSON.parse(response.data);
} catch {
// API unavailable, return null to fall back to GitHub
return null;
}
}

View File

@@ -0,0 +1,251 @@
import { Octokit } from '@octokit/rest';
import { INSTRUCTION_FILE_PATTERNS, type SourceFormat } from 'skillhub-core';
import https from 'https';
let octokit: Octokit | null = null;
function getOctokit(): Octokit {
if (!octokit) {
octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
userAgent: 'SkillHub-CLI/1.0',
request: {
timeout: 30000, // 30 second timeout
},
});
}
return octokit;
}
export interface SkillContent {
skillMd: string;
scripts: Array<{ name: string; content: string }>;
references: Array<{ name: string; content: string }>;
assets: Array<{ name: string; content: string }>; // base64 encoded
}
/**
* Fetch file from raw.githubusercontent.com (fallback for network issues)
*/
async function fetchRawFile(
owner: string,
repo: string,
path: string,
branch: string
): Promise<string> {
return new Promise((resolve, reject) => {
const url = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
https.get(url, { timeout: 10000 }, (res) => {
if (res.statusCode === 404) {
reject(new Error('File not found'));
return;
}
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => resolve(data));
}).on('error', (err) => {
reject(err);
}).on('timeout', () => {
reject(new Error('Request timeout'));
});
});
}
/**
* Get the filename to look for based on sourceFormat
*/
function getInstructionFilename(sourceFormat: SourceFormat): string {
const pattern = INSTRUCTION_FILE_PATTERNS.find(p => p.format === sourceFormat);
return pattern?.filename || 'SKILL.md';
}
/**
* Fetch skill content from GitHub
*/
export async function fetchSkillContent(
owner: string,
repo: string,
skillPath: string,
branch = 'main',
sourceFormat: SourceFormat = 'skill.md'
): Promise<SkillContent> {
const client = getOctokit();
const filename = getInstructionFilename(sourceFormat);
const isStandalone = sourceFormat !== 'skill.md';
// Build paths to try for the instruction file
const basePath = skillPath ? `${skillPath}/${filename}` : filename;
let skillMdResponse;
// For standalone formats (.cursorrules, .windsurfrules), they're at specific locations
let pathsToTry: string[];
if (sourceFormat === 'cursorrules' || sourceFormat === 'windsurfrules') {
// Root-only files
pathsToTry = [filename];
} else if (sourceFormat === 'copilot-instructions') {
// Must be in .github/
pathsToTry = [`.github/${filename}`];
} else if (sourceFormat === 'agents.md') {
// Can be in root or subdirectories
pathsToTry = [basePath];
if (skillPath) {
pathsToTry.push(filename); // Also try root
}
} else {
// SKILL.md - try multiple common paths
pathsToTry = [
basePath,
...(skillPath && !skillPath.startsWith('skills/') ? [`skills/${skillPath}/SKILL.md`] : []),
...(skillPath && !skillPath.startsWith('.claude/') ? [`.claude/skills/${skillPath}/SKILL.md`] : []),
...(skillPath && !skillPath.startsWith('.github/') ? [`.github/skills/${skillPath}/SKILL.md`] : []),
];
}
for (const pathToTry of pathsToTry) {
try {
skillMdResponse = await client.repos.getContent({
owner,
repo,
path: pathToTry,
ref: branch,
});
// Success! Break out of loop
break;
} catch (error: any) {
// If it's a timeout or network error, try raw.githubusercontent.com fallback
if (error.message?.includes('timeout') || error.message?.includes('network')) {
try {
const rawContent = await fetchRawFile(owner, repo, pathToTry, branch);
// Create a mock response compatible with Octokit
skillMdResponse = {
data: {
content: Buffer.from(rawContent).toString('base64'),
encoding: 'base64' as const,
}
} as any;
break;
} catch (rawError) {
// If raw fetch also fails, throw original error
throw new Error(`GitHub API timeout. Try using --no-api flag or check your network connection.`);
}
}
// If 404, try next path
if (error.status === 404) {
continue;
}
// Other errors, throw immediately
throw new Error(`Failed to fetch from GitHub: ${error.message}`);
}
}
if (!skillMdResponse) {
throw new Error(`${filename} not found at ${owner}/${repo} (tried ${pathsToTry.length} paths)`);
}
if (!('content' in skillMdResponse.data)) {
throw new Error(`${filename} not found`);
}
const skillMd = Buffer.from(skillMdResponse.data.content, 'base64').toString('utf-8');
// For standalone formats, skip scripts/references (they don't have subdirectories)
if (isStandalone) {
return {
skillMd,
scripts: [],
references: [],
assets: [],
};
}
// Fetch scripts
const scripts: SkillContent['scripts'] = [];
try {
const scriptsPath = skillPath ? `${skillPath}/scripts` : 'scripts';
const scriptsResponse = await client.repos.getContent({
owner,
repo,
path: scriptsPath,
ref: branch,
});
if (Array.isArray(scriptsResponse.data)) {
for (const file of scriptsResponse.data) {
if (file.type === 'file') {
const fileResponse = await client.repos.getContent({
owner,
repo,
path: file.path,
ref: branch,
});
if ('content' in fileResponse.data) {
scripts.push({
name: file.name,
content: Buffer.from(fileResponse.data.content, 'base64').toString('utf-8'),
});
}
}
}
}
} catch {
// No scripts directory
}
// Fetch references
const references: SkillContent['references'] = [];
try {
const refsPath = skillPath ? `${skillPath}/references` : 'references';
const refsResponse = await client.repos.getContent({
owner,
repo,
path: refsPath,
ref: branch,
});
if (Array.isArray(refsResponse.data)) {
for (const file of refsResponse.data) {
if (file.type === 'file' && file.size && file.size < 100000) {
const fileResponse = await client.repos.getContent({
owner,
repo,
path: file.path,
ref: branch,
});
if ('content' in fileResponse.data) {
references.push({
name: file.name,
content: Buffer.from(fileResponse.data.content, 'base64').toString('utf-8'),
});
}
}
}
}
} catch {
// No references directory
}
return {
skillMd,
scripts,
references,
assets: [], // TODO: handle binary assets
};
}
/**
* Get default branch for a repository
*/
export async function getDefaultBranch(owner: string, repo: string): Promise<string> {
const client = getOctokit();
const response = await client.repos.get({ owner, repo });
return response.data.default_branch;
}

View File

@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock fs-extra before importing paths
vi.mock('fs-extra', () => ({
default: {
pathExists: vi.fn(),
ensureDir: vi.fn(),
readJson: vi.fn(),
writeJson: vi.fn(),
},
}));
import fs from 'fs-extra';
import {
getSkillsPath,
getSkillPath,
ensureSkillsDir,
isSkillInstalled,
getConfigPath,
loadConfig,
saveConfig,
isFlatFilePlatform,
getPlatformFilePath,
} from './paths.js';
describe('Path Utils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getSkillsPath', () => {
it('should return path ending with .claude/skills for claude', () => {
const result = getSkillsPath('claude');
expect(result).toContain('.claude');
expect(result).toContain('skills');
});
it('should return path ending with .codex/skills for codex', () => {
const result = getSkillsPath('codex');
expect(result).toContain('.codex');
expect(result).toContain('skills');
});
it('should return path ending with .github/instructions for copilot', () => {
const result = getSkillsPath('copilot');
expect(result).toContain('.github');
expect(result).toContain('instructions');
});
it('should return path ending with .cursor/rules for cursor', () => {
const result = getSkillsPath('cursor');
expect(result).toContain('.cursor');
expect(result).toContain('rules');
});
it('should return path ending with .windsurf/rules for windsurf', () => {
const result = getSkillsPath('windsurf');
expect(result).toContain('.windsurf');
expect(result).toContain('rules');
});
it('should return different path for user vs project', () => {
const userPath = getSkillsPath('claude', false);
const projectPath = getSkillsPath('claude', true);
expect(userPath).not.toBe(projectPath);
});
});
describe('getSkillPath', () => {
it('should append skill name to base path', () => {
const result = getSkillPath('claude', 'my-skill');
expect(result).toContain('my-skill');
expect(result).toContain('.claude');
});
it('should handle project path', () => {
const result = getSkillPath('codex', 'my-skill', true);
expect(result).toContain('my-skill');
expect(result).toContain('.codex');
});
});
describe('ensureSkillsDir', () => {
it('should call fs.ensureDir', async () => {
vi.mocked(fs.ensureDir).mockResolvedValue(undefined);
const result = await ensureSkillsDir('claude');
expect(fs.ensureDir).toHaveBeenCalled();
expect(result).toContain('.claude');
});
});
describe('isSkillInstalled', () => {
it('should return true when skill exists', async () => {
vi.mocked(fs.pathExists).mockResolvedValue(true as never);
const result = await isSkillInstalled('claude', 'my-skill');
expect(result).toBe(true);
expect(fs.pathExists).toHaveBeenCalled();
});
it('should return false when skill does not exist', async () => {
vi.mocked(fs.pathExists).mockResolvedValue(false as never);
const result = await isSkillInstalled('claude', 'my-skill');
expect(result).toBe(false);
});
});
describe('getConfigPath', () => {
it('should return path ending with .skillhub/config.json', () => {
const result = getConfigPath();
expect(result).toContain('.skillhub');
expect(result).toContain('config.json');
});
});
describe('loadConfig', () => {
it('should load config from file when exists', async () => {
const mockConfig = { defaultPlatform: 'claude' };
vi.mocked(fs.pathExists).mockResolvedValue(true as never);
vi.mocked(fs.readJson).mockResolvedValue(mockConfig as never);
const result = await loadConfig();
expect(result).toEqual(mockConfig);
expect(fs.readJson).toHaveBeenCalled();
});
it('should return empty object when config does not exist', async () => {
vi.mocked(fs.pathExists).mockResolvedValue(false as never);
const result = await loadConfig();
expect(result).toEqual({});
expect(fs.readJson).not.toHaveBeenCalled();
});
});
describe('saveConfig', () => {
it('should save config to file', async () => {
vi.mocked(fs.ensureDir).mockResolvedValue(undefined);
vi.mocked(fs.writeJson).mockResolvedValue(undefined);
await saveConfig({ defaultPlatform: 'claude' });
expect(fs.ensureDir).toHaveBeenCalled();
expect(fs.writeJson).toHaveBeenCalledWith(
expect.stringContaining('config.json'),
{ defaultPlatform: 'claude' },
{ spaces: 2 }
);
});
});
describe('isFlatFilePlatform', () => {
it('returns false for claude', () => {
expect(isFlatFilePlatform('claude')).toBe(false);
});
it('returns false for codex', () => {
expect(isFlatFilePlatform('codex')).toBe(false);
});
it('returns true for cursor', () => {
expect(isFlatFilePlatform('cursor')).toBe(true);
});
it('returns true for windsurf', () => {
expect(isFlatFilePlatform('windsurf')).toBe(true);
});
it('returns true for copilot', () => {
expect(isFlatFilePlatform('copilot')).toBe(true);
});
});
describe('getPlatformFilePath', () => {
it('returns file inside skill subdirectory for claude', () => {
const result = getPlatformFilePath('claude', 'my-skill', 'SKILL.md');
expect(result).toContain('my-skill');
expect(result).toContain('SKILL.md');
});
it('returns file in flat directory for cursor', () => {
const result = getPlatformFilePath('cursor', 'my-skill', 'my-skill.mdc');
expect(result).toContain('rules');
expect(result).toContain('my-skill.mdc');
// Should not have skill name as a directory segment
expect(result).not.toMatch(/rules[/\\]my-skill[/\\]my-skill\.mdc/);
});
it('returns file in flat directory for windsurf', () => {
const result = getPlatformFilePath('windsurf', 'my-skill', 'my-skill.md');
expect(result).toContain('rules');
expect(result).toContain('my-skill.md');
});
it('returns file in flat directory for copilot', () => {
const result = getPlatformFilePath('copilot', 'my-skill', 'my-skill.instructions.md');
expect(result).toContain('instructions');
expect(result).toContain('my-skill.instructions.md');
});
});
});

152
apps/cli/src/utils/paths.ts Normal file
View File

@@ -0,0 +1,152 @@
import * as os from 'os';
import * as path from 'path';
import fs from 'fs-extra';
export type Platform = 'claude' | 'codex' | 'copilot' | 'cursor' | 'windsurf';
const PLATFORM_PATHS: Record<Platform, { user: string; project: string }> = {
claude: {
user: '.claude/skills',
project: '.claude/skills',
},
codex: {
user: '.codex/skills',
project: '.codex/skills',
},
copilot: {
user: '.github/instructions',
project: '.github/instructions',
},
cursor: {
user: '.cursor/rules',
project: '.cursor/rules',
},
windsurf: {
user: '.windsurf/rules',
project: '.windsurf/rules',
},
};
const FLAT_FILE_PLATFORMS: Platform[] = ['cursor', 'windsurf', 'copilot'];
/**
* Whether this platform uses a flat file structure
* (skill file in base dir, tracking data in subdirectory)
*/
export function isFlatFilePlatform(platform: Platform): boolean {
return FLAT_FILE_PLATFORMS.includes(platform);
}
/**
* Get the path where the platform-specific skill file should be written.
* For flat-file platforms, this is the base directory + fileName.
* For subdirectory platforms, this is the skill's subdirectory + fileName.
*/
export function getPlatformFilePath(
platform: Platform,
skillName: string,
fileName: string,
project = false
): string {
const basePath = getSkillsPath(platform, project);
if (isFlatFilePlatform(platform)) {
return path.join(basePath, fileName);
}
return path.join(basePath, skillName, fileName);
}
/**
* Get the skills directory path for a platform
*/
export function getSkillsPath(platform: Platform, project = false): string {
const home = os.homedir();
const cwd = process.cwd();
const paths = PLATFORM_PATHS[platform];
if (project) {
return path.join(cwd, paths.project);
}
return path.join(home, paths.user);
}
/**
* Get the path for a specific skill
*/
export function getSkillPath(platform: Platform, skillName: string, project = false): string {
const basePath = getSkillsPath(platform, project);
return path.join(basePath, skillName);
}
/**
* Ensure the skills directory exists
*/
export async function ensureSkillsDir(platform: Platform, project = false): Promise<string> {
const skillsPath = getSkillsPath(platform, project);
await fs.ensureDir(skillsPath);
return skillsPath;
}
/**
* Check if a skill is already installed
*/
export async function isSkillInstalled(
platform: Platform,
skillName: string,
project = false
): Promise<boolean> {
const skillPath = getSkillPath(platform, skillName, project);
return fs.pathExists(skillPath);
}
/**
* Detect which platform is being used in the current directory
*/
export async function detectPlatform(): Promise<Platform | null> {
const cwd = process.cwd();
for (const platform of Object.keys(PLATFORM_PATHS) as Platform[]) {
const configPath = path.join(cwd, `.${platform}`);
if (await fs.pathExists(configPath)) {
return platform;
}
}
// Check for common config files
if (await fs.pathExists(path.join(cwd, '.github'))) {
return 'copilot';
}
return null;
}
/**
* Get config file path
*/
export function getConfigPath(): string {
const home = os.homedir();
return path.join(home, '.skillhub', 'config.json');
}
/**
* Load CLI config
*/
export async function loadConfig(): Promise<Record<string, unknown>> {
const configPath = getConfigPath();
if (await fs.pathExists(configPath)) {
return fs.readJson(configPath);
}
return {};
}
/**
* Save CLI config
*/
export async function saveConfig(config: Record<string, unknown>): Promise<void> {
const configPath = getConfigPath();
await fs.ensureDir(path.dirname(configPath));
await fs.writeJson(configPath, config, { spaces: 2 });
}

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('fs-extra', () => ({
default: {
pathExists: vi.fn(),
ensureDir: vi.fn(),
readJson: vi.fn(),
writeJson: vi.fn(),
},
}));
import { getSkillsPath, type Platform } from './paths.js';
const ALL_PLATFORMS: Platform[] = ['claude', 'codex', 'copilot', 'cursor', 'windsurf'];
describe('Platform Configuration Completeness', () => {
describe('All platforms have path configuration', () => {
ALL_PLATFORMS.forEach((platform) => {
it(`should have user path for ${platform}`, () => {
const userPath = getSkillsPath(platform, false);
expect(userPath).toBeTruthy();
});
it(`should have project path for ${platform}`, () => {
const projectPath = getSkillsPath(platform, true);
expect(projectPath).toBeTruthy();
});
});
});
describe('Platform paths are unique', () => {
it('should have different user paths for each platform', () => {
const paths = ALL_PLATFORMS.map((p) => getSkillsPath(p, false));
const uniquePaths = new Set(paths);
expect(uniquePaths.size).toBe(ALL_PLATFORMS.length);
});
it('should have different project paths for each platform', () => {
const paths = ALL_PLATFORMS.map((p) => getSkillsPath(p, true));
const uniquePaths = new Set(paths);
expect(uniquePaths.size).toBe(ALL_PLATFORMS.length);
});
});
describe('Platform paths match expected patterns', () => {
it('claude should use .claude/skills', () => {
expect(getSkillsPath('claude', false)).toContain('.claude');
});
it('codex should use .codex/skills', () => {
expect(getSkillsPath('codex', false)).toContain('.codex');
});
it('copilot should use .github/instructions', () => {
const p = getSkillsPath('copilot', false);
expect(p).toContain('.github');
expect(p).toContain('instructions');
});
it('cursor should use .cursor/rules', () => {
const p = getSkillsPath('cursor', false);
expect(p).toContain('.cursor');
expect(p).toContain('rules');
});
it('windsurf should use .windsurf/rules', () => {
const p = getSkillsPath('windsurf', false);
expect(p).toContain('.windsurf');
expect(p).toContain('rules');
});
});
});

View File

@@ -0,0 +1,197 @@
import { describe, it, expect } from 'vitest';
import { getPlatformFileName, transformForPlatform, shouldKeepOriginal } from './transform.js';
import type { ParsedSkill } from 'skillhub-core';
function makeParsed(overrides: Partial<ParsedSkill['metadata']> = {}, body = '# Test Skill\n\nThis is a test skill.\n\n## Usage\n\nUse this skill for testing.'): ParsedSkill {
return {
metadata: {
name: 'test-skill',
description: 'A comprehensive test skill',
version: '1.0.0',
...overrides,
},
content: body,
resources: { scripts: [], references: [], assets: [] },
validation: { isValid: true, errors: [], warnings: [] },
rawFrontmatter: { name: 'test-skill', description: 'A comprehensive test skill', version: '1.0.0', ...overrides },
} as ParsedSkill;
}
const RAW_SKILL_MD = `---
name: test-skill
description: A comprehensive test skill
version: 1.0.0
---
# Test Skill
This is a test skill.
## Usage
Use this skill for testing.
`;
describe('getPlatformFileName', () => {
it('returns SKILL.md for claude', () => {
expect(getPlatformFileName('claude', 'my-skill')).toBe('SKILL.md');
});
it('returns SKILL.md for codex', () => {
expect(getPlatformFileName('codex', 'my-skill')).toBe('SKILL.md');
});
it('returns .mdc for cursor', () => {
expect(getPlatformFileName('cursor', 'my-skill')).toBe('my-skill.mdc');
});
it('returns .md for windsurf', () => {
expect(getPlatformFileName('windsurf', 'my-skill')).toBe('my-skill.md');
});
it('returns .instructions.md for copilot', () => {
expect(getPlatformFileName('copilot', 'my-skill')).toBe('my-skill.instructions.md');
});
});
describe('shouldKeepOriginal', () => {
it('returns false for claude', () => {
expect(shouldKeepOriginal('claude')).toBe(false);
});
it('returns false for codex', () => {
expect(shouldKeepOriginal('codex')).toBe(false);
});
it('returns true for cursor', () => {
expect(shouldKeepOriginal('cursor')).toBe(true);
});
it('returns true for windsurf', () => {
expect(shouldKeepOriginal('windsurf')).toBe(true);
});
it('returns true for copilot', () => {
expect(shouldKeepOriginal('copilot')).toBe(true);
});
});
describe('transformForPlatform', () => {
describe('claude', () => {
it('returns content unchanged', () => {
const parsed = makeParsed();
const result = transformForPlatform('claude', RAW_SKILL_MD, parsed);
expect(result.content).toBe(RAW_SKILL_MD);
expect(result.warnings).toHaveLength(0);
});
});
describe('codex', () => {
it('returns content unchanged', () => {
const parsed = makeParsed();
const result = transformForPlatform('codex', RAW_SKILL_MD, parsed);
expect(result.content).toBe(RAW_SKILL_MD);
expect(result.warnings).toHaveLength(0);
});
});
describe('cursor (MDC format)', () => {
it('produces MDC with description and alwaysApply', () => {
const parsed = makeParsed();
const result = transformForPlatform('cursor', RAW_SKILL_MD, parsed);
expect(result.content).toContain('---');
expect(result.content).toContain('description: A comprehensive test skill');
expect(result.content).toContain('alwaysApply: true');
expect(result.warnings).toHaveLength(0);
});
it('maps triggers.filePatterns to globs', () => {
const parsed = makeParsed({
triggers: { filePatterns: ['*.tsx', '*.jsx'] },
});
const result = transformForPlatform('cursor', RAW_SKILL_MD, parsed);
expect(result.content).toContain('globs: *.tsx, *.jsx');
expect(result.content).toContain('alwaysApply: false');
});
it('sets alwaysApply true when no triggers', () => {
const parsed = makeParsed({ triggers: undefined });
const result = transformForPlatform('cursor', RAW_SKILL_MD, parsed);
expect(result.content).toContain('alwaysApply: true');
expect(result.content).not.toContain('globs:');
});
it('strips original YAML frontmatter fields', () => {
const parsed = makeParsed();
const result = transformForPlatform('cursor', RAW_SKILL_MD, parsed);
expect(result.content).not.toContain('version: 1.0.0');
expect(result.content).not.toContain('name: test-skill');
});
it('preserves markdown body', () => {
const parsed = makeParsed();
const result = transformForPlatform('cursor', RAW_SKILL_MD, parsed);
expect(result.content).toContain('# Test Skill');
expect(result.content).toContain('## Usage');
});
});
describe('windsurf', () => {
it('strips YAML frontmatter', () => {
const parsed = makeParsed();
const result = transformForPlatform('windsurf', RAW_SKILL_MD, parsed);
expect(result.content).not.toContain('---');
expect(result.content).not.toContain('version:');
expect(result.content).toContain('# Test Skill');
});
it('adds heading when body does not start with one', () => {
const parsed = makeParsed({}, 'Some content without heading.');
const result = transformForPlatform('windsurf', RAW_SKILL_MD, parsed);
expect(result.content).toMatch(/^# test-skill\n/);
});
it('does not add heading when body starts with one', () => {
const parsed = makeParsed();
const result = transformForPlatform('windsurf', RAW_SKILL_MD, parsed);
expect(result.content).not.toMatch(/^# test-skill\n/);
expect(result.content).toMatch(/^# Test Skill\n/);
});
it('truncates and warns when content exceeds 6K limit', () => {
const longBody = '# Long Skill\n\n' + 'Lorem ipsum dolor sit amet. '.repeat(300);
const parsed = makeParsed({}, longBody);
const result = transformForPlatform('windsurf', RAW_SKILL_MD, parsed);
expect(result.warnings.length).toBeGreaterThan(0);
expect(result.warnings[0]).toContain('6000');
expect(result.content.length).toBeLessThanOrEqual(6000);
});
it('no warning when content is under limit', () => {
const parsed = makeParsed();
const result = transformForPlatform('windsurf', RAW_SKILL_MD, parsed);
expect(result.warnings).toHaveLength(0);
});
});
describe('copilot', () => {
it('strips YAML frontmatter', () => {
const parsed = makeParsed();
const result = transformForPlatform('copilot', RAW_SKILL_MD, parsed);
expect(result.content).not.toContain('---');
expect(result.content).toContain('# Test Skill');
});
it('adds heading when body does not start with one', () => {
const parsed = makeParsed({}, 'Some content without heading.');
const result = transformForPlatform('copilot', RAW_SKILL_MD, parsed);
expect(result.content).toMatch(/^# test-skill\n/);
});
it('returns no warnings', () => {
const parsed = makeParsed();
const result = transformForPlatform('copilot', RAW_SKILL_MD, parsed);
expect(result.warnings).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,150 @@
import type { Platform } from './paths.js';
import type { ParsedSkill } from 'skillhub-core';
interface TransformResult {
content: string;
warnings: string[];
}
interface PlatformFileConfig {
getFileName: (skillName: string) => string;
keepOriginal: boolean;
transform: (rawSkillMd: string, parsed: ParsedSkill) => TransformResult;
}
const WINDSURF_CHAR_LIMIT = 6000;
const PLATFORM_FILE_CONFIGS: Record<Platform, PlatformFileConfig> = {
claude: {
getFileName: () => 'SKILL.md',
keepOriginal: false,
transform: (raw) => ({ content: raw, warnings: [] }),
},
codex: {
getFileName: () => 'SKILL.md',
keepOriginal: false,
transform: (raw) => ({ content: raw, warnings: [] }),
},
cursor: {
getFileName: (skillName) => `${skillName}.mdc`,
keepOriginal: true,
transform: transformForCursor,
},
windsurf: {
getFileName: (skillName) => `${skillName}.md`,
keepOriginal: true,
transform: transformForWindsurf,
},
copilot: {
getFileName: (skillName) => `${skillName}.instructions.md`,
keepOriginal: true,
transform: transformForCopilot,
},
};
/**
* Get the platform-specific output filename
*/
export function getPlatformFileName(platform: Platform, skillName: string): string {
return PLATFORM_FILE_CONFIGS[platform].getFileName(skillName);
}
/**
* Transform SKILL.md content for a target platform
*/
export function transformForPlatform(
platform: Platform,
rawSkillMd: string,
parsed: ParsedSkill
): TransformResult {
return PLATFORM_FILE_CONFIGS[platform].transform(rawSkillMd, parsed);
}
/**
* Whether the platform needs the original SKILL.md kept alongside
*/
export function shouldKeepOriginal(platform: Platform): boolean {
return PLATFORM_FILE_CONFIGS[platform].keepOriginal;
}
/**
* Convert SKILL.md to Cursor's MDC format
* MDC uses its own frontmatter: description, globs, alwaysApply
*/
function transformForCursor(_raw: string, parsed: ParsedSkill): TransformResult {
const warnings: string[] = [];
const mdcFields: string[] = [];
if (parsed.metadata.description) {
mdcFields.push(`description: ${parsed.metadata.description}`);
}
const filePatterns = parsed.metadata.triggers?.filePatterns;
if (filePatterns && filePatterns.length > 0) {
mdcFields.push(`globs: ${filePatterns.join(', ')}`);
mdcFields.push('alwaysApply: false');
} else {
mdcFields.push('alwaysApply: true');
}
const body = parsed.content.trim();
const mdcContent = `---\n${mdcFields.join('\n')}\n---\n${body}\n`;
return { content: mdcContent, warnings };
}
/**
* Convert SKILL.md to Windsurf format: plain markdown, 6K char limit
*/
function transformForWindsurf(_raw: string, parsed: ParsedSkill): TransformResult {
const warnings: string[] = [];
let body = parsed.content.trim();
if (!body.startsWith('# ')) {
body = `# ${parsed.metadata.name}\n\n${body}`;
}
if (body.length > WINDSURF_CHAR_LIMIT) {
warnings.push(
`Content exceeds Windsurf's ${WINDSURF_CHAR_LIMIT} character limit (${body.length} chars). Truncating.`
);
body = truncateAtSectionBoundary(body, WINDSURF_CHAR_LIMIT);
}
return { content: body + '\n', warnings };
}
/**
* Convert SKILL.md to Copilot format: plain markdown, no frontmatter
*/
function transformForCopilot(_raw: string, parsed: ParsedSkill): TransformResult {
let body = parsed.content.trim();
if (!body.startsWith('# ')) {
body = `# ${parsed.metadata.name}\n\n${body}`;
}
return { content: body + '\n', warnings: [] };
}
function truncateAtSectionBoundary(content: string, limit: number): string {
const notice = '\n\n<!-- Truncated by SkillHub: see SKILL.md for full content -->\n';
const maxLen = limit - notice.length;
if (content.length <= maxLen) return content;
const truncated = content.slice(0, maxLen);
const lastHeading = truncated.lastIndexOf('\n## ');
const lastH1 = truncated.lastIndexOf('\n# ');
const cutPoint = Math.max(lastHeading, lastH1);
if (cutPoint > 0) {
return truncated.slice(0, cutPoint) + notice;
}
const lastParagraph = truncated.lastIndexOf('\n\n');
if (lastParagraph > 0) {
return truncated.slice(0, lastParagraph) + notice;
}
return truncated + notice;
}

9
apps/cli/tsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

16
apps/cli/tsup.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
target: 'node20',
clean: true,
// Bundle skillhub-core into the CLI so the published package
// doesn't depend on a separate npm package that may be outdated
noExternal: ['skillhub-core'],
// gray-matter (dep of skillhub-core) uses CJS require('fs') which
// breaks in ESM bundles - provide a require shim for Node builtins
banner: {
js: `import { createRequire as __createRequire } from 'module'; const require = __createRequire(import.meta.url);`,
},
});

15
apps/cli/vitest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/index.ts'],
},
},
});