feat(cli): 优化帮助界面和命令结构

- 重新组织帮助分类(Publish → Publish & Manage)
- 统一参数命名为 <skill>,添加参数说明
- 简化 config 命令描述
- 优化 explore 帮助,添加选项分组和示例
- 修复参数重复显示问题
- 子命令帮助显示 Arguments 部分
This commit is contained in:
chenbaowang 2026-04-23 12:33:04 +08:00
parent c7d4f8273d
commit 08ce3d59a1
21 changed files with 199 additions and 77 deletions

2
.gitignore vendored
View file

@ -1,6 +1,7 @@
# OS files
.DS_Store
Thumbs.db
.nfs*
# Editors / IDEs / local tooling
.claude/
@ -14,6 +15,7 @@ Thumbs.db
*.iml
*.swp
*.swo
*.code-workspace
# Logs
*.log

View file

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

View file

@ -30,14 +30,12 @@ interface HelpEntry {
}
function formatSection(header: string, entries: HelpEntry[]): string {
const displayWidth = (e: HelpEntry) => e.cmd.length + (e.alias ? e.alias.length + 3 : 0);
const maxW = entries.reduce((max, e) => Math.max(max, displayWidth(e)), 0);
const col = Math.max(maxW + 4, 28);
const lines = [bold(header)];
for (const e of entries) {
const aliasPart = e.alias ? dim(` (${e.alias})`) : "";
const pad = " ".repeat(Math.max(col - displayWidth(e), 2));
lines.push(` ${cyan(e.cmd)}${aliasPart}${pad}${e.desc}`);
lines.push(` ${cyan(e.cmd)}${aliasPart}`);
lines.push(` ${e.desc}`);
}
return lines.join("\n");
}
@ -51,8 +49,8 @@ function buildTopLevelHelp(version: string): string {
sections.push(formatSection("Configuration", [
{ cmd: "config list", desc: "Show current registry configuration" },
{ cmd: "config set <key> <value>", desc: "Set configuration (e.g., registry URL)" },
{ cmd: "config get <key>", desc: "Get configuration value" },
{ cmd: "config set <value>", desc: "Set registry URL" },
{ cmd: "config get", desc: "Get current registry configuration" },
{ cmd: "config show-env-instructions", desc: "Show environment variable setup guide" },
]));
sections.push("");
@ -64,37 +62,37 @@ function buildTopLevelHelp(version: string): string {
]));
sections.push("");
sections.push(formatSection("Discover & Info", [
sections.push(formatSection("Discovery", [
{ 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" },
]));
sections.push("");
sections.push(formatSection("Social & Reviews", [
{ cmd: "star <slug>", desc: "Star or unstar a skill" },
{ cmd: "rating <slug>", desc: "View your rating for a skill" },
{ cmd: "rate <slug> <score>", desc: "Rate a skill (1-5)" },
{ cmd: "report <slug>", desc: "Report a skill for review" },
{ cmd: "inspect <skill>", desc: "View skill metadata and versions", alias: "info, view" },
{ cmd: "resolve <skill>", desc: "Resolve the latest version of a skill" },
]));
sections.push("");
sections.push(formatSection("Install & Manage", [
{ 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" },
{ cmd: "install <skill>", desc: "Install from registry, git, or local path", alias: "i" },
{ cmd: "download <skill>", desc: "Download a skill package to local directory" },
{ cmd: "update [skill]", desc: "Update installed skills from their source", alias: "up" },
{ cmd: "uninstall [skill]", desc: "Uninstall a skill from local agent", alias: "un" },
{ cmd: "list", desc: "List installed skills", alias: "ls" },
{ cmd: "check", desc: "Check installed skills against lock file" },
]));
sections.push("");
sections.push(formatSection("Publish", [
sections.push(formatSection("Social", [
{ cmd: "star <skill>", desc: "Star or unstar a skill" },
{ cmd: "rating <skill>", desc: "View your rating for a skill" },
{ cmd: "rate <skill> <score>", desc: "Rate a skill (1-5)" },
{ cmd: "report <skill>", desc: "Report a skill for review" },
]));
sections.push("");
sections.push(formatSection("Publish & Manage", [
{ cmd: "init [name]", desc: "Create a new SKILL.md template" },
{ cmd: "publish [path]", desc: "Publish a skill to SkillHub registry" },
{ 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: "delete <skill>", desc: "Delete a skill you own", alias: "del, unpublish" },
{ cmd: "archive <skill>", desc: "Archive a skill you own" },
]));
sections.push("");
@ -108,8 +106,8 @@ function buildTopLevelHelp(version: string): string {
sections.push("");
sections.push(formatSection("Admin", [
{ cmd: "hide <slug>", desc: "Hide a skill (admin only)" },
{ cmd: "unhide <slug>", desc: "Unhide a skill (admin only)" },
{ cmd: "hide <skill>", desc: "Hide a skill (admin only)" },
{ cmd: "unhide <skill>", desc: "Unhide a skill (admin only)" },
{ cmd: "transfer <ns> <user>", desc: "Transfer namespace ownership" },
]));
sections.push("");

View file

@ -7,8 +7,9 @@ import { parseSkillName } from "../core/skill-name.js";
export function registerArchive(program: Command) {
program
.command("archive <slug>")
.command("archive")
.description("Archive a skill you own")
.argument("<skill>", "Skill name or namespace/skill-name")
.option("-y, --yes", "Skip confirmation")
.action(async (slug: string, opts: { yes?: boolean }) => {
const { namespace, slug: skillSlug } = parseSkillName(slug);

View file

@ -20,7 +20,7 @@ export function registerConfig(program: Command) {
configCmd
.command("list")
.description("List all configuration sources and their values")
.description("Show all config values and their sources")
.action(() => {
const env = process.env.SKILLHUB_REGISTRY;
let fileConfig: { registry?: string } = {};
@ -56,7 +56,7 @@ export function registerConfig(program: Command) {
configCmd
.command("set <value>")
.description("Set registry URL in ~/.skillhub/config.json")
.description("Set registry URL (e.g., https://api.example.com)")
.action((value: string) => {
if (!existsSync(CONFIG_DIR)) {
mkdirSync(CONFIG_DIR, { recursive: true });
@ -79,7 +79,7 @@ export function registerConfig(program: Command) {
configCmd
.command("get")
.description("Get registry configuration value")
.description("Show the current registry URL")
.option("--source <source>", "Source: env, file, or resolved (default)")
.action((opts: { source?: string }) => {
const source = opts.source || "resolved";

View file

@ -7,9 +7,9 @@ import { parseSkillName } from "../core/skill-name.js";
export function registerDelete(program: Command) {
program
.command("delete <slug>")
.aliases(["del", "unpublish"])
.command("delete")
.description("Delete a skill you own")
.argument("<skill>", "Skill name or namespace/skill-name")
.option("-y, --yes", "Skip confirmation")
.action(async (slug: string, opts: { yes?: boolean }) => {
const { namespace, slug: skillSlug } = parseSkillName(slug);

View file

@ -12,8 +12,9 @@ import ora from "ora";
export function registerDownload(program: Command) {
program
.command("download <slug>")
.command("download")
.description("Download a skill package to local directory")
.argument("<skill>", "Skill name or namespace/skill-name")
.option("-v, --skill-version <ver>", "Specific version")
.option("--tag <tag>", "Tag to download", "latest")
.option("--output <dir>", "Output directory")

View file

@ -214,20 +214,60 @@ async function runInteractiveSearch(
});
}
function buildExploreHelp(cmd: Command): string {
const lines: string[] = [];
lines.push(`${BOLD}Usage:${RESET} skillhub explore [options] [query]`);
lines.push("");
lines.push("Browse or search skills from the registry");
lines.push("");
lines.push(`${BOLD}Arguments:${RESET}`);
lines.push(` ${CYAN}[query]${RESET} Search query for finding skills`);
lines.push("");
lines.push(`${BOLD}Search Options:${RESET}`);
lines.push(` ${CYAN}-n, --limit <n>${RESET} Max results (default: "20")`);
lines.push("");
lines.push(`${BOLD}Sorting Options:${RESET}`);
lines.push(` ${CYAN}-s, --sort <sort>${RESET} Sort by: hot, newest, downloads, stars, rating`);
lines.push(` ${CYAN}--hot${RESET} Sort by comprehensive popularity (downloads + stars)`);
lines.push(` ${CYAN}--newest${RESET} Sort by newest first`);
lines.push(` ${CYAN}--downloads${RESET} Sort by download count`);
lines.push(` ${CYAN}--stars${RESET} Sort by star count`);
lines.push(` ${CYAN}--rating${RESET} Sort by average rating`);
lines.push("");
lines.push(`${BOLD}Other Options:${RESET}`);
lines.push(` ${CYAN}-h, --help${RESET} Display help for command`);
lines.push("");
lines.push(`${BOLD}Examples:${RESET}`);
lines.push(`${DIM} skillhub explore Interactive skill search${RESET}`);
lines.push(`${DIM} skillhub explore --hot Browse popular skills${RESET}`);
lines.push(`${DIM} skillhub explore ai-assistant Search for skills${RESET}`);
lines.push(`${DIM} skillhub explore --sort newest --limit 10 Show 10 newest skills${RESET}`);
return lines.join("\n");
}
export function registerExplore(program: Command) {
program
const exploreCmd = program
.command("explore")
.aliases(["find", "find-skills", "search"])
.description("Browse or search skills from the registry")
.argument("[query]", "Search query for finding skills")
.option("-n, --limit <n>", "Max results", "20")
.option("-s, --sort <sort>", "Sort by: hot, newest, downloads, stars, rating (default: interactive mode)")
.option("-s, --sort <sort>", "Sort by: hot, newest, downloads, stars, rating (browse mode)")
.option("--hot", "Sort by comprehensive popularity (downloads + stars)")
.option("--newest", "Sort by newest first (shorthand for --sort newest)")
.option("--downloads", "Sort by download count (shorthand for --sort downloads)")
.option("--stars", "Sort by star count (shorthand for --sort stars)")
.option("--rating", "Sort by average rating (shorthand for --sort rating)")
.action(async (query: string | undefined, opts: { limit: string; sort?: string; hot?: boolean; newest?: boolean; downloads?: boolean; stars?: boolean; rating?: boolean }) => {
.option("--rating", "Sort by average rating (shorthand for --sort rating)");
exploreCmd.helpInformation = () => buildExploreHelp(exploreCmd);
exploreCmd.action(async (query: string | undefined, opts: { limit: string; sort?: string; hot?: boolean; newest?: boolean; downloads?: boolean; stars?: boolean; rating?: boolean }) => {
const config = loadConfigFromProgram(program);
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });

View file

@ -7,8 +7,9 @@ import { parseSkillName } from "../core/skill-name.js";
export function registerHide(program: Command) {
const hideCmd = program
.command("hide <slug>")
.command("hide")
.description("Hide a skill (admin only)")
.argument("<skill>", "Skill name or namespace/skill-name")
.option("-y, --yes", "Skip confirmation")
.action(async (slug: string, opts: { yes?: boolean }) => {
const { namespace, slug: skillSlug } = parseSkillName(slug);
@ -47,8 +48,9 @@ export function registerHide(program: Command) {
});
hideCmd
.command("unhide <slug>")
.command("unhide")
.description("Unhide a skill (admin only)")
.argument("<skill>", "Skill name or namespace/skill-name")
.option("-y, --yes", "Skip confirmation")
.action(async (slug: string, opts: { yes?: boolean }) => {
const { namespace, slug: skillSlug } = parseSkillName(slug);

View file

@ -137,12 +137,13 @@ function printInspectHeader(detail: SkillDetailResponse, versions?: SkillVersion
export function registerInspect(program: Command) {
program
.command("inspect <slug>")
.aliases(["info", "view"])
.command("inspect")
.description("View skill metadata without installing")
.argument("<skill>", "Skill name or namespace/skill-name")
.option("--namespace <ns>", "Search in specific namespace (searches all if not specified)")
.option("--details", "Show all versions with tags")
.action(async (slug: string, opts: { namespace?: string; details?: boolean }) => {
.option("-v, --version <ver>", "Inspect specific version")
.action(async (slug: string, opts: { namespace?: string; details?: boolean; version?: string }) => {
const config = loadConfigFromProgram(program);
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });
@ -152,7 +153,6 @@ export function registerInspect(program: Command) {
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`),
@ -164,21 +164,91 @@ export function registerInspect(program: Command) {
}
}
async function displaySkillDetail(ns: string, skillSlug: string) {
const detail = await client.get<SkillDetailResponse>(
`${ApiRoutes.skillDetail.replace("{namespace}", ns).replace("{slug}", skillSlug)}`
);
const { versions, tags } = await fetchVersionsAndTags(ns, skillSlug);
if (isJson) {
const output = opts.details ? { ...detail, versions, tags } : detail;
console.log(JSON.stringify(output, null, 2));
} else {
printSkillDetail(detail, versions, tags);
async function displaySkillDetail(ns: string, skillSlug: string, version?: string) {
try {
const detail = await client.get<SkillDetailResponse>(
`${ApiRoutes.skillDetail.replace("{namespace}", ns).replace("{slug}", skillSlug)}`
);
const { versions, tags } = await fetchVersionsAndTags(ns, skillSlug);
if (version && versions) {
const selectedVersion = versions.find(v => v.version === version);
if (selectedVersion) {
detail.publishedVersion = { version: selectedVersion.version };
}
}
if (isJson) {
const output = opts.details ? { ...detail, versions, tags } : detail;
console.log(JSON.stringify(output, null, 2));
} else {
printSkillDetail(detail, opts.details ? versions : undefined, opts.details ? tags : undefined);
}
} catch (e: any) {
if (e.statusCode === 403) {
error(`Access denied: ${ns}/${skillSlug}`);
dim("Run 'skillhub login' to authenticate.");
} else if (e.statusCode === 404) {
error(`Skill not found: ${ns}/${skillSlug}`);
} else {
error(`Failed to fetch skill details: ${e.message}`);
}
process.exitCode = 1;
}
}
async function inspectWithVersionSelection(ns: string, skillSlug: string) {
const { versions, tags } = await fetchVersionsAndTags(ns, skillSlug);
if (!versions || versions.length === 0) {
await displaySkillDetail(ns, skillSlug);
return;
}
if (opts.details) {
await displaySkillDetail(ns, skillSlug);
return;
}
if (versions.length === 1) {
await displaySkillDetail(ns, skillSlug);
return;
}
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);
}
}
const selected = await p.select({
message: "Select version to inspect",
options: versions.map((v) => ({
value: v.version,
label: `v${v.version}`,
hint: versionTagsMap.get(v.id)?.join(", ") || "",
})),
});
if (p.isCancel(selected)) {
console.log("Cancelled.");
return;
}
await displaySkillDetail(ns, skillSlug, selected as string);
}
if (targetNamespace) {
await displaySkillDetail(targetNamespace, parsedSlug);
if (opts.version) {
await displaySkillDetail(targetNamespace, parsedSlug, opts.version);
} else {
await inspectWithVersionSelection(targetNamespace, parsedSlug);
}
return;
}
@ -207,7 +277,11 @@ export function registerInspect(program: Command) {
spinner.stop();
const ns = uniqueResults[0].namespace;
const name = uniqueResults[0].name;
await displaySkillDetail(ns, name);
if (opts.version) {
await displaySkillDetail(ns, name, opts.version);
} else {
await inspectWithVersionSelection(ns, name);
}
return;
}
@ -228,7 +302,11 @@ export function registerInspect(program: Command) {
}
const [selectedNs, selectedName] = (selected as string).split("/", 2);
await displaySkillDetail(selectedNs, selectedName);
if (opts.version) {
await displaySkillDetail(selectedNs, selectedName, opts.version);
} else {
await inspectWithVersionSelection(selectedNs, selectedName);
}
} catch (e: any) {
spinner.fail(e.message);
process.exitCode = 1;

View file

@ -200,13 +200,13 @@ function buildAgentSummary(targetAgents: AgentInfo[], mode: "symlink" | "copy",
function buildInstallHelp(cmd: Command): string {
const lines: string[] = [];
lines.push(`${chalk.bold("Usage:")} skillhub install|i [options] ${chalk.cyan("<skill-name>")}`);
lines.push(`${chalk.bold("Usage:")} skillhub install [options] ${chalk.cyan("<skill>")}`);
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(` ${chalk.cyan("skill")} Skill name or namespace/skill-name from registry`);
lines.push("");
lines.push(chalk.bold("Source Options:"));
@ -246,9 +246,10 @@ function buildInstallHelp(cmd: Command): string {
export function registerInstall(program: Command) {
const installCmd = program
.command("install <skill-name>")
.command("install")
.alias("i")
.description("Install skills from registry, git repositories, or local paths")
.argument("<skill>", "Skill name or namespace/skill-name from registry")
.option("-a, --add <source>", "Install from GitHub or local path (alias for --from)")
.option("--from <source>", "Install from GitHub or local path (alias for -a)")
.option("--agent <agents...>", "Target specific agents")

View file

@ -18,7 +18,6 @@ interface ListOptions {
export function registerList(program: Command) {
program
.command("list")
.alias("ls")
.description("List installed skills")
.option("-g, --global", "List global skills only")
.option("-p, --project", "List project skills only")

View file

@ -28,7 +28,6 @@ export function registerMe(program: Command) {
me
.command("skills")
.alias("ls")
.description("List your published skills")
.action(async () => {
try {

View file

@ -15,12 +15,10 @@ export interface Notification {
export function registerNotifications(program: Command) {
const cmd = program
.command("notifications")
.alias("notif")
.description("Manage notifications");
cmd
.command("list")
.alias("ls")
.description("List notifications")
.option("--unread", "Show unread only")
.action(async (opts: { unread?: boolean }) => {

View file

@ -7,8 +7,9 @@ import { parseSkillName } from "../core/skill-name.js";
export function registerRating(program: Command) {
program
.command("rating <slug>")
.command("rating")
.description("View your rating for a skill")
.argument("<skill>", "Skill name or namespace/skill-name")
.action(async (slug: string) => {
try {
const { namespace, slug: skillSlug } = parseSkillName(slug);
@ -28,7 +29,7 @@ export function registerRating(program: Command) {
info(`${skillSlug}: ${"★".repeat(rating.score)}${"☆".repeat(5 - rating.score)} (${rating.score}/5)`);
} else {
info(`${skillSlug}: Not rated yet`);
dim("Use: skillhub rate <slug> <score>");
dim("Use: skillhub rate <skill> <score>");
}
} catch (e: any) {
error(`Failed: ${e.message}`);
@ -39,8 +40,10 @@ export function registerRating(program: Command) {
export function registerRate(program: Command) {
program
.command("rate <slug> <score>")
.command("rate")
.description("Rate a skill (1-5)")
.argument("<skill>", "Skill name or namespace/skill-name")
.argument("<score>", "Rating score (1-5)")
.action(async (slug: string, scoreStr: string) => {
const score = parseInt(scoreStr, 10);
if (isNaN(score) || score < 1 || score > 5) {

View file

@ -8,8 +8,9 @@ import { parseSkillName } from "../core/skill-name.js";
export function registerReport(program: Command) {
program
.command("report <slug>")
.command("report")
.description("Report a skill for review")
.argument("<skill>", "Skill name or namespace/skill-name")
.option("--reason <reason>", "Report reason")
.action(async (slug: string, opts: { reason?: string }) => {
try {

View file

@ -43,8 +43,9 @@ async function resolveWithVersion(
export function registerResolve(program: Command) {
program
.command("resolve <slug>")
.command("resolve")
.description("Resolve the latest version of a skill")
.argument("<skill>", "Skill name or namespace/skill-name")
.option("-v, --skill-version <ver>", "Specific version")
.option("--tag <tag>", "Tag to resolve (default: latest, ignored if --skill-version)")
.option("--hash <hash>", "Content hash")

View file

@ -19,7 +19,6 @@ export function registerReviews(program: Command) {
reviews
.command("my")
.alias("submissions")
.description("List your review submissions")
.action(async () => {
try {

View file

@ -8,8 +8,9 @@ import { parseSkillName } from "../core/skill-name.js";
export function registerStar(program: Command) {
program
.command("star <slug>")
.command("star")
.description("Star a skill")
.argument("<skill>", "Skill name or namespace/skill-name")
.option("--unstar", "Remove star")
.action(async (slug: string, opts: { unstar: boolean }) => {
try {

View file

@ -110,8 +110,7 @@ function findAgentsWithSkill(skillName: string, scope: "global" | "local", agent
export function registerUninstall(program: Command) {
program
.command("uninstall [name]")
.alias("un")
.command("uninstall [skill]")
.description("Uninstall a skill or all skills from local agent")
.option("-g, --global", "Uninstall from global scope")
.option("-a, --agent <agents...>", "Uninstall from specific agents")

View file

@ -12,8 +12,7 @@ function getCliCommand(): string {
export function registerUpdate(program: Command) {
program
.command("update [slug]")
.alias("up")
.command("update [skill]")
.description("Update installed skills from their source")
.option("-a, --all", "Update all installed skills")
.option("-g, --global", "Update global scope skills")