diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index ef491bf7..c60f4c33 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,6 +1,6 @@ { "name": "motovis-skillhub", - "version": "1.2.0", + "version": "1.2.3", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", "bin": { diff --git a/skillhub-cli/src/cli.ts b/skillhub-cli/src/cli.ts index 5367e392..82a4bb53 100644 --- a/skillhub-cli/src/cli.ts +++ b/skillhub-cli/src/cli.ts @@ -56,13 +56,19 @@ function buildTopLevelHelp(version: string): string { ])); sections.push(""); - sections.push(formatSection("Discover", [ + sections.push(formatSection("Discover & Info", [ { cmd: "explore", desc: "Browse or search skills from the registry", alias: "find, find-skills, search" }, + { cmd: "inspect ", desc: "View skill metadata and versions", alias: "info, view" }, + { cmd: "resolve ", desc: "Resolve the latest version of a skill" }, + { cmd: "rating ", desc: "View your rating for a skill" }, + { cmd: "rate ", desc: "Rate a skill (1-5)" }, + { cmd: "star ", desc: "Star a skill" }, + { cmd: "report ", desc: "Report a skill for review" }, ])); sections.push(""); sections.push(formatSection("Install & Manage", [ - { cmd: "install ", desc: "Install from registry, git, or local path", alias: "i" }, + { cmd: "install ", desc: "Install from registry, git, or local path", alias: "i" }, { cmd: "download ", desc: "Download a skill package to local directory" }, { cmd: "update [slug]", desc: "Update installed skills from their source", alias: "up" }, { cmd: "uninstall [name]", desc: "Uninstall a skill from local agent", alias: "un" }, @@ -77,17 +83,6 @@ function buildTopLevelHelp(version: string): string { { cmd: "sync [path]", desc: "Scan and publish all skills from a directory" }, { cmd: "delete ", desc: "Delete a skill you own", alias: "del, unpublish" }, { cmd: "archive ", desc: "Archive a skill you own" }, - { cmd: "versions ", desc: "List skill versions" }, - ])); - sections.push(""); - - sections.push(formatSection("Info & Review", [ - { cmd: "inspect ", desc: "View skill metadata without installing", alias: "info, view" }, - { cmd: "resolve ", desc: "Resolve the latest version of a skill" }, - { cmd: "rating ", desc: "View your rating for a skill" }, - { cmd: "rate ", desc: "Rate a skill (1-5)" }, - { cmd: "star ", desc: "Star a skill" }, - { cmd: "report ", desc: "Report a skill for review" }, ])); sections.push(""); @@ -171,7 +166,6 @@ export async function createCli(): Promise { { registerReviews }, { registerNotifications }, { registerDelete }, - { registerVersions }, { registerReport }, { registerResolve }, { registerRating, registerRate }, @@ -200,7 +194,6 @@ export async function createCli(): Promise { import("./commands/reviews.js"), import("./commands/notifications.js"), import("./commands/delete.js"), - import("./commands/versions.js"), import("./commands/report.js"), import("./commands/resolve.js"), import("./commands/rating.js"), @@ -230,7 +223,6 @@ export async function createCli(): Promise { registerReviews(program); registerNotifications(program); registerDelete(program); - registerVersions(program); registerReport(program); registerResolve(program); registerRating(program); diff --git a/skillhub-cli/src/commands/inspect.ts b/skillhub-cli/src/commands/inspect.ts index 399ab437..3037658e 100644 --- a/skillhub-cli/src/commands/inspect.ts +++ b/skillhub-cli/src/commands/inspect.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import { ApiClient } from "../core/api-client.js"; import { ApiRoutes } from "../schema/routes.js"; -import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { loadConfigFromProgram } from "../core/config.js"; import { readToken } from "../core/auth-token.js"; import { parseSkillName } from "../core/skill-name.js"; import { info, dim, error } from "../utils/logger.js"; @@ -28,7 +28,38 @@ interface NamespaceInfo { status: string; } -function printSkillDetail(detail: SkillDetailResponse) { +interface SkillVersionItem { + id: number; + version: string; + status: string; + changelog: string | null; + fileCount: number; + totalSize: number; + publishedAt: string; + downloadAvailable: boolean; +} + +interface VersionsResponse { + items: SkillVersionItem[]; + total: number; + page: number; + size: number; +} + +interface SkillTag { + id: number; + tagName: string; + versionId: number; + createdAt: string; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function printSkillDetail(detail: SkillDetailResponse, versions?: SkillVersionItem[], tags?: SkillTag[]) { console.log(""); info(`${detail.displayName} (${detail.slug})`); dim(`Namespace: ${detail.namespace}`); @@ -40,10 +71,29 @@ function printSkillDetail(detail: SkillDetailResponse) { if (detail.labels && detail.labels.length > 0) { dim(`Labels: ${detail.labels.map((l) => l.name || l.slug).join(", ")}`); } + + if (versions && versions.length > 0) { + console.log(""); + info("Versions:"); + const versionTagsMap = new Map(); + if (tags) { + for (const tag of tags) { + if (!versionTagsMap.has(tag.versionId)) { + versionTagsMap.set(tag.versionId, []); + } + versionTagsMap.get(tag.versionId)!.push(tag.tagName); + } + } + for (const v of versions) { + const tagStr = versionTagsMap.get(v.id)?.join(", ") || ""; + dim(` v${v.version} ${v.status} · ${v.fileCount} files · ${formatBytes(v.totalSize)} · ${v.publishedAt}${tagStr ? " · tags: " + tagStr : ""}`); + } + } + console.log(""); } -function printInspectHeader(detail: SkillDetailResponse) { +function printInspectHeader(detail: SkillDetailResponse, versions?: SkillVersionItem[], tags?: SkillTag[]) { console.log(""); info(`=== ${detail.displayName} ===`); dim(`Namespace: ${detail.namespace}`); @@ -60,6 +110,25 @@ function printInspectHeader(detail: SkillDetailResponse) { console.log(""); dim(`Labels: ${detail.labels.map((l) => l.name || l.slug).join(", ")}`); } + + if (versions && versions.length > 0) { + console.log(""); + info("Versions:"); + const versionTagsMap = new Map(); + if (tags) { + for (const tag of tags) { + if (!versionTagsMap.has(tag.versionId)) { + versionTagsMap.set(tag.versionId, []); + } + versionTagsMap.get(tag.versionId)!.push(tag.tagName); + } + } + for (const v of versions) { + const tagStr = versionTagsMap.get(v.id)?.join(", ") || ""; + dim(` v${v.version} ${v.status} · ${v.fileCount} files · ${formatBytes(v.totalSize)} · ${v.publishedAt}${tagStr ? " · tags: " + tagStr : ""}`); + } + } + console.log(""); } @@ -69,7 +138,8 @@ export function registerInspect(program: Command) { .aliases(["info", "view"]) .description("View skill metadata without installing") .option("--namespace ", "Search in specific namespace (searches all if not specified)") - .action(async (slug: string, opts: { namespace?: string }) => { + .option("--details", "Show all versions with tags") + .action(async (slug: string, opts: { namespace?: string; details?: boolean }) => { const config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); @@ -78,14 +148,29 @@ export function registerInspect(program: Command) { const { namespace: defaultNs, slug: parsedSlug } = parseSkillName(slug, ""); const targetNamespace = opts.namespace || defaultNs; + async function fetchVersionsAndTags(ns: string, skillSlug: string) { + if (!opts.details) return { versions: undefined, tags: undefined }; + try { + const [versionsResp, tagsResp] = await Promise.all([ + client.get(`/api/v1/skills/${ns}/${skillSlug}/versions`), + client.get(`/api/v1/skills/${ns}/${skillSlug}/tags`).catch(() => [] as SkillTag[]), + ]); + return { versions: versionsResp.items || [], tags: tagsResp || [] }; + } catch { + return { versions: undefined, tags: undefined }; + } + } + if (targetNamespace) { const detail = await client.get( `${ApiRoutes.skillDetail.replace("{namespace}", targetNamespace).replace("{slug}", parsedSlug)}` ); + const { versions, tags } = await fetchVersionsAndTags(targetNamespace, parsedSlug); if (isJson) { - console.log(JSON.stringify(detail, null, 2)); + const output = opts.versions ? { ...detail, versions, tags } : detail; + console.log(JSON.stringify(output, null, 2)); } else { - printSkillDetail(detail); + printSkillDetail(detail, versions, tags); } return; } @@ -95,6 +180,7 @@ export function registerInspect(program: Command) { if (!namespaces || namespaces.length === 0) { error("No namespaces found. You may need to log in."); process.exitCode = 1; + return; } const searchPromises = namespaces.map(async (ns) => { @@ -117,19 +203,30 @@ export function registerInspect(program: Command) { dim(`Tried namespaces: ${namespaces.map((n) => n.slug).join(", ")}`); } process.exitCode = 1; + return; } if (isJson) { if (matches.length === 1) { - console.log(JSON.stringify(matches[0], null, 2)); + const { versions, tags } = await fetchVersionsAndTags(matches[0].namespace, matches[0].slug); + const output = opts.details ? { ...matches[0], versions, tags } : matches[0]; + console.log(JSON.stringify(output, null, 2)); } else { - console.log(JSON.stringify(matches, null, 2)); + const outputs = await Promise.all( + matches.map(async (m) => { + const { versions, tags } = await fetchVersionsAndTags(m.namespace, m.slug); + return opts.details ? { ...m, versions, tags } : m; + }) + ); + console.log(JSON.stringify(outputs, null, 2)); } } else if (matches.length === 1) { - printSkillDetail(matches[0]); + const { versions, tags } = await fetchVersionsAndTags(matches[0].namespace, matches[0].slug); + printSkillDetail(matches[0], versions, tags); } else { for (const detail of matches) { - printInspectHeader(detail); + const { versions, tags } = await fetchVersionsAndTags(detail.namespace, detail.slug); + printInspectHeader(detail, versions, tags); } } }); diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index 03a64a6d..bb48ae2c 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -12,11 +12,12 @@ import { getAllAgents, detectInstalledAgents, isUniversalForScope, getAgentTarge import { parseSource, getCloneUrl } from "../core/source-parser.js"; import { addToLock } from "../core/skill-lock.js"; import { success, error, info, dim } from "../utils/logger.js"; +import chalk from "chalk"; import unzipper from "unzipper"; import { multiSelect, sectionMultiSelect } from "../utils/prompts.js"; import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js"; import { runInteractiveSearch, searchSkills } from "../core/interactive-search.js"; -import type { SkillVersionItem } from "./versions.js"; +import type { SkillVersionItem } from "../schema/routes.js"; interface SkillTag { id: number; @@ -196,9 +197,56 @@ function buildAgentSummary(targetAgents: AgentInfo[], mode: "symlink" | "copy", return lines; } +function buildInstallHelp(cmd: Command): string { + const lines: string[] = []; + + lines.push(`${chalk.bold("Usage:")} skillhub install|i [options] ${chalk.cyan("")}`); + lines.push(""); + lines.push("Install skills from registry, git repositories, or local paths"); + lines.push(""); + + lines.push(chalk.bold("Arguments:")); + lines.push(` ${chalk.cyan("skill-name")} Skill name or namespace/skill-name from registry`); + lines.push(""); + + lines.push(chalk.bold("Source Options:")); + lines.push(` ${chalk.cyan("-a, --add ")} Install from GitHub or local path (alias for --from)`); + lines.push(` ${chalk.cyan("--from ")} Install from GitHub or local path (alias for -a)`); + lines.push(""); + + lines.push(chalk.bold("Target Options:")); + lines.push(` ${chalk.cyan("--agent ")} Target specific agents`); + lines.push(` ${chalk.cyan("-g, --global")} Install to global scope`); + lines.push(""); + + lines.push(chalk.bold("Version Options:")); + lines.push(` ${chalk.cyan("-v, --skill-version ")} Install specific version (non-interactive)`); + lines.push(` ${chalk.cyan("--tag ")} Install specific tag (non-interactive, resolves to version)`); + lines.push(""); + + lines.push(chalk.bold("Mode Options:")); + lines.push(` ${chalk.cyan("--copy")} Copy instead of symlink`); + lines.push(` ${chalk.cyan("--list")} List available skills without installing`); + lines.push(""); + + lines.push(chalk.bold("Other Options:")); + lines.push(` ${chalk.cyan("-y, --yes")} Skip all prompts`); + lines.push(` ${chalk.cyan("-h, --help")} Display help for command`); + lines.push(""); + + lines.push(chalk.bold("Examples:")); + lines.push(chalk.dim(" skillhub install vision2group/fork-workflow Install a skill from registry")); + lines.push(chalk.dim(" skillhub install my-skill --from ./local/path Install from local directory")); + lines.push(chalk.dim(" skillhub install my-skill --from github.com/user/repo Install from GitHub")); + lines.push(chalk.dim(" skillhub install my-skill -g --yes Install globally, skip prompts")); + lines.push(chalk.dim(" skillhub install my-skill --tag v1.0.0 Install specific tag")); + + return lines.join("\n"); +} + export function registerInstall(program: Command) { - program - .command("install ") + const installCmd = program + .command("install ") .alias("i") .description("Install skills from registry, git repositories, or local paths") .option("-a, --add ", "Install from GitHub or local path (alias for --from)") @@ -210,7 +258,14 @@ export function registerInstall(program: Command) { .option("--list", "List available skills without installing") .option("-v, --skill-version ", "Install specific version (non-interactive)") .option("--tag ", "Install specific tag (non-interactive, resolves to version)") - .action(async (source: string, opts: Record) => { + .configureHelp({ showGlobalOptions: true }); + + const originalHelp = installCmd.helpInformation.bind(installCmd); + installCmd.helpInformation = () => { + return buildInstallHelp(installCmd); + }; + + installCmd.action(async (source: string, opts: Record) => { const fromSource = (opts.from || opts.add) as string | undefined; let effectiveSource: SourceType; @@ -316,10 +371,18 @@ async function installFromRegistry( // Present version selection let selectedVersion: string = "latest"; - if (opts.yes && opts.skillVersion) { - selectedVersion = String(opts.skillVersion); - } else if (opts.yes && opts.tag) { - // Non-interactive: resolve tag to version + if (opts.skillVersion) { + selectedVersion = String(opts.skillVersion).replace(/^v/, ""); + const versionExists = versions.some((v) => v.version === selectedVersion); + if (!versionExists) { + spinner.fail(`Version not found: ${opts.skillVersion}`); + if (versions.length > 0) { + info(`Available versions: ${versions.map((v) => v.version).join(", ")}`); + } + process.exitCode = 1; + return; + } + } else if (opts.tag) { for (const [vid, tags] of versionTagsMap) { if (tags.includes(opts.tag as string)) { const v = versions.find((ver) => ver.id === vid); @@ -383,6 +446,7 @@ async function installFromRegistry( spinner.fail(`Skill not found: ${ns}/${actualSlug}`); await rm(tmpDir, { recursive: true, force: true }); process.exitCode = 1; + return; } const fileStream = createWriteStream(zipPath); diff --git a/skillhub-cli/src/commands/versions.ts b/skillhub-cli/src/commands/versions.ts index 87c79eab..3c74df73 100644 --- a/skillhub-cli/src/commands/versions.ts +++ b/skillhub-cli/src/commands/versions.ts @@ -30,11 +30,27 @@ function formatBytes(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } +interface SkillDetailResponse { + id: number; + namespace: string; + slug: string; + displayName: string; + ownerDisplayName: string; + summary: string; + visibility: string; + status: string; + starCount: number; + downloadCount: number; + labels: Array<{ slug: string; name: string }>; + publishedVersion?: { version: string }; +} + export function registerVersions(program: Command) { program .command("versions ") .description("List skill versions") - .action(async (slug: string) => { + .option("--detail", "Show additional skill metadata (stars, downloads, summary)") + .action(async (slug: string, opts: { detail?: boolean }) => { try { const { namespace, slug: skillSlug } = parseSkillName(slug); const config = loadConfigFromProgram(program); @@ -86,7 +102,25 @@ export function registerVersions(program: Command) { return; } - if (targetNamespace !== "global") { + if (opts.detail) { + try { + const detail = await client.get( + `/api/v1/skills/${targetNamespace}/${targetSlug}` + ); + console.log(""); + info(`${detail.displayName} (${detail.slug})`); + dim(`Namespace: ${detail.namespace}`); + dim(`Author: ${detail.ownerDisplayName}`); + dim(`Stars: ${detail.starCount} Downloads: ${detail.downloadCount}`); + if (detail.summary) console.log(`\n${detail.summary}`); + if (detail.labels && detail.labels.length > 0) { + dim(`Labels: ${detail.labels.map((l) => l.name || l.slug).join(", ")}`); + } + console.log(""); + } catch {} + } + + if (targetNamespace !== "global" && !opts.detail) { success(`${targetNamespace}/${targetSlug}`); } for (const v of versions) { diff --git a/skillhub-cli/src/schema/routes.ts b/skillhub-cli/src/schema/routes.ts index 662242f7..d0411f71 100644 --- a/skillhub-cli/src/schema/routes.ts +++ b/skillhub-cli/src/schema/routes.ts @@ -65,3 +65,21 @@ export interface SkillsListResponse { }>; nextCursor: string | null; } + +export interface SkillVersionItem { + id: number; + version: string; + status: string; + changelog: string | null; + fileCount: number; + totalSize: number; + publishedAt: string; + downloadAvailable: boolean; +} + +export interface VersionsResponse { + items: SkillVersionItem[]; + total: number; + page: number; + size: number; +} diff --git a/skillhub-cli/tests/commands.test.ts b/skillhub-cli/tests/commands.test.ts index b33b9a7f..6b90d9dc 100644 --- a/skillhub-cli/tests/commands.test.ts +++ b/skillhub-cli/tests/commands.test.ts @@ -5,7 +5,6 @@ import { registerWhoami } from "../src/commands/whoami.js"; import { registerLogin } from "../src/commands/login.js"; import { registerPublish } from "../src/commands/publish.js"; import { registerMe } from "../src/commands/me.js"; -import { registerVersions } from "../src/commands/versions.js"; import { registerNotifications } from "../src/commands/notifications.js"; import { registerReviews } from "../src/commands/reviews.js"; import { registerNamespaces } from "../src/commands/namespaces.js"; @@ -70,12 +69,6 @@ describe("Command registrations", () => { expect(subNames).toContain("stars"); }); - it("registers versions command", () => { - const program = new Command(); - registerVersions(program); - expect(getCommandNames(program)).toContain("versions"); - }); - it("registers notifications command with subcommands", () => { const program = new Command(); registerNotifications(program);