From f4752f2d9beac4b218a04f828563564aeb5222e5 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:32:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20=E4=BC=98=E5=8C=96=20download=20?= =?UTF-8?q?=E5=92=8C=20update=20=E5=91=BD=E4=BB=A4=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=8F=8B=E5=A5=BD=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - download: 添加智能搜索 namespace 和交互式版本选择 - download: 修复下载失败仍创建文件的问题 - update: 重写为带版本对比的更新流程 - update: 添加 -y/--yes 选项跳过确认 - star/rating/rate/report/delete/archive/hide/unhide: 添加 404 友好错误提示 - 提示用户使用 namespace/skill-name 格式 --- skillhub-cli/src/commands/archive.ts | 10 +- skillhub-cli/src/commands/delete.ts | 10 +- skillhub-cli/src/commands/download.ts | 93 ++++++++++++-- skillhub-cli/src/commands/hide.ts | 20 ++- skillhub-cli/src/commands/rating.ts | 20 ++- skillhub-cli/src/commands/report.ts | 10 +- skillhub-cli/src/commands/star.ts | 12 +- skillhub-cli/src/commands/update.ts | 172 ++++++++++++++++++++++---- 8 files changed, 308 insertions(+), 39 deletions(-) diff --git a/skillhub-cli/src/commands/archive.ts b/skillhub-cli/src/commands/archive.ts index c80c80f9..777cabe1 100644 --- a/skillhub-cli/src/commands/archive.ts +++ b/skillhub-cli/src/commands/archive.ts @@ -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; } }); diff --git a/skillhub-cli/src/commands/delete.ts b/skillhub-cli/src/commands/delete.ts index 968e24b6..afe16cbd 100644 --- a/skillhub-cli/src/commands/delete.ts +++ b/skillhub-cli/src/commands/delete.ts @@ -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; } }); diff --git a/skillhub-cli/src/commands/download.ts b/skillhub-cli/src/commands/download.ts index 2a0e0723..24862218 100644 --- a/skillhub-cli/src/commands/download.ts +++ b/skillhub-cli/src/commands/download.ts @@ -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) => { - 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>( + `/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`); diff --git a/skillhub-cli/src/commands/hide.ts b/skillhub-cli/src/commands/hide.ts index b758ba1f..4f7deaf8 100644 --- a/skillhub-cli/src/commands/hide.ts +++ b/skillhub-cli/src/commands/hide.ts @@ -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; } }); diff --git a/skillhub-cli/src/commands/rating.ts b/skillhub-cli/src/commands/rating.ts index 8e8d5cb1..f4aec898 100644 --- a/skillhub-cli/src/commands/rating.ts +++ b/skillhub-cli/src/commands/rating.ts @@ -32,7 +32,15 @@ export function registerRating(program: Command) { dim("Use: skillhub rate "); } } 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; } }); diff --git a/skillhub-cli/src/commands/report.ts b/skillhub-cli/src/commands/report.ts index bb2cf22d..b0740b75 100644 --- a/skillhub-cli/src/commands/report.ts +++ b/skillhub-cli/src/commands/report.ts @@ -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; } }); diff --git a/skillhub-cli/src/commands/star.ts b/skillhub-cli/src/commands/star.ts index 606fc34c..a42223dc 100644 --- a/skillhub-cli/src/commands/star.ts +++ b/skillhub-cli/src/commands/star.ts @@ -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; } }); diff --git a/skillhub-cli/src/commands/update.ts b/skillhub-cli/src/commands/update.ts index 1082cff4..9f4c1c43 100644 --- a/skillhub-cli/src/commands/update.ts +++ b/skillhub-cli/src/commands/update.ts @@ -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) => { 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++; } }