From 273e14a26f9430ca0fdecf37220862baa8183ec4 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:20:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20=E6=B7=BB=E5=8A=A0=20--namespace?= =?UTF-8?q?=20=E9=80=89=E9=A1=B9=E5=92=8C=E6=99=BA=E8=83=BD=20namespace=20?= =?UTF-8?q?=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 skill-resolver.ts 公共函数,提供 resolveSkillNamespace 和 parseSkillNamespace - 为 resolve, download, star, rating, rate, report, delete, archive, hide, unhide 添加 --namespace 选项 - inspect 使用 resolveSkillNamespace 替代手动解析 - install 优化 --from 为主选项,-a, --add 为别名 - download 优化帮助界面,添加 Examples 和更清晰的描述 --- skillhub-cli/src/commands/archive.ts | 8 +-- skillhub-cli/src/commands/delete.ts | 8 +-- skillhub-cli/src/commands/download.ts | 49 ++++++++++++-- skillhub-cli/src/commands/hide.ts | 14 ++-- skillhub-cli/src/commands/inspect.ts | 90 ++++--------------------- skillhub-cli/src/commands/install.ts | 8 +-- skillhub-cli/src/commands/rating.ts | 14 ++-- skillhub-cli/src/commands/report.ts | 8 +-- skillhub-cli/src/commands/resolve.ts | 4 +- skillhub-cli/src/commands/star.ts | 8 ++- skillhub-cli/src/core/skill-resolver.ts | 86 +++++++++++++++++++++++ 11 files changed, 185 insertions(+), 112 deletions(-) create mode 100644 skillhub-cli/src/core/skill-resolver.ts diff --git a/skillhub-cli/src/commands/archive.ts b/skillhub-cli/src/commands/archive.ts index 4d9c0c56..c80c80f9 100644 --- a/skillhub-cli/src/commands/archive.ts +++ b/skillhub-cli/src/commands/archive.ts @@ -3,16 +3,16 @@ import { ApiClient } from "../core/api-client.js"; import { requireToken } from "../core/auth-token.js"; import { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { success, error } from "../utils/logger.js"; -import { parseSkillName } from "../core/skill-name.js"; - export function registerArchive(program: Command) { program .command("archive") .description("Archive a skill you own") .argument("", "Skill name or namespace/skill-name") .option("-y, --yes", "Skip confirmation") - .action(async (slug: string, opts: { yes?: boolean }) => { - const { namespace, slug: skillSlug } = parseSkillName(slug); + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { yes?: boolean; namespace?: string }) => { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); if (!opts.yes) { const { createInterface } = await import("node:readline"); const rl = createInterface({ input: process.stdin, output: process.stdout }); diff --git a/skillhub-cli/src/commands/delete.ts b/skillhub-cli/src/commands/delete.ts index 9911243e..968e24b6 100644 --- a/skillhub-cli/src/commands/delete.ts +++ b/skillhub-cli/src/commands/delete.ts @@ -3,16 +3,16 @@ import { ApiClient } from "../core/api-client.js"; import { requireToken } from "../core/auth-token.js"; import { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { success, error } from "../utils/logger.js"; -import { parseSkillName } from "../core/skill-name.js"; - export function registerDelete(program: Command) { program .command("delete") .description("Delete a skill you own") .argument("", "Skill name or namespace/skill-name") .option("-y, --yes", "Skip confirmation") - .action(async (slug: string, opts: { yes?: boolean }) => { - const { namespace, slug: skillSlug } = parseSkillName(slug); + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { yes?: boolean; namespace?: string }) => { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); if (!opts.yes) { const { createInterface } = await import("node:readline"); const rl = createInterface({ input: process.stdin, output: process.stdout }); diff --git a/skillhub-cli/src/commands/download.ts b/skillhub-cli/src/commands/download.ts index bf40f465..2a0e0723 100644 --- a/skillhub-cli/src/commands/download.ts +++ b/skillhub-cli/src/commands/download.ts @@ -7,19 +7,58 @@ import { ApiRoutes } from "../schema/routes.js"; import { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { readToken } from "../core/auth-token.js"; import { success, error } from "../utils/logger.js"; -import { parseSkillName } from "../core/skill-name.js"; + import ora from "ora"; +function buildDownloadHelp(cmd: Command): string { + const lines: string[] = []; + const BOLD = "\x1b[1m"; + const RESET = "\x1b[0m"; + const CYAN = "\x1b[36m"; + const DIM = "\x1b[38;5;102m"; + + lines.push(`${BOLD}Usage:${RESET} skillhub download [options] `); + lines.push(""); + lines.push("Download a skill package as a .zip file"); + lines.push(""); + + lines.push(`${BOLD}Arguments:${RESET}`); + lines.push(` ${CYAN}skill${RESET} Skill name or namespace/skill-name`); + lines.push(""); + + lines.push(`${BOLD}Options:${RESET}`); + lines.push(` ${CYAN}-v, --skill-version ${RESET} Specific version to download`); + lines.push(` ${CYAN}--tag ${RESET} Tag to download (default: "latest")`); + lines.push(` ${CYAN}--output ${RESET} Output directory (default: current directory)`); + lines.push(` ${CYAN}--namespace ${RESET} Override namespace (default: parsed from skill or 'global')`); + lines.push(` ${CYAN}-h, --help${RESET} Display help for command`); + lines.push(""); + + lines.push(`${BOLD}Examples:${RESET}`); + lines.push(`${DIM} skillhub download docker-build-push${RESET}`); + lines.push(`${DIM} skillhub download vision2group/docker-build-push${RESET}`); + lines.push(`${DIM} skillhub download docker-build-push --output ./skills${RESET}`); + lines.push(`${DIM} skillhub download docker-build-push --skill-version 1.0.0${RESET}`); + lines.push(`${DIM} skillhub download docker-build-push --tag v1.0.0${RESET}`); + + return lines.join("\n"); +} + export function registerDownload(program: Command) { - program + const downloadCmd = program .command("download") - .description("Download a skill package to local directory") + .description("Download a skill package as a .zip file") .argument("", "Skill name or namespace/skill-name") .option("-v, --skill-version ", "Specific version") .option("--tag ", "Tag to download", "latest") .option("--output ", "Output directory") - .action(async (slug: string, opts: Record) => { - const { namespace, slug: skillSlug } = parseSkillName(slug); + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')"); + + downloadCmd.helpInformation = () => buildDownloadHelp(downloadCmd); + + downloadCmd.action(async (slug: string, opts: Record) => { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); const config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); diff --git a/skillhub-cli/src/commands/hide.ts b/skillhub-cli/src/commands/hide.ts index 8b98fc3a..b758ba1f 100644 --- a/skillhub-cli/src/commands/hide.ts +++ b/skillhub-cli/src/commands/hide.ts @@ -3,7 +3,7 @@ import { ApiClient } from "../core/api-client.js"; import { requireToken } from "../core/auth-token.js"; import { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { success, error } from "../utils/logger.js"; -import { parseSkillName } from "../core/skill-name.js"; + export function registerHide(program: Command) { const hideCmd = program @@ -11,8 +11,10 @@ export function registerHide(program: Command) { .description("Hide a skill (admin only)") .argument("", "Skill name or namespace/skill-name") .option("-y, --yes", "Skip confirmation") - .action(async (slug: string, opts: { yes?: boolean }) => { - const { namespace, slug: skillSlug } = parseSkillName(slug); + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { yes?: boolean; namespace?: string }) => { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); if (!opts.yes) { const { createInterface } = await import("node:readline"); const rl = createInterface({ input: process.stdin, output: process.stdout }); @@ -52,8 +54,10 @@ export function registerHide(program: Command) { .description("Unhide a skill (admin only)") .argument("", "Skill name or namespace/skill-name") .option("-y, --yes", "Skip confirmation") - .action(async (slug: string, opts: { yes?: boolean }) => { - const { namespace, slug: skillSlug } = parseSkillName(slug); + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { yes?: boolean; namespace?: string }) => { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); if (!opts.yes) { const { createInterface } = await import("node:readline"); const rl = createInterface({ input: process.stdin, output: process.stdout }); diff --git a/skillhub-cli/src/commands/inspect.ts b/skillhub-cli/src/commands/inspect.ts index cec97781..19968d46 100644 --- a/skillhub-cli/src/commands/inspect.ts +++ b/skillhub-cli/src/commands/inspect.ts @@ -3,7 +3,7 @@ import { ApiClient } from "../core/api-client.js"; import { ApiRoutes } from "../schema/routes.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"; import { searchSkills } from "../core/interactive-search.js"; import * as p from "@clack/prompts"; @@ -149,8 +149,6 @@ export function registerInspect(program: Command) { const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); const isJson = program.opts().json; - const { namespace: defaultNs, slug: parsedSlug } = parseSkillName(slug, ""); - const targetNamespace = opts.namespace || defaultNs; async function fetchVersionsAndTags(ns: string, skillSlug: string) { try { @@ -169,16 +167,16 @@ export function registerInspect(program: Command) { const detail = await client.get( `${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)); @@ -200,22 +198,22 @@ export function registerInspect(program: Command) { 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(); if (tags) { for (const tag of tags) { @@ -225,7 +223,7 @@ export function registerInspect(program: Command) { versionTagsMap.get(tag.versionId)!.push(tag.tagName); } } - + const selected = await p.select({ message: "Select version to inspect", options: versions.map((v) => ({ @@ -234,82 +232,22 @@ export function registerInspect(program: Command) { hint: versionTagsMap.get(v.id)?.join(", ") || "", })), }); - + if (p.isCancel(selected)) { console.log("Cancelled."); return; } - + await displaySkillDetail(ns, skillSlug, selected as string); } - if (targetNamespace) { + const { resolveSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace: targetNamespace, slug: parsedSlug } = await resolveSkillNamespace(client, slug, opts.namespace); + if (opts.skillVersion) { await displaySkillDetail(targetNamespace, parsedSlug, opts.skillVersion); } else { await inspectWithVersionSelection(targetNamespace, parsedSlug); } - return; - } - - const spinner = ora(`Searching for ${parsedSlug}`).start(); - - try { - const results = await searchSkills(client, parsedSlug, 50); - - const seen = new Set(); - const uniqueResults = results.filter(r => { - const key = `${r.namespace}/${r.name}`; - if (!seen.has(key)) { - seen.add(key); - return true; - } - return false; - }); - - if (uniqueResults.length === 0) { - spinner.fail(`Skill not found: ${parsedSlug}`); - process.exitCode = 1; - return; - } - - if (uniqueResults.length === 1) { - spinner.stop(); - const ns = uniqueResults[0].namespace; - const name = uniqueResults[0].name; - if (opts.skillVersion) { - await displaySkillDetail(ns, name, opts.skillVersion); - } else { - await inspectWithVersionSelection(ns, name); - } - return; - } - - spinner.succeed(`Found ${uniqueResults.length} matches for ${parsedSlug}`); - - const selected = await p.select({ - message: "Select skill to inspect", - options: uniqueResults.map((r) => ({ - value: `${r.namespace}/${r.name}`, - label: `${r.namespace}/${r.name}`, - hint: r.summary ? r.summary.slice(0, 50) : undefined, - })), - }); - - if (p.isCancel(selected)) { - console.log("Cancelled."); - return; - } - - const [selectedNs, selectedName] = (selected as string).split("/", 2); - if (opts.skillVersion) { - await displaySkillDetail(selectedNs, selectedName, opts.skillVersion); - } else { - await inspectWithVersionSelection(selectedNs, selectedName); - } - } catch (e: any) { - spinner.fail(e.message); - process.exitCode = 1; - } }); } diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index 8552f02c..edcf11b2 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -210,8 +210,8 @@ function buildInstallHelp(cmd: Command): string { lines.push(""); lines.push(chalk.bold("Source Options:")); - lines.push(` ${chalk.cyan("-a, --add ")} Install from GitHub or local path`); - lines.push(` ${chalk.cyan("--from ")} Alias for --add`); + lines.push(` ${chalk.cyan("--from ")} Install from GitHub or local path`); + lines.push(` ${chalk.cyan("-a, --add ")} Alias for --from`); lines.push(""); lines.push(chalk.bold("Target Options:")); @@ -250,8 +250,8 @@ export function registerInstall(program: Command) { .alias("i") .description("Install skills from registry, git repositories, or local paths") .argument("", "Skill name or namespace/skill-name from registry") - .option("-a, --add ", "Install from GitHub or local path") - .option("--from ", "Alias for --add") + .option("--from ", "Install from GitHub or local path") + .option("-a, --add ", "Alias for --from") .option("--agent ", "Target specific agents") .option("-g, --global", "Install to global scope") .option("-y, --yes", "Skip all prompts") diff --git a/skillhub-cli/src/commands/rating.ts b/skillhub-cli/src/commands/rating.ts index 9df10fa2..8e8d5cb1 100644 --- a/skillhub-cli/src/commands/rating.ts +++ b/skillhub-cli/src/commands/rating.ts @@ -3,16 +3,16 @@ import { ApiClient } from "../core/api-client.js"; import { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { requireToken } from "../core/auth-token.js"; import { success, error, info, dim } from "../utils/logger.js"; -import { parseSkillName } from "../core/skill-name.js"; - export function registerRating(program: Command) { program .command("rating") .description("View your rating for a skill") .argument("", "Skill name or namespace/skill-name") - .action(async (slug: string) => { + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { namespace?: string }) => { try { - const { namespace, slug: skillSlug } = parseSkillName(slug); + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); const token = await requireToken(); const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); @@ -44,7 +44,8 @@ export function registerRate(program: Command) { .description("Rate a skill (1-5)") .argument("", "Skill name or namespace/skill-name") .argument("", "Rating score (1-5)") - .action(async (slug: string, scoreStr: string) => { + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, scoreStr: string, opts: { namespace?: string }) => { const score = parseInt(scoreStr, 10); if (isNaN(score) || score < 1 || score > 5) { error("Score must be between 1 and 5"); @@ -52,7 +53,8 @@ export function registerRate(program: Command) { } try { - const { namespace, slug: skillSlug } = parseSkillName(slug); + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); const token = await requireToken(); const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); diff --git a/skillhub-cli/src/commands/report.ts b/skillhub-cli/src/commands/report.ts index 87368230..bb2cf22d 100644 --- a/skillhub-cli/src/commands/report.ts +++ b/skillhub-cli/src/commands/report.ts @@ -4,17 +4,17 @@ import { ApiClient } from "../core/api-client.js"; import { requireToken } from "../core/auth-token.js"; import { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { success, error } from "../utils/logger.js"; -import { parseSkillName } from "../core/skill-name.js"; - export function registerReport(program: Command) { program .command("report") .description("Report a skill for review") .argument("", "Skill name or namespace/skill-name") .option("--reason ", "Report reason") - .action(async (slug: string, opts: { reason?: string }) => { + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { reason?: string; namespace?: string }) => { try { - const { namespace, slug: skillSlug } = parseSkillName(slug); + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); const token = await requireToken(); const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); diff --git a/skillhub-cli/src/commands/resolve.ts b/skillhub-cli/src/commands/resolve.ts index 3d44935c..13deba67 100644 --- a/skillhub-cli/src/commands/resolve.ts +++ b/skillhub-cli/src/commands/resolve.ts @@ -49,9 +49,11 @@ export function registerResolve(program: Command) { .option("-v, --skill-version ", "Specific version") .option("--tag ", "Tag to resolve (default: latest, ignored if --skill-version)") .option("--hash ", "Content hash") + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") .action(async (slug: string, opts: Record) => { try { - const { namespace, slug: skillSlug } = parseSkillName(slug); + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); const config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); diff --git a/skillhub-cli/src/commands/star.ts b/skillhub-cli/src/commands/star.ts index fbd06599..606fc34c 100644 --- a/skillhub-cli/src/commands/star.ts +++ b/skillhub-cli/src/commands/star.ts @@ -4,7 +4,7 @@ import { ApiRoutes } from "../schema/routes.js"; import { requireToken } from "../core/auth-token.js"; import { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { success, error } from "../utils/logger.js"; -import { parseSkillName } from "../core/skill-name.js"; + export function registerStar(program: Command) { program @@ -12,9 +12,11 @@ export function registerStar(program: Command) { .description("Star a skill") .argument("", "Skill name or namespace/skill-name") .option("--unstar", "Remove star") - .action(async (slug: string, opts: { unstar: boolean }) => { + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { unstar: boolean; namespace?: string }) => { try { - const { namespace, slug: skillSlug } = parseSkillName(slug); + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); const token = await requireToken(); const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); diff --git a/skillhub-cli/src/core/skill-resolver.ts b/skillhub-cli/src/core/skill-resolver.ts new file mode 100644 index 00000000..71f7ae9e --- /dev/null +++ b/skillhub-cli/src/core/skill-resolver.ts @@ -0,0 +1,86 @@ +import { ApiClient } from "./api-client.js"; +import { parseSkillName } from "./skill-name.js"; +import { searchSkills, runInteractiveSearch } from "./interactive-search.js"; + +export interface ResolvedSkill { + namespace: string; + slug: string; + userSpecified: boolean; +} + +/** + * 解析 skill 的 namespace,支持智能搜索 + * + * @param client - API 客户端 + * @param slug - 输入的 skill 名称(可能包含 namespace) + * @param explicitNamespace - 通过 --namespace 选项显式指定的 namespace + * @returns 解析后的 namespace 和 slug + */ +export async function resolveSkillNamespace( + client: ApiClient, + slug: string, + explicitNamespace?: string +): Promise { + const { namespace: parsedNs, slug: actualSlug } = parseSkillName(slug); + + if (explicitNamespace) { + return { namespace: explicitNamespace, slug: actualSlug, userSpecified: true }; + } + + if (parsedNs !== "global") { + return { namespace: parsedNs, slug: actualSlug, userSpecified: true }; + } + + const results = await searchSkills(client, actualSlug, 50); + + const seen = new Set(); + const uniqueResults = results.filter((r) => { + const key = `${r.namespace}/${r.name}`; + if (!seen.has(key)) { + seen.add(key); + return true; + } + return false; + }); + + if (uniqueResults.length === 0) { + throw new Error(`Skill not found: ${actualSlug}`); + } + + if (uniqueResults.length === 1) { + return { + namespace: uniqueResults[0].namespace, + slug: uniqueResults[0].name, + userSpecified: false, + }; + } + + const selected = await runInteractiveSearch(client, actualSlug); + if (!selected) { + throw new Error("Cancelled"); + } + + const [ns, name] = selected.split("/", 2); + return { namespace: ns, slug: name, userSpecified: false }; +} + +/** + * 简单解析 skill namespace,不触发智能搜索 + * 用于不需要搜索的命令(delete, star 等) + */ +export function parseSkillNamespace( + slug: string, + explicitNamespace?: string +): ResolvedSkill { + const { namespace: parsedNs, slug: actualSlug } = parseSkillName(slug); + + if (explicitNamespace) { + return { namespace: explicitNamespace, slug: actualSlug, userSpecified: true }; + } + + return { + namespace: parsedNs, + slug: actualSlug, + userSpecified: parsedNs !== "global", + }; +}