feat(cli): add command aliases and unhide command

This commit is contained in:
chenbaowang 2026-04-24 10:16:16 +08:00
parent 803540b157
commit aa9fd5f771
16 changed files with 108 additions and 97 deletions

View file

@ -144,7 +144,8 @@ export async function createCli(): Promise<Command> {
.description("CLI for SkillHub — publish, search, and manage agent skills")
.version(version)
.option("--registry <url>", "Registry API base URL")
.option("--json", "Output results as JSON");
.option("--json", "Output results as JSON")
.option("--debug", "Show debug information for API requests");
const customHelp = buildTopLevelHelp(version);
const originalHelpInformation = program.helpInformation.bind(program);
@ -179,7 +180,7 @@ export async function createCli(): Promise<Command> {
{ registerInspect },
{ registerExplore },
{ registerTransfer },
{ registerHide },
{ registerHide, registerUnhide },
{ registerConfig },
] = await Promise.all([
import("./commands/login.js"),
@ -238,6 +239,7 @@ export async function createCli(): Promise<Command> {
registerExplore(program);
registerTransfer(program);
registerHide(program);
registerUnhide(program);
registerConfig(program);
return program;

View file

@ -2,7 +2,7 @@ import { Command } from "commander";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { success, error, info } from "../utils/logger.js";
import { success, error, info, dim } from "../utils/logger.js";
import chalk from "chalk";
const CONFIG_DIR = join(homedir(), ".skillhub");

View file

@ -6,6 +6,7 @@ import { success, error } from "../utils/logger.js";
export function registerDelete(program: Command) {
program
.command("delete")
.aliases(["del", "unpublish"])
.description("Delete a skill you own")
.argument("<skill>", "Skill name or namespace/skill-name")
.option("-y, --yes", "Skip confirmation")
@ -29,7 +30,7 @@ export function registerDelete(program: Command) {
try {
const token = await requireToken();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug });
await client.delete(`/api/v1/skills/${namespace}/${skillSlug}`);
success(`Deleted ${skillSlug} from ${namespace}`);
} catch (e: any) {

View file

@ -255,6 +255,7 @@ function buildExploreHelp(cmd: Command): string {
export function registerExplore(program: Command) {
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")

View file

@ -2,109 +2,79 @@ import { Command } from "commander";
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 { success, error, dim } from "../utils/logger.js";
async function hideSkill(
program: Command,
slug: string,
opts: { yes?: boolean; namespace?: string },
action: "hide" | "unhide"
) {
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 });
const actionText = action === "hide" ? "Hide" : "Unhide";
const answer = await new Promise<string>((r) =>
rl.question(`${actionText} ${skillSlug} from ${namespace}? [y/N] `, r)
);
rl.close();
if (answer.toLowerCase() !== "y") {
console.log("Cancelled.");
return;
}
}
try {
const token = await requireToken();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const detail = await client.get<{ id: number }>(
`/api/v1/skills/${namespace}/${skillSlug}`
);
await client.post(`/api/v1/admin/skills/${detail.id}/${action}`, {
body: JSON.stringify({}),
headers: { "Content-Type": "application/json" },
});
success(`${action === "hide" ? "Hidden" : "Unhidden"} ${skillSlug}`);
} catch (e: any) {
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;
}
}
export function registerHide(program: Command) {
const hideCmd = program
program
.command("hide")
.description("Hide a skill (admin only)")
.argument("<skill>", "Skill name or namespace/skill-name")
.option("-y, --yes", "Skip confirmation")
.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 });
const answer = await new Promise<string>((r) =>
rl.question(`Hide ${skillSlug} from ${namespace}? [y/N] `, r)
);
rl.close();
if (answer.toLowerCase() !== "y") {
console.log("Cancelled.");
return;
}
}
try {
const token = await requireToken();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const detail = await client.get<{ id: number }>(
`/api/v1/skills/${namespace}/${skillSlug}`
);
await client.post(`/api/v1/admin/skills/${detail.id}/hide`, {
body: JSON.stringify({}),
headers: { "Content-Type": "application/json" },
});
success(`Hidden ${skillSlug}`);
} catch (e: any) {
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;
}
await hideSkill(program, slug, opts, "hide");
});
}
hideCmd
export function registerUnhide(program: Command) {
program
.command("unhide")
.description("Unhide a skill (admin only)")
.argument("<skill>", "Skill name or namespace/skill-name")
.option("-y, --yes", "Skip confirmation")
.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 });
const answer = await new Promise<string>((r) =>
rl.question(`Unhide ${skillSlug} from ${namespace}? [y/N] `, r)
);
rl.close();
if (answer.toLowerCase() !== "y") {
console.log("Cancelled.");
return;
}
}
try {
const token = await requireToken();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const detail = await client.get<{ id: number }>(
`/api/v1/skills/${namespace}/${skillSlug}`
);
await client.post(`/api/v1/admin/skills/${detail.id}/unhide`, {
body: JSON.stringify({}),
headers: { "Content-Type": "application/json" },
});
success(`Unhidden ${skillSlug}`);
} catch (e: any) {
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;
}
await hideSkill(program, slug, opts, "unhide");
});
}

