feat(cli): enhance inspect/install commands and reorganize help

- Add --details option to inspect for version history and tags
- Normalize version input (strip 'v' prefix) in install command
- Skip version selection when --skill-version or --tag is specified
- Reorganize help: merge Discover & Info sections
- Move SkillVersionItem/VersionsResponse types to schema/routes.ts
- Remove versions command registration from CLI (file retained for now)
- Fix error handling to return after setting exit code
- Update tests to reflect command changes
This commit is contained in:
chenbaowang 2026-04-21 19:40:02 +08:00
parent d4ea485662
commit acea441625
7 changed files with 242 additions and 44 deletions

View file

@ -1,6 +1,6 @@
{
"name": "motovis-skillhub",
"version": "1.2.0",
"version": "1.2.3",
"type": "module",
"description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间",
"bin": {

View file

@ -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 <slug>", desc: "View skill metadata and versions", alias: "info, view" },
{ cmd: "resolve <slug>", desc: "Resolve the latest version of a skill" },
{ cmd: "rating <slug>", desc: "View your rating for a skill" },
{ cmd: "rate <slug> <score>", desc: "Rate a skill (1-5)" },
{ cmd: "star <slug>", desc: "Star a skill" },
{ cmd: "report <slug>", desc: "Report a skill for review" },
]));
sections.push("");
sections.push(formatSection("Install & Manage", [
{ cmd: "install <source>", desc: "Install from registry, git, or local path", alias: "i" },
{ cmd: "install <skill-name>", desc: "Install from registry, git, or local path", alias: "i" },
{ cmd: "download <slug>", 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 <slug>", desc: "Delete a skill you own", alias: "del, unpublish" },
{ cmd: "archive <slug>", desc: "Archive a skill you own" },
{ cmd: "versions <slug>", desc: "List skill versions" },
]));
sections.push("");
sections.push(formatSection("Info & Review", [
{ cmd: "inspect <slug>", desc: "View skill metadata without installing", alias: "info, view" },
{ cmd: "resolve <slug>", desc: "Resolve the latest version of a skill" },
{ cmd: "rating <slug>", desc: "View your rating for a skill" },
{ cmd: "rate <slug> <score>", desc: "Rate a skill (1-5)" },
{ cmd: "star <slug>", desc: "Star a skill" },
{ cmd: "report <slug>", desc: "Report a skill for review" },
]));
sections.push("");
@ -171,7 +166,6 @@ export async function createCli(): Promise<Command> {
{ registerReviews },
{ registerNotifications },
{ registerDelete },
{ registerVersions },
{ registerReport },
{ registerResolve },
{ registerRating, registerRate },
@ -200,7 +194,6 @@ export async function createCli(): Promise<Command> {
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<Command> {
registerReviews(program);
registerNotifications(program);
registerDelete(program);
registerVersions(program);
registerReport(program);
registerResolve(program);
registerRating(program);

View file

@ -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<number, string[]>();
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<number, string[]>();
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 <ns>", "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<VersionsResponse>(`/api/v1/skills/${ns}/${skillSlug}/versions`),
client.get<SkillTag[]>(`/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<SkillDetailResponse>(
`${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);
}
}
});

View file

@ -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("<skill-name>")}`);
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 <source>")} Install from GitHub or local path (alias for --from)`);
lines.push(` ${chalk.cyan("--from <source>")} Install from GitHub or local path (alias for -a)`);
lines.push("");
lines.push(chalk.bold("Target Options:"));
lines.push(` ${chalk.cyan("--agent <agents...>")} 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 <ver>")} Install specific version (non-interactive)`);
lines.push(` ${chalk.cyan("--tag <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 <source>")
const installCmd = program
.command("install <skill-name>")
.alias("i")
.description("Install skills from registry, git repositories, or local paths")
.option("-a, --add <source>", "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 <ver>", "Install specific version (non-interactive)")
.option("--tag <tag>", "Install specific tag (non-interactive, resolves to version)")
.action(async (source: string, opts: Record<string, string | string[] | boolean>) => {
.configureHelp({ showGlobalOptions: true });
const originalHelp = installCmd.helpInformation.bind(installCmd);
installCmd.helpInformation = () => {
return buildInstallHelp(installCmd);
};
installCmd.action(async (source: string, opts: Record<string, string | string[] | boolean>) => {
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);

View file

@ -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 <slug>")
.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<SkillDetailResponse>(
`/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) {

View file

@ -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;
}

View file

@ -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);