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:
121
apps/cli/README.md
Normal file
121
apps/cli/README.md
Normal 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
61
apps/cli/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
79
apps/cli/src/commands/config.ts
Normal file
79
apps/cli/src/commands/config.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import chalk from 'chalk';
|
||||
import { loadConfig, saveConfig, getConfigPath } from '../utils/paths.js';
|
||||
|
||||
interface ConfigOptions {
|
||||
set?: string;
|
||||
get?: string;
|
||||
list?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage CLI configuration
|
||||
*/
|
||||
export async function config(options: ConfigOptions): Promise<void> {
|
||||
const currentConfig = await loadConfig();
|
||||
|
||||
// List all config
|
||||
if (options.list || (!options.set && !options.get)) {
|
||||
console.log(chalk.bold('SkillHub CLI Configuration:\n'));
|
||||
console.log(chalk.dim(`Config file: ${getConfigPath()}\n`));
|
||||
|
||||
if (Object.keys(currentConfig).length === 0) {
|
||||
console.log(chalk.yellow('No configuration set.'));
|
||||
console.log(chalk.dim('\nAvailable settings:'));
|
||||
console.log(chalk.dim(' defaultPlatform - Default platform for installations (claude, codex, copilot)'));
|
||||
console.log(chalk.dim(' apiUrl - SkillHub API URL'));
|
||||
console.log(chalk.dim(' githubToken - GitHub personal access token for private repos'));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(currentConfig)) {
|
||||
const displayValue = key.toLowerCase().includes('token')
|
||||
? maskSecret(String(value))
|
||||
: String(value);
|
||||
console.log(` ${chalk.cyan(key)}: ${displayValue}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get a config value
|
||||
if (options.get) {
|
||||
const value = currentConfig[options.get];
|
||||
if (value === undefined) {
|
||||
console.log(chalk.yellow(`Config '${options.get}' is not set.`));
|
||||
return;
|
||||
}
|
||||
|
||||
const displayValue = options.get.toLowerCase().includes('token')
|
||||
? maskSecret(String(value))
|
||||
: String(value);
|
||||
console.log(displayValue);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set a config value
|
||||
if (options.set) {
|
||||
const [key, ...valueParts] = options.set.split('=');
|
||||
const value = valueParts.join('=');
|
||||
|
||||
if (!key || value === undefined) {
|
||||
console.error(chalk.red('Invalid format. Use: --set key=value'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
currentConfig[key] = value;
|
||||
await saveConfig(currentConfig);
|
||||
|
||||
const displayValue = key.toLowerCase().includes('token')
|
||||
? maskSecret(value)
|
||||
: value;
|
||||
console.log(chalk.green(`Set ${chalk.cyan(key)} = ${displayValue}`));
|
||||
}
|
||||
}
|
||||
|
||||
function maskSecret(value: string): string {
|
||||
if (value.length <= 8) {
|
||||
return '*'.repeat(value.length);
|
||||
}
|
||||
return value.slice(0, 4) + '*'.repeat(value.length - 8) + value.slice(-4);
|
||||
}
|
||||
391
apps/cli/src/commands/install.ts
Normal file
391
apps/cli/src/commands/install.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { parseSkillMd, parseGenericInstructionFile, INSTRUCTION_FILE_PATTERNS, type SourceFormat } from 'skillhub-core';
|
||||
import { getSkillPath, ensureSkillsDir, isSkillInstalled, isFlatFilePlatform, getPlatformFilePath, type Platform } from '../utils/paths.js';
|
||||
import { getSkill, trackInstall, getSkillFiles, type SkillFilesResponse } from '../utils/api.js';
|
||||
import { fetchSkillContent, getDefaultBranch, type SkillContent } from '../utils/github.js';
|
||||
import { getPlatformFileName, transformForPlatform, shouldKeepOriginal } from '../utils/transform.js';
|
||||
|
||||
interface InstallOptions {
|
||||
platform: Platform;
|
||||
project?: boolean;
|
||||
force?: boolean;
|
||||
noApi?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a skill from the registry
|
||||
*/
|
||||
export async function install(skillId: string, options: InstallOptions): Promise<void> {
|
||||
const spinner = ora('Parsing skill ID...').start();
|
||||
|
||||
try {
|
||||
// Parse skill ID
|
||||
const parts = skillId.split('/');
|
||||
if (parts.length < 2) {
|
||||
spinner.fail('Invalid skill ID format. Use: owner/repo or owner/repo/skill-name');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [owner, repo, ...rest] = parts;
|
||||
let skillPath = rest.join('/');
|
||||
|
||||
// Try to get skill info from API (unless --no-api)
|
||||
let skillInfo;
|
||||
if (!options.noApi) {
|
||||
spinner.text = `Connecting to ${process.env.SKILLHUB_API_URL || 'https://skills.palebluedot.live'}...`;
|
||||
try {
|
||||
skillInfo = await getSkill(skillId);
|
||||
if (skillInfo) {
|
||||
spinner.succeed(`Found in registry: ${skillInfo.name}`);
|
||||
spinner.start('Preparing installation...');
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.warn(`API unavailable: ${(error as Error).message}`);
|
||||
spinner.start('Falling back to direct GitHub fetch...');
|
||||
}
|
||||
} else {
|
||||
spinner.text = 'Skipping API lookup (--no-api flag)';
|
||||
}
|
||||
|
||||
let skillName: string;
|
||||
let branch = 'main';
|
||||
let sourceFormat: SourceFormat = 'skill.md';
|
||||
|
||||
if (skillInfo) {
|
||||
skillName = skillInfo.name;
|
||||
// Use the actual skillPath from database (e.g., 'skills/nuxt-ui' not 'nuxt-ui')
|
||||
skillPath = skillInfo.skillPath;
|
||||
branch = skillInfo.branch || 'main';
|
||||
sourceFormat = (skillInfo.sourceFormat as SourceFormat) || 'skill.md';
|
||||
spinner.text = `Found skill: ${chalk.cyan(skillName)}`;
|
||||
} else {
|
||||
// Fall back to fetching directly from GitHub
|
||||
spinner.text = 'Skill not in registry, fetching from GitHub...';
|
||||
try {
|
||||
branch = await getDefaultBranch(owner, repo);
|
||||
skillName = skillPath || repo;
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to connect to GitHub');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already installed
|
||||
const installed = await isSkillInstalled(options.platform, skillName, options.project);
|
||||
if (installed && !options.force) {
|
||||
spinner.fail(
|
||||
`Skill ${chalk.cyan(skillName)} is already installed. Use ${chalk.yellow('--force')} to overwrite.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure skills directory exists
|
||||
await ensureSkillsDir(options.platform, options.project);
|
||||
|
||||
// Fetch skill content - try API first, fall back to GitHub
|
||||
spinner.text = `Downloading ${skillInfo?.name || skillId}...`;
|
||||
let content: SkillContent | undefined;
|
||||
let apiWasReachable = false;
|
||||
|
||||
// Try API first (unless --no-api)
|
||||
if (!options.noApi && skillInfo) {
|
||||
apiWasReachable = true; // API responded to getSkill, so it's reachable
|
||||
try {
|
||||
spinner.text = 'Downloading skill files...';
|
||||
const cachedFiles = await getSkillFiles(skillInfo.id);
|
||||
|
||||
if (cachedFiles && cachedFiles.files.length > 0) {
|
||||
// Use sourceFormat from API response if available
|
||||
if (cachedFiles.sourceFormat) {
|
||||
sourceFormat = cachedFiles.sourceFormat as SourceFormat;
|
||||
}
|
||||
// Convert API response to SkillContent format
|
||||
content = convertCachedFilesToSkillContent(cachedFiles, sourceFormat);
|
||||
spinner.text = cachedFiles.fromCache
|
||||
? `Using cached files (${cachedFiles.files.length} files)`
|
||||
: `Downloaded ${cachedFiles.files.length} files via API`;
|
||||
}
|
||||
} catch {
|
||||
// API was reachable but file fetch failed (timeout, server error, etc.)
|
||||
spinner.text = 'API file fetch failed, falling back to GitHub...';
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to direct GitHub fetch only if:
|
||||
// - API was not used (--no-api or skill not in registry)
|
||||
// - OR API file fetch returned empty/null (not a timeout - timeout means server is working on it)
|
||||
if (!content) {
|
||||
if (apiWasReachable) {
|
||||
// API was reachable but couldn't provide files - still try GitHub as last resort
|
||||
spinner.text = `Falling back to GitHub: ${owner}/${repo}/${skillPath || ''}...`;
|
||||
} else {
|
||||
spinner.text = `Downloading from GitHub: ${owner}/${repo}/${skillPath || ''}...`;
|
||||
}
|
||||
try {
|
||||
content = await fetchSkillContent(owner, repo, skillPath, branch, sourceFormat);
|
||||
spinner.text = `Downloaded ${content.scripts.length} scripts, ${content.references.length} references`;
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to download skill files');
|
||||
console.error(chalk.red((error as Error).message));
|
||||
console.log();
|
||||
console.log(chalk.yellow('Troubleshooting tips:'));
|
||||
console.log(chalk.dim(' 1. Check your internet connection'));
|
||||
if (apiWasReachable) {
|
||||
console.log(chalk.dim(' 2. The API server could not fetch files either - try again in a minute'));
|
||||
console.log(chalk.dim(' 3. The server may be caching the files now - retry shortly'));
|
||||
} else {
|
||||
console.log(chalk.dim(' 2. If behind a proxy, configure HTTP_PROXY/HTTPS_PROXY environment variables'));
|
||||
}
|
||||
console.log(chalk.dim(` ${apiWasReachable ? '4' : '3'}. Set GITHUB_TOKEN environment variable for higher rate limits`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure content is available (TypeScript narrowing)
|
||||
if (!content) {
|
||||
spinner.fail('Failed to download skill content');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse and validate (format-aware)
|
||||
const parsed = sourceFormat === 'skill.md'
|
||||
? parseSkillMd(content.skillMd)
|
||||
: parseGenericInstructionFile(content.skillMd, sourceFormat, {
|
||||
name: skillName,
|
||||
description: skillInfo?.description || null,
|
||||
owner,
|
||||
});
|
||||
if (!parsed.validation.isValid) {
|
||||
spinner.warn('Skill has validation issues:');
|
||||
for (const error of parsed.validation.errors) {
|
||||
console.log(chalk.yellow(` - ${error.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Get the actual skill name from metadata
|
||||
const actualName = parsed.metadata.name || skillName;
|
||||
const installPath = getSkillPath(options.platform, actualName, options.project);
|
||||
|
||||
// Check for name collision with different skill
|
||||
const metadataPath = path.join(installPath, '.skillhub.json');
|
||||
if (await fs.pathExists(metadataPath)) {
|
||||
try {
|
||||
const existingMetadata = await fs.readJson(metadataPath);
|
||||
if (existingMetadata.skillId && existingMetadata.skillId !== skillId) {
|
||||
spinner.warn(`Name collision detected!`);
|
||||
console.log(chalk.yellow(`\nA different skill is already installed with the name "${actualName}":`));
|
||||
console.log(chalk.dim(` Existing: ${existingMetadata.skillId}`));
|
||||
console.log(chalk.dim(` New: ${skillId}`));
|
||||
console.log();
|
||||
|
||||
if (!options.force) {
|
||||
console.log(chalk.red('Installation cancelled to prevent overwriting.'));
|
||||
console.log(chalk.dim('Use --force to overwrite the existing skill.'));
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(chalk.yellow('Overwriting existing skill (--force flag used).\n'));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore metadata read errors
|
||||
}
|
||||
}
|
||||
|
||||
// Remove existing if force
|
||||
if (installed && options.force) {
|
||||
await fs.remove(installPath);
|
||||
}
|
||||
|
||||
// Create skill directory and write files
|
||||
spinner.text = 'Installing skill...';
|
||||
await fs.ensureDir(installPath);
|
||||
|
||||
// Transform content for target platform
|
||||
const platformFileName = getPlatformFileName(options.platform, actualName);
|
||||
const { content: transformedContent, warnings: transformWarnings } =
|
||||
transformForPlatform(options.platform, content.skillMd, parsed);
|
||||
|
||||
for (const warning of transformWarnings) {
|
||||
console.log(chalk.yellow(` Warning: ${warning}`));
|
||||
}
|
||||
|
||||
// Write the platform-specific file
|
||||
if (isFlatFilePlatform(options.platform)) {
|
||||
const platformFilePath = getPlatformFilePath(
|
||||
options.platform, actualName, platformFileName, options.project
|
||||
);
|
||||
await fs.writeFile(platformFilePath, transformedContent);
|
||||
} else {
|
||||
await fs.writeFile(path.join(installPath, platformFileName), transformedContent);
|
||||
}
|
||||
|
||||
// Keep original SKILL.md in tracking directory for re-transformation
|
||||
if (shouldKeepOriginal(options.platform)) {
|
||||
await fs.writeFile(path.join(installPath, 'SKILL.md'), content.skillMd);
|
||||
}
|
||||
|
||||
// Write metadata file for update tracking
|
||||
// Use canonical ID from registry if available for proper tracking
|
||||
const canonicalId = skillInfo?.id || skillId;
|
||||
const platformFilePath = isFlatFilePlatform(options.platform)
|
||||
? getPlatformFilePath(options.platform, actualName, platformFileName, options.project)
|
||||
: null;
|
||||
await fs.writeJson(path.join(installPath, '.skillhub.json'), {
|
||||
skillId: canonicalId,
|
||||
installedAt: new Date().toISOString(),
|
||||
platform: options.platform,
|
||||
version: parsed.metadata.version || null,
|
||||
platformFileName,
|
||||
platformFilePath,
|
||||
});
|
||||
|
||||
// Write scripts
|
||||
if (content.scripts.length > 0) {
|
||||
const scriptsDir = path.join(installPath, 'scripts');
|
||||
await fs.ensureDir(scriptsDir);
|
||||
|
||||
for (const script of content.scripts) {
|
||||
const scriptPath = path.join(scriptsDir, script.name);
|
||||
await fs.writeFile(scriptPath, script.content);
|
||||
await fs.chmod(scriptPath, '755');
|
||||
}
|
||||
}
|
||||
|
||||
// Write references
|
||||
if (content.references.length > 0) {
|
||||
const refsDir = path.join(installPath, 'references');
|
||||
await fs.ensureDir(refsDir);
|
||||
|
||||
for (const ref of content.references) {
|
||||
await fs.writeFile(path.join(refsDir, ref.name), ref.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Track installation using canonical skill ID from registry (if available)
|
||||
// This ensures the tracking matches the database record
|
||||
const trackingId = skillInfo?.id || skillId;
|
||||
await trackInstall(trackingId, options.platform, 'cli');
|
||||
|
||||
spinner.succeed(`Skill ${chalk.green(actualName)} installed successfully!`);
|
||||
|
||||
// Print info
|
||||
console.log();
|
||||
console.log(chalk.dim(`Path: ${installPath}`));
|
||||
console.log();
|
||||
|
||||
if (parsed.metadata.description) {
|
||||
console.log(chalk.dim(parsed.metadata.description));
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(chalk.yellow('Usage:'));
|
||||
console.log(
|
||||
` This skill will be automatically activated when your ${getPlatformName(options.platform)} agent recognizes it's relevant.`
|
||||
);
|
||||
|
||||
const setupInstructions = getPlatformSetupInstructions(options.platform, installPath);
|
||||
if (setupInstructions) {
|
||||
console.log();
|
||||
console.log(chalk.cyan('Next Steps:'));
|
||||
console.log(setupInstructions);
|
||||
}
|
||||
|
||||
if (content.scripts.length > 0) {
|
||||
console.log();
|
||||
console.log(chalk.dim(`Scripts: ${content.scripts.map((s) => s.name).join(', ')}`));
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Installation failed');
|
||||
console.error(chalk.red((error as Error).message));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function getPlatformName(platform: Platform): string {
|
||||
const names: Record<Platform, string> = {
|
||||
claude: 'Claude',
|
||||
codex: 'OpenAI Codex',
|
||||
copilot: 'GitHub Copilot',
|
||||
cursor: 'Cursor',
|
||||
windsurf: 'Windsurf',
|
||||
};
|
||||
return names[platform];
|
||||
}
|
||||
|
||||
function getPlatformSetupInstructions(platform: Platform, installPath: string): string | null {
|
||||
switch (platform) {
|
||||
case 'claude':
|
||||
return chalk.dim(' Skills in .claude/skills/ are automatically discovered by Claude Code.');
|
||||
case 'codex':
|
||||
return chalk.dim(` Reference this skill in your AGENTS.md:\n @import ${installPath}/SKILL.md`);
|
||||
case 'copilot':
|
||||
return chalk.dim(' Instructions in .github/instructions/ are automatically loaded by GitHub Copilot.');
|
||||
case 'cursor':
|
||||
return chalk.dim(' Rules in .cursor/rules/ are automatically loaded by Cursor.');
|
||||
case 'windsurf':
|
||||
return chalk.dim(' Rules in .windsurf/rules/ are automatically loaded by Windsurf.');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All known main instruction file names across platforms
|
||||
*/
|
||||
const MAIN_FILE_NAMES = INSTRUCTION_FILE_PATTERNS.map(p => p.filename);
|
||||
|
||||
/**
|
||||
* Convert cached files API response to SkillContent format.
|
||||
* Detects the main instruction file by name (SKILL.md, AGENTS.md, .cursorrules, etc.)
|
||||
*/
|
||||
function convertCachedFilesToSkillContent(
|
||||
response: SkillFilesResponse,
|
||||
sourceFormat: SourceFormat = 'skill.md'
|
||||
): SkillContent {
|
||||
let skillMd = '';
|
||||
const scripts: SkillContent['scripts'] = [];
|
||||
const references: SkillContent['references'] = [];
|
||||
|
||||
// Find the expected main filename for this format
|
||||
const expectedPattern = INSTRUCTION_FILE_PATTERNS.find(p => p.format === sourceFormat);
|
||||
const expectedFilename = expectedPattern?.filename || 'SKILL.md';
|
||||
|
||||
for (const file of response.files) {
|
||||
// Skip files without content (binary files)
|
||||
if (!file.content) continue;
|
||||
|
||||
// Main instruction file: match expected filename or any known instruction file
|
||||
if (!skillMd && (file.name === expectedFilename || MAIN_FILE_NAMES.includes(file.name)) &&
|
||||
file.path === file.name) {
|
||||
skillMd = file.content;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scripts folder
|
||||
if (file.path.startsWith('scripts/')) {
|
||||
scripts.push({
|
||||
name: file.name,
|
||||
content: file.content,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// References folder
|
||||
if (file.path.startsWith('references/')) {
|
||||
references.push({
|
||||
name: file.name,
|
||||
content: file.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
skillMd,
|
||||
scripts,
|
||||
references,
|
||||
assets: [],
|
||||
};
|
||||
}
|
||||
177
apps/cli/src/commands/list.ts
Normal file
177
apps/cli/src/commands/list.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import { parseSkillMd } from 'skillhub-core';
|
||||
import { getSkillsPath, type Platform } from '../utils/paths.js';
|
||||
|
||||
interface ListOptions {
|
||||
platform?: Platform;
|
||||
project?: boolean;
|
||||
all?: boolean;
|
||||
}
|
||||
|
||||
const ALL_PLATFORMS: Platform[] = ['claude', 'codex', 'copilot', 'cursor', 'windsurf'];
|
||||
|
||||
/**
|
||||
* List installed skills
|
||||
*/
|
||||
export async function list(options: ListOptions): Promise<void> {
|
||||
const platforms = options.platform ? [options.platform] : ALL_PLATFORMS;
|
||||
let totalSkills = 0;
|
||||
|
||||
// Determine which locations to check
|
||||
const checkGlobal = options.all || !options.project;
|
||||
const checkProject = options.all || options.project;
|
||||
|
||||
// List global skills
|
||||
if (checkGlobal) {
|
||||
for (const platform of platforms) {
|
||||
const skills = await getInstalledSkills(platform, false);
|
||||
|
||||
if (skills.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
totalSkills += skills.length;
|
||||
|
||||
const header = options.all
|
||||
? `${getPlatformName(platform)} - Global (${skills.length} skills)`
|
||||
: `${getPlatformName(platform)} (${skills.length} skills)`;
|
||||
|
||||
console.log(chalk.bold(`\n${header}:`));
|
||||
console.log(chalk.dim('─'.repeat(60)));
|
||||
|
||||
for (const skill of skills) {
|
||||
const version = skill.version ? chalk.dim(`v${skill.version}`) : '';
|
||||
console.log(` ${chalk.cyan(skill.name.padEnd(25))} ${version}`);
|
||||
|
||||
if (skill.description) {
|
||||
console.log(` ${chalk.dim(skill.description.slice(0, 55))}${skill.description.length > 55 ? '...' : ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List project skills
|
||||
if (checkProject) {
|
||||
for (const platform of platforms) {
|
||||
const skills = await getInstalledSkills(platform, true);
|
||||
|
||||
if (skills.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
totalSkills += skills.length;
|
||||
|
||||
const header = options.all
|
||||
? `${getPlatformName(platform)} - Project (${skills.length} skills)`
|
||||
: `${getPlatformName(platform)} (${skills.length} skills)`;
|
||||
|
||||
console.log(chalk.bold(`\n${header}:`));
|
||||
console.log(chalk.dim('─'.repeat(60)));
|
||||
console.log(chalk.dim(` Path: ${getSkillsPath(platform, true)}`));
|
||||
|
||||
for (const skill of skills) {
|
||||
const version = skill.version ? chalk.dim(`v${skill.version}`) : '';
|
||||
console.log(` ${chalk.cyan(skill.name.padEnd(25))} ${version}`);
|
||||
|
||||
if (skill.description) {
|
||||
console.log(` ${chalk.dim(skill.description.slice(0, 55))}${skill.description.length > 55 ? '...' : ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalSkills === 0) {
|
||||
if (options.project) {
|
||||
console.log(chalk.yellow('\nNo skills installed in project.'));
|
||||
console.log(chalk.dim('Install a skill with: npx skillhub install <skill-id> --project'));
|
||||
} else if (options.all) {
|
||||
console.log(chalk.yellow('\nNo skills installed (global or project).'));
|
||||
} else {
|
||||
console.log(chalk.yellow('\nNo skills installed globally.'));
|
||||
console.log(chalk.dim('Use --project to list project-level skills'));
|
||||
console.log(chalk.dim('Use --all to list both global and project skills'));
|
||||
}
|
||||
console.log(chalk.dim('\nSearch for skills with: npx skillhub search <query>'));
|
||||
console.log(chalk.dim('Install a skill with: npx skillhub install <skill-id>'));
|
||||
} else {
|
||||
console.log(chalk.dim(`\n${totalSkills} total skill(s) installed.`));
|
||||
}
|
||||
}
|
||||
|
||||
interface InstalledSkill {
|
||||
name: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
async function getInstalledSkills(platform: Platform, project: boolean = false): Promise<InstalledSkill[]> {
|
||||
const skillsPath = getSkillsPath(platform, project);
|
||||
const skills: InstalledSkill[] = [];
|
||||
|
||||
if (!(await fs.pathExists(skillsPath))) {
|
||||
return skills;
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(skillsPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const skillPath = path.join(skillsPath, entry.name);
|
||||
const skillMdPath = path.join(skillPath, 'SKILL.md');
|
||||
const metadataPath = path.join(skillPath, '.skillhub.json');
|
||||
|
||||
const hasSkillMd = await fs.pathExists(skillMdPath);
|
||||
const hasMetadata = await fs.pathExists(metadataPath);
|
||||
|
||||
if (!hasSkillMd && !hasMetadata) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (hasSkillMd) {
|
||||
const content = await fs.readFile(skillMdPath, 'utf-8');
|
||||
const parsed = parseSkillMd(content);
|
||||
|
||||
skills.push({
|
||||
name: parsed.metadata.name || entry.name,
|
||||
description: parsed.metadata.description,
|
||||
version: parsed.metadata.version,
|
||||
path: skillPath,
|
||||
});
|
||||
} else if (hasMetadata) {
|
||||
const metadata = await fs.readJson(metadataPath);
|
||||
skills.push({
|
||||
name: entry.name,
|
||||
description: undefined,
|
||||
version: metadata.version || undefined,
|
||||
path: skillPath,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid skills
|
||||
skills.push({
|
||||
name: entry.name,
|
||||
path: skillPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function getPlatformName(platform: Platform): string {
|
||||
const names: Record<Platform, string> = {
|
||||
claude: 'Claude',
|
||||
codex: 'OpenAI Codex',
|
||||
copilot: 'GitHub Copilot',
|
||||
cursor: 'Cursor',
|
||||
windsurf: 'Windsurf',
|
||||
};
|
||||
return names[platform];
|
||||
}
|
||||
144
apps/cli/src/commands/search.test.ts
Normal file
144
apps/cli/src/commands/search.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('chalk', () => ({
|
||||
default: {
|
||||
bold: vi.fn((s) => s),
|
||||
dim: vi.fn((s) => s),
|
||||
cyan: vi.fn((s) => s),
|
||||
yellow: vi.fn((s) => s),
|
||||
red: vi.fn((s) => s),
|
||||
green: vi.fn((s) => s),
|
||||
white: vi.fn((s) => s),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('ora', () => ({
|
||||
default: vi.fn(() => ({
|
||||
start: vi.fn().mockReturnThis(),
|
||||
stop: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/api.js', () => ({
|
||||
searchSkills: vi.fn(),
|
||||
}));
|
||||
|
||||
import { search } from './search.js';
|
||||
import { searchSkills } from '../utils/api.js';
|
||||
|
||||
describe('search command', () => {
|
||||
let consoleLogSpy: MockInstance;
|
||||
let consoleErrorSpy: MockInstance;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let processExitSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should search skills via API', async () => {
|
||||
vi.mocked(searchSkills).mockResolvedValue({
|
||||
skills: [
|
||||
{
|
||||
id: 'test/skill',
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
githubOwner: 'test',
|
||||
githubRepo: 'skill',
|
||||
skillPath: 'skills/test',
|
||||
branch: 'main',
|
||||
githubStars: 100,
|
||||
downloadCount: 50,
|
||||
securityScore: 85,
|
||||
isVerified: true,
|
||||
compatibility: { platforms: ['claude', 'codex'] },
|
||||
},
|
||||
],
|
||||
pagination: { page: 1, limit: 10, total: 1, totalPages: 1 },
|
||||
});
|
||||
|
||||
await search('test', {});
|
||||
|
||||
expect(searchSkills).toHaveBeenCalledWith('test', { platform: undefined, limit: 10, page: 1, sort: 'downloads' });
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show "no skills found" message', async () => {
|
||||
vi.mocked(searchSkills).mockResolvedValue({
|
||||
skills: [],
|
||||
pagination: { page: 1, limit: 10, total: 0, totalPages: 0 },
|
||||
});
|
||||
|
||||
await search('nonexistent', {});
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('No skills found'));
|
||||
});
|
||||
|
||||
it('should filter by platform', async () => {
|
||||
vi.mocked(searchSkills).mockResolvedValue({
|
||||
skills: [],
|
||||
pagination: { page: 1, limit: 10, total: 0, totalPages: 0 },
|
||||
});
|
||||
|
||||
await search('test', { platform: 'claude' });
|
||||
|
||||
expect(searchSkills).toHaveBeenCalledWith('test', expect.objectContaining({ platform: 'claude' }));
|
||||
});
|
||||
|
||||
it('should respect limit option', async () => {
|
||||
vi.mocked(searchSkills).mockResolvedValue({
|
||||
skills: [],
|
||||
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
|
||||
});
|
||||
|
||||
await search('test', { limit: '20' });
|
||||
|
||||
expect(searchSkills).toHaveBeenCalledWith('test', expect.objectContaining({ limit: 20 }));
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
vi.mocked(searchSkills).mockRejectedValue(new Error('API error'));
|
||||
|
||||
await search('test', {});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('API error'));
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should display pagination info when more results available', async () => {
|
||||
vi.mocked(searchSkills).mockResolvedValue({
|
||||
skills: [
|
||||
{
|
||||
id: 'test/skill',
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
githubOwner: 'test',
|
||||
githubRepo: 'skill',
|
||||
skillPath: 'skills/test',
|
||||
branch: 'main',
|
||||
githubStars: 100,
|
||||
downloadCount: 50,
|
||||
securityScore: 85,
|
||||
isVerified: false,
|
||||
compatibility: { platforms: ['claude'] },
|
||||
},
|
||||
],
|
||||
pagination: { page: 1, limit: 10, total: 50, totalPages: 5 },
|
||||
});
|
||||
|
||||
await search('test', {});
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Page 1 of 5'));
|
||||
});
|
||||
});
|
||||
132
apps/cli/src/commands/search.ts
Normal file
132
apps/cli/src/commands/search.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { searchSkills } from '../utils/api.js';
|
||||
|
||||
interface SearchOptions {
|
||||
platform?: string;
|
||||
sort?: string;
|
||||
limit?: string;
|
||||
page?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for skills in the registry
|
||||
*/
|
||||
export async function search(query: string, options: SearchOptions): Promise<void> {
|
||||
const spinner = ora('Searching skills...').start();
|
||||
|
||||
try {
|
||||
const limit = parseInt(options.limit || '10');
|
||||
const page = parseInt(options.page || '1');
|
||||
const sort = options.sort || 'downloads';
|
||||
const result = await searchSkills(query, {
|
||||
platform: options.platform,
|
||||
limit,
|
||||
page,
|
||||
sort,
|
||||
});
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (result.skills.length === 0) {
|
||||
console.log(chalk.yellow('No skills found.'));
|
||||
console.log(chalk.dim('Try a different search term or check the spelling.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const sortLabel = { downloads: 'downloads', stars: 'GitHub stars', rating: 'rating', recent: 'recently updated' }[sort] || sort;
|
||||
console.log(chalk.bold(`Found ${result.pagination.total} skills (sorted by ${sortLabel}):\n`));
|
||||
|
||||
// Print results as a table
|
||||
console.log(
|
||||
chalk.dim('─'.repeat(80))
|
||||
);
|
||||
|
||||
const startIndex = (page - 1) * limit;
|
||||
for (let i = 0; i < result.skills.length; i++) {
|
||||
const skill = result.skills[i];
|
||||
const num = chalk.dim(`[${startIndex + i + 1}]`);
|
||||
const verified = skill.isVerified ? chalk.green('✓') : ' ';
|
||||
// Use securityStatus if available, otherwise fall back to score-based badge
|
||||
const security = skill.securityStatus
|
||||
? getSecurityStatusBadge(skill.securityStatus)
|
||||
: getSecurityBadge(skill.securityScore);
|
||||
|
||||
// First line: number, ID, security
|
||||
console.log(
|
||||
`${num} ${verified} ${chalk.cyan(skill.id.padEnd(38))} ${security}`
|
||||
);
|
||||
|
||||
// Second line: downloads, stars, description
|
||||
console.log(
|
||||
` ⬇ ${formatNumber(skill.downloadCount).padStart(6)} ⭐ ${formatNumber(skill.githubStars).padStart(6)} ${chalk.dim(skill.description.slice(0, 55))}${skill.description.length > 55 ? '...' : ''}`
|
||||
);
|
||||
|
||||
// Third line: Rating (only if ratingCount >= 3)
|
||||
const showRating = (skill.ratingCount ?? 0) >= 3;
|
||||
if (showRating && skill.rating) {
|
||||
console.log(
|
||||
` ${chalk.yellow('★')} ${skill.rating.toFixed(1)} ${chalk.dim(`(${skill.ratingCount} ratings)`)}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(chalk.dim('─'.repeat(80)));
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(chalk.dim(`Install with: ${chalk.white('npx skillhub install <skill-id>')}`));
|
||||
|
||||
const totalPages = result.pagination.totalPages;
|
||||
if (totalPages > 1) {
|
||||
console.log(
|
||||
chalk.dim(`Page ${page} of ${totalPages}. Use ${chalk.white(`--page ${page + 1}`)} for next page.`)
|
||||
);
|
||||
}
|
||||
|
||||
if (sort === 'downloads') {
|
||||
console.log(chalk.dim(`Sort options: ${chalk.white('--sort stars|rating|recent')}`));
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Search failed');
|
||||
const err = error as Error;
|
||||
console.error(chalk.red(err.message || 'Unknown error'));
|
||||
if (process.env.DEBUG) {
|
||||
console.error(chalk.dim('Stack:'), err.stack);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'k';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security badge from securityStatus (new system)
|
||||
*/
|
||||
function getSecurityStatusBadge(status: string): string {
|
||||
switch (status) {
|
||||
case 'pass':
|
||||
return chalk.green('🛡️ Pass');
|
||||
case 'warning':
|
||||
return chalk.yellow('⚠️ Warn');
|
||||
case 'fail':
|
||||
return chalk.red('❌ Fail');
|
||||
default:
|
||||
return chalk.dim('- N/A');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security badge from securityScore (legacy system)
|
||||
*/
|
||||
function getSecurityBadge(score: number): string {
|
||||
if (score >= 90) return chalk.green('●●●●●');
|
||||
if (score >= 70) return chalk.yellow('●●●●○');
|
||||
if (score >= 50) return chalk.yellow('●●●○○');
|
||||
if (score >= 30) return chalk.red('●●○○○');
|
||||
return chalk.red('●○○○○');
|
||||
}
|
||||
239
apps/cli/src/index.ts
Normal file
239
apps/cli/src/index.ts
Normal 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();
|
||||
}
|
||||
176
apps/cli/src/utils/api.test.ts
Normal file
176
apps/cli/src/utils/api.test.ts
Normal 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
260
apps/cli/src/utils/api.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
251
apps/cli/src/utils/github.ts
Normal file
251
apps/cli/src/utils/github.ts
Normal 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;
|
||||
}
|
||||
208
apps/cli/src/utils/paths.test.ts
Normal file
208
apps/cli/src/utils/paths.test.ts
Normal 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
152
apps/cli/src/utils/paths.ts
Normal 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 });
|
||||
}
|
||||
72
apps/cli/src/utils/platform-config.test.ts
Normal file
72
apps/cli/src/utils/platform-config.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
197
apps/cli/src/utils/transform.test.ts
Normal file
197
apps/cli/src/utils/transform.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
150
apps/cli/src/utils/transform.ts
Normal file
150
apps/cli/src/utils/transform.ts
Normal 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
9
apps/cli/tsconfig.json
Normal 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
16
apps/cli/tsup.config.ts
Normal 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
15
apps/cli/vitest.config.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user