View file

@ -138,6 +138,7 @@ function printInspectHeader(detail: SkillDetailResponse, versions?: SkillVersion
export function registerInspect(program: Command) {
program
.command("inspect")
.aliases(["info", "view"])
.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)")

View file

@ -18,6 +18,7 @@ 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,12 +28,13 @@ export function registerMe(program: Command) {
me
.command("skills")
.alias("ls")
.description("List your published skills")
.action(async () => {
try {
const token = await requireToken();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug });
const resp = await client.get<MeSkillsResponse>("/api/v1/me/skills");
const skills = resp.items || [];
const isJson = program.opts().json;
@ -63,7 +64,7 @@ export function registerMe(program: Command) {
try {
const token = await requireToken();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug });
const resp = await client.get<MeSkillsResponse>("/api/v1/me/stars");
const skills = resp.items || [];
const isJson = program.opts().json;

View file

@ -13,7 +13,7 @@ export function registerNamespaces(program: Command) {
try {
const token = await requireToken();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug });
const namespaces = await client.get<NamespaceResponse[]>(ApiRoutes.meNamespaces);
const isJson = program.opts().json;
if (isJson) {

View file

@ -15,6 +15,7 @@ export interface Notification {
export function registerNotifications(program: Command) {
const cmd = program
.command("notifications")
.alias("notif")
.description("Manage notifications");
cmd

View file

@ -16,7 +16,7 @@ export function registerPublish(program: Command) {
.description("Publish a skill to SkillHub registry")
.option("--namespace <ns>", "Target namespace (default: global)")
.option("--slug <slug>", "Skill slug")
.option("-v, --skill-version <ver>", "Version (semver)")
.option("--skill-version <ver>", "Version (semver)")
.option("--name <name>", "Display name")
.option("--changelog <text>", "Changelog text")
.option("--tag <tags>", "Comma-separated tags (e.g. beta,stable)", "latest")
@ -29,7 +29,7 @@ export function registerPublish(program: Command) {
}
const slug = opts.slug || basename(folder);
let version = opts["skill-version"] || opts.ver;
let version = opts["skill-version"] || opts.v;
if (!version) {
const now = new Date();
const yyyymmdd = now.getFullYear() * 10000 + (now.getMonth() + 1) * 100 + now.getDate();

View file

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

View file

@ -111,6 +111,7 @@ function findAgentsWithSkill(skillName: string, scope: "global" | "local", agent
export function registerUninstall(program: Command) {
program
.command("uninstall [skill]")
.alias("un")
.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

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

View file

@ -13,7 +13,7 @@ export function registerWhoami(program: Command) {
try {
const token = await requireToken();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug });
const resp = await client.get<WhoamiResponse>(ApiRoutes.whoami);
const isJson = program.opts().json;
if (isJson) {

View file

@ -3,6 +3,7 @@ import { request, FormData as UndiciFormData } from "undici";
export interface ApiClientOptions {
baseUrl: string;
token?: string;
debug?: boolean;
}
interface NativeApiResponse<T> {
@ -37,13 +38,29 @@ export class ApiClient {
return data as T;
}
private logDebug(method: string, url: string, statusCode?: number, body?: unknown) {
if (!this.options.debug) return;
const token = this.options.token;
const tokenPreview = token ? `${token.substring(0, 20)}...` : "none";
console.error(`[DEBUG] ${method} ${url}`);
console.error(`[DEBUG] Token: ${tokenPreview}`);
if (statusCode !== undefined) {
console.error(`[DEBUG] Status: ${statusCode}`);
}
if (body !== undefined) {
console.error(`[DEBUG] Body:`, JSON.stringify(body, null, 2));
}
}
async get<T>(path: string): Promise<T> {
const url = new URL(path, this.options.baseUrl);
this.logDebug("GET", url.toString());
const { statusCode, body } = await request(url.toString(), {
method: "GET",
headers: this.headers(),
});
const data = await body.json();
this.logDebug("GET", url.toString(), statusCode, data);
if (statusCode >= 400) {
throw new ApiError(statusCode, data);
}
@ -130,6 +147,11 @@ function extractHumanMessage(body: unknown): string | null {
if (typeof b.msg === "string" && b.msg.length > 0) return b.msg;
if (typeof b.message === "string" && b.message.length > 0) return b.message;
if (typeof b.error === "string" && b.error.length > 0) return b.error;
if (typeof b.detail === "string" && b.detail.length > 0) return b.detail;
if (typeof b.reason === "string" && b.reason.length > 0) return b.reason;
if (typeof b.description === "string" && b.description.length > 0) return b.description;
if (typeof b.data === "string" && b.data.length > 0) return b.data;
return null;
}
@ -154,6 +176,14 @@ export class ApiError extends Error {
detail += " - Or use: skillhub --registry <url> <command>";
}
// Enhanced error messages for 403 Forbidden
if (statusCode === 403) {
detail += "\n\n💡 Access denied. This could mean:\n";
detail += " - Your account doesn't have permission to access this resource\n";
detail += " - Contact your administrator if you believe this is an error\n";
detail += " - Run 'skillhub whoami' to verify your account";
}
super(detail);
}
}