diff --git a/marketplace/sources.json b/marketplace/sources.json index e9999940..4927f0be 100644 --- a/marketplace/sources.json +++ b/marketplace/sources.json @@ -34,6 +34,7 @@ { "source": "jimliu/baoyu-skills", "name": "Baoyu Tools" }, { "source": "giuseppe-trisciuoglio/developer-kit", "name": "Shadcn / Radix" }, { "source": "openrouterteam/agent-skills", "name": "OpenRouter SDK" }, - { "source": "intellectronica/agent-skills", "name": "Context7" } + { "source": "intellectronica/agent-skills", "name": "Context7" }, + { "source": "skills.sh", "name": "Skills.sh Leaderboard", "official": true, "registry": "skills-sh" } ] } diff --git a/packages/cli/src/commands/find.ts b/packages/cli/src/commands/find.ts index cb043113..52c1ba46 100644 --- a/packages/cli/src/commands/find.ts +++ b/packages/cli/src/commands/find.ts @@ -1,4 +1,4 @@ -import { Command, Option } from 'clipanion'; +import { Command, Option } from "clipanion"; import { colors, symbols, @@ -7,8 +7,13 @@ import { isCancel, select, header, -} from '../onboarding/index.js'; -import { FederatedSearch, GitHubSkillRegistry, RateLimitError } from '@skillkit/core'; +} from "../onboarding/index.js"; +import { + FederatedSearch, + GitHubSkillRegistry, + SkillsShRegistry, +} from "@skillkit/core"; +import { formatCount } from "../helpers.js"; interface SkillResult { name: string; @@ -17,46 +22,46 @@ interface SkillResult { repoName: string; } -import skillsData from '../../../../marketplace/skills.json' with { type: 'json' }; -import sourcesData from '../../../../marketplace/sources.json' with { type: 'json' }; +import skillsData from "../../../../marketplace/skills.json" with { type: "json" }; +import sourcesData from "../../../../marketplace/sources.json" with { type: "json" }; export class FindCommand extends Command { - static override paths = [['find'], ['search']]; + static override paths = [["find"], ["search"]]; static override usage = Command.Usage({ - description: 'Search for skills in the marketplace', + description: "Search for skills in the marketplace", details: ` Quickly find and install skills from the marketplace. Interactive mode lets you browse and install in one step. `, examples: [ - ['Interactive search', '$0 find'], - ['Search for specific skill', '$0 find pdf'], - ['Search with keyword', '$0 find "nextjs"'], - ['List top skills', '$0 find --top'], + ["Interactive search", "$0 find"], + ["Search for specific skill", "$0 find pdf"], + ["Search with keyword", '$0 find "nextjs"'], + ["List top skills", "$0 find --top"], ], }); query = Option.String({ required: false }); - top = Option.Boolean('--top,-t', false, { - description: 'Show top/featured skills', + top = Option.Boolean("--top,-t", false, { + description: "Show top/featured skills", }); - limit = Option.String('--limit,-l', '10', { - description: 'Maximum results to show', + limit = Option.String("--limit,-l", "10", { + description: "Maximum results to show", }); - install = Option.Boolean('--install,-i', false, { - description: 'Prompt to install after finding', + install = Option.Boolean("--install,-i", false, { + description: "Prompt to install after finding", }); - quiet = Option.Boolean('--quiet,-q', false, { - description: 'Minimal output (just list skills)', + quiet = Option.Boolean("--quiet,-q", false, { + description: "Minimal output (just list skills)", }); - federated = Option.Boolean('--federated,-f', false, { - description: 'Search external registries (GitHub SKILL.md files)', + federated = Option.Boolean("--federated,-f", false, { + description: "Search external registries (GitHub SKILL.md files)", }); async execute(): Promise { @@ -64,17 +69,25 @@ export class FindCommand extends Command { const limit = parseInt(this.limit, 10) || 10; if (!this.quiet) { - header('Find Skills'); + header("Find Skills"); } - const allSkills: SkillResult[] = (skillsData.skills || []).map((skill: { name: string; description?: string; source?: string; repo?: string }) => ({ - name: skill.name, - description: skill.description, - source: skill.source || '', - repoName: skill.repo || skill.source?.split('/').pop() || '', - })); + const allSkills: SkillResult[] = (skillsData.skills || []).map( + (skill: { + name: string; + description?: string; + source?: string; + repo?: string; + }) => ({ + name: skill.name, + description: skill.description, + source: skill.source || "", + repoName: skill.repo || skill.source?.split("/").pop() || "", + }), + ); let results: SkillResult[]; + let searchTerm: string | undefined; if (this.top) { const featured = sourcesData.sources @@ -82,35 +95,43 @@ export class FindCommand extends Command { .slice(0, 5); results = allSkills - .filter(skill => featured.some((f: { source: string }) => skill.source.includes(f.source))) + .filter((skill) => + featured.some((f: { source: string }) => + skill.source.includes(f.source), + ), + ) .slice(0, limit); if (!this.quiet) { - step('Showing featured skills'); + step("Showing featured skills"); } } else if (this.query) { const query = this.query.toLowerCase(); + searchTerm = this.query; - s.start('Searching...'); + s.start("Searching..."); - results = allSkills.filter(skill => - skill.name.toLowerCase().includes(query) || - skill.description?.toLowerCase().includes(query) || - skill.source.toLowerCase().includes(query) || - skill.repoName.toLowerCase().includes(query) - ).slice(0, limit); + results = allSkills + .filter( + (skill) => + skill.name.toLowerCase().includes(query) || + skill.description?.toLowerCase().includes(query) || + skill.source.toLowerCase().includes(query) || + skill.repoName.toLowerCase().includes(query), + ) + .slice(0, limit); s.stop(`Found ${results.length} skill(s)`); } else { if (!this.quiet) { - step('Enter a search term or browse featured skills'); + step("Enter a search term or browse featured skills"); } - const { text } = await import('../onboarding/prompts.js'); + const { text } = await import("../onboarding/prompts.js"); const searchResult = await text({ - message: 'Search skills', - placeholder: 'e.g., pdf, nextjs, testing', + message: "Search skills", + placeholder: "e.g., pdf, nextjs, testing", }); if (isCancel(searchResult)) { @@ -118,101 +139,163 @@ export class FindCommand extends Command { } const query = (searchResult as string).toLowerCase(); + searchTerm = searchResult as string; if (query) { - s.start('Searching...'); - results = allSkills.filter(skill => - skill.name.toLowerCase().includes(query) || - skill.description?.toLowerCase().includes(query) || - skill.source.toLowerCase().includes(query) || - skill.repoName.toLowerCase().includes(query) - ).slice(0, limit); + s.start("Searching..."); + results = allSkills + .filter( + (skill) => + skill.name.toLowerCase().includes(query) || + skill.description?.toLowerCase().includes(query) || + skill.source.toLowerCase().includes(query) || + skill.repoName.toLowerCase().includes(query), + ) + .slice(0, limit); s.stop(`Found ${results.length} skill(s)`); } else { results = allSkills.slice(0, limit); } } - if (this.federated && this.query) { - s.start('Searching external registries...'); + if (searchTerm) { + s.start("Searching skills.sh + external registries..."); const fedSearch = new FederatedSearch(); - fedSearch.addRegistry(new GitHubSkillRegistry()); + fedSearch.addRegistry(new SkillsShRegistry()); + if (this.federated) { + fedSearch.addRegistry(new GitHubSkillRegistry()); + } try { - const fedResult = await fedSearch.search(this.query, { limit: parseInt(this.limit, 10) || 10 }); - s.stop(`Found ${fedResult.total} external skill(s) from ${fedResult.registries.join(', ') || 'none'}`); + const fedResult = await fedSearch.search(searchTerm, { + limit: parseInt(this.limit, 10) || 10, + }); + const sourceLabel = fedResult.registries.join(", ") || "none"; + s.stop(`Found ${fedResult.total} skill(s) from ${sourceLabel}`); if (fedResult.skills.length > 0) { - console.log(''); - console.log(colors.bold('External Skills (SKILL.md):')); - for (const skill of fedResult.skills) { - const stars = typeof skill.stars === 'number' ? colors.muted(` ★${skill.stars}`) : ''; - const desc = skill.description - ? colors.muted(` - ${skill.description.slice(0, 50)}${skill.description.length > 50 ? '...' : ''}`) - : ''; - console.log(` ${colors.cyan(symbols.bullet)} ${colors.primary(skill.name)}${stars}${desc}`); - if (!this.quiet) { - console.log(` ${colors.muted(skill.source)}`); + const skillsShResults = fedResult.skills.filter( + (sk) => sk.registry === "skills.sh", + ); + const githubResults = fedResult.skills.filter( + (sk) => sk.registry !== "skills.sh", + ); + + if (skillsShResults.length > 0) { + console.log(""); + console.log(colors.bold("Skills.sh Registry:")); + for (const skill of skillsShResults) { + const installs = + typeof skill.stars === "number" && skill.stars > 0 + ? colors.muted(` ${formatCount(skill.stars)} installs`) + : ""; + const desc = skill.description + ? colors.muted( + ` - ${skill.description.slice(0, 50)}${skill.description.length > 50 ? "..." : ""}`, + ) + : ""; + console.log( + ` ${colors.cyan(symbols.bullet)} ${colors.primary(skill.name)}${installs}${desc}`, + ); + if (!this.quiet) { + const installSource = skill.source.replace( + "https://github.com/", + "", + ); + console.log( + ` ${colors.muted(`skillkit install skills.sh/${installSource}/${skill.name}`)}`, + ); + } } } - console.log(''); - } - } catch (err) { - if (err instanceof RateLimitError) { - s.stop(colors.warning('Rate limited by GitHub API')); - console.log(colors.muted('Set GITHUB_TOKEN env var or wait before retrying.')); - } else { - s.stop(colors.warning('Federated search failed')); + + if (githubResults.length > 0) { + console.log(""); + console.log(colors.bold("GitHub (SKILL.md):")); + for (const skill of githubResults) { + const stars = + typeof skill.stars === "number" + ? colors.muted(` ★${skill.stars}`) + : ""; + const desc = skill.description + ? colors.muted( + ` - ${skill.description.slice(0, 50)}${skill.description.length > 50 ? "..." : ""}`, + ) + : ""; + console.log( + ` ${colors.cyan(symbols.bullet)} ${colors.primary(skill.name)}${stars}${desc}`, + ); + if (!this.quiet) { + console.log(` ${colors.muted(skill.source)}`); + } + } + } + + console.log(""); } + } catch { + s.stop(colors.warning("External search unavailable")); } } if (results.length === 0) { - console.log(colors.muted('No skills found matching your search')); - console.log(''); - console.log(colors.muted('Try:')); - console.log(colors.muted(' skillkit find --top # Show featured skills')); - console.log(colors.muted(' skillkit find -f # Search external registries')); - console.log(colors.muted(' skillkit ui # Browse in TUI')); + console.log(colors.muted("No skills found matching your search")); + console.log(""); + console.log(colors.muted("Try:")); + console.log( + colors.muted(" skillkit find --top # Show featured skills"), + ); + console.log( + colors.muted( + " skillkit find -f # Also search GitHub SKILL.md files", + ), + ); + console.log(colors.muted(" skillkit ui # Browse in TUI")); return 0; } - console.log(''); + console.log(""); for (const skill of results) { const desc = skill.description - ? colors.muted(` - ${skill.description.slice(0, 50)}${skill.description.length > 50 ? '...' : ''}`) - : ''; - console.log(` ${colors.success(symbols.bullet)} ${colors.primary(skill.name)}${desc}`); + ? colors.muted( + ` - ${skill.description.slice(0, 50)}${skill.description.length > 50 ? "..." : ""}`, + ) + : ""; + console.log( + ` ${colors.success(symbols.bullet)} ${colors.primary(skill.name)}${desc}`, + ); if (!this.quiet && skill.source) { console.log(` ${colors.muted(skill.source)}`); } } - console.log(''); - console.log(colors.muted(`Showing ${results.length} of ${allSkills.length} skills`)); - console.log(''); + console.log(""); + console.log( + colors.muted(`Showing ${results.length} of ${allSkills.length} skills`), + ); + console.log(""); if (this.install || (!this.query && !this.top && process.stdin.isTTY)) { const installResult = await select({ - message: 'Install a skill?', + message: "Install a skill?", options: [ - { value: 'none', label: 'No, just browsing' }, - ...results.slice(0, 5).map(skill => ({ + { value: "none", label: "No, just browsing" }, + ...results.slice(0, 5).map((skill) => ({ value: skill.source, label: skill.name, hint: skill.repoName, })), ], - initialValue: 'none', + initialValue: "none", }); - if (!isCancel(installResult) && installResult !== 'none') { - console.log(''); - console.log(colors.cyan('To install, run:')); + if (!isCancel(installResult) && installResult !== "none") { + console.log(""); + console.log(colors.cyan("To install, run:")); console.log(` ${colors.bold(`skillkit install ${installResult}`)}`); } } else { - console.log(colors.muted('To install: skillkit install ')); + console.log(colors.muted("To install: skillkit install ")); } return 0; diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index 5d1c9ab0..9aadb42a 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -1,15 +1,36 @@ -import { existsSync, mkdirSync, cpSync, rmSync, symlinkSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; +import { + existsSync, + mkdirSync, + cpSync, + rmSync, + symlinkSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; const execFileAsync = promisify(execFile); -import { Command, Option } from 'clipanion'; -import { detectProvider, isLocalPath, getProvider, evaluateSkillDirectory, SkillScanner, formatSummary, Severity, WellKnownProvider, AgentsMdParser, AgentsMdGenerator } from '@skillkit/core'; -import type { SkillMetadata, GitProvider, AgentType } from '@skillkit/core'; -import { isPathInside } from '@skillkit/core'; -import { getAdapter, detectAgent, getAllAdapters } from '@skillkit/agents'; -import { getInstallDir, saveSkillMetadata } from '../helpers.js'; +import { Command, Option } from "clipanion"; +import { + detectProvider, + isLocalPath, + getProvider, + evaluateSkillDirectory, + SkillScanner, + formatSummary, + Severity, + WellKnownProvider, + AgentsMdParser, + AgentsMdGenerator, + SkillsShRegistry, +} from "@skillkit/core"; +import type { SkillsShStats } from "@skillkit/core"; +import type { SkillMetadata, GitProvider, AgentType } from "@skillkit/core"; +import { isPathInside } from "@skillkit/core"; +import { getAdapter, detectAgent, getAllAdapters } from "@skillkit/agents"; +import { getInstallDir, saveSkillMetadata, formatCount } from "../helpers.js"; import { welcome, colors, @@ -35,71 +56,80 @@ import { formatQualityBadge, getQualityGradeFromScore, type InstallResult, -} from '../onboarding/index.js'; +} from "../onboarding/index.js"; export class InstallCommand extends Command { - static override paths = [['install'], ['i'], ['add']]; + static override paths = [["install"], ["i"], ["add"]]; static override usage = Command.Usage({ - description: 'Install skills from GitHub, GitLab, Bitbucket, or local path', + description: + "Install skills from GitHub, GitLab, Bitbucket, skills.sh, or local path", examples: [ - ['Install from GitHub', '$0 install owner/repo'], - ['Install from GitLab', '$0 install gitlab:owner/repo'], - ['Install from Bitbucket', '$0 install bitbucket:owner/repo'], - ['Install specific skill', '$0 install owner/repo --skill=pdf'], - ['Install multiple skills (CI/CD)', '$0 install owner/repo --skills=pdf,xlsx'], - ['Install all skills non-interactively', '$0 install owner/repo --all'], - ['Install from local path', '$0 install ./my-skills'], - ['Install globally', '$0 install owner/repo --global'], - ['List available skills', '$0 install owner/repo --list'], - ['Install to specific agents', '$0 install owner/repo --agent claude-code --agent cursor'], + ["Install from GitHub", "$0 install owner/repo"], + ["Install from skills.sh", "$0 install skills.sh/owner/repo/skill-name"], + ["Install from GitLab", "$0 install gitlab:owner/repo"], + ["Install from Bitbucket", "$0 install bitbucket:owner/repo"], + ["Install specific skill", "$0 install owner/repo --skill=pdf"], + [ + "Install multiple skills (CI/CD)", + "$0 install owner/repo --skills=pdf,xlsx", + ], + ["Install all skills non-interactively", "$0 install owner/repo --all"], + ["Install from local path", "$0 install ./my-skills"], + ["Install globally", "$0 install owner/repo --global"], + ["List available skills", "$0 install owner/repo --list"], + [ + "Install to specific agents", + "$0 install owner/repo --agent claude-code --agent cursor", + ], ], }); source = Option.String({ required: true }); - skills = Option.String('--skills,--skill,-s', { - description: 'Comma-separated list of skills to install (non-interactive)', + skills = Option.String("--skills,--skill,-s", { + description: "Comma-separated list of skills to install (non-interactive)", }); - all = Option.Boolean('--all,-a', false, { - description: 'Install all discovered skills (non-interactive)', + all = Option.Boolean("--all,-a", false, { + description: "Install all discovered skills (non-interactive)", }); - yes = Option.Boolean('--yes,-y', false, { - description: 'Skip confirmation prompts', + yes = Option.Boolean("--yes,-y", false, { + description: "Skip confirmation prompts", }); - global = Option.Boolean('--global,-g', false, { - description: 'Install to global skills directory', + global = Option.Boolean("--global,-g", false, { + description: "Install to global skills directory", }); - force = Option.Boolean('--force,-f', false, { - description: 'Overwrite existing skills', + force = Option.Boolean("--force,-f", false, { + description: "Overwrite existing skills", }); - provider = Option.String('--provider,-p', { - description: 'Force specific provider (github, gitlab, bitbucket)', + provider = Option.String("--provider,-p", { + description: "Force specific provider (github, gitlab, bitbucket)", }); - list = Option.Boolean('--list,-l', false, { - description: 'List available skills without installing', + list = Option.Boolean("--list,-l", false, { + description: "List available skills without installing", }); - agent = Option.Array('--agent', { - description: 'Target specific agents (can specify multiple)', + agent = Option.Array("--agent", { + description: "Target specific agents (can specify multiple)", }); - quiet = Option.Boolean('--quiet,-q', false, { - description: 'Minimal output (no logo)', + quiet = Option.Boolean("--quiet,-q", false, { + description: "Minimal output (no logo)", }); - scan = Option.Boolean('--scan', true, { - description: 'Run security scan before installing (default: true)', + scan = Option.Boolean("--scan", true, { + description: "Run security scan before installing (default: true)", }); async execute(): Promise { - const isInteractive = process.stdin.isTTY && !this.skills && !this.all && !this.yes; + const isInteractive = + process.stdin.isTTY && !this.skills && !this.all && !this.yes; const s = spinner(); try { @@ -109,21 +139,37 @@ export class InstallCommand extends Command { } let providerAdapter = detectProvider(this.source); - let result: { success: boolean; path?: string; tempRoot?: string; error?: string; skills?: string[]; discoveredSkills?: Array<{ name: string; dirName: string; path: string }> } | null = null; - - const isUrl = this.source.startsWith('http://') || this.source.startsWith('https://'); + let result: { + success: boolean; + path?: string; + tempRoot?: string; + error?: string; + skills?: string[]; + discoveredSkills?: Array<{ + name: string; + dirName: string; + path: string; + }>; + } | null = null; + + const isUrl = + this.source.startsWith("http://") || this.source.startsWith("https://"); if (isUrl && !this.provider && !providerAdapter) { - s.start('Checking for well-known skills...'); + s.start("Checking for well-known skills..."); const wellKnown = new WellKnownProvider(); const discovery = await wellKnown.discoverFromUrl(this.source); if (discovery.success) { - s.stop(`Found ${discovery.skills?.length || 0} skill(s) via well-known discovery`); + s.stop( + `Found ${discovery.skills?.length || 0} skill(s) via well-known discovery`, + ); providerAdapter = wellKnown; result = discovery; } else { - s.stop('No well-known skills found'); + s.stop("No well-known skills found"); warn(`No well-known skills found at ${this.source}`); - console.log(colors.muted('You can save this URL as a skill instead:')); + console.log( + colors.muted("You can save this URL as a skill instead:"), + ); console.log(colors.muted(` skillkit save ${this.source}`)); return 1; } @@ -135,20 +181,31 @@ export class InstallCommand extends Command { if (!providerAdapter) { error(`Could not detect provider for: ${this.source}`); - console.log(colors.muted('Use --provider flag or specify source as:')); - console.log(colors.muted(' GitHub: owner/repo or https://github.com/owner/repo')); - console.log(colors.muted(' GitLab: gitlab:owner/repo or https://gitlab.com/owner/repo')); - console.log(colors.muted(' Bitbucket: bitbucket:owner/repo')); - console.log(colors.muted(' Local: ./path or ~/path')); + console.log(colors.muted("Use --provider flag or specify source as:")); + console.log( + colors.muted( + " GitHub: owner/repo or https://github.com/owner/repo", + ), + ); + console.log( + colors.muted(" Skills.sh: skills.sh/owner/repo/skill-name"), + ); + console.log( + colors.muted( + " GitLab: gitlab:owner/repo or https://gitlab.com/owner/repo", + ), + ); + console.log(colors.muted(" Bitbucket: bitbucket:owner/repo")); + console.log(colors.muted(" Local: ./path or ~/path")); return 1; } if (!result) { s.start(`Fetching from ${providerAdapter.name}...`); - result = await providerAdapter.clone(this.source, '', { depth: 1 }); + result = await providerAdapter.clone(this.source, "", { depth: 1 }); if (!result.success || !result.path) { - s.stop(colors.error(result.error || 'Failed to fetch source')); + s.stop(colors.error(result.error || "Failed to fetch source")); return 1; } @@ -161,23 +218,35 @@ export class InstallCommand extends Command { // List mode - just show skills and exit if (this.list) { if (discoveredSkills.length === 0) { - warn('No skills found in this repository'); + warn("No skills found in this repository"); } else { - console.log(''); - console.log(colors.bold('Available skills:')); - console.log(''); + console.log(""); + console.log(colors.bold("Available skills:")); + console.log(""); for (const skill of discoveredSkills) { const quality = evaluateSkillDirectory(skill.path); - const qualityBadge = quality ? ` ${formatQualityBadge(quality.overall)}` : ''; - console.log(` ${colors.success(symbols.stepActive)} ${colors.primary(skill.name)}${qualityBadge}`); + const qualityBadge = quality + ? ` ${formatQualityBadge(quality.overall)}` + : ""; + console.log( + ` ${colors.success(symbols.stepActive)} ${colors.primary(skill.name)}${qualityBadge}`, + ); } - console.log(''); - console.log(colors.muted(`Total: ${discoveredSkills.length} skill(s)`)); - console.log(colors.muted('To install: skillkit install --skill=name')); + console.log(""); + console.log( + colors.muted(`Total: ${discoveredSkills.length} skill(s)`), + ); + console.log( + colors.muted("To install: skillkit install --skill=name"), + ); } const cleanupPath = cloneResult.tempRoot || cloneResult.path; - if (!isLocalPath(this.source) && cleanupPath && existsSync(cleanupPath)) { + if ( + !isLocalPath(this.source) && + cleanupPath && + existsSync(cleanupPath) + ) { rmSync(cleanupPath, { recursive: true, force: true }); } @@ -188,17 +257,19 @@ export class InstallCommand extends Command { // Non-interactive: use --skills filter if (this.skills) { - const requestedSkills = this.skills.split(',').map(s => s.trim()); - const available = discoveredSkills.map(s => s.name); - const notFound = requestedSkills.filter(s => !available.includes(s)); + const requestedSkills = this.skills.split(",").map((s) => s.trim()); + const available = discoveredSkills.map((s) => s.name); + const notFound = requestedSkills.filter((s) => !available.includes(s)); if (notFound.length > 0) { - error(`Skills not found: ${notFound.join(', ')}`); - console.log(colors.muted(`Available: ${available.join(', ')}`)); + error(`Skills not found: ${notFound.join(", ")}`); + console.log(colors.muted(`Available: ${available.join(", ")}`)); return 1; } - skillsToInstall = discoveredSkills.filter(s => requestedSkills.includes(s.name)); + skillsToInstall = discoveredSkills.filter((s) => + requestedSkills.includes(s.name), + ); } else if (this.all || this.yes) { skillsToInstall = discoveredSkills; } else if (isInteractive && discoveredSkills.length > 1) { @@ -206,21 +277,23 @@ export class InstallCommand extends Command { step(`Source: ${colors.cyan(this.source)}`); const skillResult = await skillMultiselect({ - message: 'Select skills to install', - skills: discoveredSkills.map(s => ({ name: s.name })), - initialValues: discoveredSkills.map(s => s.name), + message: "Select skills to install", + skills: discoveredSkills.map((s) => ({ name: s.name })), + initialValues: discoveredSkills.map((s) => s.name), }); if (isCancel(skillResult)) { - cancel('Installation cancelled'); + cancel("Installation cancelled"); return 0; } - skillsToInstall = discoveredSkills.filter(s => (skillResult as string[]).includes(s.name)); + skillsToInstall = discoveredSkills.filter((s) => + (skillResult as string[]).includes(s.name), + ); } if (skillsToInstall.length === 0) { - warn('No skills to install'); + warn("No skills to install"); return 0; } @@ -228,26 +301,35 @@ export class InstallCommand extends Command { let targetAgents: AgentType[]; if (this.agent && this.agent.length > 0) { - // Explicitly specified agents + const allValid = getAllAdapters().map((a) => a.type); + const invalid = this.agent.filter( + (a) => !allValid.includes(a as AgentType), + ); + if (invalid.length > 0) { + error(`Unknown agent(s): ${invalid.join(", ")}`); + console.log(colors.muted(`Available: ${allValid.join(", ")}`)); + return 1; + } targetAgents = this.agent as AgentType[]; } else if (isInteractive) { - const allAgentTypes = getAllAdapters().map(a => a.type); + const allAgentTypes = getAllAdapters().map((a) => a.type); const lastAgents = getLastAgents(); step(`Detected ${allAgentTypes.length} agents`); const agentResult = await quickAgentSelect({ - message: 'Install to', + message: "Install to", agents: allAgentTypes, lastSelected: lastAgents, }); if (isCancel(agentResult)) { - cancel('Installation cancelled'); + cancel("Installation cancelled"); return 0; } - targetAgents = (agentResult as { agents: string[] }).agents as AgentType[]; + targetAgents = (agentResult as { agents: string[] }) + .agents as AgentType[]; // Save selection for next time saveLastAgents(targetAgents); @@ -258,21 +340,25 @@ export class InstallCommand extends Command { } // Interactive: select installation method - let installMethod: 'symlink' | 'copy' = 'copy'; + let installMethod: "symlink" | "copy" = "copy"; if (isInteractive && targetAgents.length > 1) { const methodResult = await selectInstallMethod({}); if (isCancel(methodResult)) { - cancel('Installation cancelled'); + cancel("Installation cancelled"); return 0; } - installMethod = methodResult as 'symlink' | 'copy'; + installMethod = methodResult as "symlink" | "copy"; } // Check for low-quality skills and warn - const lowQualitySkills: Array<{ name: string; score: number; warnings: string[] }> = []; + const lowQualitySkills: Array<{ + name: string; + score: number; + warnings: string[]; + }> = []; for (const skill of skillsToInstall) { const quality = evaluateSkillDirectory(skill.path); if (quality && quality.overall < 60) { @@ -289,42 +375,60 @@ export class InstallCommand extends Command { for (const skill of skillsToInstall) { const scanResult = await scanner.scan(skill.path); - if (scanResult.verdict === 'fail' && !this.force) { + if (scanResult.verdict === "fail" && !this.force) { error(`Security scan FAILED for "${skill.name}"`); console.log(formatSummary(scanResult)); - console.log(colors.muted('Use --force to install anyway, or --no-scan to skip scanning')); + console.log( + colors.muted( + "Use --force to install anyway, or --no-scan to skip scanning", + ), + ); const cleanupPath = cloneResult.tempRoot || cloneResult.path; - if (!isLocalPath(this.source) && cleanupPath && existsSync(cleanupPath)) { + if ( + !isLocalPath(this.source) && + cleanupPath && + existsSync(cleanupPath) + ) { rmSync(cleanupPath, { recursive: true, force: true }); } return 1; } - if (scanResult.verdict === 'warn' && !this.quiet) { - warn(`Security warnings for "${skill.name}" (${scanResult.stats.medium} medium, ${scanResult.stats.low} low)`); + if (scanResult.verdict === "warn" && !this.quiet) { + warn( + `Security warnings for "${skill.name}" (${scanResult.stats.medium} medium, ${scanResult.stats.low} low)`, + ); } } } // Confirm installation if (isInteractive && !this.yes) { - console.log(''); + console.log(""); // Show low-quality warning if any if (lowQualitySkills.length > 0) { - console.log(colors.warning(`${symbols.warning} Warning: ${lowQualitySkills.length} skill(s) have low quality scores (< 60)`)); + console.log( + colors.warning( + `${symbols.warning} Warning: ${lowQualitySkills.length} skill(s) have low quality scores (< 60)`, + ), + ); for (const lq of lowQualitySkills) { const grade = getQualityGradeFromScore(lq.score); - const warningText = lq.warnings.length > 0 ? ` - ${lq.warnings.join(', ')}` : ''; - console.log(colors.muted(` - ${lq.name} [${grade}]${warningText}`)); + const warningText = + lq.warnings.length > 0 ? ` - ${lq.warnings.join(", ")}` : ""; + console.log( + colors.muted(` - ${lq.name} [${grade}]${warningText}`), + ); } - console.log(''); + console.log(""); } - const agentDisplay = targetAgents.length <= 3 - ? targetAgents.map(formatAgent).join(', ') - : `${targetAgents.slice(0, 2).map(formatAgent).join(', ')} +${targetAgents.length - 2} more`; + const agentDisplay = + targetAgents.length <= 3 + ? targetAgents.map(formatAgent).join(", ") + : `${targetAgents.slice(0, 2).map(formatAgent).join(", ")} +${targetAgents.length - 2} more`; const confirmResult = await confirm({ message: `Install ${skillsToInstall.length} skill(s) to ${agentDisplay}?`, @@ -332,7 +436,7 @@ export class InstallCommand extends Command { }); if (isCancel(confirmResult) || !confirmResult) { - cancel('Installation cancelled'); + cancel("Installation cancelled"); return 0; } } @@ -359,21 +463,26 @@ export class InstallCommand extends Command { if (existsSync(targetPath) && !this.force) { if (!this.quiet) { - warn(`Skipping ${skillName} for ${adapter.name} (already exists, use --force)`); + warn( + `Skipping ${skillName} for ${adapter.name} (already exists, use --force)`, + ); } continue; } - const securityRoot = cloneResult.tempRoot || cloneResult.path || ''; + const securityRoot = cloneResult.tempRoot || cloneResult.path || ""; if (!securityRoot || !isPathInside(sourcePath, securityRoot)) { error(`Skipping ${skillName} (path traversal detected)`); continue; } - const isSymlinkMode = installMethod === 'symlink' && targetAgents.length > 1; + const isSymlinkMode = + installMethod === "symlink" && targetAgents.length > 1; const useSymlink = isSymlinkMode && primaryPath !== null; - s.start(`Installing ${skillName} to ${adapter.name}${useSymlink ? ' (symlink)' : ''}...`); + s.start( + `Installing ${skillName} to ${adapter.name}${useSymlink ? " (symlink)" : ""}...`, + ); try { if (existsSync(targetPath)) { @@ -381,24 +490,33 @@ export class InstallCommand extends Command { } if (useSymlink && primaryPath) { - symlinkSync(primaryPath, targetPath, 'dir'); + symlinkSync(primaryPath, targetPath, "dir"); } else { - cpSync(sourcePath, targetPath, { recursive: true, dereference: true }); + cpSync(sourcePath, targetPath, { + recursive: true, + dereference: true, + }); if (isSymlinkMode && primaryPath === null) { primaryPath = targetPath; } // Auto-install npm dependencies if package.json exists - const packageJsonPath = join(targetPath, 'package.json'); + const packageJsonPath = join(targetPath, "package.json"); if (existsSync(packageJsonPath)) { s.stop(`Installed ${skillName} to ${adapter.name}`); s.start(`Installing npm dependencies for ${skillName}...`); try { - await execFileAsync('npm', ['install', '--production'], { cwd: targetPath }); + await execFileAsync("npm", ["install", "--omit=dev"], { + cwd: targetPath, + }); s.stop(`Installed dependencies for ${skillName}`); } catch (npmErr) { - s.stop(colors.warning(`Dependencies failed for ${skillName}`)); - console.log(colors.muted('Run manually: npm install in ' + targetPath)); + s.stop( + colors.warning(`Dependencies failed for ${skillName}`), + ); + console.log( + colors.muted("Run manually: npm install in " + targetPath), + ); } s.start(`Finishing ${skillName} installation...`); } @@ -406,7 +524,7 @@ export class InstallCommand extends Command { const metadata: SkillMetadata = { name: skillName, - description: '', + description: "", source: this.source, sourceType: providerAdapter.type, subpath: skillName, @@ -416,10 +534,16 @@ export class InstallCommand extends Command { saveSkillMetadata(targetPath, metadata); installedAgents.push(agentType); - s.stop(`Installed ${skillName} to ${adapter.name}${useSymlink ? ' (symlink)' : ''}`); + s.stop( + `Installed ${skillName} to ${adapter.name}${useSymlink ? " (symlink)" : ""}`, + ); } catch (err) { - s.stop(colors.error(`Failed to install ${skillName} to ${adapter.name}`)); - console.log(colors.muted(err instanceof Error ? err.message : String(err))); + s.stop( + colors.error(`Failed to install ${skillName} to ${adapter.name}`), + ); + console.log( + colors.muted(err instanceof Error ? err.message : String(err)), + ); } } @@ -429,7 +553,10 @@ export class InstallCommand extends Command { skillName, method: installMethod, agents: installedAgents, - path: join(getInstallDir(this.global, installedAgents[0] as AgentType), skillName), + path: join( + getInstallDir(this.global, installedAgents[0] as AgentType), + skillName, + ), }); } } @@ -442,19 +569,22 @@ export class InstallCommand extends Command { if (totalInstalled > 0) { try { - const agentsMdPath = join(process.cwd(), 'AGENTS.md'); + const agentsMdPath = join(process.cwd(), "AGENTS.md"); if (existsSync(agentsMdPath)) { const parser = new AgentsMdParser(); - const existing = readFileSync(agentsMdPath, 'utf-8'); + const existing = readFileSync(agentsMdPath, "utf-8"); if (parser.hasManagedSections(existing)) { const gen = new AgentsMdGenerator({ projectPath: process.cwd() }); const genResult = gen.generate(); - const updated = parser.updateManagedSections(existing, genResult.sections.filter(s => s.managed)); - writeFileSync(agentsMdPath, updated, 'utf-8'); + const updated = parser.updateManagedSections( + existing, + genResult.sections.filter((s) => s.managed), + ); + writeFileSync(agentsMdPath, updated, "utf-8"); } } } catch { - warn('Failed to update AGENTS.md'); + warn("Failed to update AGENTS.md"); } } @@ -468,32 +598,105 @@ export class InstallCommand extends Command { source: this.source, }); - outro('Installation complete!'); + await this.showSkillsShStats( + installResults.map((r) => r.skillName), + providerAdapter, + ); + + outro("Installation complete!"); if (!this.yes) { showNextSteps({ - skillNames: installResults.map(r => r.skillName), + skillNames: installResults.map((r) => r.skillName), agentTypes: targetAgents, syncNeeded: true, }); + + this.showProTips(installResults.map((r) => r.skillName)); } } else { - success(`Installed ${totalInstalled} skill(s) to ${targetAgents.length} agent(s)`); + success( + `Installed ${totalInstalled} skill(s) to ${targetAgents.length} agent(s)`, + ); for (const r of installResults) { - console.log(colors.muted(` ${symbols.success} ${r.skillName} ${symbols.arrowRight} ${r.agents.map(getAgentIcon).join(' ')}`)); + console.log( + colors.muted( + ` ${symbols.success} ${r.skillName} ${symbols.arrowRight} ${r.agents.map(getAgentIcon).join(" ")}`, + ), + ); } - console.log(''); - console.log(colors.muted('Run `skillkit sync` to update agent configs')); + console.log(""); + console.log( + colors.muted("Run `skillkit sync` to update agent configs"), + ); } } else { - warn('No skills were installed'); + warn("No skills were installed"); } return 0; } catch (err) { - s.stop(colors.error('Installation failed')); - console.log(colors.muted(err instanceof Error ? err.message : String(err))); + s.stop(colors.error("Installation failed")); + console.log( + colors.muted(err instanceof Error ? err.message : String(err)), + ); return 1; } } + + private async showSkillsShStats( + skillNames: string[], + provider: { + type: string; + parseSource: (s: string) => { owner: string; repo: string } | null; + }, + ): Promise { + try { + if (provider.type === "local") return; + const parsed = provider.parseSource(this.source); + if (!parsed) return; + + const registry = new SkillsShRegistry(); + const statsResults: SkillsShStats[] = []; + + for (const name of skillNames) { + const stats = await registry.getSkillStats( + parsed.owner, + parsed.repo, + name, + ); + if (stats) statsResults.push(stats); + } + + if (statsResults.length > 0) { + console.log(""); + console.log(colors.bold("Skills.sh Popularity:")); + for (const s of statsResults) { + const rank = + s.rank <= 10 + ? colors.success(`#${s.rank}`) + : colors.muted(`#${s.rank}`); + const installs = s.installs > 0 ? formatCount(s.installs) : "n/a"; + console.log( + ` ${rank} ${colors.primary(s.skillName)} ${colors.muted(`(${installs} installs)`)}`, + ); + } + } + } catch { + // Non-critical, silently skip + } + } + + private showProTips(skillNames: string[]): void { + const tips = [ + `Translate to any agent format: ${colors.bold(`skillkit translate ${skillNames[0] || "skill-name"} --to cursor`)}`, + `Get AI recommendations: ${colors.bold("skillkit recommend")}`, + `Score quality: ${colors.bold(`skillkit evaluate ${skillNames[0] || "skill-name"}`)}`, + `Browse all skills: ${colors.bold("skillkit find --top")}`, + ]; + + const tip = tips[Math.floor(Math.random() * tips.length)]; + console.log(""); + console.log(colors.muted(`Pro tip: ${tip}`)); + } } diff --git a/packages/cli/src/helpers.ts b/packages/cli/src/helpers.ts index ff8d028f..fdd78f06 100644 --- a/packages/cli/src/helpers.ts +++ b/packages/cli/src/helpers.ts @@ -7,9 +7,9 @@ import { initProject as coreInitProject, loadSkillMetadata as coreLoadSkillMetadata, saveSkillMetadata as coreSaveSkillMetadata, -} from '@skillkit/core'; -import { getAdapter, detectAgent } from '@skillkit/agents'; -import type { AgentType, AgentAdapterInfo } from '@skillkit/core'; +} from "@skillkit/core"; +import { getAdapter, detectAgent } from "@skillkit/agents"; +import type { AgentType, AgentAdapterInfo } from "@skillkit/core"; // Re-export metadata functions directly (they don't need adapter bridging) export const loadSkillMetadata = coreLoadSkillMetadata; @@ -62,3 +62,9 @@ export async function initProject(agentType?: AgentType): Promise { }; return coreInitProject(type, adapterInfo); } + +export function formatCount(count: number): string { + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; + return String(count); +} diff --git a/packages/core/src/providers/index.ts b/packages/core/src/providers/index.ts index 4c453ce6..9532b417 100644 --- a/packages/core/src/providers/index.ts +++ b/packages/core/src/providers/index.ts @@ -1,20 +1,23 @@ -import type { GitProviderAdapter } from './base.js'; -import type { GitProvider } from '../types.js'; -import { GitHubProvider } from './github.js'; -import { GitLabProvider } from './gitlab.js'; -import { BitbucketProvider } from './bitbucket.js'; -import { LocalProvider } from './local.js'; -import { WellKnownProvider } from './wellknown.js'; +import type { GitProviderAdapter } from "./base.js"; +import type { GitProvider } from "../types.js"; +import { GitHubProvider } from "./github.js"; +import { GitLabProvider } from "./gitlab.js"; +import { BitbucketProvider } from "./bitbucket.js"; +import { LocalProvider } from "./local.js"; +import { WellKnownProvider } from "./wellknown.js"; +import { SkillsShProvider } from "./skills-sh.js"; -export * from './base.js'; -export * from './github.js'; -export * from './gitlab.js'; -export * from './bitbucket.js'; -export * from './local.js'; -export * from './wellknown.js'; +export * from "./base.js"; +export * from "./github.js"; +export * from "./gitlab.js"; +export * from "./bitbucket.js"; +export * from "./local.js"; +export * from "./wellknown.js"; +export * from "./skills-sh.js"; const providers: GitProviderAdapter[] = [ new LocalProvider(), + new SkillsShProvider(), new GitLabProvider(), new BitbucketProvider(), new WellKnownProvider(), @@ -22,7 +25,7 @@ const providers: GitProviderAdapter[] = [ ]; export function getProvider(type: GitProvider): GitProviderAdapter | undefined { - return providers.find(p => p.type === type); + return providers.find((p) => p.type === type); } export function getAllProviders(): GitProviderAdapter[] { @@ -30,7 +33,7 @@ export function getAllProviders(): GitProviderAdapter[] { } export function detectProvider(source: string): GitProviderAdapter | undefined { - return providers.find(p => p.matches(source)); + return providers.find((p) => p.matches(source)); } export function parseSource(source: string): { diff --git a/packages/core/src/providers/skills-sh.ts b/packages/core/src/providers/skills-sh.ts new file mode 100644 index 00000000..32d382d2 --- /dev/null +++ b/packages/core/src/providers/skills-sh.ts @@ -0,0 +1,122 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; +import { join, basename, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import type { GitProviderAdapter, CloneOptions } from "./base.js"; +import type { GitProvider, CloneResult } from "../types.js"; +import { discoverSkills } from "../skills.js"; + +/** + * Resolves `skills.sh/owner/repo/skill` references to their underlying + * GitHub repos and clones them the same way the GitHubProvider does. + */ +export class SkillsShProvider implements GitProviderAdapter { + readonly type: GitProvider = "skills-sh"; + readonly name = "Skills.sh"; + readonly baseUrl = "https://skills.sh"; + + parseSource( + source: string, + ): { owner: string; repo: string; subpath?: string } | null { + const cleaned = source + .replace(/^https?:\/\//, "") + .replace(/^skills\.sh\//, ""); + + if (!this.isSkillsShSource(source)) return null; + + const parts = cleaned.split("/").filter(Boolean); + if (parts.length < 2) return null; + + return { + owner: parts[0], + repo: parts[1], + subpath: parts.length > 2 ? parts.slice(2).join("/") : undefined, + }; + } + + matches(source: string): boolean { + return this.isSkillsShSource(source); + } + + getCloneUrl(owner: string, repo: string): string { + return `https://github.com/${owner}/${repo}.git`; + } + + getSshUrl(owner: string, repo: string): string { + return `git@github.com:${owner}/${repo}.git`; + } + + async clone( + source: string, + _targetDir: string, + options: CloneOptions = {}, + ): Promise { + const parsed = this.parseSource(source); + if (!parsed) { + return { success: false, error: `Invalid skills.sh source: ${source}` }; + } + + const { owner, repo, subpath } = parsed; + const cloneUrl = options.ssh + ? this.getSshUrl(owner, repo) + : this.getCloneUrl(owner, repo); + + const tempDir = join(tmpdir(), `skillkit-skillssh-${randomUUID()}`); + + try { + const args = ["clone"]; + if (options.depth) { + args.push("--depth", String(options.depth)); + } + if (options.branch) { + args.push("--branch", options.branch); + } + args.push(cloneUrl, tempDir); + + execFileSync("git", args, { + stdio: ["pipe", "pipe", "pipe"], + encoding: "utf-8", + }); + + const searchDir = subpath ? join(tempDir, subpath) : tempDir; + + if (!resolve(searchDir).startsWith(resolve(tempDir))) { + rmSync(tempDir, { recursive: true, force: true }); + return { + success: false, + error: `Invalid subpath in source: ${source}`, + }; + } + + const skills = discoverSkills(searchDir); + + return { + success: true, + path: searchDir, + tempRoot: tempDir, + skills: skills.map((s) => s.name), + discoveredSkills: skills.map((s) => ({ + name: s.name, + dirName: basename(s.path), + path: s.path, + })), + }; + } catch (error) { + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: `Failed to clone: ${message}` }; + } + } + + private isSkillsShSource(source: string): boolean { + return ( + source.startsWith("skills.sh/") || + source.startsWith("https://skills.sh/") || + source.startsWith("http://skills.sh/") + ); + } +} diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 7541cec5..70be5c71 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -10,7 +10,10 @@ export interface ExternalSkill { export interface ExternalRegistry { name: string; - search(query: string, options?: { limit?: number; timeoutMs?: number }): Promise; + search( + query: string, + options?: { limit?: number; timeoutMs?: number }, + ): Promise; } export interface FederatedResult { @@ -22,16 +25,21 @@ export interface FederatedResult { export class RateLimitError extends Error { constructor(registry: string) { - super(`Rate limited by ${registry} API. Authenticate with a token or wait before retrying.`); - this.name = 'RateLimitError'; + super( + `Rate limited by ${registry} API. Authenticate with a token or wait before retrying.`, + ); + this.name = "RateLimitError"; } } export class GitHubSkillRegistry implements ExternalRegistry { - name = 'github'; - private baseUrl = 'https://api.github.com'; + name = "github"; + private baseUrl = "https://api.github.com"; - async search(query: string, options?: { limit?: number; timeoutMs?: number }): Promise { + async search( + query: string, + options?: { limit?: number; timeoutMs?: number }, + ): Promise { const limit = options?.limit ?? 20; const timeoutMs = options?.timeoutMs ?? 10_000; const searchQuery = `SKILL.md ${query} in:path,file`; @@ -41,8 +49,8 @@ export class GitHubSkillRegistry implements ExternalRegistry { try { const headers: Record = { - Accept: 'application/vnd.github.v3+json', - 'User-Agent': 'skillkit-cli', + Accept: "application/vnd.github.v3+json", + "User-Agent": "skillkit-cli", }; const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; if (token) { @@ -89,15 +97,15 @@ export class GitHubSkillRegistry implements ExternalRegistry { if (seen.has(repo)) continue; seen.add(repo); - const pathParts = item.path.split('/'); + const pathParts = item.path.split("/"); const skillName = pathParts.length > 1 ? pathParts[pathParts.length - 2] - : repo.split('/').pop() || repo; + : repo.split("/").pop() || repo; skills.push({ name: skillName, - description: item.repository.description || '', + description: item.repository.description || "", source: item.repository.html_url, registry: this.name, path: item.path, @@ -115,7 +123,9 @@ export class GitHubSkillRegistry implements ExternalRegistry { } } -export { CommunityRegistry } from './community.js'; +export { CommunityRegistry } from "./community.js"; +export { SkillsShRegistry, resolveSkillsShUrl } from "./skills-sh.js"; +export type { SkillsShStats } from "./skills-sh.js"; export class FederatedSearch { private registries: ExternalRegistry[] = []; @@ -124,10 +134,15 @@ export class FederatedSearch { this.registries.push(registry); } - async search(query: string, options?: { limit?: number }): Promise { + async search( + query: string, + options?: { limit?: number; timeoutMs?: number }, + ): Promise { const limit = options?.limit ?? 20; const results = await Promise.allSettled( - this.registries.map((r) => r.search(query, { limit })), + this.registries.map((r) => + r.search(query, { limit, timeoutMs: options?.timeoutMs }), + ), ); const allSkills: ExternalSkill[] = []; @@ -135,11 +150,9 @@ export class FederatedSearch { for (let i = 0; i < results.length; i++) { const result = results[i]; - if (result.status === 'fulfilled' && result.value.length > 0) { + if (result.status === "fulfilled" && result.value.length > 0) { allSkills.push(...result.value); activeRegistries.push(this.registries[i].name); - } else if (result.status === 'rejected' && result.reason instanceof RateLimitError) { - throw result.reason; } } diff --git a/packages/core/src/registry/skills-sh.ts b/packages/core/src/registry/skills-sh.ts new file mode 100644 index 00000000..0f5a59b8 --- /dev/null +++ b/packages/core/src/registry/skills-sh.ts @@ -0,0 +1,190 @@ +import type { ExternalRegistry, ExternalSkill } from "./index.js"; + +const SKILLS_SH_BASE = "https://skills.sh"; +const SKILLS_SH_CACHE_TTL = 10 * 60 * 1000; // 10 minutes + +export interface SkillsShStats { + rank: number; + installs: number; + name: string; + source: string; + skillName: string; +} + +interface CachedLeaderboard { + skills: ExternalSkill[]; + fetchedAt: number; +} + +export class SkillsShRegistry implements ExternalRegistry { + name = "skills.sh"; + private cache: CachedLeaderboard | null = null; + + async search( + query: string, + options?: { limit?: number; timeoutMs?: number }, + ): Promise { + const limit = options?.limit ?? 20; + const skills = await this.fetchLeaderboard(options?.timeoutMs); + const q = query.toLowerCase(); + + return skills + .filter( + (s) => + s.name.toLowerCase().includes(q) || + s.description.toLowerCase().includes(q) || + s.source.toLowerCase().includes(q), + ) + .slice(0, limit); + } + + async getLeaderboard( + limit = 50, + timeoutMs?: number, + ): Promise { + const skills = await this.fetchLeaderboard(timeoutMs); + return skills.slice(0, limit); + } + + async getSkillStats( + owner: string, + repo: string, + skillName: string, + ): Promise { + const skills = await this.fetchLeaderboard(); + const source = `${owner}/${repo}`; + + for (let i = 0; i < skills.length; i++) { + const skill = skills[i]; + if (skill.name === skillName && skill.source.endsWith(`/${source}`)) { + return { + rank: i + 1, + installs: skill.stars ?? 0, + name: skill.name, + source: skill.source, + skillName, + }; + } + } + + return null; + } + + private async fetchLeaderboard(timeoutMs = 15_000): Promise { + if (this.cache && Date.now() - this.cache.fetchedAt < SKILLS_SH_CACHE_TTL) { + return this.cache.skills; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(SKILLS_SH_BASE, { + headers: { + Accept: "text/html", + "User-Agent": "skillkit-cli", + }, + signal: controller.signal, + }); + + if (!response.ok) return this.cache?.skills ?? []; + + const html = await response.text(); + const skills = this.parseLeaderboard(html); + + this.cache = { skills, fetchedAt: Date.now() }; + return skills; + } catch { + return this.cache?.skills ?? []; + } finally { + clearTimeout(timer); + } + } + + private parseLeaderboard(html: string): ExternalSkill[] { + const skills: ExternalSkill[] = []; + + const lines = html.split("\n"); + for (const line of lines) { + // Match patterns like: [1vercel-labs/skills290.0K] from the page links + const hrefMatch = line.match( + /href="\/([^/]+)\/([^/]+)\/([^"]+)".*?(\d[\d,.]*[KMB]?)\s*<\/a>/, + ); + if (hrefMatch) { + const [, owner, repo, skillName, installStr] = hrefMatch; + const installs = this.parseInstallCount(installStr); + + skills.push({ + name: skillName, + description: `${owner}/${repo}`, + source: `https://github.com/${owner}/${repo}`, + registry: this.name, + path: skillName, + stars: installs, + }); + } + } + + // Fallback: try simpler pattern matching if structured parsing found nothing + if (skills.length === 0) { + const simplePattern = /skills\.sh\/([^/]+)\/([^/]+)\/([^/"]+)/g; + let match; + while ((match = simplePattern.exec(html)) !== null) { + const [, owner, repo, skillName] = match; + skills.push({ + name: skillName, + description: `${owner}/${repo}`, + source: `https://github.com/${owner}/${repo}`, + registry: this.name, + path: skillName, + }); + } + } + + return skills; + } + + private parseInstallCount(str: string): number { + if (!str) return 0; + const cleaned = str.replace(/,/g, ""); + const multiplier = cleaned.endsWith("K") + ? 1_000 + : cleaned.endsWith("M") + ? 1_000_000 + : cleaned.endsWith("B") + ? 1_000_000_000 + : 1; + const num = parseFloat(cleaned.replace(/[KMB]$/, "")); + return Math.round(num * multiplier); + } +} + +function parseSkillsShParts( + source: string, +): { owner: string; repo: string; rest?: string } | null { + const cleaned = source + .replace(/^https?:\/\//, "") + .replace(/^skills\.sh\//, ""); + + const parts = cleaned.split("/").filter(Boolean); + if (parts.length < 2) return null; + + return { + owner: parts[0], + repo: parts[1], + rest: parts.length > 2 ? parts.slice(2).join("/") : undefined, + }; +} + +export function resolveSkillsShUrl( + source: string, +): { owner: string; repo: string; skillName?: string } | null { + const parsed = parseSkillsShParts(source); + if (!parsed) return null; + + return { + owner: parsed.owner, + repo: parsed.repo, + skillName: parsed.rest, + }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 645a6b17..7ae424d4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,66 +1,77 @@ -import { z } from 'zod'; +import { z } from "zod"; export const AgentType = z.enum([ - 'claude-code', - 'codex', - 'cursor', - 'antigravity', - 'opencode', - 'gemini-cli', - 'amp', - 'clawdbot', - 'droid', - 'github-copilot', - 'goose', - 'kilo', - 'kiro-cli', - 'roo', - 'trae', - 'windsurf', - 'universal', - 'cline', - 'codebuddy', - 'commandcode', - 'continue', - 'crush', - 'factory', - 'mcpjam', - 'mux', - 'neovate', - 'openhands', - 'pi', - 'qoder', - 'qwen', - 'vercel', - 'zencoder', - 'devin', - 'aider', - 'sourcegraph-cody', - 'amazon-q', - 'augment-code', - 'replit-agent', - 'bolt', - 'lovable', - 'tabby', - 'tabnine', - 'codegpt', - 'playcode-agent', + "claude-code", + "codex", + "cursor", + "antigravity", + "opencode", + "gemini-cli", + "amp", + "clawdbot", + "droid", + "github-copilot", + "goose", + "kilo", + "kiro-cli", + "roo", + "trae", + "windsurf", + "universal", + "cline", + "codebuddy", + "commandcode", + "continue", + "crush", + "factory", + "mcpjam", + "mux", + "neovate", + "openhands", + "pi", + "qoder", + "qwen", + "vercel", + "zencoder", + "devin", + "aider", + "sourcegraph-cody", + "amazon-q", + "augment-code", + "replit-agent", + "bolt", + "lovable", + "tabby", + "tabnine", + "codegpt", + "playcode-agent", ]); export type AgentType = z.infer; -export const GitProvider = z.enum(['github', 'gitlab', 'bitbucket', 'local', 'wellknown']); +export const GitProvider = z.enum([ + "github", + "gitlab", + "bitbucket", + "local", + "wellknown", + "skills-sh", +]); export type GitProvider = z.infer; export const SkillFrontmatter = z.object({ - name: z.string() + name: z + .string() .min(1) .max(64) - .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'Skill name must be lowercase alphanumeric with hyphens, no leading/trailing/consecutive hyphens'), + .regex( + /^[a-z0-9]+(-[a-z0-9]+)*$/, + "Skill name must be lowercase alphanumeric with hyphens, no leading/trailing/consecutive hyphens", + ), description: z.string().min(1).max(1024), license: z.string().optional(), compatibility: z.string().max(500).optional(), metadata: z.record(z.string()).optional(), - 'allowed-tools': z.string().optional(), + "allowed-tools": z.string().optional(), version: z.string().optional(), author: z.string().optional(), tags: z.array(z.string()).optional(), @@ -82,7 +93,7 @@ export const SkillMetadata = z.object({ }); export type SkillMetadata = z.infer; -export const SkillLocation = z.enum(['project', 'global']); +export const SkillLocation = z.enum(["project", "global"]); export type SkillLocation = z.infer; export const Skill = z.object({ @@ -97,7 +108,7 @@ export type Skill = z.infer; export const SkillkitConfig = z.object({ version: z.literal(1), - agent: AgentType.default('universal'), + agent: AgentType.default("universal"), skillsDir: z.string().optional(), enabledSkills: z.array(z.string()).optional(), disabledSkills: z.array(z.string()).optional(),