feat(cli): 添加 --namespace 选项和智能 namespace 解析

- 新建 skill-resolver.ts 公共函数,提供 resolveSkillNamespace 和 parseSkillNamespace
- 为 resolve, download, star, rating, rate, report, delete, archive, hide, unhide 添加 --namespace 选项
- inspect 使用 resolveSkillNamespace 替代手动解析
- install 优化 --from 为主选项,-a, --add 为别名
- download 优化帮助界面,添加 Examples 和更清晰的描述
This commit is contained in:
chenbaowang 2026-04-23 15:20:38 +08:00
parent 58ee1a5220
commit 273e14a26f
11 changed files with 185 additions and 112 deletions

View file

@ -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>", "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 <ns>", "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 });

View file

@ -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>", "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 <ns>", "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 });

View file

@ -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] <skill>`);
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 <ver>${RESET} Specific version to download`);
lines.push(` ${CYAN}--tag <tag>${RESET} Tag to download (default: "latest")`);
lines.push(` ${CYAN}--output <dir>${RESET} Output directory (default: current directory)`);
lines.push(` ${CYAN}--namespace <ns>${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>", "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")
.action(async (slug: string, opts: Record<string, string>) => {
const { namespace, slug: skillSlug } = parseSkillName(slug);
.option("--namespace <ns>", "Override namespace (default: parsed from skill or 'global')");
downloadCmd.helpInformation = () => buildDownloadHelp(downloadCmd);
downloadCmd.action(async (slug: string, opts: Record<string, string>) => {
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 });

View file

@ -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>", "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 <ns>", "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>", "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 <ns>", "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 });

View file

@ -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<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));
@ -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<number, string[]>();
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<string>();
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;
}
});
}

View file

@ -210,8 +210,8 @@ function buildInstallHelp(cmd: Command): string {
lines.push("");
lines.push(chalk.bold("Source Options:"));
lines.push(` ${chalk.cyan("-a, --add <source>")} Install from GitHub or local path`);
lines.push(` ${chalk.cyan("--from <source>")} Alias for --add`);
lines.push(` ${chalk.cyan("--from <source>")} Install from GitHub or local path`);
lines.push(` ${chalk.cyan("-a, --add <source>")} 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>", "Skill name or namespace/skill-name from registry")
.option("-a, --add <source>", "Install from GitHub or local path")
.option("--from <source>", "Alias for --add")
.option("--from <source>", "Install from GitHub or local path")
.option("-a, --add <source>", "Alias for --from")
.option("--agent <agents...>", "Target specific agents")
.option("-g, --global", "Install to global scope")
.option("-y, --yes", "Skip all prompts")

View file

@ -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>", "Skill name or namespace/skill-name")
.action(async (slug: string) => {
.option("--namespace <ns>", "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>", "Skill name or namespace/skill-name")
.argument("<score>", "Rating score (1-5)")
.action(async (slug: string, scoreStr: string) => {
.option("--namespace <ns>", "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 });

View file

@ -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>", "Skill name or namespace/skill-name")
.option("--reason <reason>", "Report reason")
.action(async (slug: string, opts: { reason?: string }) => {
.option("--namespace <ns>", "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 });

View file

@ -49,9 +49,11 @@ export function registerResolve(program: Command) {
.option("-v, --skill-version <ver>", "Specific version")
.option("--tag <tag>", "Tag to resolve (default: latest, ignored if --skill-version)")
.option("--hash <hash>", "Content hash")
.option("--namespace <ns>", "Override namespace (default: parsed from skill or 'global')")
.action(async (slug: string, opts: Record<string, 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 config = loadConfigFromProgram(program);
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });

View file

@ -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>", "Skill name or namespace/skill-name")
.option("--unstar", "Remove star")
.action(async (slug: string, opts: { unstar: boolean }) => {
.option("--namespace <ns>", "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 });

View file

@ -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<ResolvedSkill> {
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<string>();
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",
};
}