feat(cli): 优化 download 和 update 命令,添加友好错误提示

- download: 添加智能搜索 namespace 和交互式版本选择
- download: 修复下载失败仍创建文件的问题
- update: 重写为带版本对比的更新流程
- update: 添加 -y/--yes 选项跳过确认
- star/rating/rate/report/delete/archive/hide/unhide: 添加 404 友好错误提示
- 提示用户使用 namespace/skill-name 格式
This commit is contained in:
chenbaowang 2026-04-23 17:32:13 +08:00
parent 273e14a26f
commit f4752f2d9b
8 changed files with 308 additions and 39 deletions

View file

@ -33,7 +33,15 @@ export function registerArchive(program: Command) {
await client.post(`/api/v1/skills/${namespace}/${skillSlug}/archive`);
success(`Archived ${skillSlug}`);
} catch (e: any) {
error(`Failed: ${e.message}`);
const status = e.status || e.statusCode;
if (status === 404) {
error(`Skill not found: ${namespace}/${skillSlug}`);
if (!slug.includes("/")) {
dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push");
}
} else {
error(`Failed: ${e.message}`);
}
process.exitCode = 1;
}
});

View file

@ -33,7 +33,15 @@ export function registerDelete(program: Command) {
await client.delete(`/api/v1/skills/${namespace}/${skillSlug}`);
success(`Deleted ${skillSlug} from ${namespace}`);
} catch (e: any) {
error(`Failed: ${e.message}`);
const status = e.status || e.statusCode;
if (status === 404) {
error(`Skill not found: ${namespace}/${skillSlug}`);
if (!slug.includes("/")) {
dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push");
}
} else {
error(`Failed: ${e.message}`);
}
process.exitCode = 1;
}
});

View file

@ -3,10 +3,10 @@ import { createWriteStream } from "node:fs";
import { resolve } from "node:path";
import { finished } from "node:stream/promises";
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 { success, error } from "../utils/logger.js";
import * as p from "@clack/prompts";
import ora from "ora";
@ -57,8 +57,7 @@ export function registerDownload(program: Command) {
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 { resolveSkillNamespace, parseSkillNamespace } = await import("../core/skill-resolver.js");
const config = loadConfigFromProgram(program);
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });
@ -66,15 +65,91 @@ export function registerDownload(program: Command) {
const outputDir = opts.output ? resolve(process.cwd(), opts.output) : process.cwd();
try {
let namespace: string;
let skillSlug: string;
if (opts.namespace || slug.includes("/")) {
const parsed = parseSkillNamespace(slug, opts.namespace);
namespace = parsed.namespace;
skillSlug = parsed.slug;
} else {
const spinner = ora(`Searching for ${slug}`).start();
try {
const resolved = await resolveSkillNamespace(client, slug);
namespace = resolved.namespace;
skillSlug = resolved.slug;
spinner.succeed(`Found ${namespace}/${skillSlug}`);
} catch (e: any) {
spinner.fail(e.message);
process.exitCode = 1;
return;
}
}
const spinner = ora(`Downloading ${skillSlug} from ${namespace}`).start();
let downloadUrl = `${ApiRoutes.skillDownload.replace("{namespace}", namespace).replace("{slug}", skillSlug)}`;
let selectedVersion: string;
if (opts.skillVersion) {
downloadUrl = `/api/v1/skills/${namespace}/${skillSlug}/versions/${opts.skillVersion}/download`;
} else if (opts.tag) {
downloadUrl = `/api/v1/skills/${namespace}/${skillSlug}/tags/${opts.tag}/download`;
selectedVersion = opts.skillVersion;
} else if (opts.tag && opts.tag !== "latest") {
spinner.text = `Resolving tag ${opts.tag}`;
try {
const tagsResp = await client.get<Array<{ tagName: string; version: string }>>(
`/api/v1/skills/${namespace}/${skillSlug}/tags`
);
const tags = tagsResp || [];
const matchedTag = tags.find((t) => t.tagName === opts.tag);
if (matchedTag) {
selectedVersion = matchedTag.version;
} else {
spinner.fail(`Tag not found: ${opts.tag}`);
process.exitCode = 1;
return;
}
} catch (e: any) {
spinner.fail(`Failed to fetch tags: ${e.message}`);
process.exitCode = 1;
return;
}
} else {
spinner.stop();
try {
const versionsResp = await client.get<{ items: Array<{ version: string; publishedAt: string }> }>(
`/api/v1/skills/${namespace}/${skillSlug}/versions`
);
const versions = versionsResp.items || [];
if (versions.length === 0) {
error(`No versions found for ${namespace}/${skillSlug}`);
process.exitCode = 1;
return;
}
if (versions.length === 1) {
selectedVersion = versions[0].version;
} else {
const picked = await p.select({
message: "Select version to download",
options: versions.map((v) => ({
value: v.version,
label: `v${v.version}`,
hint: new Date(v.publishedAt).toLocaleDateString(),
})),
});
if (p.isCancel(picked)) {
console.log("Cancelled.");
return;
}
selectedVersion = picked as string;
}
spinner.start(`Downloading ${skillSlug}@${selectedVersion}`);
} catch (e: any) {
error(`Failed to fetch versions: ${e.message}`);
process.exitCode = 1;
return;
}
}
const downloadUrl = `${config.registry.replace(/\/$/, "")}/api/v1/skills/${namespace}/${skillSlug}/versions/${selectedVersion}/download`;
const { request } = await import("undici");
const url = new URL(downloadUrl, config.registry);
let response = await request(url.toString(), {
@ -86,6 +161,7 @@ export function registerDownload(program: Command) {
if (!location) {
spinner.fail(`Redirect response has no Location header`);
process.exitCode = 1;
return;
}
response = await request(location as string, { method: "GET" });
}
@ -94,6 +170,7 @@ export function registerDownload(program: Command) {
if (statusCode >= 400) {
spinner.fail(`Download failed: HTTP ${statusCode}`);
process.exitCode = 1;
return;
}
const outPath = resolve(outputDir, `${skillSlug}.zip`);

View file

@ -44,7 +44,15 @@ export function registerHide(program: Command) {
success(`Hidden ${skillSlug}`);
} catch (e: any) {
error(`Failed: ${e.message}`);
const status = e.status || e.statusCode;
if (status === 404) {
error(`Skill not found: ${namespace}/${skillSlug}`);
if (!slug.includes("/")) {
dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push");
}
} else {
error(`Failed: ${e.message}`);
}
process.exitCode = 1;
}
});
@ -87,7 +95,15 @@ export function registerHide(program: Command) {
success(`Unhidden ${skillSlug}`);
} catch (e: any) {
error(`Failed: ${e.message}`);
const status = e.status || e.statusCode;
if (status === 404) {
error(`Skill not found: ${namespace}/${skillSlug}`);
if (!slug.includes("/")) {
dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push");
}
} else {
error(`Failed: ${e.message}`);
}
process.exitCode = 1;
}
});

View file

@ -32,7 +32,15 @@ export function registerRating(program: Command) {
dim("Use: skillhub rate <skill> <score>");
}
} catch (e: any) {
error(`Failed: ${e.message}`);
const status = e.status || e.statusCode;
if (status === 404) {
error(`Skill not found: ${namespace}/${skillSlug}`);
if (!slug.includes("/")) {
dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push");
}
} else {
error(`Failed: ${e.message}`);
}
process.exitCode = 1;
}
});
@ -69,7 +77,15 @@ export function registerRate(program: Command) {
});
success(`Rated ${skillSlug}: ${"★".repeat(score)}${"☆".repeat(5 - score)}`);
} catch (e: any) {
error(`Failed: ${e.message}`);
const status = e.status || e.statusCode;
if (status === 404) {
error(`Skill not found: ${namespace}/${skillSlug}`);
if (!slug.includes("/")) {
dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push");
}
} else {
error(`Failed: ${e.message}`);
}
process.exitCode = 1;
}
});

