diff --git a/src/cli.ts b/src/cli.ts index 6cc0727d..ca35c854 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ import { createHash } from 'crypto'; import { fileURLToPath } from 'url'; import { runAdd, parseAddOptions, initTelemetry } from './add.ts'; import { runFind } from './find.ts'; +import { runInfo } from './info.ts'; import { runInstallFromLock } from './install.ts'; import { runList } from './list.ts'; import { removeCommand, parseRemoveOptions } from './remove.ts'; @@ -117,6 +118,7 @@ ${BOLD}Manage Skills:${RESET} ${BOLD}Updates:${RESET} check Check for available skill updates + info Show skill info and SKILL.md content update Update all skills to latest versions ${BOLD}Project:${RESET} @@ -167,6 +169,8 @@ ${BOLD}Examples:${RESET} ${DIM}$${RESET} skills find ${DIM}# interactive search${RESET} ${DIM}$${RESET} skills find typescript ${DIM}# search by keyword${RESET} ${DIM}$${RESET} skills check + ${DIM}$${RESET} skills info my-skill ${DIM}# show installed skill info${RESET} + ${DIM}$${RESET} skills info owner/repo@skill ${DIM}# fetch remote skill info${RESET} ${DIM}$${RESET} skills update ${DIM}$${RESET} skills experimental_install ${DIM}# restore from skills-lock.json${RESET} ${DIM}$${RESET} skills init my-skill @@ -682,6 +686,9 @@ async function main(): Promise { case 'check': runCheck(restArgs); break; + case 'info': + await runInfo(restArgs); + break; case 'update': case 'upgrade': runUpdate(); diff --git a/src/info.ts b/src/info.ts new file mode 100644 index 00000000..adb4e01c --- /dev/null +++ b/src/info.ts @@ -0,0 +1,148 @@ +import { readSkillLock, getGitHubToken } from './skill-lock.ts'; +import { parseSource } from './source-parser.ts'; + +const RESET = '\x1b[0m'; +const BOLD = '\x1b[1m'; +const DIM = '\x1b[38;5;102m'; +const TEXT = '\x1b[38;5;145m'; + +/** + * Fetch SKILL.md content from a GitHub repo. + */ +async function fetchSkillMd( + ownerRepo: string, + skillPath: string | undefined, + token: string | null +): Promise { + // Determine the path to SKILL.md + let filePath = 'SKILL.md'; + if (skillPath) { + filePath = skillPath.replace(/\\/g, '/'); + if (!filePath.endsWith('SKILL.md')) { + filePath = filePath.endsWith('/') ? `${filePath}SKILL.md` : `${filePath}/SKILL.md`; + } + } + + const branches = ['main', 'master']; + const headers: Record = { + Accept: 'application/vnd.github.v3.raw', + 'User-Agent': 'skills-cli', + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + for (const branch of branches) { + try { + const url = `https://api.github.com/repos/${ownerRepo}/contents/${filePath}?ref=${branch}`; + const response = await fetch(url, { headers }); + if (response.ok) { + return await response.text(); + } + } catch { + continue; + } + } + return null; +} + +/** + * Strip YAML frontmatter from markdown content. + */ +function stripFrontmatter(content: string): string { + if (content.startsWith('---')) { + const endIndex = content.indexOf('---', 3); + if (endIndex !== -1) { + return content.slice(endIndex + 3).trimStart(); + } + } + return content; +} + +/** + * Run the `skills info` command. + * + * Usage: + * skills info — show info for an installed skill + * skills info owner/repo@skill — fetch SKILL.md from GitHub + */ +export async function runInfo(args: string[]): Promise { + const target = args[0]; + + if (!target) { + console.log(`${BOLD}Usage:${RESET} skills info `); + console.log(); + console.log(`${DIM}Examples:${RESET}`); + console.log(` ${DIM}$${RESET} skills info my-skill`); + console.log(` ${DIM}$${RESET} skills info vercel-labs/agent-skills@pr-review`); + return; + } + + const token = getGitHubToken(); + + // Check if it's an installed skill (by name) + const lock = readSkillLock(); + const entry = lock.skills[target]; + + if (entry) { + // Show metadata for installed skill + console.log(`${BOLD}${target}${RESET}`); + console.log(); + console.log(` ${DIM}Source:${RESET} ${TEXT}${entry.source}${RESET}`); + console.log(` ${DIM}Type:${RESET} ${TEXT}${entry.sourceType}${RESET}`); + console.log(` ${DIM}URL:${RESET} ${TEXT}${entry.sourceUrl}${RESET}`); + if (entry.skillPath) { + console.log(` ${DIM}Path:${RESET} ${TEXT}${entry.skillPath}${RESET}`); + } + console.log(` ${DIM}Installed:${RESET} ${TEXT}${entry.installedAt}${RESET}`); + console.log(` ${DIM}Updated:${RESET} ${TEXT}${entry.updatedAt}${RESET}`); + + // Try to fetch and display SKILL.md content + if (entry.sourceType === 'github' && entry.source) { + console.log(); + const content = await fetchSkillMd(entry.source, entry.skillPath, token); + if (content) { + console.log(`${DIM}--- SKILL.md ---${RESET}`); + console.log(stripFrontmatter(content)); + } + } + return; + } + + // Try to parse as owner/repo@skill format + const atIndex = target.lastIndexOf('@'); + if (atIndex > 0) { + const repoSource = target.slice(0, atIndex); + const skillName = target.slice(atIndex + 1); + const parsed = parseSource(repoSource); + + if (parsed.type === 'github') { + // Try common skill paths + const possiblePaths = [ + `${skillName}/SKILL.md`, + `.claude/skills/${skillName}/SKILL.md`, + `skills/${skillName}/SKILL.md`, + ]; + + // Extract owner/repo + const match = parsed.url.match(/github\.com[/:]([^/]+\/[^/.]+)/); + const ownerRepo = match ? match[1]!.replace(/\.git$/, '') : repoSource; + + for (const path of possiblePaths) { + const content = await fetchSkillMd(ownerRepo, path, token); + if (content) { + console.log(`${BOLD}${skillName}${RESET} ${DIM}(${ownerRepo})${RESET}`); + console.log(); + console.log(stripFrontmatter(content)); + return; + } + } + + console.log(`${DIM}Could not find SKILL.md for "${skillName}" in ${ownerRepo}${RESET}`); + return; + } + } + + console.log(`${DIM}Skill "${target}" not found.${RESET}`); + console.log(`${DIM}Try: skills info owner/repo@skill-name${RESET}`); +}