feat: add Skill Hub feature for managing community skills

- Implemented Skill Hub page with tabs for remote, local, and installed skills.
- Added localization support for Chinese in skill-hub.json.
- Created API routes for fetching remote skills, listing local skills, and managing installed skills.
- Developed functionality for installing and uninstalling skills from both remote and local sources.
- Introduced caching mechanism for remote skills and handling updates for installed skills.
This commit is contained in:
catlog22
2026-02-22 19:02:57 +08:00
parent 87634740a3
commit 367fb94718
23 changed files with 2952 additions and 171 deletions

View File

@@ -2,7 +2,7 @@ import { Command } from 'commander';
import { viewCommand } from './commands/view.js';
import { serveCommand } from './commands/serve.js';
import { stopCommand } from './commands/stop.js';
import { installCommand } from './commands/install.js';
import { installCommand, installSkillHubCommand } from './commands/install.js';
import { uninstallCommand } from './commands/uninstall.js';
import { upgradeCommand } from './commands/upgrade.js';
import { listCommand } from './commands/list.js';
@@ -115,7 +115,21 @@ export function run(argv: string[]): void {
.option('-m, --mode <mode>', 'Installation mode: Global or Path')
.option('-p, --path <path>', 'Installation path (for Path mode)')
.option('-f, --force', 'Force installation without prompts')
.action(installCommand);
.option('--skill-hub [skillId]', 'Install skill from skill-hub (use --list to see available)')
.option('--cli <type>', 'Target CLI for skill installation (claude or codex)', 'claude')
.option('--list', 'List available skills in skill-hub')
.action((options) => {
// If skill-hub option is used, route to skill hub command
if (options.skillHub !== undefined || options.list) {
return installSkillHubCommand({
skillId: typeof options.skillHub === 'string' ? options.skillHub : undefined,
cliType: options.cli,
list: options.list,
});
}
// Otherwise use normal install
return installCommand(options);
});
// Uninstall command
program

View File

@@ -963,3 +963,231 @@ function getVersion(): string {
return '1.0.0';
}
}
// ========================================
// Skill Hub Installation Functions
// ========================================
/**
* Options for skill-hub installation
*/
interface SkillHubInstallOptions {
skillId?: string;
cliType?: 'claude' | 'codex';
list?: boolean;
}
/**
* Skill hub index entry
*/
interface SkillHubEntry {
id: string;
name: string;
description: string;
version: string;
author: string;
category: string;
downloadUrl: string;
}
/**
* Get skill-hub directory path
*/
function getSkillHubDir(): string {
return join(homedir(), '.ccw', 'skill-hub');
}
/**
* Get local skills directory
*/
function getLocalSkillsDir(): string {
return join(getSkillHubDir(), 'local');
}
/**
* Parse skill frontmatter from SKILL.md content
*/
function parseSkillFrontmatter(content: string): {
name: string;
description: string;
version: string;
} {
const result = { name: '', description: '', version: '1.0.0' };
if (content.startsWith('---')) {
const endIndex = content.indexOf('---', 3);
if (endIndex > 0) {
const frontmatter = content.substring(3, endIndex).trim();
const lines = frontmatter.split('\n');
for (const line of lines) {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.substring(0, colonIndex).trim().toLowerCase();
const value = line.substring(colonIndex + 1).trim().replace(/^["']|["']$/g, '');
if (key === 'name') result.name = value;
if (key === 'description') result.description = value;
if (key === 'version') result.version = value;
}
}
}
}
return result;
}
/**
* List available skills from local skill-hub
*/
function listLocalSkillHubSkills(): Array<{ id: string; name: string; description: string; version: string; path: string }> {
const result: Array<{ id: string; name: string; description: string; version: string; path: string }> = [];
const localDir = getLocalSkillsDir();
if (!existsSync(localDir)) {
return result;
}
try {
const entries = readdirSync(localDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillDir = join(localDir, entry.name);
const skillMdPath = join(skillDir, 'SKILL.md');
if (!existsSync(skillMdPath)) continue;
try {
const content = readFileSync(skillMdPath, 'utf8');
const parsed = parseSkillFrontmatter(content);
result.push({
id: `local-${entry.name}`,
name: parsed.name || entry.name,
description: parsed.description || '',
version: parsed.version || '1.0.0',
path: skillDir,
});
} catch {
// Skip invalid skills
}
}
} catch (error) {
console.error('Failed to list local skills:', error);
}
return result;
}
/**
* Install a skill from skill-hub to CLI skills directory
*/
async function installSkillFromHub(
skillId: string,
cliType: 'claude' | 'codex'
): Promise<{ success: boolean; message: string }> {
// Only support local skills for now
if (!skillId.startsWith('local-')) {
return {
success: false,
message: 'Only local skills are supported in CLI. Use the web dashboard for remote skills.',
};
}
const skillName = skillId.replace('local-', '');
const localDir = getLocalSkillsDir();
const skillDir = join(localDir, skillName);
if (!existsSync(skillDir)) {
return { success: false, message: `Skill '${skillName}' not found in local skill-hub` };
}
// Get target directory
const cliDir = cliType === 'codex' ? '.codex' : '.claude';
const targetDir = join(homedir(), cliDir, 'skills', skillName);
// Check if already exists
if (existsSync(targetDir)) {
return { success: false, message: `Skill '${skillName}' already installed to ${cliType}` };
}
// Create target parent directory
const targetParent = join(homedir(), cliDir, 'skills');
if (!existsSync(targetParent)) {
mkdirSync(targetParent, { recursive: true });
}
// Copy skill directory
try {
cpSync(skillDir, targetDir, { recursive: true });
return { success: true, message: `Skill '${skillName}' installed to ${cliType}` };
} catch (error) {
return { success: false, message: `Failed to install: ${(error as Error).message}` };
}
}
/**
* Skill Hub installation command
*/
export async function installSkillHubCommand(options: SkillHubInstallOptions): Promise<void> {
const version = getVersion();
showHeader(version);
// List mode
if (options.list) {
const skills = listLocalSkillHubSkills();
if (skills.length === 0) {
info('No local skills found in skill-hub');
info(`Add skills to: ${getLocalSkillsDir()}`);
return;
}
info(`Found ${skills.length} local skills in skill-hub:`);
console.log('');
for (const skill of skills) {
console.log(chalk.cyan(` ${skill.id}`));
console.log(chalk.gray(` Name: ${skill.name}`));
console.log(chalk.gray(` Version: ${skill.version}`));
if (skill.description) {
console.log(chalk.gray(` Description: ${skill.description}`));
}
console.log('');
}
info('To install a skill:');
console.log(chalk.gray(' ccw install --skill-hub local-skill-name --cli claude'));
return;
}
// Install mode
if (options.skillId) {
const cliType = options.cliType || 'claude';
const spinner = createSpinner(`Installing skill '${options.skillId}' to ${cliType}...`).start();
const result = await installSkillFromHub(options.skillId, cliType as 'claude' | 'codex');
if (result.success) {
spinner.succeed(result.message);
} else {
spinner.fail(result.message);
}
return;
}
// No options - show help
info('Skill Hub Installation');
console.log('');
console.log(chalk.gray('Usage:'));
console.log(chalk.cyan(' ccw install --skill-hub --list') + chalk.gray(' List available local skills'));
console.log(chalk.cyan(' ccw install --skill-hub <id> --cli <type>') + chalk.gray(' Install a skill'));
console.log('');
console.log(chalk.gray('Options:'));
console.log(chalk.gray(' --skill-hub, --skill Skill ID to install'));
console.log(chalk.gray(' --cli Target CLI (claude or codex, default: claude)'));
console.log(chalk.gray(' --list List available skills'));
}

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ import { handleGraphRoutes } from './routes/graph-routes.js';
import { handleSystemRoutes } from './routes/system-routes.js';
import { handleFilesRoutes } from './routes/files-routes.js';
import { handleSkillsRoutes } from './routes/skills-routes.js';
import { handleSkillHubRoutes } from './routes/skill-hub-routes.js';
import { handleCommandsRoutes } from './routes/commands-routes.js';
import { handleIssueRoutes } from './routes/issue-routes.js';
import { handleDiscoveryRoutes } from './routes/discovery-routes.js';
@@ -564,6 +565,11 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleSkillsRoutes(routeContext)) return;
}
// Skill Hub routes (/api/skill-hub*)
if (pathname.startsWith('/api/skill-hub')) {
if (await handleSkillHubRoutes(routeContext)) return;
}
// Commands routes (/api/commands*)
if (pathname.startsWith('/api/commands')) {
if (await handleCommandsRoutes(routeContext)) return;