View file

@ -34,7 +34,15 @@ export function registerReport(program: Command) {
});
success(`Report submitted for ${skillSlug}`);
} catch (e: any) {
error(`Failed: ${e.message}`);
const status = e.status || e.statusCode;
if (status === 404) {
error(`Skill not found: ${namespace}/${skillSlug}`);
if (!slug.includes("/")) {
dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push");
}
} else {
error(`Failed: ${e.message}`);
}
process.exitCode = 1;
}
});

View file

@ -3,7 +3,7 @@ import { ApiClient } from "../core/api-client.js";
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 { success, error, dim } from "../utils/logger.js";
export function registerStar(program: Command) {
@ -33,7 +33,15 @@ export function registerStar(program: Command) {
success(`Starred ${skillSlug}`);
}
} catch (e: any) {
error(`Failed: ${e.message}`);
const status = e.status || e.statusCode;
if (status === 404) {
error(`Skill not found: ${namespace}/${skillSlug}`);
if (!slug.includes("/")) {
dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push");
}
} else {
error(`Failed: ${e.message}`);
}
process.exitCode = 1;
}
});

View file

@ -1,27 +1,45 @@
import { Command } from "commander";
import { success, error, info, warn } from "../utils/logger.js";
import { getAllLockedSkills, getSkillLockPath } from "../core/skill-lock.js";
import { success, error, info, warn, dim } from "../utils/logger.js";
import { getAllLockedSkills, getSkillLockPath, type SkillLockEntry } from "../core/skill-lock.js";
import { existsSync } from "node:fs";
import { execSync } from "node:child_process";
import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js";
import { ApiClient } from "../core/api-client.js";
import { loadConfigFromProgram } from "../core/config.js";
import { readToken } from "../core/auth-token.js";
import * as p from "@clack/prompts";
import ora from "ora";
function getCliCommand(): string {
const cliPath = process.argv[1];
return `node "${cliPath}"`;
}
interface UpdateInfo {
name: string;
currentVersion: string;
latestVersion: string;
namespace: string;
slug: string;
source: string;
sourceType: string;
hasUpdate: boolean;
}
export function registerUpdate(program: Command) {
program
.command("update [skill]")
.description("Update installed skills from their source")
.option("-a, --all", "Update all installed skills")
.option("-g, --global", "Update global scope skills")
.option("-y, --yes", "Skip confirmation and update all outdated skills")
.action(async (slug: string | undefined, opts: Record<string, boolean>) => {
const lockPath = getSkillLockPath();
if (!existsSync(lockPath)) {
error("No skillhub.lock found. Have you installed any skills?");
process.exitCode = 1;
return;
}
const lockedSkills = await getAllLockedSkills();
@ -30,21 +48,32 @@ export function registerUpdate(program: Command) {
if (allSkillNames.length === 0) {
error("No skills in lock file.");
process.exitCode = 1;
return;
}
let skillsToUpdate: string[] = [];
const config = loadConfigFromProgram(program);
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });
let skillsToCheck: string[] = [];
if (opts.all) {
skillsToUpdate = allSkillNames;
skillsToCheck = allSkillNames;
} else if (slug) {
skillsToUpdate = [slug];
if (!lockedSkills[slug]) {
error(`Skill not found in lock file: ${slug}`);
error(`Installed skills: ${allSkillNames.join(", ")}`);
process.exitCode = 1;
return;
}
skillsToCheck = [slug];
} else {
const selected = await searchMultiselect({
message: "Select skills to update",
message: "Select skills to check for updates",
items: allSkillNames.map((name) => ({
value: name,
label: name,
hint: lockedSkills[name].sourceType,
hint: `${lockedSkills[name].namespace}/${lockedSkills[name].slug} @ ${lockedSkills[name].version}`,
})),
required: true,
});
@ -54,7 +83,116 @@ export function registerUpdate(program: Command) {
return;
}
skillsToUpdate = selected as string[];
skillsToCheck = selected as string[];
}
const spinner = ora("Checking for updates...").start();
const updates: UpdateInfo[] = [];
const upToDate: string[] = [];
const checkFailed: string[] = [];
for (const name of skillsToCheck) {
const entry = lockedSkills[name];
if (!entry) continue;
if (entry.sourceType !== "registry") {
checkFailed.push(name);
continue;
}
try {
const versionsResp = await client.get<{ items: Array<{ version: string }> }>(
`/api/v1/skills/${entry.namespace}/${entry.slug}/versions`
);
const versions = versionsResp.items || [];
if (versions.length === 0) {
checkFailed.push(name);
continue;
}
const latestVersion = versions[0].version;
const currentVersion = entry.version;
if (latestVersion === currentVersion) {
upToDate.push(name);
} else {
updates.push({
name,
currentVersion,
latestVersion,
namespace: entry.namespace,
slug: entry.slug,
source: entry.source,
sourceType: entry.sourceType,
hasUpdate: true,
});
}
} catch (e: any) {
checkFailed.push(name);
}
}
spinner.stop();
if (upToDate.length > 0) {
console.log("");
info(`Up to date (${upToDate.length}):`);
for (const name of upToDate) {
dim(`${name} @ ${lockedSkills[name].version}`);
}
}
if (checkFailed.length > 0) {
console.log("");
warn(`Check failed (${checkFailed.length}):`);
for (const name of checkFailed) {
dim(`${name}`);
}
}
if (updates.length === 0) {
console.log("");
success("All skills are up to date!");
return;
}
console.log("");
info(`Updates available (${updates.length}):`);
for (const u of updates) {
console.log(`${u.name}: ${u.currentVersion}${u.latestVersion}`);
}
let skillsToUpdate = updates;
if (!opts.yes && !opts.all && !slug) {
const selected = await searchMultiselect({
message: "Select skills to update",
items: updates.map((u) => ({
value: u.name,
label: u.name,
hint: `${u.currentVersion}${u.latestVersion}`,
})),
required: true,
});
if (selected === cancelSymbol) {
console.log("Cancelled.");
return;
}
skillsToUpdate = updates.filter((u) => (selected as string[]).includes(u.name));
}
if (!opts.yes) {
const confirmed = await p.confirm({
message: `Update ${skillsToUpdate.length} skill(s)?`,
});
if (p.isCancel(confirmed) || !confirmed) {
console.log("Cancelled.");
return;
}
}
const scope = opts.global ? "--global" : "";
@ -63,24 +201,14 @@ export function registerUpdate(program: Command) {
let updated = 0;
let failed = 0;
for (const name of skillsToUpdate) {
const entry = lockedSkills[name];
if (!entry) {
warn(`Skill not found in lock: ${name}`);
continue;
}
for (const info of skillsToUpdate) {
try {
info(`Updating ${name} from ${entry.source}...`);
const source = entry.sourceType === "registry"
? entry.source
: entry.sourceUrl;
const cmd = `${cliCmd} install ${source} ${scope}`.trim();
info(`Updating ${info.name} from ${info.currentVersion} to ${info.latestVersion}...`);
const cmd = `${cliCmd} install ${info.namespace}/${info.slug} --skill-version ${info.latestVersion} ${scope}`.trim();
execSync(cmd, { stdio: "inherit" });
updated++;
} catch (e: any) {
error(`Failed to update ${name}: ${e.message}`);
error(`Failed to update ${info.name}: ${e.message}`);
failed++;
}
}