feat(skillhub-cli): add skillhub CLI with interactive multi-agent support

- Interactive agent selection for install/uninstall/list
- Namespace search selection for unspecified namespace
- --skill-version option to skip interactive selection
- --from option for local/github source installation
- Remove deprecated add command (use install --from instead)
- Path-based grouping for uninstall results
This commit is contained in:
chenbaowang 2026-04-14 10:21:09 +08:00
parent 38ebb13133
commit 6c0d4f4f16
59 changed files with 9328 additions and 0 deletions

35
skillhub-cli/package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "@motovis/skillhub",
"version": "1.0.0",
"type": "module",
"description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间",
"bin": {
"skillhub": "dist/cli.mjs"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"scripts": {
"build": "unbuild",
"dev": "unbuild --stub",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@clack/prompts": "^1.2.0",
"chalk": "^5.3.0",
"commander": "^14.0.3",
"ora": "^9.3.0",
"picocolors": "^1.1.1",
"semver": "^7.7.4",
"undici": "^7.24.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/semver": "^7.5.8",
"typescript": "^5.6.0",
"unbuild": "^3.0.0",
"vitest": "^3.0.0"
}
}

2642
skillhub-cli/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

126
skillhub-cli/src/cli.ts Normal file
View file

@ -0,0 +1,126 @@
import { Command } from "commander";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
function getPackageVersion(): string {
try {
const pkgPath = resolve(__dirname, "../package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
return pkg.version;
} catch {
return "0.1.0";
}
}
export async function createCli(): Promise<Command> {
const program = new Command();
const version = getPackageVersion();
program
.name("skillhub")
.description("CLI for SkillHub — publish, search, and manage agent skills")
.version(version)
.option("--registry <url>", "Registry API base URL", "http://localhost:8080")
.option("--json", "Output results as JSON");
const [
{ registerLogin },
{ registerLogout },
{ registerWhoami },
{ registerPublish },
{ registerNamespaces },
{ registerInstall },
{ registerDownload },
{ registerList },
{ registerStar },
{ registerInit },
{ registerMe },
{ registerReviews },
{ registerNotifications },
{ registerDelete },
{ registerVersions },
{ registerReport },
{ registerResolve },
{ registerRating, registerRate },
{ registerArchive },
{ registerUpdate },
{ registerCheck },
{ registerUninstall },
{ registerSync },
{ registerInspect },
{ registerExplore },
{ registerTransfer },
{ registerHide },
] = await Promise.all([
import("./commands/login.js"),
import("./commands/logout.js"),
import("./commands/whoami.js"),
import("./commands/publish.js"),
import("./commands/namespaces.js"),
import("./commands/install.js"),
import("./commands/download.js"),
import("./commands/list.js"),
import("./commands/star.js"),
import("./commands/init.js"),
import("./commands/me.js"),
import("./commands/reviews.js"),
import("./commands/notifications.js"),
import("./commands/delete.js"),
import("./commands/versions.js"),
import("./commands/report.js"),
import("./commands/resolve.js"),
import("./commands/rating.js"),
import("./commands/archive.js"),
import("./commands/update.js"),
import("./commands/check.js"),
import("./commands/uninstall.js"),
import("./commands/sync.js"),
import("./commands/inspect.js"),
import("./commands/explore.js"),
import("./commands/transfer.js"),
import("./commands/hide.js"),
]);
registerLogin(program);
registerLogout(program);
registerWhoami(program);
registerPublish(program);
registerNamespaces(program);
registerInstall(program);
registerDownload(program);
registerList(program);
registerStar(program);
registerInit(program);
registerMe(program);
registerReviews(program);
registerNotifications(program);
registerDelete(program);
registerVersions(program);
registerReport(program);
registerResolve(program);
registerRating(program);
registerRate(program);
registerArchive(program);
registerUpdate(program);
registerCheck(program);
registerUninstall(program);
registerSync(program);
registerInspect(program);
registerExplore(program);
registerTransfer(program);
registerHide(program);
return program;
}
export async function main() {
const program = await createCli();
program.parse();
}
main();

View file

@ -0,0 +1,39 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } 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 <slug>")
.description("Archive a skill you own")
.option("-y, --yes", "Skip confirmation")
.action(async (slug: string, opts: { yes?: boolean }) => {
const { namespace, slug: skillSlug } = parseSkillName(slug);
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(`Archive ${skillSlug} from ${namespace}? [y/N] `, r)
);
rl.close();
if (answer.toLowerCase() !== "y") {
console.log("Cancelled.");
return;
}
}
try {
const token = await requireToken();
const config = loadConfig();
const client = new ApiClient({ baseUrl: config.registry, token });
await client.post(`/api/v1/skills/${namespace}/${skillSlug}/archive`);
success(`Archived ${skillSlug}`);
} catch (e: any) {
error(`Failed: ${e.message}`);
process.exit(1);
}
});
}

View file

@ -0,0 +1,135 @@
import { Command } from "commander";
import { existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { getAllAgents } from "../core/agent-detector.js";
import { getAllLockedSkills, getSkillLockPath } from "../core/skill-lock.js";
import { success, error, info, warn, dim } from "../utils/logger.js";
interface CheckResult {
name: string;
status: "ok" | "missing" | "orphaned";
source?: string;
location?: string;
}
function findInstalledSkills(scope: "local" | "global"): Map<string, string[]> {
const skillsMap = new Map<string, string[]>();
const agents = getAllAgents();
for (const agent of agents) {
const baseDir = scope === "global"
? join(homedir(), agent.globalSkillsDir || agent.skillsDir)
: join(process.cwd(), agent.skillsDir);
if (!existsSync(baseDir)) continue;
try {
for (const entry of readdirSync(baseDir)) {
const skillPath = join(baseDir, entry);
if (statSync(skillPath).isDirectory() && existsSync(join(skillPath, "SKILL.md"))) {
const existing = skillsMap.get(entry) || [];
existing.push(agent.name);
skillsMap.set(entry, existing);
}
}
} catch {}
}
return skillsMap;
}
export function registerCheck(program: Command) {
program
.command("check")
.description("Check installed skills against lock file")
.option("--global", "Check global scope skills")
.option("--json", "Output results as JSON")
.action(async (opts: { global?: boolean; json?: boolean }) => {
const scope = opts.global ? "global" : "local";
const lockPath = getSkillLockPath();
if (!existsSync(lockPath)) {
if (opts.json) {
console.log(JSON.stringify({ error: "No lock file found" }, null, 2));
} else {
warn("No skillhub.lock found. Have you installed any skills?");
}
return;
}
const lockedSkills = await getAllLockedSkills();
const installedSkills = findInstalledSkills(scope);
const results: CheckResult[] = [];
for (const [name, entry] of Object.entries(lockedSkills)) {
const installedLocations = installedSkills.get(name);
if (installedLocations && installedLocations.length > 0) {
results.push({
name,
status: "ok",
source: entry.source,
location: installedLocations.join(", "),
});
} else {
results.push({
name,
status: "missing",
source: entry.source,
});
}
}
for (const [name, locations] of installedSkills.entries()) {
if (!lockedSkills[name]) {
results.push({
name,
status: "orphaned",
location: locations.join(", "),
});
}
}
if (opts.json) {
console.log(JSON.stringify(results, null, 2));
return;
}
console.log("");
info(`SkillHub Lock Check (${scope} scope):`);
console.log("");
if (results.length === 0) {
dim(" No skills found.");
console.log("");
return;
}
let ok = 0, missing = 0, orphaned = 0;
for (const r of results) {
if (r.status === "ok") {
ok++;
success(`${r.name}`);
dim(` Source: ${r.source}`);
dim(` Location: ${r.location}`);
} else if (r.status === "missing") {
missing++;
error(`${r.name}`);
dim(` Source: ${r.source}`);
dim(` Status: NOT INSTALLED`);
} else if (r.status === "orphaned") {
orphaned++;
warn(` ! ${r.name}`);
dim(` Location: ${r.location}`);
dim(` Status: NOT IN LOCK FILE`);
}
}
console.log("");
dim(`Lock file: ${lockPath}`);
dim(`Summary: ${ok} OK, ${missing} missing, ${orphaned} orphaned`);
console.log("");
});
}

View file

@ -0,0 +1,40 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } 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 <slug>")
.aliases(["del", "unpublish"])
.description("Delete a skill you own")
.option("-y, --yes", "Skip confirmation")
.action(async (slug: string, opts: { yes?: boolean }) => {
const { namespace, slug: skillSlug } = parseSkillName(slug);
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(`Delete ${skillSlug} from ${namespace}? This cannot be undone. [y/N] `, r)
);
rl.close();
if (answer.toLowerCase() !== "y") {
console.log("Cancelled.");
return;
}
}
try {
const token = await requireToken();
const config = loadConfig();
const client = new ApiClient({ baseUrl: config.registry, token });
await client.delete(`/api/v1/skills/${namespace}/${skillSlug}`);
success(`Deleted ${skillSlug} from ${namespace}`);
} catch (e: any) {
error(`Failed: ${e.message}`);
process.exit(1);
}
});
}

View file

@ -0,0 +1,59 @@
import { Command } from "commander";
import { createWriteStream } from "node:fs";
import { resolve } from "node:path";
import { ApiClient } from "../core/api-client.js";
import { ApiRoutes } from "../schema/routes.js";
import { loadConfig } 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";
export function registerDownload(program: Command) {
program
.command("download <slug>")
.description("Download a skill package to local directory")
.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);
const config = loadConfig();
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });
const outputDir = opts.output ? resolve(process.cwd(), opts.output) : process.cwd();
try {
const spinner = ora(`Downloading ${skillSlug} from ${namespace}`).start();
let downloadUrl = `${ApiRoutes.skillDownload.replace("{namespace}", namespace).replace("{slug}", skillSlug)}`;
if (opts["skill-version"]) {
downloadUrl = `/api/v1/skills/${namespace}/${skillSlug}/versions/${opts["skill-version"]}/download`;
} else if (opts.tag) {
downloadUrl = `/api/v1/skills/${namespace}/${skillSlug}/tags/${opts.tag}/download`;
}
const { request } = await import("undici");
const url = new URL(downloadUrl, config.registry);
const { statusCode, body } = await request(url.toString(), {
method: "GET",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (statusCode >= 400) {
spinner.fail(`Download failed: HTTP ${statusCode}`);
process.exit(1);
}
const outPath = resolve(outputDir, `${skillSlug}.zip`);
const fileStream = createWriteStream(outPath);
await body.pipe(fileStream);
spinner.succeed(`Downloaded ${skillSlug} to ${outPath}`);
} catch (e: any) {
error(`Download failed: ${e.message}`);
process.exit(1);
}
});
}

View file

@ -0,0 +1,278 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { loadConfig } from "../core/config.js";
import { readToken } from "../core/auth-token.js";
import { ApiRoutes } from "../schema/routes.js";
import { info, dim } from "../utils/logger.js";
import * as readline from "readline";
import { searchSkills, type SearchSkill } from "../core/interactive-search.js";
const HIDE_CURSOR = "\x1b[?25l";
const SHOW_CURSOR = "\x1b[?25h";
const CLEAR_DOWN = "\x1b[J";
const MOVE_UP = (n: number) => `\x1b[${n}A`;
const MOVE_TO_COL = (n: number) => `\x1b[${n}G`;
const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
const DIM = "\x1b[38;5;102m";
const TEXT = "\x1b[38;5;145m";
const CYAN = "\x1b[36m";
const YELLOW = "\x1b[33m";
const GREEN = "\x1b[32m";
function formatInstalls(count: number): string {
if (!count || count <= 0) return "";
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M installs`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}K installs`;
return `${count} install${count === 1 ? "" : "s"}`;
}
interface SkillDetail {
starCount: number;
downloadCount: number;
version: string;
}
async function fetchSkillDetail(client: ApiClient, namespace: string, name: string): Promise<SkillDetail | null> {
try {
const detail = await client.get<SkillDetail>(
`${ApiRoutes.skillDetail.replace("{namespace}", namespace).replace("{slug}", name)}`
);
return detail;
} catch {
return null;
}
}
async function runInteractiveSearch(
client: ApiClient,
initialQuery: string = ""
): Promise<string | null> {
const MAX_VISIBLE = 8;
let query = initialQuery;
let results: SearchSkill[] = [];
let selectedIndex = 0;
let loading = false;
let lastRenderedLines = 0;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const width = process.stdout.columns || 80;
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdout.write(HIDE_CURSOR);
function render(): void {
if (lastRenderedLines > 0) {
process.stdout.write(MOVE_UP(lastRenderedLines) + MOVE_TO_COL(1));
}
process.stdout.write(CLEAR_DOWN);
const lines: string[] = [];
const cursor = `${BOLD}_${RESET}`;
const searchLine = `${TEXT}Search skills:${RESET} ${query}${cursor}`;
lines.push(searchLine);
lines.push("");
if (!query || query.length < 2) {
lines.push(`${DIM}Start typing to search (min 2 chars)${RESET}`);
} else if (results.length === 0 && loading) {
lines.push(`${DIM}Searching...${RESET}`);
} else if (results.length === 0) {
lines.push(`${DIM}No skills found${RESET}`);
} else {
const visible = results.slice(0, MAX_VISIBLE);
for (let i = 0; i < visible.length; i++) {
const skill = visible[i]!;
const isSelected = i === selectedIndex;
const arrow = isSelected ? `${BOLD}>${RESET}` : " ";
const name = isSelected ? `${BOLD}${skill.name}${RESET}` : `${TEXT}${skill.name}${RESET}`;
const nsBadge = skill.namespace !== "global" ? ` ${YELLOW}[${skill.namespace}]${RESET}` : "";
const versionBadge = skill.version ? ` ${DIM}v${skill.version}${RESET}` : "";
const loadingIndicator = loading && i === 0 ? ` ${DIM}...${RESET}` : "";
lines.push(` ${arrow} ${name}${nsBadge}${versionBadge}${loadingIndicator}`);
}
}
lines.push("");
lines.push(`${DIM}up/down navigate | enter select | esc cancel${RESET}`);
for (const line of lines) {
process.stdout.write(line + "\n");
}
lastRenderedLines = lines.length;
}
function triggerSearch(q: string): void {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
loading = false;
if (!q || q.length < 2) {
results = [];
selectedIndex = 0;
render();
return;
}
loading = true;
render();
const debounceMs = Math.max(150, 350 - q.length * 50);
debounceTimer = setTimeout(async () => {
try {
results = await searchSkills(client, q);
selectedIndex = 0;
} catch {
results = [];
} finally {
loading = false;
debounceTimer = null;
render();
}
}, debounceMs);
}
if (initialQuery) {
triggerSearch(initialQuery);
}
render();
return new Promise<string | null>((resolve) => {
function cleanup(): void {
process.stdin.removeListener("keypress", handleKeypress);
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
process.stdout.write(SHOW_CURSOR);
process.stdin.pause();
rl.close();
}
function handleKeypress(_ch: string | undefined, key: readline.Key): void {
if (!key) return;
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
cleanup();
resolve(null);
return;
}
if (key.name === "return") {
cleanup();
resolve(results[selectedIndex] ? `${results[selectedIndex].namespace}/${results[selectedIndex].name}` : null);
return;
}
if (key.name === "up" || key.name === "k") {
selectedIndex = Math.max(0, selectedIndex - 1);
render();
return;
}
if (key.name === "down" || key.name === "j") {
selectedIndex = Math.min(Math.max(0, results.length - 1), selectedIndex + 1);
render();
return;
}
if (key.name === "backspace") {
if (query.length > 0) {
query = query.slice(0, -1);
triggerSearch(query);
}
return;
}
if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) {
const char = key.sequence;
if (char >= " " && char <= "~") {
query += char;
triggerSearch(query);
}
}
}
process.stdin.on("keypress", handleKeypress);
});
}
export function registerExplore(program: Command) {
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")
.action(async (query: string | undefined, opts: { limit: string }) => {
const config = loadConfig();
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });
try {
if (!query) {
const selected = await runInteractiveSearch(client, "");
if (!selected) {
console.log("\nCancelled.");
return;
}
info(`\nSelected: ${selected}`);
dim("Run: skillhub install " + selected);
return;
}
const results = await searchSkills(client, query, parseInt(opts.limit, 10));
if (results.length === 0) {
console.log(`${DIM}No skills found for "${query}"${RESET}`);
return;
}
const maxResults = Math.min(results.length, 6);
const detailPromises = results.slice(0, maxResults).map((s) =>
fetchSkillDetail(client, s.namespace, s.name)
);
const details = await Promise.all(detailPromises);
console.log(`${DIM}Install with${RESET} skillhub install <slug>`);
console.log();
for (let i = 0; i < maxResults; i++) {
const skill = results[i]!;
const detail = details[i];
const slug = `${skill.namespace}--${skill.name}`;
const nsBadge = skill.namespace !== "global" ? ` ${YELLOW}[${skill.namespace}]${RESET}` : "";
const stars = detail?.starCount ? ` ${YELLOW}${detail.starCount}${RESET}` : "";
const downloads = detail?.downloadCount ? ` ${CYAN}${formatInstalls(detail.downloadCount)}${RESET}` : "";
console.log(`${TEXT}${skill.name}${RESET}${nsBadge}${stars}${downloads}`);
console.log(`${DIM}└ skillhub install ${skill.namespace}/${skill.name}${RESET}`);
if (skill.summary) {
console.log(`${DIM} ${skill.summary.slice(0, 60)}${RESET}`);
}
console.log();
}
dim("Tip: Use skillhub explore without args for interactive mode");
console.log("");
} catch (e: any) {
console.log(`Error: ${e.message}`);
}
});
}

View file

@ -0,0 +1,88 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } 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
.command("hide <slug>")
.description("Hide a skill (admin only)")
.option("-y, --yes", "Skip confirmation")
.action(async (slug: string, opts: { yes?: boolean }) => {
const { namespace, slug: skillSlug } = parseSkillName(slug);
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 = loadConfig();
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) {
error(`Failed: ${e.message}`);
process.exit(1);
}
});
hideCmd
.command("unhide <slug>")
.description("Unhide a skill (admin only)")
.option("-y, --yes", "Skip confirmation")
.action(async (slug: string, opts: { yes?: boolean }) => {
const { namespace, slug: skillSlug } = parseSkillName(slug);
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 = loadConfig();
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) {
error(`Failed: ${e.message}`);
process.exit(1);
}
});
}

View file

@ -0,0 +1,46 @@
import { Command } from "commander";
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { success, error } from "../utils/logger.js";
export function registerInit(program: Command) {
program
.command("init [name]")
.description("Create a new SKILL.md template")
.action((name?: string) => {
const dir = name ? resolve(process.cwd(), name) : process.cwd();
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const skillMd = join(dir, "SKILL.md");
if (existsSync(skillMd)) {
error("SKILL.md already exists");
process.exit(1);
}
const slug = name || "my-skill";
const content = `---
name: ${slug}
description: What this skill does and when to use it
---
# ${slug}
Instructions for the agent to follow when this skill is activated.
## When to Use
Describe the scenarios where this skill should be used.
## Steps
1. First, do this
2. Then, do that
`;
writeFileSync(skillMd, content);
success(`Created SKILL.md at ${skillMd}`);
});
}

View file

@ -0,0 +1,136 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { ApiRoutes } from "../schema/routes.js";
import { loadConfig } 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";
interface SkillDetailResponse {
id: number;
namespace: string;
slug: string;
displayName: string;
ownerDisplayName: string;
summary: string;
visibility: string;
status: string;
starCount: number;
downloadCount: number;
labels: Array<{ slug: string; name: string }>;
publishedVersion?: { version: string };
}
interface NamespaceInfo {
slug: string;
displayName: string;
currentUserRole: string;
status: string;
}
function printSkillDetail(detail: SkillDetailResponse) {
console.log("");
info(`${detail.displayName} (${detail.slug})`);
dim(`Namespace: ${detail.namespace}`);
dim(`Version: ${detail.publishedVersion?.version || "N/A"}`);
dim(`Author: ${detail.ownerDisplayName}`);
dim(`Stars: ${detail.starCount} Downloads: ${detail.downloadCount}`);
if (detail.summary) console.log(`\n${detail.summary}`);
dim(`Status: ${detail.status}`);
if (detail.labels && detail.labels.length > 0) {
dim(`Labels: ${detail.labels.map((l) => l.name || l.slug).join(", ")}`);
}
console.log("");
}
function printInspectHeader(detail: SkillDetailResponse) {
console.log("");
info(`=== ${detail.displayName} ===`);
dim(`Namespace: ${detail.namespace}`);
dim(`Slug: ${detail.slug}`);
dim(`Version: ${detail.publishedVersion?.version || "N/A"}`);
dim(`Author: ${detail.ownerDisplayName}`);
console.log("");
info("Summary:");
console.log(` ${detail.summary || "N/A"}`);
console.log("");
dim(`Stars: ${detail.starCount} · Downloads: ${detail.downloadCount}`);
dim(`Visibility: ${detail.visibility} · Status: ${detail.status}`);
if (detail.labels && detail.labels.length > 0) {
console.log("");
dim(`Labels: ${detail.labels.map((l) => l.name || l.slug).join(", ")}`);
}
console.log("");
}
export function registerInspect(program: Command) {
program
.command("inspect <slug>")
.aliases(["info", "view"])
.description("View skill metadata without installing")
.option("--namespace <ns>", "Search in specific namespace (searches all if not specified)")
.action(async (slug: string, opts: { namespace?: string }) => {
const config = loadConfig();
const token = await readToken();
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;
if (targetNamespace) {
const detail = await client.get<SkillDetailResponse>(
`${ApiRoutes.skillDetail.replace("{namespace}", targetNamespace).replace("{slug}", parsedSlug)}`
);
if (isJson) {
console.log(JSON.stringify(detail, null, 2));
} else {
printSkillDetail(detail);
}
return;
}
const namespaces = await client.get<NamespaceInfo[]>(ApiRoutes.meNamespaces);
if (!namespaces || namespaces.length === 0) {
error("No namespaces found. You may need to log in.");
process.exit(1);
}
const searchPromises = namespaces.map(async (ns) => {
try {
const detail = await client.get<SkillDetailResponse>(
`${ApiRoutes.skillDetail.replace("{namespace}", ns.slug).replace("{slug}", parsedSlug)}`
);
return { found: true, detail, namespace: ns.slug };
} catch {
return { found: false, detail: null, namespace: ns.slug };
}
});
const results = await Promise.all(searchPromises);
const matches = results.filter((r) => r.found && r.detail).map((r) => r.detail!);
if (matches.length === 0) {
error(`Skill not found: ${parsedSlug}`);
if (namespaces.length > 1) {
dim(`Tried namespaces: ${namespaces.map((n) => n.slug).join(", ")}`);
}
process.exit(1);
}
if (isJson) {
if (matches.length === 1) {
console.log(JSON.stringify(matches[0], null, 2));
} else {
console.log(JSON.stringify(matches, null, 2));
}
} else if (matches.length === 1) {
printSkillDetail(matches[0]);
} else {
for (const detail of matches) {
printInspectHeader(detail);
}
}
});
}

View file

@ -0,0 +1,759 @@
import { Command } from "commander";
import { mkdtemp, rm, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createWriteStream, existsSync, mkdirSync } from "node:fs";
import { ApiClient } from "../core/api-client.js";
import { loadConfig } from "../core/config.js";
import { readToken } from "../core/auth-token.js";
import { discoverSkills } from "../core/skill-discovery.js";
import { installSkill } from "../core/installer.js";
import { getAllAgents, detectInstalledAgents, getUniversalAgents, getNonUniversalAgents, isUniversalAgent } from "../core/agent-detector.js";
import { parseSource, getCloneUrl } from "../core/source-parser.js";
import { addToLock } from "../core/skill-lock.js";
import { success, error, info, dim } from "../utils/logger.js";
import { multiSelect, sectionMultiSelect } from "../utils/prompts.js";
import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js";
import { runInteractiveSearch, searchSkills } from "../core/interactive-search.js";
import type { SkillVersionItem } from "./versions.js";
interface SkillTag {
id: number;
tagName: string;
versionId: number;
createdAt: string;
}
import * as p from "@clack/prompts";
import pc from "picocolors";
import ora from "ora";
import { execSync } from "node:child_process";
import { finished } from "node:stream/promises";
export type SourceType = "auto" | "registry" | "git" | "local";
function detectSourceType(arg: string): SourceType {
if (arg.startsWith(".") || arg.startsWith("/") || arg.startsWith("~")) {
return "local";
}
if (arg.includes("github.com") || arg.includes("gitlab.com") || arg.includes("://") || arg.endsWith(".git")) {
return "git";
}
if (/^[\w-]+\/[\w-]+$/.test(arg)) {
return "registry";
}
return "registry";
}
function getInstallSpinner(sourceType: SourceType, arg: string): string {
if (sourceType === "registry") {
return `Fetching ${arg}`;
}
return `Resolving ${arg}`;
}
async function selectAgentsInteractive(isGlobal: boolean): Promise<string[] | null> {
const universalAgents = getUniversalAgents();
const nonUniversalAgents = getNonUniversalAgents();
const lockedSection = {
title: "Universal (.agents/skills)",
items: universalAgents.map((a) => ({
value: a.key,
label: a.name,
})),
};
const selectableItems = nonUniversalAgents.map((a) => ({
value: a.key,
label: a.name,
hint: isGlobal ? (a.globalSkillsDir || a.skillsDir) : a.skillsDir,
}));
const result = await searchMultiselect({
message: "Which agents do you want to install to?",
items: selectableItems,
lockedSection,
});
if (result === cancelSymbol) {
return null;
}
return result as string[];
}
async function selectInstallMode(): Promise<"symlink" | "copy" | null> {
const result = await searchMultiselect({
message: "Installation method?",
items: [
{ value: "symlink", label: "Symlink (Recommended)", hint: "single source of truth" },
{ value: "copy", label: "Copy to all agents", hint: "independent copies" },
],
initialSelected: ["symlink"],
});
if (result === cancelSymbol) {
return null;
}
if ((result as string[]).includes("symlink")) {
return "symlink";
}
return "copy";
}
function buildAgentSummary(targetAgents: { key: string; name: string; skillsDir: string }[], mode: "symlink" | "copy"): string[] {
const lines: string[] = [];
const universal = targetAgents.filter((a) => isUniversalAgent(a));
const symlinked = targetAgents.filter((a) => !isUniversalAgent(a));
if (mode === "symlink") {
if (universal.length > 0) {
lines.push(` universal: ${universal.map((a) => a.name).join(", ")}`);
}
if (symlinked.length > 0) {
lines.push(` symlink → ${symlinked.map((a) => a.name).join(", ")}`);
}
} else {
lines.push(` copy → ${targetAgents.map((a) => a.name).join(", ")}`);
}
return lines;
}
export function registerInstall(program: Command) {
program
.command("install <source>")
.alias("i")
.description("Install skills from registry, git repositories, or local paths")
.option("-a, --add <source>", "Install from GitHub or local path (alias for --from)")
.option("--from <source>", "Install from GitHub or local path (alias for -a)")
.option("--agent <agents...>", "Target specific agents")
.option("-g, --global", "Install to global scope")
.option("-y, --yes", "Skip all prompts")
.option("--copy", "Copy instead of symlink")
.option("--list", "List available skills without installing")
.option("-v, --skill-version <ver>", "Install specific version (non-interactive)")
.option("--tag <tag>", "Install specific tag (non-interactive, resolves to version)")
.action(async (source: string, opts: Record<string, string | string[] | boolean>) => {
const fromSource = (opts.from || opts.add) as string | undefined;
let effectiveSource: SourceType;
let installSource = source;
if (fromSource) {
effectiveSource = detectSourceType(fromSource);
installSource = fromSource;
} else {
effectiveSource = detectSourceType(source);
}
const spinner = ora(getInstallSpinner(effectiveSource, installSource)).start();
try {
if (effectiveSource === "registry") {
await installFromRegistry(source, opts, spinner);
} else {
await installFromGit(source, installSource, effectiveSource, opts, spinner);
}
} catch (e: any) {
spinner.fail(e.message);
process.exit(1);
}
});
}
async function installFromRegistry(slug: string, opts: Record<string, string | string[] | boolean>, spinner: any) {
let ns = "global";
let actualSlug = slug;
let userSpecifiedNamespace = false;
if (slug.includes("/") && !slug.startsWith("/")) {
const parts = slug.split("/");
if (parts.length === 2) {
ns = parts[0];
actualSlug = parts[1];
userSpecifiedNamespace = true;
}
}
const config = loadConfig();
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });
if (!userSpecifiedNamespace) {
const results = await searchSkills(client, actualSlug, 50);
// Deduplicate by namespace/name
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: ${actualSlug}`);
process.exit(1);
}
if (uniqueResults.length === 1) {
ns = uniqueResults[0].namespace;
actualSlug = uniqueResults[0].name;
} else {
const selected = await runInteractiveSearch(client, actualSlug);
if (!selected) {
console.log("Cancelled.");
return;
}
const [selectedNs, selectedName] = selected.split("/", 2);
ns = selectedNs;
actualSlug = selectedName;
}
}
spinner.text = `Fetching ${ns}/${actualSlug}`;
// Fetch versions and tags for selection
const [versionsResp, tagsResp] = await Promise.all([
client.get<{ items: SkillVersionItem[] }>(`/api/v1/skills/${ns}/${actualSlug}/versions`),
client.get<SkillTag[]>(`/api/v1/skills/${ns}/${actualSlug}/tags`).catch(() => [] as SkillTag[]),
]);
const versions = versionsResp.items || [];
// Map tags to versions by versionId
const versionTagsMap = new Map<number, string[]>();
for (const tag of tagsResp || []) {
if (!versionTagsMap.has(tag.versionId)) {
versionTagsMap.set(tag.versionId, []);
}
versionTagsMap.get(tag.versionId)!.push(tag.tagName);
}
// Present version selection
let selectedVersion: string = "latest";
if (opts.yes && opts["skill-version"]) {
// Non-interactive: use command-line version if provided
selectedVersion = opts["skill-version"] as string;
} else if (opts.yes && opts.tag) {
// Non-interactive: resolve tag to version
for (const [vid, tags] of versionTagsMap) {
if (tags.includes(opts.tag as string)) {
const v = versions.find((ver) => ver.id === vid);
if (v) {
selectedVersion = v.version;
break;
}
}
}
if (!selectedVersion) {
// Fallback: use latest if tag not found
selectedVersion = versions[0]?.version || "latest";
}
} else {
// Interactive: show version selection
const picked = await p.select({
message: "Select version",
options: versions.map((v) => ({
value: v.version,
label: `v${v.version}`,
hint: versionTagsMap.get(v.id)?.join(", ") || "",
})),
});
if (p.isCancel(picked)) {
console.log("Cancelled.");
return;
}
selectedVersion = picked as string;
}
const baseUrl = config.registry.replace(/\/$/, "");
const downloadUrl = `${baseUrl}/api/v1/skills/${ns}/${actualSlug}/versions/${selectedVersion}/download`;
const tmpDir = await mkdtemp(join(tmpdir(), "skillhub-install-"));
const zipPath = join(tmpDir, `${actualSlug}.zip`);
spinner.text = "Downloading";
const { request } = await import("undici");
const { statusCode, body } = await request(downloadUrl, {
method: "GET",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (statusCode >= 400) {
spinner.fail(`Skill not found: ${ns}/${actualSlug}`);
await rm(tmpDir, { recursive: true, force: true });
process.exit(1);
}
const fileStream = createWriteStream(zipPath);
await finished(body.pipe(fileStream));
spinner.text = "Extracting";
const extractDir = join(tmpDir, "extracted");
mkdirSync(extractDir, { recursive: true });
execSync(`unzip -o "${zipPath}" -d "${extractDir}"`, { stdio: "pipe" });
const skills = discoverSkills(extractDir);
if (skills.length === 0) {
spinner.fail("No SKILL.md found in package");
process.exit(1);
}
spinner.succeed(`Found ${skills.length} skill(s) in ${ns}/${actualSlug}`);
if (opts.list) {
for (const s of skills) {
info(`${s.name}`);
dim(` ${s.description}`);
}
return;
}
let selectedSkills = skills;
if (!opts.yes && skills.length > 1) {
const selected = await searchMultiselect({
message: "Select skills to install",
items: skills.map((s) => ({ value: s.name, label: s.name, hint: s.description })),
});
if (selected === cancelSymbol) {
console.log("Cancelled.");
return;
}
selectedSkills = skills.filter((s) => (selected as string[]).includes(s.name));
}
let isGlobal = !!opts.global;
let targetAgents = opts.agent
? getAllAgents().filter((a) => (opts.agent as string[]).includes(a.key))
: detectInstalledAgents();
if (targetAgents.length === 0) {
const claude = getAllAgents().find((a) => a.key === "claude-code");
if (claude) targetAgents.push(claude);
}
let mode: "symlink" | "copy" = opts.copy ? "copy" : "symlink";
if (!opts.yes && !opts.agent) {
const selected = await selectAgentsInteractive(isGlobal);
if (!selected) {
console.log("Cancelled.");
return;
}
targetAgents = getAllAgents().filter((a) => selected.includes(a.key));
}
const supportsGlobal = targetAgents.some((a) => a.globalSkillsDir);
if (opts.global === undefined && !opts.yes && supportsGlobal) {
const scope = await p.select({
message: "Installation scope",
options: [
{
value: false,
label: "Project",
hint: "Install in current directory (committed with your project)",
},
{
value: true,
label: "Global",
hint: "Install in home directory (available across all projects)",
},
],
});
if (p.isCancel(scope)) {
console.log("Cancelled.");
return;
}
isGlobal = scope as boolean;
}
// Only prompt for install mode when there are multiple unique target directories.
// When all selected agents share the same skillsDir, symlink vs copy is meaningless.
const uniqueDirs = new Set(targetAgents.map((a) =>
isGlobal ? (a.globalSkillsDir || a.skillsDir) : a.skillsDir
));
if (uniqueDirs.size <= 1) {
// Single target directory — default to copy (no symlink needed)
mode = 'copy';
} else if (!opts.yes) {
const selectedMode = await selectInstallMode();
if (selectedMode === null) {
console.log("Cancelled.");
return;
}
mode = selectedMode;
}
const cwd = process.cwd();
const summaryLines: string[] = [];
for (const skill of selectedSkills) {
if (summaryLines.length > 0) summaryLines.push("");
const canonicalPath = isGlobal
? `~/.agents/skills/${skill.name}`
: `./.agents/skills/${skill.name}`;
summaryLines.push(`${pc.cyan(canonicalPath)}`);
for (const line of buildAgentSummary(targetAgents, mode)) {
summaryLines.push(` ${line}`);
}
}
summaryLines.push("");
summaryLines.push(`${pc.dim("Mode:")} ${mode}`);
summaryLines.push(`${pc.dim("Scope:")} ${isGlobal ? "global" : "project"}`);
console.log("");
p.note(summaryLines.join("\n"), "Installation Summary");
if (!opts.yes) {
const confirmed = await p.confirm({ message: "Proceed with installation?" });
if (p.isCancel(confirmed) || !confirmed) {
console.log("Cancelled.");
return;
}
}
spinner.start("Installing skills...");
let installed = 0;
let failed = 0;
const results: { skill: string; agent: string; success: boolean; path: string; error?: string }[] = [];
for (const skill of selectedSkills) {
for (const agent of targetAgents) {
const result = installSkill(
skill.dir,
skill.name,
agent.key,
isGlobal ? agent.globalSkillsDir || agent.skillsDir : agent.skillsDir,
mode,
isGlobal,
);
results.push({
skill: skill.name,
agent: agent.name,
success: result.success,
path: result.path || "",
error: result.error,
});
if (result.success) {
installed++;
await addToLock(skill.name, {
source: `${ns}/${slug}`,
sourceType: "registry",
sourceUrl: `${config.registry}/api/v1/skills/${ns}/${slug}`,
namespace: ns,
slug: skill.name,
version: "latest",
});
} else {
failed++;
error(`Failed to install ${skill.name} to ${agent.name}: ${result.error}`);
}
}
}
spinner.stop("Installation complete");
console.log("");
const successful = results.filter((r) => r.success);
if (successful.length > 0) {
const resultLines: string[] = [];
for (const skill of selectedSkills) {
const skillResults = results.filter((r) => r.skill === skill.name && r.success);
if (skillResults.length > 0) {
resultLines.push(`${pc.green("✓")} ${skill.name}`);
for (const r of skillResults) {
resultLines.push(` ${pc.dim("→")} ${r.agent}: ${r.path}`);
}
}
}
p.note(resultLines.join("\n"), `Installed ${successful.length} skill(s)`);
}
if (failed > 0) {
p.log.error(pc.red(`Failed to install ${failed}`));
for (const r of results.filter((r) => !r.success)) {
p.log.message(`${pc.red("✗")} ${r.skill}${r.agent}: ${pc.dim(r.error || "unknown error")}`);
}
}
console.log("");
p.outro(pc.green("Done!") + pc.dim(" Review skills before use; they run with full agent permissions."));
await rm(tmpDir, { recursive: true, force: true });
}
async function installFromGit(skillName: string, source: string, sourceType: SourceType, opts: Record<string, string | string[] | boolean>, spinner: any) {
let skillsDir: string;
const parsed = parseSource(source);
if (parsed.skillFilter) {
opts.skill = opts.skill || [];
if (!Array.isArray(opts.skill)) {
opts.skill = [opts.skill as string];
}
if (!opts.skill.includes(parsed.skillFilter)) {
opts.skill.push(parsed.skillFilter);
}
}
// If skillName is a skill identifier (not a path), use it to filter
if (skillName && !skillName.startsWith(".") && !skillName.startsWith("/") && !skillName.startsWith("~")) {
opts.skill = opts.skill || [];
if (!Array.isArray(opts.skill)) {
opts.skill = [opts.skill as string];
}
if (!opts.skill.includes(skillName)) {
opts.skill.push(skillName);
}
}
if (parsed.type === "local") {
skillsDir = parsed.localPath!;
spinner.text = "Scanning local directory";
} else {
const cloneUrl = getCloneUrl(parsed);
spinner.text = `Cloning ${cloneUrl}`;
const tmpDir = await mkdtemp(join(tmpdir(), "skillhub-install-"));
const refArg = parsed.ref ? `--branch ${parsed.ref}` : "";
const depth = parsed.ref ? "" : "--depth 1";
execSync(`git clone ${depth} ${refArg} ${cloneUrl} ${tmpDir}`, { stdio: "pipe" });
skillsDir = tmpDir;
process.on("exit", () => { rm(tmpDir, { recursive: true, force: true }).catch(() => {}); });
}
spinner.text = "Discovering skills";
const skills = discoverSkills(skillsDir);
if (skills.length === 0) {
spinner.fail("No skills found. Ensure the directory contains SKILL.md files.");
process.exit(1);
}
spinner.succeed(`Found ${skills.length} skill(s)`);
if (opts.list) {
for (const s of skills) {
info(`${s.name}`);
dim(` ${s.description}`);
}
return;
}
let selectedSkills = skills;
if (opts.skill) {
const skillNames = opts.skill as string[];
if (skillNames.includes("*")) {
selectedSkills = skills;
} else {
selectedSkills = skills.filter((s) => skillNames.includes(s.name));
if (selectedSkills.length === 0) {
error(`No matching skills for: ${skillNames.join(", ")}`);
info("Available: " + skills.map((s) => s.name).join(", "));
process.exit(1);
}
}
} else if (!opts.yes && skills.length > 1) {
const selected = await searchMultiselect({
message: "Select skills to install",
items: skills.map((s) => ({ value: s.name, label: s.name, hint: s.description })),
});
if (selected === cancelSymbol) {
console.log("Cancelled.");
return;
}
selectedSkills = skills.filter((s) => (selected as string[]).includes(s.name));
}
let isGlobal = !!opts.global;
let targetAgents = opts.agent
? getAllAgents().filter((a) => (opts.agent as string[]).includes(a.key))
: detectInstalledAgents();
if (targetAgents.length === 0) {
const all = getAllAgents();
if (!opts.yes) {
info("No agents detected. Installing to Claude Code by default.");
}
const claude = all.find((a) => a.key === "claude-code");
if (claude) targetAgents.push(claude);
else targetAgents.push(all[0]);
}
let mode: "symlink" | "copy" = opts.copy ? "copy" : "symlink";
if (!opts.yes && !opts.agent) {
const selected = await selectAgentsInteractive(isGlobal);
if (!selected) {
console.log("Cancelled.");
return;
}
targetAgents = getAllAgents().filter((a) => selected.includes(a.key));
}
const supportsGlobal = targetAgents.some((a) => a.globalSkillsDir);
if (opts.global === undefined && !opts.yes && supportsGlobal) {
const scope = await p.select({
message: "Installation scope",
options: [
{
value: false,
label: "Project",
hint: "Install in current directory (committed with your project)",
},
{
value: true,
label: "Global",
hint: "Install in home directory (available across all projects)",
},
],
});
if (p.isCancel(scope)) {
console.log("Cancelled.");
return;
}
isGlobal = scope as boolean;
}
// Only prompt for install mode when there are multiple unique target directories.
// When all selected agents share the same skillsDir, symlink vs copy is meaningless.
const uniqueDirs = new Set(targetAgents.map((a) =>
isGlobal ? (a.globalSkillsDir || a.skillsDir) : a.skillsDir
));
if (uniqueDirs.size <= 1) {
// Single target directory — default to copy (no symlink needed)
mode = 'copy';
} else if (!opts.yes) {
const selectedMode = await selectInstallMode();
if (selectedMode === null) {
console.log("Cancelled.");
return;
}
mode = selectedMode;
}
const cwd = process.cwd();
const summaryLines: string[] = [];
for (const skill of selectedSkills) {
if (summaryLines.length > 0) summaryLines.push("");
const canonicalPath = isGlobal
? `~/.agents/skills/${skill.name}`
: `./.agents/skills/${skill.name}`;
summaryLines.push(`${pc.cyan(canonicalPath)}`);
for (const line of buildAgentSummary(targetAgents, mode)) {
summaryLines.push(` ${line}`);
}
}
summaryLines.push("");
summaryLines.push(`${pc.dim("Mode:")} ${mode}`);
summaryLines.push(`${pc.dim("Scope:")} ${isGlobal ? "global" : "project"}`);
console.log("");
p.note(summaryLines.join("\n"), "Installation Summary");
if (!opts.yes) {
const confirmed = await p.confirm({ message: "Proceed with installation?" });
if (p.isCancel(confirmed) || !confirmed) {
console.log("Cancelled.");
return;
}
}
spinner.start("Installing skills...");
let installed = 0;
let failed = 0;
const results: { skill: string; agent: string; success: boolean; path: string; error?: string }[] = [];
for (const skill of selectedSkills) {
for (const agent of targetAgents) {
const result = installSkill(
skill.dir,
skill.name,
agent.key,
isGlobal ? agent.globalSkillsDir || agent.skillsDir : agent.skillsDir,
mode,
isGlobal,
);
results.push({
skill: skill.name,
agent: agent.name,
success: result.success,
path: result.path || "",
error: result.error,
});
if (result.success) {
installed++;
const sourceUrl = parsed.type === "local"
? (parsed.localPath as string)
: getCloneUrl(parsed);
await addToLock(skill.name, {
source: source,
sourceType: parsed.type === "local" ? "local" : "git",
sourceUrl: sourceUrl,
ref: parsed.ref,
namespace: "global",
slug: skill.name,
version: parsed.ref || "main",
});
} else {
failed++;
error(`Failed to install ${skill.name} to ${agent.name}: ${result.error}`);
}
}
}
spinner.stop("Installation complete");
console.log("");
const successful = results.filter((r) => r.success);
if (successful.length > 0) {
const resultLines: string[] = [];
for (const skill of selectedSkills) {
const skillResults = results.filter((r) => r.skill === skill.name && r.success);
if (skillResults.length > 0) {
resultLines.push(`${pc.green("✓")} ${skill.name}`);
for (const r of skillResults) {
resultLines.push(` ${pc.dim("→")} ${r.agent}: ${r.path}`);
}
}
}
p.note(resultLines.join("\n"), `Installed ${successful.length} skill(s)`);
}
if (failed > 0) {
p.log.error(pc.red(`Failed to install ${failed}`));
for (const r of results.filter((r) => !r.success)) {
p.log.message(`${pc.red("✗")} ${r.skill}${r.agent}: ${pc.dim(r.error || "unknown error")}`);
}
}
console.log("");
p.outro(pc.green("Done!") + pc.dim(" Review skills before use; they run with full agent permissions."));
}

View file

@ -0,0 +1,142 @@
import { Command } from "commander";
import { existsSync, readdirSync, lstatSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { getAllAgents, isUniversalAgent, getUniversalAgents, getNonUniversalAgents } from "../core/agent-detector.js";
import { info, dim } from "../utils/logger.js";
import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js";
import * as p from "@clack/prompts";
import pc from "picocolors";
interface ListOptions {
global?: boolean;
project?: boolean;
agent?: string[];
all?: boolean;
}
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")
.option("-a, --all", "List all skills (both global and project)")
.option("--agent <agents...>", "Filter by specific agents")
.action(async (opts: ListOptions) => {
let scopeGlobal: boolean | null = null;
if (opts.global) {
scopeGlobal = true;
} else if (opts.project) {
scopeGlobal = false;
} else {
const scopeSelection = await p.select({
message: "Which scope to list?",
options: [
{ value: "all", label: "All (global + project)" },
{ value: "global", label: "Global only" },
{ value: "project", label: "Project only" },
],
});
if (p.isCancel(scopeSelection)) {
console.log("Cancelled.");
return;
}
if (scopeSelection === "global") {
scopeGlobal = true;
} else if (scopeSelection === "project") {
scopeGlobal = false;
}
}
const universalAgents = getUniversalAgents();
const nonUniversalAgents = getNonUniversalAgents();
const universalSection = {
title: "Universal (.agents/skills)",
items: universalAgents.map((a) => ({
value: a.key,
label: a.name,
})),
};
const selectableItems = nonUniversalAgents.map((a) => ({
value: a.key,
label: a.name,
}));
const agentSelection = await searchMultiselect({
message: "Which agents to list from?",
items: selectableItems,
lockedSection: universalSection,
});
if (agentSelection === cancelSymbol) {
console.log("Cancelled.");
return;
}
const selectedAgents = agentSelection as string[];
const agents = getAllAgents().filter((a) => selectedAgents.includes(a.key));
if (agents.length === 0) {
console.log("No agents selected.");
return;
}
console.log("");
let found = false;
for (const agent of agents) {
const showProject = scopeGlobal === null || scopeGlobal === false;
const showGlobal = scopeGlobal === null || scopeGlobal === true;
if (showProject) {
const projectDir = join(process.cwd(), agent.skillsDir);
const skills = getSkillsInDir(projectDir);
if (skills.length > 0) {
found = true;
info(`\n${agent.name} (project):`);
for (const s of skills) {
dim(` ${s}`);
}
}
}
if (showGlobal && agent.globalSkillsDir) {
const globalDir = join(homedir(), agent.globalSkillsDir);
const skills = getSkillsInDir(globalDir);
if (skills.length > 0) {
found = true;
info(`\n${agent.name} (global):`);
for (const s of skills) {
dim(` ${s}`);
}
}
}
}
if (!found) {
dim("No skills installed for selected agents and scope.");
}
console.log("");
});
}
function getSkillsInDir(dir: string): string[] {
if (!existsSync(dir)) return [];
return readdirSync(dir).filter((f) => {
const full = join(dir, f);
try {
const stat = lstatSync(full);
return stat.isDirectory() && existsSync(join(full, "SKILL.md"));
} catch {
return false;
}
});
}

View file

@ -0,0 +1,34 @@
import { Command } from "commander";
import { createInterface } from "node:readline";
import { stdin, stdout } from "node:process";
import { writeToken } from "../core/auth-token.js";
import { ApiClient } from "../core/api-client.js";
import { ApiRoutes, WhoamiResponse } from "../schema/routes.js";
import { success, error, info } from "../utils/logger.js";
export function registerLogin(program: Command) {
program
.command("login")
.description("Authenticate with SkillHub registry")
.option("--token <token>", "Auth token (skipped prompt)")
.option("--registry <url>", "Registry URL override")
.action(async (opts: { token?: string; registry?: string }) => {
const rl = createInterface({ input: stdin, output: stdout });
const ask = (q: string) => new Promise<string>((r) => rl.question(q, r));
const token = opts.token || (await ask("Enter your SkillHub token: "));
rl.close();
const registry = opts.registry || "http://localhost:8080";
const client = new ApiClient({ baseUrl: registry, token });
try {
const resp = await client.get<WhoamiResponse>(ApiRoutes.whoami);
await writeToken(token);
success(`Authenticated as ${resp.user.displayName} (@${resp.user.handle})`);
} catch (e: any) {
error(`Authentication failed: ${e.message}`);
process.exit(1);
}
});
}

View file

@ -0,0 +1,13 @@
import { Command } from "commander";
import { removeToken } from "../core/auth-token.js";
import { success } from "../utils/logger.js";
export function registerLogout(program: Command) {
program
.command("logout")
.description("Remove stored authentication token")
.action(async () => {
await removeToken();
success("Logged out successfully");
});
}

View file

@ -0,0 +1,89 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { loadConfig } from "../core/config.js";
import { requireToken } from "../core/auth-token.js";
import { error, info, dim } from "../utils/logger.js";
export interface MeSkillItem {
id: number;
namespace: string;
slug: string;
displayName: string;
status: string;
starCount: number;
downloadCount: number;
headlineVersion?: { version: string };
publishedVersion?: { version: string };
}
export interface MeSkillsResponse {
items: MeSkillItem[];
total: number;
page: number;
size: number;
}
export function registerMe(program: Command) {
const me = program.command("me").description("View your skills and stars");
me
.command("skills")
.alias("ls")
.description("List your published skills")
.action(async () => {
try {
const token = await requireToken();
const config = loadConfig();
const client = new ApiClient({ baseUrl: config.registry, token });
const resp = await client.get<MeSkillsResponse>("/api/v1/me/skills");
const skills = resp.items || [];
const isJson = program.opts().json;
if (isJson) {
console.log(JSON.stringify(resp, null, 2));
} else {
if (skills.length === 0) {
console.log("No skills published yet.");
return;
}
for (const s of skills) {
const version = s.headlineVersion?.version || s.publishedVersion?.version || "unknown";
info(`${s.displayName} (${s.slug})`);
dim(` ${s.namespace} · v${version} · ⭐ ${s.starCount} · ↓ ${s.downloadCount} · ${s.status}`);
}
}
} catch (e: any) {
error(`Failed: ${e.message}`);
process.exit(1);
}
});
me
.command("stars")
.description("List your starred skills")
.action(async () => {
try {
const token = await requireToken();
const config = loadConfig();
const client = new ApiClient({ baseUrl: config.registry, token });
const resp = await client.get<MeSkillsResponse>("/api/v1/me/stars");
const skills = resp.items || [];
const isJson = program.opts().json;
if (isJson) {
console.log(JSON.stringify(resp, null, 2));
} else {
if (skills.length === 0) {
console.log("No starred skills.");
return;
}
for (const s of skills) {
const version = s.headlineVersion?.version || s.publishedVersion?.version || "unknown";
info(`${s.displayName} (${s.slug})`);
dim(` ${s.namespace} · v${version} · ⭐ ${s.starCount}`);
}
}
} catch (e: any) {
error(`Failed: ${e.message}`);
process.exit(1);
}
});
}

View file

@ -0,0 +1,35 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { ApiRoutes, NamespaceResponse } from "../schema/routes.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } from "../core/config.js";
import { error } from "../utils/logger.js";
export function registerNamespaces(program: Command) {
program
.command("namespaces")
.description("List namespaces you have access to")
.action(async () => {
try {
const token = await requireToken();
const config = loadConfig();
const client = new ApiClient({ baseUrl: config.registry, token });
const namespaces = await client.get<NamespaceResponse[]>(ApiRoutes.meNamespaces);
const isJson = program.opts().json;
if (isJson) {
console.log(JSON.stringify(namespaces, null, 2));
} else {
if (!namespaces || namespaces.length === 0) {
console.log("No namespaces found.");
return;
}
for (const ns of namespaces) {
console.log(`${ns.slug}${ns.displayName} [${ns.currentUserRole}] (${ns.status})`);
}
}
} catch (e: any) {
error(`Failed to list namespaces: ${e.message}`);
process.exit(1);
}
});
}

View file

@ -0,0 +1,78 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { loadConfig } from "../core/config.js";
import { requireToken } from "../core/auth-token.js";
import { success, error, info, dim } from "../utils/logger.js";
export interface Notification {
id: number;
title: string;
message: string;
read: boolean;
createdAt: string;
}
export function registerNotifications(program: Command) {
const cmd = program
.command("notifications")
.alias("notif")
.description("Manage notifications");
cmd
.command("list")
.alias("ls")
.description("List notifications")
.option("--unread", "Show unread only")
.action(async (opts: { unread?: boolean }) => {
try {
const token = await requireToken();
const config = loadConfig();
const client = new ApiClient({ baseUrl: config.registry, token });
const notifs = await client.get<Notification[]>("/api/v1/notifications");
const filtered = opts.unread ? notifs.filter((n) => !n.read) : notifs;
if (filtered.length === 0) {
console.log(opts.unread ? "No unread notifications." : "No notifications.");
return;
}
for (const n of filtered) {
info(`${n.read ? "✓" : "○"} ${n.title}`);
dim(` ${n.message} · ${n.createdAt}`);
}
} catch (e: any) {
error(`Failed: ${e.message}`);
process.exit(1);
}
});
cmd
.command("read <id>")
.description("Mark notification as read")
.action(async (id: string) => {
try {
const token = await requireToken();
const config = loadConfig();
const client = new ApiClient({ baseUrl: config.registry, token });
await client.put(`/api/v1/notifications/${id}/read`);
success(`Marked notification ${id} as read`);
} catch (e: any) {
error(`Failed: ${e.message}`);
process.exit(1);
}
});
cmd
.command("read-all")
.description("Mark all notifications as read")
.action(async () => {
try {
const token = await requireToken();
const config = loadConfig();
const client = new ApiClient({ baseUrl: config.registry, token });
await client.put("/api/v1/notifications/read-all");
success("All notifications marked as read");
} catch (e: any) {
error(`Failed: ${e.message}`);
process.exit(1);
}
});
}

View file

@ -0,0 +1,84 @@
import { Command } from "commander";
import { stat, readFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { FormData } from "undici";
import { ApiClient } from "../core/api-client.js";
import { ApiRoutes, PublishResponse } from "../schema/routes.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } from "../core/config.js";
import { success, error, info } from "../utils/logger.js";
import ora from "ora";
import semver from "semver";
export function registerPublish(program: Command) {
program
.command("publish [path]")
.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("--name <name>", "Display name")
.option("--changelog <text>", "Changelog text")
.option("--tag <tags>", "Comma-separated tags (e.g. beta,stable)", "latest")
.action(async (path: string | undefined, opts: Record<string, string>) => {
const folder = path ? resolve(process.cwd(), path) : process.cwd();
const folderStat = await stat(folder).catch(() => null);
if (!folderStat || !folderStat.isDirectory()) {
error("Path must be a directory containing SKILL.md");
process.exit(1);
}
const slug = opts.slug || basename(folder);
const version = opts["skill-version"] || opts.ver;
if (!version || !semver.valid(version)) {
error("--skill-version must be a valid semver (e.g. 1.0.0)");
process.exit(1);
}
const namespace = opts.namespace || "global";
const changelog = opts.changelog || "";
const tags = opts.tag.split(",").map((t: string) => t.trim()).filter(Boolean);
try {
const token = await requireToken();
const config = loadConfig();
const client = new ApiClient({ baseUrl: config.registry, token });
const spinner = ora(`Publishing ${slug}@${version} to ${namespace}`).start();
const skillMdPath = resolve(folder, "SKILL.md");
const skillMdStat = await stat(skillMdPath).catch(() => null);
if (!skillMdStat) {
spinner.fail("SKILL.md not found in directory");
process.exit(1);
}
const skillMdContent = await readFile(skillMdPath, "utf-8");
const form = new FormData();
form.set("payload", JSON.stringify({
slug,
displayName: opts.name || slug,
version,
changelog,
acceptLicenseTerms: true,
tags,
}));
const blob = new Blob([Buffer.from(skillMdContent)], { type: "text/markdown" });
form.append("files", blob, "SKILL.md");
const result = await client.postForm<PublishResponse>(
ApiRoutes.skills,
form,
{ namespace }
);
spinner.succeed(`Published ${slug}@${version} (${result.skillId})`);
info(`Namespace: ${result.namespace}`);
info(`Status: ${result.status}`);
} catch (e: any) {
error(`Publish failed: ${e.message}`);
process.exit(1);
}
});
}

View file

@ -0,0 +1,71 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { loadConfig } 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 <slug>")
.description("View your rating for a skill")
.action(async (slug: string) => {
try {
const { namespace, slug: skillSlug } = parseSkillName(slug);
const token = await requireToken();
const config = loadConfig();
const client = new ApiClient({ baseUrl: config.registry, token });
const detail = await client.get<{ id: number }>(
`/api/v1/skills/${namespace}/${skillSlug}`
);
const rating = await client.get<{ score: number; rated: boolean }>(
`/api/v1/skills/${detail.id}/rating`
);
if (rating.rated) {
info(`${skillSlug}: ${"★".repeat(rating.score)}${"☆".repeat(5 - rating.score)} (${rating.score}/5)`);
} else {
info(`${skillSlug}: Not rated yet`);
dim("Use: skillhub rate <slug> <score>");
}
} catch (e: any) {
error(`Failed: ${e.message}`);
process.exit(1);
}
});
}
export function registerRate(program: Command) {
program
.command("rate <slug> <score>")
.description("Rate a skill (1-5)")
.action(async (slug: string, scoreStr: string) => {
const score = parseInt(scoreStr, 10);
if (isNaN(score) || score < 1 || score > 5) {
error("Score must be between 1 and 5");
process.exit(1);
}
try {
const { namespace, slug: skillSlug } = parseSkillName(slug);
const token = await requireToken();
const config = loadConfig();
const client = new ApiClient({ baseUrl: config.registry, token });
const detail = await client.get<{ id: number }>(
`/api/v1/skills/${namespace}/${skillSlug}`
);
await client.put(`/api/v1/skills/${detail.id}/rating`, {
body: JSON.stringify({ score }),
headers: { "Content-Type": "application/json" },
});
success(`Rated ${skillSlug}: ${"★".repeat(score)}${"☆".repeat(5 - score)}`);
} catch (e: any) {
error(`Failed: ${e.message}`);
process.exit(1);
}
});
}

View file

@ -0,0 +1,40 @@
import { Command } from "commander";
import { createInterface } from "node:readline";
import { ApiClient } from "../core/api-client.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } 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 <slug>")
.description("Report a skill for review")
.option("--reason <reason>", "Report reason")
.action(async (slug: string, opts: { reason?: string }) => {
try {
const { namespace, slug: skillSlug } = parseSkillName(slug);
const token = await requireToken();
const config = loadConfig();
const client = new ApiClient({ baseUrl: config.registry, token });
let reason = opts.reason;
if (!reason) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
reason = await new Promise<string>((r) =>
rl.question("Report reason: ", r)
);
rl.close();
}
await client.post(`/api/v1/skills/${namespace}/${skillSlug}/reports`, {
body: JSON.stringify({ reason }),
headers: { "Content-Type": "application/json" },
});
success(`Report submitted for ${skillSlug}`);
} catch (e: any) {
error(`Failed: ${e.message}`);
process.exit(1);
}
});
}

View file

@ -0,0 +1,177 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { loadConfig } from "../core/config.js";
import { readToken } from "../core/auth-token.js";
import { success, error, info, dim } from "../utils/logger.js";
import { parseSkillName } from "../core/skill-name.js";
import { runInteractiveSearch, searchSkills } from "../core/interactive-search.js";
export interface ResolveResponse {
skillId: number;
namespace: string;
slug: string;
version: string;
versionId: number;
fingerprint: string;
matched: string;
downloadUrl: string;
}
interface VersionSearchResult {
namespace: string;
name: string;
exists: boolean;
}
async function resolveWithVersion(
client: ApiClient,
namespace: string,
slug: string,
version: string
): Promise<ResolveResponse | null> {
try {
const result = await client.get<ResolveResponse>(
`/api/v1/skills/${namespace}/${slug}/resolve?version=${version}`
);
return result;
} catch (e: any) {
const status = e.status || e.statusCode;
if (status === 404 || status === 400) return null;
throw e;
}
}
export function registerResolve(program: Command) {
program
.command("resolve <slug>")
.description("Resolve the latest version of a skill")
.option("-v, --skill-version <ver>", "Specific version")
.option("--tag <tag>", "Tag to resolve (default: latest, ignored if --skill-version)")
.option("--hash <hash>", "Content hash")
.action(async (slug: string, opts: Record<string, string>) => {
try {
const { namespace, slug: skillSlug } = parseSkillName(slug);
const config = loadConfig();
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });
let targetNamespace = namespace;
let targetSlug = skillSlug;
const specifiedVersion = opts.skillVersion;
// Case 1: User specified a version
if (specifiedVersion) {
if (namespace && namespace !== "global") {
const result = await resolveWithVersion(client, namespace, skillSlug, specifiedVersion);
if (result) {
printResolveResult(result);
return;
}
error(`Version ${specifiedVersion} not found for ${namespace}/${skillSlug}`);
error(`Please check if the version number is correct.`);
process.exit(1);
}
const results = await searchSkills(client, skillSlug, 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) {
error(`Skill not found: ${skillSlug}`);
process.exit(1);
}
const resolvePromises = uniqueResults.map(async (r) => ({
...r,
result: await resolveWithVersion(client, r.namespace, r.name, specifiedVersion),
}));
const resolvedResults = await Promise.all(resolvePromises);
const matches = resolvedResults.filter((r) => r.result !== null);
if (matches.length === 0) {
error(`Version ${specifiedVersion} not found for ${skillSlug}`);
error(`Please check if the version number is correct.`);
process.exit(1);
}
if (matches.length === 1) {
// Only one match, auto-select
printResolveResult(matches[0].result!);
return;
}
// Multiple matches, list them for user to choose manually
info(`Found multiple skills with version ${specifiedVersion}:`);
for (const m of matches) {
console.log(` ${m.namespace}/${m.name}`);
}
dim(`\nUse: resolve <namespace>/<skill> --skill-version ${specifiedVersion}`);
process.exit(1);
}
// Case 2: No version specified (original behavior)
if (namespace === "global") {
const results = await searchSkills(client, skillSlug, 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) {
error(`Skill not found: ${skillSlug}`);
process.exit(1);
}
if (uniqueResults.length === 1) {
targetNamespace = uniqueResults[0].namespace;
targetSlug = uniqueResults[0].name;
} else {
const selected = await runInteractiveSearch(client, skillSlug);
if (!selected) {
info("Cancelled.");
return;
}
const [ns, name] = selected.split("/", 2);
targetNamespace = ns;
targetSlug = name;
}
}
const params = new URLSearchParams();
if (opts.tag) {
params.set("tag", opts.tag);
}
if (opts.hash) params.set("hash", opts.hash);
const qs = params.toString();
const path = `/api/v1/skills/${targetNamespace}/${targetSlug}/resolve${qs ? "?" + qs : ""}`;
const result = await client.get<ResolveResponse>(path);
printResolveResult(result);
} catch (e: any) {
error(`Failed: ${e.message}`);
process.exit(1);
}
});
}
function printResolveResult(result: ResolveResponse) {
info(`${result.slug}@${result.version}`);
dim(`Namespace: ${result.namespace}`);
dim(`Version ID: ${result.versionId}`);
dim(`Fingerprint: ${result.fingerprint}`);
dim(`Matched: ${result.matched}`);
dim(`Download URL: ${result.downloadUrl}`);
}

View file

@ -0,0 +1,43 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } from "../core/config.js";
import { success, error, info, dim } from "../utils/logger.js";
export interface ReviewSubmission {
id: number;
skillSlug: string;
skillDisplayName: string;
namespace: string;
version: string;
status: string;
createdAt: string;
}
export function registerReviews(program: Command) {
const reviews = program.command("reviews").description("Manage skill reviews");
reviews
.command("my")
.alias("submissions")
.description("List your review submissions")
.action(async () => {
try {
const token = await requireToken();
const config = loadConfig();
const client = new ApiClient({ baseUrl: config.registry, token });
const submissions = await client.get<ReviewSubmission[]>("/api/v1/reviews/my-submissions");
if (!submissions || submissions.length === 0) {
console.log("No review submissions.");
return;
}
for (const r of submissions) {
info(`${r.skillDisplayName} (${r.skillSlug})`);
dim(` ${r.namespace} · v${r.version} · ${r.status} · ${r.createdAt}`);
}
} catch (e: any) {
error(`Failed: ${e.message}`);
process.exit(1);
}
});
}

View file

@ -0,0 +1,54 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { ApiRoutes, SearchResponse } from "../schema/routes.js";
import { loadConfig } from "../core/config.js";
import { readToken } from "../core/auth-token.js";
import { error, dim } from "../utils/logger.js";
export function registerSearch(program: Command) {
program
.command("search <query...>")
.description("[Deprecated: use 'explore' instead] Search for skills on SkillHub")
.option("-n, --limit <n>", "Max results", "20")
.option("--namespace <ns>", "Filter by namespace")
.action(async (query: string[], opts: { limit: string; namespace?: string }) => {
const config = loadConfig();
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });
const isJson = program.opts().json;
try {
let searchUrl = `${ApiRoutes.search}?q=${encodeURIComponent(query.join(" "))}&limit=${opts.limit}`;
if (opts.namespace) {
searchUrl += `&namespace=${encodeURIComponent(opts.namespace)}`;
}
const result = await client.get<SearchResponse>(searchUrl);
if (!result.results || result.results.length === 0) {
if (isJson) {
console.log(JSON.stringify({ results: [] }));
} else {
console.log("No skills found.");
}
return;
}
if (isJson) {
console.log(JSON.stringify(result, null, 2));
} else {
const hasNamespaceFilter = !!opts.namespace;
for (const s of result.results) {
const ns = s.namespace ? `[${s.namespace}] ` : '';
console.log(`${ns}${s.slug} (${s.version}) — ${s.displayName}`);
if (s.summary) console.log(` ${s.summary}`);
}
if (hasNamespaceFilter) {
dim(`\nTip: remove --namespace filter to search all namespaces`);
}
}
} catch (e: any) {
error(`Search failed: ${e.message}`);
process.exit(1);
}
});
}

View file

@ -0,0 +1,37 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { ApiRoutes } from "../schema/routes.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } from "../core/config.js";
import { success, error } from "../utils/logger.js";
import { parseSkillName } from "../core/skill-name.js";
export function registerStar(program: Command) {
program
.command("star <slug>")
.description("Star a skill")
.option("--unstar", "Remove star")
.action(async (slug: string, opts: { unstar: boolean }) => {
try {
const { namespace, slug: skillSlug } = parseSkillName(slug);
const token = await requireToken();
const config = loadConfig();
const client = new ApiClient({ baseUrl: config.registry, token });
const detailPath = ApiRoutes.skillDetail.replace("{namespace}", namespace).replace("{slug}", skillSlug);
const detail = await client.get<{ id: number }>(detailPath);
const starPath = `/api/v1/skills/${detail.id}/star`;
if (opts.unstar) {
await client.delete(starPath);
success(`Unstarred ${skillSlug}`);
} else {
await client.put(starPath);
success(`Starred ${skillSlug}`);
}
} catch (e: any) {
error(`Failed: ${e.message}`);
process.exit(1);
}
});
}

View file

@ -0,0 +1,155 @@
import { Command } from "commander";
import { stat, readFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { FormData } from "undici";
import { existsSync } from "node:fs";
import { discoverSkills } from "../core/skill-discovery.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } from "../core/config.js";
import { ApiClient } from "../core/api-client.js";
import { ApiRoutes } from "../schema/routes.js";
import { info, dim, success, error } from "../utils/logger.js";
import semver from "semver";
interface SyncResult {
name: string;
slug: string;
namespace: string;
success: boolean;
message?: string;
}
export function registerSync(program: Command) {
program
.command("sync [path]")
.description("Scan and publish all skills from a directory")
.option("--namespace <ns>", "Target namespace", "global")
.option("--all", "Include all skills (even with changes)")
.option("-y, --yes", "Skip confirmation")
.action(async (path: string | undefined, opts: { namespace: string; all?: boolean; yes?: boolean }) => {
const scanPath = path ? resolve(path) : process.cwd();
if (!existsSync(scanPath)) {
error(`Directory not found: ${scanPath}`);
process.exit(1);
}
try {
const token = await requireToken();
const config = loadConfig();
const client = new ApiClient({ baseUrl: config.registry, token });
info(`Scanning ${scanPath} for skills...`);
const skills = discoverSkills(scanPath);
if (skills.length === 0) {
console.log("No skills found. Ensure directories contain SKILL.md files.");
return;
}
console.log("");
info(`Found ${skills.length} skill(s):`);
for (const skill of skills) {
console.log(` - ${skill.name} (${skill.description})`);
}
console.log("");
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(`Publish ${skills.length} skill(s) to ${opts.namespace}? [y/N] `, r)
);
rl.close();
if (answer.toLowerCase() !== "y") {
console.log("Cancelled.");
return;
}
}
const results: SyncResult[] = [];
console.log("");
for (const skill of skills) {
const slug = skill.name;
try {
info(`Publishing ${slug}...`);
const version = generateVersion();
const skillMdPath = resolve(skill.dir, "SKILL.md");
const skillMdStat = await stat(skillMdPath);
if (!skillMdStat) {
error(`SKILL.md not found in ${skill.dir}`);
results.push({ name: skill.name, slug, namespace: opts.namespace, success: false, message: "SKILL.md not found" });
continue;
}
const skillMdContent = await readFile(skillMdPath, "utf-8");
const form = new FormData();
form.set("payload", JSON.stringify({
slug,
displayName: skill.name,
version,
changelog: "Synced from local directory",
acceptLicenseTerms: true,
tags: ["latest"],
}));
const blob = new Blob([Buffer.from(skillMdContent)], { type: "text/markdown" });
form.append("files", blob, "SKILL.md");
const publishResponse = await client.postForm<{ ok: boolean; skillId: string; versionId: string }>(
ApiRoutes.skills,
form,
{ namespace: opts.namespace }
);
if (publishResponse.ok) {
success(`Published ${slug}@${version}`);
results.push({ name: skill.name, slug, namespace: opts.namespace, success: true });
} else {
error(`Failed to publish ${slug}`);
results.push({ name: skill.name, slug, namespace: opts.namespace, success: false, message: "Server returned ok=false" });
}
} catch (e: any) {
error(`Failed to publish ${slug}: ${e.message}`);
results.push({ name: skill.name, slug, namespace: opts.namespace, success: false, message: e.message });
}
}
console.log("");
info("=== Sync Summary ===");
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
console.log(` Total: ${results.length}`);
console.log(` Success: ${successCount}`);
console.log(` Failed: ${failCount}`);
if (failCount > 0) {
console.log("");
dim("Failed skills:");
for (const r of results.filter((r) => !r.success)) {
console.log(` - ${r.slug}: ${r.message}`);
}
}
console.log("");
} catch (e: any) {
error(`Sync failed: ${e.message}`);
process.exit(1);
}
});
}
function generateVersion(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
const seconds = String(now.getSeconds()).padStart(2, "0");
return `${year}${month}${day}.${hours}${minutes}${seconds}`;
}

View file

@ -0,0 +1,38 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { ApiRoutes } from "../schema/routes.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } from "../core/config.js";
import { success, error } from "../utils/logger.js";
export function registerTransfer(program: Command) {
program
.command("transfer <namespace> <newOwnerId>")
.description("Transfer ownership of a namespace to another user")
.option("-y, --yes", "Skip confirmation")
.action(async (namespace: string, newOwnerId: string, opts: { yes?: boolean }) => {
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(`Transfer ownership of ${namespace} to ${newOwnerId}? [y/N] `, r)
);
rl.close();
if (answer.toLowerCase() !== "y") {
console.log("Cancelled.");
return;
}
}
try {
const token = await requireToken();
const config = loadConfig();
const client = new ApiClient({ baseUrl: config.registry, token });
await client.post(ApiRoutes.namespaceTransferOwnership.replace("{namespace}", namespace), { body: JSON.stringify({ newOwnerId }) });
success(`Ownership of ${namespace} transferred to ${newOwnerId}`);
} catch (e: any) {
error(`Failed: ${e.message}`);
process.exit(1);
}
});
}

View file

@ -0,0 +1,334 @@
import { Command } from "commander";
import { existsSync, readdirSync, statSync, unlinkSync, rmdirSync, lstatSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { getAllAgents, isUniversalAgent, getUniversalAgents, getNonUniversalAgents, type AgentInfo } from "../core/agent-detector.js";
import { success, info, dim } from "../utils/logger.js";
import { removeFromLock } from "../core/skill-lock.js";
import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js";
import * as p from "@clack/prompts";
function removeDir(path: string) {
try {
const stat = lstatSync(path);
if (stat.isSymbolicLink()) {
unlinkSync(path);
} else if (stat.isDirectory()) {
for (const entry of readdirSync(path)) {
removeDir(join(path, entry));
}
rmdirSync(path);
} else {
unlinkSync(path);
}
} catch {}
}
async function uninstallSkill(
name: string,
agent: AgentInfo,
scope: "local" | "global",
yes: boolean
): Promise<boolean> {
const home = homedir();
let baseDir: string;
if (scope === "global") {
if (isUniversalAgent(agent)) {
baseDir = join(home, ".agents/skills");
} else {
baseDir = join(home, agent.globalSkillsDir || agent.skillsDir);
}
} else {
baseDir = join(process.cwd(), agent.skillsDir);
}
const skillPath = join(baseDir, name);
if (!existsSync(skillPath)) return false;
if (!statSync(skillPath).isDirectory()) return false;
if (!yes) {
const confirmed = await p.confirm({
message: `Uninstall ${name} from ${agent.name}?`,
initialValue: false,
});
if (!confirmed) return false;
}
removeDir(skillPath);
return true;
}
function getSkillPath(skillName: string, agent: AgentInfo, scope: "global" | "local"): string | null {
const home = homedir();
let baseDir: string;
if (scope === "global") {
if (isUniversalAgent(agent)) {
baseDir = join(home, ".agents/skills");
} else {
baseDir = join(home, agent.globalSkillsDir || agent.skillsDir);
}
} else {
baseDir = join(process.cwd(), agent.skillsDir);
}
const skillPath = join(baseDir, skillName);
if (existsSync(skillPath) && statSync(skillPath).isDirectory()) {
return skillPath;
}
return null;
}
function discoverInstalledSkills(scope: "local" | "global", agent?: AgentInfo): string[] {
const skills: string[] = [];
const agents = agent ? [agent] : getAllAgents();
for (const a of agents) {
const skillPath = getSkillPath("*", a, scope);
if (!skillPath) continue;
const baseDir = skillPath.replace(/\/[^/]+$/, "");
try {
for (const entry of readdirSync(baseDir)) {
const fullPath = join(baseDir, entry);
if (statSync(fullPath).isDirectory() && existsSync(join(fullPath, "SKILL.md"))) {
skills.push(entry);
}
}
} catch {}
}
return [...new Set(skills)];
}
function findAgentsWithSkill(skillName: string, scope: "global" | "local", agents: AgentInfo[]): AgentInfo[] {
return agents.filter((a) => getSkillPath(skillName, a, scope) !== null);
}
export function registerUninstall(program: Command) {
program
.command("uninstall [name]")
.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")
.option("-y, --yes", "Skip confirmation")
.option("--all", "Uninstall all installed skills")
.action(async (name: string | undefined, opts: { global?: boolean; agent?: string[]; yes?: boolean; all?: boolean }) => {
let scope: "global" | "local" = opts.global ? "global" : "local";
let scopeAll = false;
if (!opts.global && !opts.agent) {
const scopeSelection = await p.select({
message: "Which scope to uninstall from?",
options: [
{ value: "all", label: "All (global + project)" },
{ value: "global", label: "Global only" },
{ value: "project", label: "Project only" },
],
});
if (p.isCancel(scopeSelection)) {
console.log("Cancelled.");
return;
}
if (scopeSelection === "global") {
scope = "global";
} else if (scopeSelection === "project") {
scope = "local";
} else if (scopeSelection === "all") {
scopeAll = true;
}
}
const allAgents = getAllAgents();
if (opts.all) {
const skills = discoverInstalledSkills(scope);
if (skills.length === 0) {
dim("No skills installed.");
return;
}
const selected = await searchMultiselect({
message: "Select skills to uninstall",
items: skills.map((s) => ({ value: s, label: s })),
required: true,
});
if (selected === cancelSymbol) {
console.log("Cancelled.");
return;
}
const selectedSkills = selected as string[];
let uninstalled = 0;
for (const skill of selectedSkills) {
const agentsWithSkill = findAgentsWithSkill(skill, scope, allAgents);
for (const agent of agentsWithSkill) {
const ok = await uninstallSkill(skill, agent, scope, true);
if (ok) uninstalled++;
}
await removeFromLock(skill);
}
success(`Uninstalled ${uninstalled} skill(s).`);
return;
}
if (!name) {
const skills = discoverInstalledSkills(scope);
if (skills.length === 0) {
dim("No skills installed.");
return;
}
const selected = await searchMultiselect({
message: "Select skills to uninstall",
items: skills.map((s) => ({ value: s, label: s })),
required: true,
});
if (selected === cancelSymbol) {
console.log("Cancelled.");
return;
}
const selectedSkills = selected as string[];
let uninstalled = 0;
for (const skill of selectedSkills) {
const agentsWithSkill = findAgentsWithSkill(skill, scope, allAgents);
for (const agent of agentsWithSkill) {
const ok = await uninstallSkill(skill, agent, scope, !!opts.yes);
if (ok) uninstalled++;
}
await removeFromLock(skill);
}
success(`Uninstalled ${uninstalled} skill(s).`);
return;
}
let agentsWithSkill = findAgentsWithSkill(name, scope, allAgents);
if (agentsWithSkill.length === 0 && !scopeAll) {
agentsWithSkill = findAgentsWithSkill(name, scope === "global" ? "local" : "global", allAgents);
if (agentsWithSkill.length > 0) {
const otherScope = scope === "global" ? "project" : "global";
dim(`Skill "${name}" not found in ${scope}, but found in ${otherScope}.`);
} else {
info(`Skill "${name}" not found.`);
return;
}
}
if (agentsWithSkill.length === 0 && scopeAll) {
agentsWithSkill = [
...findAgentsWithSkill(name, "global", allAgents),
...findAgentsWithSkill(name, "local", allAgents),
];
if (agentsWithSkill.length === 0) {
info(`Skill "${name}" not found.`);
return;
}
}
const universalAgents = getUniversalAgents();
const nonUniversalAgents = getNonUniversalAgents();
const universalSection = {
title: "Universal (.agents/skills)",
items: universalAgents
.filter((a) => agentsWithSkill.some((w) => w.key === a.key))
.map((a) => ({
value: a.key,
label: a.name,
})),
};
const selectableItems = nonUniversalAgents
.filter((a) => agentsWithSkill.some((w) => w.key === a.key))
.map((a) => ({
value: a.key,
label: a.name,
}));
if (selectableItems.length === 0 && universalSection.items.length === 0) {
info(`Skill "${name}" not found.`);
return;
}
const selected = await searchMultiselect({
message: `Uninstall ${name} from which agents?`,
items: selectableItems,
lockedSection: universalSection.items.length > 0 ? universalSection : undefined,
});
if (selected === cancelSymbol) {
console.log("Cancelled.");
return;
}
const selectedAgentKeys = selected as string[];
const pathToAgents = new Map<string, string[]>();
for (const agentKey of selectedAgentKeys) {
const agent = allAgents.find((a) => a.key === agentKey);
if (agent) {
if (scopeAll) {
const okGlobal = await uninstallSkill(name, agent, "global", !!opts.yes);
const okLocal = await uninstallSkill(name, agent, "local", !!opts.yes);
if (okGlobal) {
const skillPath = getSkillPath(name, agent, "global");
if (skillPath) {
const agents = pathToAgents.get(skillPath) || [];
agents.push(agent.name);
pathToAgents.set(skillPath, agents);
}
}
if (okLocal) {
const skillPath = getSkillPath(name, agent, "local");
if (skillPath) {
const agents = pathToAgents.get(skillPath) || [];
agents.push(agent.name);
pathToAgents.set(skillPath, agents);
}
}
} else {
const ok = await uninstallSkill(name, agent, scope, !!opts.yes);
if (ok) {
const skillPath = getSkillPath(name, agent, scope);
if (skillPath) {
const agents = pathToAgents.get(skillPath) || [];
agents.push(agent.name);
pathToAgents.set(skillPath, agents);
}
}
}
}
}
if (pathToAgents.size > 0) {
const lines: string[] = [];
for (const [path, agents] of pathToAgents) {
if (agents.length > 1) {
lines.push(` ${agents.join(", ")} (${path})`);
} else {
lines.push(` ${agents[0]} (${path})`);
}
}
success(`Uninstalled ${name} from ${selectedAgentKeys.length} agent(s):`);
console.log(lines.join("\n"));
await removeFromLock(name);
} else {
info(`Skill "${name}" not found.`);
}
});
}

View file

@ -0,0 +1,99 @@
import { Command } from "commander";
import { success, error, info, warn } from "../utils/logger.js";
import { getAllLockedSkills, getSkillLockPath } from "../core/skill-lock.js";
import { existsSync } from "node:fs";
import { execSync } from "node:child_process";
import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js";
function getCliCommand(): string {
const cliPath = process.argv[1];
if (cliPath && cliPath.endsWith("cli.mjs")) {
return `node "${cliPath}"`;
}
return "node dist/cli.mjs";
}
export function registerUpdate(program: Command) {
program
.command("update [slug]")
.alias("up")
.description("Update installed skills from their source")
.option("-a, --all", "Update all installed skills")
.option("-g, --global", "Update global scope 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.exit(1);
}
const lockedSkills = await getAllLockedSkills();
const allSkillNames = Object.keys(lockedSkills);
if (allSkillNames.length === 0) {
error("No skills in lock file.");
process.exit(1);
}
let skillsToUpdate: string[] = [];
if (opts.all) {
skillsToUpdate = allSkillNames;
} else if (slug) {
skillsToUpdate = [slug];
} else {
const selected = await searchMultiselect({
message: "Select skills to update",
items: allSkillNames.map((name) => ({
value: name,
label: name,
hint: lockedSkills[name].sourceType,
})),
required: true,
});
if (selected === cancelSymbol) {
console.log("Cancelled.");
return;
}
skillsToUpdate = selected as string[];
}
const scope = opts.global ? "--global" : "";
const cliCmd = getCliCommand();
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;
}
try {
info(`Updating ${name} from ${entry.source}...`);
const source = entry.sourceType === "registry"
? entry.source
: entry.sourceUrl;
const cmd = `${cliCmd} install ${source} ${scope}`.trim();
execSync(cmd, { stdio: "inherit" });
updated++;
} catch (e: any) {
error(`Failed to update ${name}: ${e.message}`);
failed++;
}
}
console.log("");
if (failed === 0) {
success(`Updated ${updated} skill(s)`);
} else {
warn(`Updated ${updated}, failed ${failed}`);
}
});
}

View file

@ -0,0 +1,101 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { readToken } from "../core/auth-token.js";
import { loadConfig } from "../core/config.js";
import { error, info, dim, success } from "../utils/logger.js";
import { parseSkillName } from "../core/skill-name.js";
import { searchSkills, runInteractiveSearch } from "../core/interactive-search.js";
export interface SkillVersionItem {
id: number;
version: string;
status: string;
changelog: string | null;
fileCount: number;
totalSize: number;
publishedAt: string;
downloadAvailable: boolean;
}
export interface VersionsResponse {
items: SkillVersionItem[];
total: number;
page: number;
size: number;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function registerVersions(program: Command) {
program
.command("versions <slug>")
.description("List skill versions")
.action(async (slug: string) => {
try {
const { namespace, slug: skillSlug } = parseSkillName(slug);
const config = loadConfig();
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });
let targetNamespace = namespace;
let targetSlug = skillSlug;
if (namespace === "global") {
const results = await searchSkills(client, skillSlug, 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) {
error(`Skill not found: ${skillSlug}`);
process.exit(1);
}
if (uniqueResults.length === 1) {
targetNamespace = uniqueResults[0].namespace;
targetSlug = uniqueResults[0].name;
} else {
const selected = await runInteractiveSearch(client, skillSlug);
if (!selected) {
info("Cancelled.");
return;
}
const [ns, name] = selected.split("/", 2);
targetNamespace = ns;
targetSlug = name;
}
}
const resp = await client.get<VersionsResponse>(
`/api/v1/skills/${targetNamespace}/${targetSlug}/versions`
);
const versions = resp.items || [];
if (versions.length === 0) {
console.log("No versions found.");
return;
}
if (targetNamespace !== "global") {
success(`${targetNamespace}/${targetSlug}`);
}
for (const v of versions) {
info(`v${v.version}`);
dim(` ${v.status} · ${v.fileCount} files · ${formatBytes(v.totalSize)} · ${v.publishedAt}`);
}
} catch (e: any) {
error(`Failed: ${e.message}`);
process.exit(1);
}
});
}

View file

@ -0,0 +1,30 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { ApiRoutes, WhoamiResponse } from "../schema/routes.js";
import { requireToken } from "../core/auth-token.js";
import { success, error } from "../utils/logger.js";
import { loadConfig } from "../core/config.js";
export function registerWhoami(program: Command) {
program
.command("whoami")
.description("Show current authenticated user")
.action(async () => {
try {
const token = await requireToken();
const config = loadConfig();
const client = new ApiClient({ baseUrl: config.registry, token });
const resp = await client.get<WhoamiResponse>(ApiRoutes.whoami);
const isJson = program.opts().json;
if (isJson) {
console.log(JSON.stringify(resp, null, 2));
} else {
console.log(`Handle: ${resp.user.handle}`);
console.log(`Display Name: ${resp.user.displayName}`);
}
} catch (e: any) {
error(`Not authenticated: ${e.message}`);
process.exit(1);
}
});
}

View file

@ -0,0 +1,89 @@
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export interface AgentInfo {
key: string;
name: string;
skillsDir: string;
globalSkillsDir?: string;
}
const home = homedir();
const AGENTS: AgentInfo[] = [
// Universal agents (.agents/skills)
{ key: "amp", name: "Amp", skillsDir: ".agents/skills", globalSkillsDir: ".config/agents/skills" },
{ key: "antigravity", name: "Antigravity", skillsDir: ".agents/skills", globalSkillsDir: ".gemini/antigravity/skills" },
{ key: "cline", name: "Cline", skillsDir: ".agents/skills" },
{ key: "codex", name: "Codex", skillsDir: ".agents/skills", globalSkillsDir: ".codex/skills" },
{ key: "cursor", name: "Cursor", skillsDir: ".agents/skills", globalSkillsDir: ".cursor/skills" },
{ key: "deepagents", name: "Deep Agents", skillsDir: ".agents/skills", globalSkillsDir: ".deepagents/agent/skills" },
{ key: "firebender", name: "Firebender", skillsDir: ".agents/skills", globalSkillsDir: ".firebender/skills" },
{ key: "gemini-cli", name: "Gemini CLI", skillsDir: ".agents/skills", globalSkillsDir: ".gemini/skills" },
{ key: "github-copilot", name: "GitHub Copilot", skillsDir: ".agents/skills", globalSkillsDir: ".copilot/skills" },
{ key: "kimi-cli", name: "Kimi Code CLI", skillsDir: ".agents/skills", globalSkillsDir: ".config/agents/skills" },
{ key: "kilo", name: "Kilo Code", skillsDir: ".agents/skills", globalSkillsDir: ".kilocode/skills" },
{ key: "mux", name: "Mux", skillsDir: ".agents/skills" },
{ key: "opencode", name: "OpenCode", skillsDir: ".agents/skills", globalSkillsDir: ".config/opencode/skills" },
{ key: "replit", name: "Replit", skillsDir: ".agents/skills" },
{ key: "warp", name: "Warp", skillsDir: ".agents/skills" },
// Agent-specific path agents
{ key: "claude-code", name: "Claude Code", skillsDir: ".claude/skills", globalSkillsDir: ".claude/skills" },
{ key: "augment", name: "Augment", skillsDir: ".augment/skills" },
{ key: "bob", name: "IBM Bob", skillsDir: ".bob/skills" },
{ key: "openclaw", name: "OpenClaw", skillsDir: "skills", globalSkillsDir: ".openclaw/skills" },
{ key: "codebuddy", name: "CodeBuddy", skillsDir: ".codebuddy/skills" },
{ key: "continue", name: "Continue", skillsDir: ".continue/skills" },
{ key: "cortex", name: "Cortex Code", skillsDir: ".cortex/skills", globalSkillsDir: ".snowflake/cortex/skills" },
{ key: "crush", name: "Crush", skillsDir: ".crush/skills", globalSkillsDir: ".config/crush/skills" },
{ key: "droid", name: "Droid", skillsDir: ".factory/skills" },
{ key: "goose", name: "Goose", skillsDir: ".goose/skills", globalSkillsDir: ".config/goose/skills" },
{ key: "junie", name: "Junie", skillsDir: ".junie/skills" },
{ key: "iflow-cli", name: "iFlow CLI", skillsDir: ".iflow/skills" },
{ key: "kode", name: "Kode", skillsDir: ".kode/skills" },
{ key: "mcpjam", name: "MCPJam", skillsDir: ".mcpjam/skills" },
{ key: "mistral-vibe", name: "Mistral Vibe", skillsDir: ".vibe/skills" },
{ key: "openhands", name: "OpenHands", skillsDir: ".openhands/skills" },
{ key: "pi", name: "Pi", skillsDir: ".pi/skills", globalSkillsDir: ".pi/agent/skills" },
{ key: "qoder", name: "Qoder", skillsDir: ".qoder/skills" },
{ key: "qwen-code", name: "Qwen Code", skillsDir: ".qwen/skills" },
{ key: "roo", name: "Roo Code", skillsDir: ".roo/skills" },
{ key: "trae", name: "Trae", skillsDir: ".trae/skills" },
{ key: "trae-cn", name: "Trae CN", skillsDir: ".trae/skills", globalSkillsDir: ".trae-cn/skills" },
{ key: "windsurf", name: "Windsurf", skillsDir: ".windsurf/skills", globalSkillsDir: ".codeium/windsurf/skills" },
{ key: "zencoder", name: "Zencoder", skillsDir: ".zencoder/skills" },
{ key: "neovate", name: "Neovate", skillsDir: ".neovate/skills" },
{ key: "pochi", name: "Pochi", skillsDir: ".pochi/skills" },
{ key: "adal", name: "AdaL", skillsDir: ".adal/skills" },
];
export function getAllAgents(): AgentInfo[] {
return AGENTS;
}
export function detectInstalledAgents(): AgentInfo[] {
return AGENTS.filter((agent) => {
const globalPath = agent.globalSkillsDir ? join(home, agent.globalSkillsDir) : join(home, agent.skillsDir);
return existsSync(globalPath);
});
}
export function getAgentByKey(key: string): AgentInfo | undefined {
return AGENTS.find((a) => a.key === key);
}
const UNIVERSAL_PATH = ".agents/skills";
export function isUniversalAgent(agent: AgentInfo): boolean {
return agent.skillsDir === UNIVERSAL_PATH;
}
export function getUniversalAgents(): AgentInfo[] {
return AGENTS.filter((a) => isUniversalAgent(a));
}
export function getNonUniversalAgents(): AgentInfo[] {
return AGENTS.filter((a) => !isUniversalAgent(a));
}

View file

@ -0,0 +1,131 @@
import { request, FormData as UndiciFormData } from "undici";
export interface ApiClientOptions {
baseUrl: string;
token?: string;
}
interface NativeApiResponse<T> {
code: number;
msg: string;
data: T;
timestamp: string;
}
export class ApiClient {
constructor(private options: ApiClientOptions) {}
/**
* Unwrap Native API response format:
* { code: 0, msg: "success", data: T } -> returns T
* { code: non-zero, msg: "error", data: null } -> throws ApiError
*
* Pass-through Compat API format (no code field):
* { user: {...} } -> returns as-is
* { results: [...] } -> returns as-is
*/
private unwrapResponse<T>(data: unknown): T {
// Check if it's a Native API response (has code field)
if (typeof data === "object" && data !== null && "code" in data) {
const native = data as NativeApiResponse<unknown>;
if (native.code !== 0) {
throw new ApiError(native.code, native);
}
return native.data as T;
}
// Otherwise it's a Compat API response, return as-is
return data as T;
}
async get<T>(path: string): Promise<T> {
const url = new URL(path, this.options.baseUrl);
const { statusCode, body } = await request(url.toString(), {
method: "GET",
headers: this.headers(),
});
const data = await body.json();
if (statusCode >= 400) {
throw new ApiError(statusCode, data);
}
return this.unwrapResponse<T>(data);
}
async postForm<T>(path: string, form: UndiciFormData, queryParams?: Record<string, string>): Promise<T> {
const url = new URL(path, this.options.baseUrl);
if (queryParams) {
for (const [k, v] of Object.entries(queryParams)) {
url.searchParams.set(k, v);
}
}
const { statusCode, body } = await request(url.toString(), {
method: "POST",
headers: {
...this.headers(),
},
body: form,
});
const data = await body.json();
if (statusCode >= 400) {
throw new ApiError(statusCode, data);
}
return this.unwrapResponse<T>(data);
}
async post<T>(path: string, opts?: { body?: string; headers?: Record<string, string> }): Promise<T> {
const url = new URL(path, this.options.baseUrl);
const { statusCode, body } = await request(url.toString(), {
method: "POST",
headers: { ...this.headers(), ...opts?.headers },
body: opts?.body,
});
const data = await body.json();
if (statusCode >= 400) {
throw new ApiError(statusCode, data);
}
return this.unwrapResponse<T>(data);
}
async put<T>(path: string, opts?: { body?: string; headers?: Record<string, string> }): Promise<T> {
const url = new URL(path, this.options.baseUrl);
const { statusCode, body } = await request(url.toString(), {
method: "PUT",
headers: { ...this.headers(), ...opts?.headers },
body: opts?.body,
});
const data = await body.json();
if (statusCode >= 400) {
throw new ApiError(statusCode, data);
}
return this.unwrapResponse<T>(data);
}
async delete<T>(path: string): Promise<T> {
const url = new URL(path, this.options.baseUrl);
const { statusCode, body } = await request(url.toString(), {
method: "DELETE",
headers: this.headers(),
});
const data = await body.json();
if (statusCode >= 400) {
throw new ApiError(statusCode, data);
}
return this.unwrapResponse<T>(data);
}
private headers(): Record<string, string> {
const h: Record<string, string> = {};
if (this.options.token) {
h["Authorization"] = `Bearer ${this.options.token}`;
}
return h;
}
}
export class ApiError extends Error {
constructor(
public statusCode: number,
public body: unknown,
) {
super(`API error ${statusCode}: ${JSON.stringify(body)}`);
}
}

View file

@ -0,0 +1,39 @@
import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
import { homedir } from "node:os";
import { join, dirname } from "node:path";
import { mkdir } from "node:fs/promises";
const TOKEN_DIR = join(homedir(), ".skillhub");
const TOKEN_FILE = join(TOKEN_DIR, "token");
export async function readToken(): Promise<string | null> {
if (!existsSync(TOKEN_FILE)) return null;
return readFileSync(TOKEN_FILE, "utf-8").trim();
}
export async function writeToken(token: string): Promise<void> {
if (!existsSync(TOKEN_DIR)) {
await mkdir(TOKEN_DIR, { recursive: true });
}
writeFileSync(TOKEN_FILE, token);
try {
chmodSync(TOKEN_FILE, 0o600);
} catch {
// Permission change not critical
}
}
export async function removeToken(): Promise<void> {
if (existsSync(TOKEN_FILE)) {
const { unlinkSync } = await import("node:fs");
unlinkSync(TOKEN_FILE);
}
}
export async function requireToken(): Promise<string> {
const token = await readToken();
if (!token) {
throw new Error("Not authenticated. Run `skillhub login` first.");
}
return token;
}

View file

@ -0,0 +1,34 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
const CONFIG_DIR = join(homedir(), ".skillhub");
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
export interface CliConfig {
registry: string;
dir?: string;
}
const DEFAULT_CONFIG: CliConfig = {
registry: "http://localhost:8080",
};
export function loadConfig(): CliConfig {
if (!existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG };
try {
const raw = readFileSync(CONFIG_FILE, "utf-8");
return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
} catch {
return { ...DEFAULT_CONFIG };
}
}
export function saveConfig(config: Partial<CliConfig>): void {
const existing = loadConfig();
const merged = { ...existing, ...config };
if (!existsSync(CONFIG_DIR)) {
mkdirSync(CONFIG_DIR, { recursive: true });
}
writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
}

View file

@ -0,0 +1,144 @@
import { mkdirSync, symlinkSync, copyFileSync, readdirSync, lstatSync, unlinkSync, existsSync } from "node:fs";
import { join, dirname, relative } from "node:path";
import { homedir, platform } from "node:os";
export interface SkillInstallResult {
skillName: string;
agentKey: string;
path: string;
mode: "symlink" | "copy";
success: boolean;
error?: string;
}
const UNIVERSAL_PATH = ".agents/skills";
function isUniversalAgent(skillsDir: string): boolean {
return skillsDir === UNIVERSAL_PATH;
}
function getCanonicalBase(isGlobal: boolean, cwd: string): string {
const home = homedir();
return isGlobal ? join(home, UNIVERSAL_PATH) : join(cwd, UNIVERSAL_PATH);
}
function getAgentBaseDir(skillsDir: string, isGlobal: boolean, cwd: string): string {
const home = homedir();
if (isGlobal) {
return join(home, skillsDir);
}
return join(cwd, skillsDir);
}
function removePath(path: string): void {
try {
const stat = lstatSync(path);
if (stat.isSymbolicLink()) {
unlinkSync(path);
} else if (stat.isDirectory()) {
for (const entry of readdirSync(path)) {
removePath(join(path, entry));
}
if (platform() !== "win32") {
try { unlinkSync(path); } catch { }
}
} else {
unlinkSync(path);
}
} catch { }
}
function ensureDir(path: string): void {
if (!existsSync(path)) {
mkdirSync(path, { recursive: true });
}
}
function createSymlink(target: string, linkPath: string): boolean {
try {
if (target === linkPath) {
return true;
}
removePath(linkPath);
const linkDir = dirname(linkPath);
const resolvedLinkDir = linkDir.startsWith("~") ? join(homedir(), linkDir.slice(1)) : linkDir;
ensureDir(resolvedLinkDir);
const relativePath = relative(resolvedLinkDir, target);
const symlinkType = platform() === "win32" ? "junction" : "dir";
symlinkSync(relativePath, linkPath, symlinkType);
return true;
} catch {
return false;
}
}
export function installSkill(
skillDir: string,
skillName: string,
agentKey: string,
targetDir: string,
mode: "symlink" | "copy",
isGlobal: boolean,
): SkillInstallResult {
const cwd = process.cwd();
const canonicalBase = getCanonicalBase(isGlobal, cwd);
const canonicalDir = join(canonicalBase, skillName);
const agentBase = getAgentBaseDir(targetDir, isGlobal, cwd);
const agentDir = join(agentBase, skillName);
const agentIsUniversal = isUniversalAgent(targetDir);
try {
if (mode === "copy") {
const copyDestDir = dirname(agentDir);
const resolvedCopyDestDir = copyDestDir.startsWith("~") ? join(homedir(), copyDestDir.slice(1)) : copyDestDir;
ensureDir(resolvedCopyDestDir);
removePath(agentDir);
mkdirSync(agentDir, { recursive: true });
copyDir(skillDir, agentDir);
return { skillName, agentKey, path: agentDir, mode, success: true };
}
ensureDir(dirname(canonicalDir));
removePath(canonicalDir);
mkdirSync(canonicalDir, { recursive: true });
copyDir(skillDir, canonicalDir);
if (isGlobal && agentIsUniversal) {
return { skillName, agentKey, path: canonicalDir, mode, success: true };
}
const symlinkCreated = createSymlink(canonicalDir, agentDir);
if (!symlinkCreated) {
const agentLinkDir = dirname(agentDir);
const resolvedAgentLinkDir = agentLinkDir.startsWith("~") ? join(homedir(), agentLinkDir.slice(1)) : agentLinkDir;
ensureDir(resolvedAgentLinkDir);
removePath(agentDir);
mkdirSync(agentDir, { recursive: true });
copyDir(skillDir, agentDir);
return { skillName, agentKey, path: agentDir, mode, success: true };
}
return { skillName, agentKey, path: agentDir, mode, success: true };
} catch (e: any) {
return { skillName, agentKey, path: agentDir, mode, success: false, error: e.message };
}
}
function copyDir(src: string, dest: string) {
mkdirSync(dest, { recursive: true });
for (const entry of readdirSync(src)) {
const srcPath = join(src, entry);
const destPath = join(dest, entry);
if (lstatSync(srcPath).isDirectory()) {
copyDir(srcPath, destPath);
} else {
copyFileSync(srcPath, destPath);
}
}
}

View file

@ -0,0 +1,249 @@
import { ApiClient } from "./api-client.js";
import { ApiRoutes, SearchResponse } from "../schema/routes.js";
import * as readline from "readline";
import { dim, info } from "../utils/logger.js";
const HIDE_CURSOR = "\x1b[?25l";
const SHOW_CURSOR = "\x1b[?25h";
const CLEAR_DOWN = "\x1b[J";
const MOVE_UP = (n: number) => `\x1b[${n}A`;
const MOVE_TO_COL = (n: number) => `\x1b[${n}G`;
const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
const TEXT = "\x1b[38;5;145m";
const YELLOW = "\x1b[33m";
const DIM = "\x1b[38;5;102m";
export interface SearchSkill {
name: string;
slug: string;
namespace: string;
version?: string;
summary?: string;
installs?: number;
}
interface SkillDetail {
starCount: number;
downloadCount: number;
version: string;
}
export function parseNamespace(slug: string): { namespace: string; name: string } {
const parts = slug.split("--");
if (parts.length >= 2) {
return { namespace: parts[0], name: parts.slice(1).join("--") };
}
return { namespace: "global", name: slug };
}
function formatInstalls(count: number): string {
if (!count || count <= 0) return "";
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M installs`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}K installs`;
return `${count} install${count === 1 ? "" : "s"}`;
}
async function fetchSkillDetail(client: ApiClient, namespace: string, name: string): Promise<SkillDetail | null> {
try {
const detail = await client.get<SkillDetail>(
`${ApiRoutes.skillDetail.replace("{namespace}", namespace).replace("{slug}", name)}`
);
return detail;
} catch {
return null;
}
}
export async function searchSkills(
client: ApiClient,
query: string,
limit: number = 10
): Promise<SearchSkill[]> {
const result = await client.get<SearchResponse>(
`${ApiRoutes.search}?q=${encodeURIComponent(query)}&limit=${limit}`
);
if (!result.results || result.results.length === 0) {
return [];
}
return result.results.map((s) => {
const { namespace, name } = parseNamespace(s.slug);
return {
name,
slug: s.slug,
namespace,
version: s.version,
summary: s.summary,
installs: (s as any).installCount || 0,
};
}).sort((a, b) => (b.installs || 0) - (a.installs || 0));
}
export async function runInteractiveSearch(
client: ApiClient,
initialQuery: string = ""
): Promise<string | null> {
const MAX_VISIBLE = 8;
let query = initialQuery;
let results: SearchSkill[] = [];
let selectedIndex = 0;
let loading = false;
let lastRenderedLines = 0;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const width = process.stdout.columns || 80;
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdout.write(HIDE_CURSOR);
function render(): void {
if (lastRenderedLines > 0) {
process.stdout.write(MOVE_UP(lastRenderedLines) + MOVE_TO_COL(1));
}
process.stdout.write(CLEAR_DOWN);
const lines: string[] = [];
const cursor = `${BOLD}_${RESET}`;
const searchLine = `${TEXT}Select namespace:${RESET} ${query}${cursor}`;
lines.push(searchLine);
lines.push("");
if (!query || query.length < 2) {
lines.push(`${DIM}Start typing to search (min 2 chars)${RESET}`);
} else if (results.length === 0 && loading) {
lines.push(`${DIM}Searching...${RESET}`);
} else if (results.length === 0) {
lines.push(`${DIM}No skills found${RESET}`);
} else {
const visible = results.slice(0, MAX_VISIBLE);
for (let i = 0; i < visible.length; i++) {
const skill = visible[i]!;
const isSelected = i === selectedIndex;
const arrow = isSelected ? `${BOLD}>${RESET}` : " ";
const name = isSelected ? `${BOLD}${skill.name}${RESET}` : `${TEXT}${skill.name}${RESET}`;
const nsBadge = skill.namespace !== "global" ? ` ${YELLOW}[${skill.namespace}]${RESET}` : "";
const versionBadge = skill.version ? ` ${DIM}v${skill.version}${RESET}` : "";
lines.push(` ${arrow} ${name}${nsBadge}${versionBadge}`);
}
}
lines.push("");
lines.push(`${DIM}up/down navigate | enter select | esc cancel${RESET}`);
for (const line of lines) {
process.stdout.write(line + "\n");
}
lastRenderedLines = lines.length;
}
function triggerSearch(q: string): void {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
loading = false;
if (!q || q.length < 2) {
results = [];
selectedIndex = 0;
render();
return;
}
loading = true;
render();
const debounceMs = Math.max(150, 350 - q.length * 50);
debounceTimer = setTimeout(async () => {
try {
results = await searchSkills(client, q);
selectedIndex = 0;
} catch {
results = [];
} finally {
loading = false;
debounceTimer = null;
render();
}
}, debounceMs);
}
if (initialQuery) {
triggerSearch(initialQuery);
}
render();
return new Promise<string | null>((resolve) => {
function cleanup(): void {
process.stdin.removeListener("keypress", handleKeypress);
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
process.stdout.write(SHOW_CURSOR);
process.stdin.pause();
rl.close();
}
function handleKeypress(_ch: string | undefined, key: readline.Key): void {
if (!key) return;
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
cleanup();
resolve(null);
return;
}
if (key.name === "return") {
cleanup();
resolve(results[selectedIndex] ? `${results[selectedIndex].namespace}/${results[selectedIndex].name}` : null);
return;
}
if (key.name === "up" || key.name === "k") {
selectedIndex = Math.max(0, selectedIndex - 1);
render();
return;
}
if (key.name === "down" || key.name === "j") {
selectedIndex = Math.min(Math.max(0, results.length - 1), selectedIndex + 1);
render();
return;
}
if (key.name === "backspace") {
if (query.length > 0) {
query = query.slice(0, -1);
triggerSearch(query);
}
return;
}
if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) {
const char = key.sequence;
if (char >= " " && char <= "~") {
query += char;
triggerSearch(query);
}
}
}
process.stdin.on("keypress", handleKeypress);
});
}

View file

@ -0,0 +1,86 @@
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
export interface DiscoveredSkill {
name: string;
description: string;
dir: string;
}
const SKILL_DIRS = [
"skills",
".agents/skills",
".claude/skills",
".augment/skills",
".cursor/skills",
".codex/skills",
];
export function discoverSkills(rootDir: string): DiscoveredSkill[] {
const skills: DiscoveredSkill[] = [];
for (const subDir of SKILL_DIRS) {
const fullPath = join(rootDir, subDir);
if (!existsSync(fullPath)) continue;
skills.push(...scanDir(fullPath));
}
if (skills.length === 0) {
skills.push(...scanDir(rootDir));
}
// Also check for SKILL.md directly in rootDir (for registry downloads)
if (skills.length === 0) {
const rootSkillMd = join(rootDir, "SKILL.md");
if (existsSync(rootSkillMd)) {
try {
const content = readFileSync(rootSkillMd, "utf-8");
const name = extractFrontmatterField(content, "name");
const description = extractFrontmatterField(content, "description");
if (name) {
skills.push({ name, description: description || name, dir: rootDir });
}
} catch {
// Skip unreadable files
}
}
}
return skills;
}
function scanDir(dir: string): DiscoveredSkill[] {
const skills: DiscoveredSkill[] = [];
if (!existsSync(dir)) return skills;
try {
for (const entry of readdirSync(dir)) {
const entryPath = join(dir, entry);
const stat = statSync(entryPath);
if (!stat.isDirectory()) continue;
const skillMd = join(entryPath, "SKILL.md");
if (!existsSync(skillMd)) continue;
const content = readFileSync(skillMd, "utf-8");
const name = extractFrontmatterField(content, "name");
const description = extractFrontmatterField(content, "description");
if (name) {
skills.push({ name, description: description || name, dir: entryPath });
}
}
} catch {
// Skip unreadable directories
}
return skills;
}
function extractFrontmatterField(content: string, field: string): string | undefined {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return undefined;
const frontmatter = match[1];
const fieldMatch = frontmatter.match(new RegExp(`^${field}:\\s*(.+)$`, "m"));
return fieldMatch ? fieldMatch[1].trim().replace(/^["']|["']$/g, "") : undefined;
}

View file

@ -0,0 +1,106 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
const LOCK_FILE_VERSION = 1;
const LOCK_DIR = join(homedir(), ".skillhub");
const LOCK_FILE = join(LOCK_DIR, "lock.json");
export interface SkillLockEntry {
source: string;
sourceType: "git" | "registry" | "local";
sourceUrl: string;
ref?: string;
namespace: string;
slug: string;
version: string;
fingerprint?: string;
installedAt: string;
updatedAt: string;
}
export interface SkillLockFile {
version: number;
skills: Record<string, SkillLockEntry>;
lastSelectedAgents?: string[];
}
function createEmptyLock(): SkillLockFile {
return {
version: LOCK_FILE_VERSION,
skills: {},
};
}
export function getSkillLockPath(): string {
return LOCK_FILE;
}
export async function readSkillLock(): Promise<SkillLockFile> {
if (!existsSync(LOCK_FILE)) {
return createEmptyLock();
}
try {
const content = readFileSync(LOCK_FILE, "utf-8");
const lock = JSON.parse(content) as SkillLockFile;
if (typeof lock.version !== "number" || !lock.skills) {
return createEmptyLock();
}
return lock;
} catch {
return createEmptyLock();
}
}
export async function writeSkillLock(lock: SkillLockFile): Promise<void> {
if (!existsSync(LOCK_DIR)) {
mkdirSync(LOCK_DIR, { recursive: true });
}
writeFileSync(LOCK_FILE, JSON.stringify(lock, null, 2));
}
export async function addToLock(
name: string,
entry: Omit<SkillLockEntry, "installedAt" | "updatedAt">
): Promise<void> {
const lock = await readSkillLock();
const now = new Date().toISOString();
const existing = lock.skills[name];
lock.skills[name] = {
...entry,
installedAt: existing?.installedAt ?? now,
updatedAt: now,
};
await writeSkillLock(lock);
}
export async function removeFromLock(name: string): Promise<boolean> {
const lock = await readSkillLock();
if (!(name in lock.skills)) {
return false;
}
delete lock.skills[name];
await writeSkillLock(lock);
return true;
}
export async function getFromLock(name: string): Promise<SkillLockEntry | null> {
const lock = await readSkillLock();
return lock.skills[name] ?? null;
}
export async function getAllLockedSkills(): Promise<Record<string, SkillLockEntry>> {
const lock = await readSkillLock();
return lock.skills;
}
export async function getLastSelectedAgents(): Promise<string[] | undefined> {
const lock = await readSkillLock();
return lock.lastSelectedAgents;
}
export async function saveLastSelectedAgents(agents: string[]): Promise<void> {
const lock = await readSkillLock();
lock.lastSelectedAgents = agents;
await writeSkillLock(lock);
}

View file

@ -0,0 +1,12 @@
export interface ParsedSkillName {
namespace: string;
slug: string;
}
export function parseSkillName(input: string, defaultNamespace = "global"): ParsedSkillName {
const parts = input.split("/");
if (parts.length >= 2) {
return { namespace: parts[0], slug: parts.slice(1).join("/") };
}
return { namespace: defaultNamespace, slug: input };
}

View file

@ -0,0 +1,69 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
const DEFAULT_GITHUB_HOST = "github.com";
function getGitHubHost(): string {
return process.env.GITHUB_MIRROR || DEFAULT_GITHUB_HOST;
}
function isGitHubHost(hostname: string): boolean {
const ghHost = getGitHubHost();
return hostname === ghHost || hostname.endsWith(`.${ghHost}`);
}
export interface ParsedSource {
type: "local" | "github" | "gitlab" | "url";
owner?: string;
repo?: string;
ref?: string;
subpath?: string;
localPath?: string;
cloneUrl?: string;
skillFilter?: string;
}
export function parseSource(input: string): ParsedSource {
if (input.startsWith(".") || input.startsWith("/") || /^[a-zA-Z]:\\/.test(input)) {
const localPath = resolve(process.cwd(), input);
if (!existsSync(localPath)) {
throw new Error(`Local path not found: ${localPath}`);
}
return { type: "local", localPath };
}
if (input.startsWith("http://") || input.startsWith("https://")) {
const url = new URL(input);
if (isGitHubHost(url.hostname)) {
const [, owner, repo, , ref] = url.pathname.split("/");
return { type: "github", owner, repo: repo?.replace(/\.git$/, ""), ref, cloneUrl: input };
}
if (url.hostname.includes("gitlab.com")) {
const [, owner, repo] = url.pathname.split("/");
return { type: "gitlab", owner, repo, cloneUrl: input };
}
return { type: "url", cloneUrl: input };
}
const parts = input.split("/");
if (parts.length === 2) {
const atIndex = parts[1].indexOf("@");
if (atIndex > 0) {
const repo = parts[1].substring(0, atIndex);
const skillFilter = parts[1].substring(atIndex + 1);
return { type: "github", owner: parts[0], repo, skillFilter };
}
return { type: "github", owner: parts[0], repo: parts[1] };
}
throw new Error(`Invalid source format: ${input}. Use owner/repo, local path, URL, or registry namespace/slug.`);
}
export function getCloneUrl(source: ParsedSource): string {
if (source.cloneUrl) return source.cloneUrl;
if (source.type === "github" && source.owner && source.repo) {
const ghHost = getGitHubHost();
return `https://${ghHost}/${source.owner}/${source.repo}.git`;
}
throw new Error("Cannot determine clone URL");
}

View file

@ -0,0 +1,46 @@
export const ApiRoutes = {
whoami: "/api/v1/whoami",
skills: "/api/v1/skills",
search: "/api/v1/search",
meNamespaces: "/api/v1/me/namespaces",
skillDetail: "/api/v1/skills/{namespace}/{slug}",
skillStar: "/api/v1/skills/{namespace}/{slug}/star",
skillVersions: "/api/v1/skills/{namespace}/{slug}/versions",
skillDownload: "/api/v1/skills/{namespace}/{slug}/download",
skillResolve: "/api/v1/skills/{namespace}/{slug}/resolve",
namespaceTransferOwnership: "/api/v1/namespaces/{namespace}/transfer-ownership",
} as const;
export interface PublishResponse {
skillId: string;
namespace: string;
slug: string;
version: string;
status: string;
}
export interface WhoamiResponse {
user: {
handle: string;
displayName: string;
image: string | null;
};
}
export interface NamespaceResponse {
id: number;
slug: string;
displayName: string;
currentUserRole: string;
status: string;
}
export interface SearchResponse {
results: Array<{
slug: string;
displayName: string;
summary: string;
version: string;
namespace?: string; // Namespace where the skill is published
}>;
}

View file

@ -0,0 +1,253 @@
import * as p from "@clack/prompts";
import pc from "picocolors";
import { homedir } from "node:os";
import { sep } from "node:path";
export function riskLabel(risk: string): string {
switch (risk) {
case "critical":
return pc.red(pc.bold("Critical Risk"));
case "high":
return pc.red("High Risk");
case "medium":
return pc.yellow("Med Risk");
case "low":
return pc.green("Low Risk");
case "safe":
return pc.green("Safe");
default:
return pc.dim("--");
}
}
export function socketLabel(audit: { alerts?: number } | undefined): string {
if (!audit) return pc.dim("--");
const count = audit.alerts ?? 0;
return count > 0 ? pc.red(`${count} alert${count !== 1 ? "s" : ""}`) : pc.green("0 alerts");
}
export function padEnd(str: string, width: number): string {
const visible = str.replace(/\x1b\[[0-9;]*m/g, "");
const pad = Math.max(0, width - visible.length);
return str + " ".repeat(pad);
}
export interface AuditSkill {
slug: string;
displayName: string;
}
export interface AuditData {
ath?: { risk: string };
socket?: { alerts?: number };
snyk?: { risk: string };
}
export type AuditResponse = Record<string, AuditData>;
export function buildSecurityLines(
auditData: AuditResponse | null,
skills: AuditSkill[],
_source: string
): string[] {
if (!auditData) return [];
const hasAny = skills.some((s) => {
const data = auditData[s.slug];
return data && Object.keys(data).length > 0;
});
if (!hasAny) return [];
const nameWidth = Math.min(Math.max(...skills.map((s) => s.displayName.length)), 36);
const lines: string[] = [];
const header =
padEnd("", nameWidth + 2) +
padEnd(pc.dim("Gen"), 18) +
padEnd(pc.dim("Socket"), 18) +
pc.dim("Snyk");
lines.push(header);
for (const skill of skills) {
const data = auditData[skill.slug];
const name =
skill.displayName.length > nameWidth
? skill.displayName.slice(0, nameWidth - 1) + "\u2026"
: skill.displayName;
const ath = data?.ath ? riskLabel(data.ath.risk) : pc.dim("--");
const socket = data?.socket ? socketLabel(data.socket) : pc.dim("--");
const snyk = data?.snyk ? riskLabel(data.snyk.risk) : pc.dim("--");
lines.push(padEnd(pc.cyan(name), nameWidth + 2) + padEnd(ath, 18) + padEnd(socket, 18) + snyk);
}
lines.push("");
lines.push(`${pc.dim("Details:")} ${pc.dim(`https://skills.sh/${_source}`)}`);
return lines;
}
export function shortenPath(fullPath: string, cwd: string): string {
const home = homedir();
if (fullPath === home || fullPath.startsWith(home + sep)) {
return "~" + fullPath.slice(home.length);
}
if (fullPath === cwd || fullPath.startsWith(cwd + sep)) {
return "." + fullPath.slice(cwd.length);
}
return fullPath;
}
export function formatList(items: string[], maxShow: number = 5): string {
if (items.length <= maxShow) {
return items.join(", ");
}
const shown = items.slice(0, maxShow);
const remaining = items.length - maxShow;
return `${shown.join(", ")} +${remaining} more`;
}
export interface AgentInfo {
key: string;
name: string;
skillsDir: string;
globalSkillsDir?: string;
}
export function splitAgentsByType(
agentTypes: string[],
agents: Record<string, AgentInfo>
): { universal: string[]; symlinked: string[] } {
const universal: string[] = [];
const symlinked: string[] = [];
for (const a of agentTypes) {
const agent = agents[a];
if (agent) {
if (agent.skillsDir === ".agents/skills") {
universal.push(agent.name);
} else {
symlinked.push(agent.name);
}
}
}
return { universal, symlinked };
}
export function buildAgentSummaryLines(
targetAgents: string[],
installMode: string,
agents: Record<string, AgentInfo>
): string[] {
const lines: string[] = [];
const { universal, symlinked } = splitAgentsByType(targetAgents, agents);
if (installMode === "symlink") {
if (universal.length > 0) {
lines.push(` ${pc.green("universal:")} ${formatList(universal)}`);
}
if (symlinked.length > 0) {
lines.push(` ${pc.dim("symlink →")} ${formatList(symlinked)}`);
}
} else {
const allNames = targetAgents.map((a) => agents[a]?.name || a);
lines.push(` ${pc.dim("copy →")} ${formatList(allNames)}`);
}
return lines;
}
export function ensureUniversalAgents(
targetAgents: string[],
getUniversalAgentsFn: () => string[]
): string[] {
const universalAgents = getUniversalAgentsFn();
const result = [...targetAgents];
for (const ua of universalAgents) {
if (!result.includes(ua)) {
result.push(ua);
}
}
return result;
}
export interface InstallResult {
agent: string;
symlinkFailed?: boolean;
}
export function buildResultLines(
results: InstallResult[],
targetAgents: string[],
agents: Record<string, AgentInfo>
): string[] {
const lines: string[] = [];
const { universal, symlinked } = splitAgentsByType(targetAgents, agents);
const successfulSymlinks = results
.filter((r) => !r.symlinkFailed && !universal.includes(r.agent))
.map((r) => r.agent);
const failedSymlinks = results.filter((r) => r.symlinkFailed).map((r) => r.agent);
if (universal.length > 0) {
lines.push(` ${pc.green("universal:")} ${formatList(universal)}`);
}
if (successfulSymlinks.length > 0) {
lines.push(` ${pc.dim("symlinked:")} ${formatList(successfulSymlinks)}`);
}
if (failedSymlinks.length > 0) {
lines.push(` ${pc.yellow("copied:")} ${formatList(failedSymlinks)}`);
}
return lines;
}
export function isCancelled(value: unknown): value is symbol {
return typeof value === "symbol";
}
export async function interactiveSelect<T>(opts: {
message: string;
options: Array<{ value: T; label: string; hint?: string }>;
}): Promise<T | symbol> {
const selected = await p.select({
message: opts.message,
options: opts.options as p.Option<T>[],
});
return selected as T | symbol;
}
export async function interactiveConfirm(message: string): Promise<boolean | symbol> {
const confirmed = await p.confirm({ message });
return confirmed as boolean | symbol;
}
export async function interactiveMultiSelect<T>(opts: {
message: string;
options: Array<{ value: T; label: string; hint?: string }>;
initialValues?: T[];
required?: boolean;
}): Promise<T[] | symbol> {
return p.multiselect({
message: `${opts.message} ${pc.dim("(space to toggle)")}`,
options: opts.options as p.Option<T>[],
initialValues: opts.initialValues as T[],
required: opts.required,
}) as Promise<T[] | symbol>;
}
export function getCanonicalPath(skillName: string, isGlobal: boolean, agents: Record<string, AgentInfo>): string {
const universalAgents = Object.values(agents).filter((a) => a.skillsDir === ".agents/skills");
if (universalAgents.length > 0) {
return `~/.agents/skills/${skillName}`;
}
if (isGlobal) {
const home = homedir();
return `${home}/.agents/skills/${skillName}`;
}
return `.agents/skills/${skillName}`;
}

View file

@ -0,0 +1,25 @@
import chalk from "chalk";
export function log(msg: string) {
console.log(msg);
}
export function success(msg: string) {
console.log(chalk.green(msg));
}
export function error(msg: string) {
console.error(chalk.red(msg));
}
export function warn(msg: string) {
console.warn(chalk.yellow(msg));
}
export function info(msg: string) {
console.log(chalk.cyan(msg));
}
export function dim(msg: string) {
console.log(chalk.dim(msg));
}

View file

@ -0,0 +1,449 @@
import * as readline from "readline";
import { Writable } from "stream";
const silentOutput = new Writable({
write(_chunk, _encoding, callback) {
callback();
},
});
const S_STEP_ACTIVE = "\x1b[32m◆\x1b[0m";
const S_STEP_CANCEL = "\x1b[31m■\x1b[0m";
const S_STEP_SUBMIT = "\x1b[32m◇\x1b[0m";
const S_RADIO_ACTIVE = "\x1b[32m●\x1b[0m";
const S_RADIO_INACTIVE = "\x1b[2m○\x1b[0m";
const S_BULLET = "\x1b[32m•\x1b[0m";
const S_BAR = "\x1b[2m│\x1b[0m";
const S_BAR_H = "\x1b[2m─\x1b[0m";
const S_ESC = "\x1b[";
const S_BOLD = "\x1b[1m";
const S_DIM = "\x1b[2m";
const S_UNDERLINE = "\x1b[4m";
const S_INVERSE = "\x1b[7m";
const S_RESET = "\x1b[0m";
const S_CYAN = "\x1b[36m";
const S_GREEN = "\x1b[32m";
const S_YELLOW = "\x1b[33m";
const S_RED = "\x1b[31m";
const bold = (s: string) => `${S_BOLD}${s}${S_RESET}`;
const dim = (s: string) => `${S_DIM}${s}${S_RESET}`;
const cyan = (s: string) => `${S_CYAN}${s}${S_RESET}`;
const green = (s: string) => `${S_GREEN}${s}${S_RESET}`;
const yellow = (s: string) => `${S_YELLOW}${s}${S_RESET}`;
const red = (s: string) => `${S_RED}${s}${S_RESET}`;
function moveUp(n: number): string {
return `${S_ESC}${n}A`;
}
function clearLine(): string {
return `${S_ESC}2K`;
}
function clearRender(lastHeight: number): void {
if (lastHeight > 0) {
process.stdout.write(moveUp(lastHeight));
for (let i = 0; i < lastHeight; i++) {
process.stdout.write(clearLine() + (i < lastHeight - 1 ? moveUp(1) + "\x1b[G" : "\n"));
}
process.stdout.write(moveUp(lastHeight));
}
}
export interface SelectItem {
value: string;
label: string;
hint?: string;
}
export interface SelectSection {
title: string;
items: SelectItem[];
locked?: boolean;
}
export async function multiSelect(
message: string,
items: SelectItem[]
): Promise<string[] | null> {
return new Promise((resolve) => {
console.log("");
console.log(message);
console.log(dim("(输入数字选择,逗号分隔,如 1,3,5输入 a 全选n 取消)"));
console.log("");
for (let i = 0; i < items.length; i++) {
const item = items[i];
const num = (i + 1).toString().padStart(2, " ");
console.log(` [${num}] ${item.label}`);
}
console.log("");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question("请选择: ", (answer) => {
rl.close();
console.log("");
const trimmed = answer.trim().toLowerCase();
if (trimmed === "n" || trimmed === "no") {
resolve(null);
return;
}
if (trimmed === "a" || trimmed === "all") {
resolve(items.map((i) => i.value));
return;
}
const selected: string[] = [];
const parts = trimmed.split(",").map((s) => s.trim());
for (const part of parts) {
const num = parseInt(part, 10);
if (!isNaN(num) && num >= 1 && num <= items.length) {
selected.push(items[num - 1].value);
}
}
if (selected.length === 0) {
console.log("未选择任何 skill");
resolve(null);
return;
}
resolve(selected);
});
});
}
export async function sectionMultiSelect(
message: string,
sections: SelectSection[]
): Promise<string[] | null> {
return new Promise((resolve) => {
console.log("");
console.log(message);
console.log(dim("(输入数字选择,逗号分隔,如 1,3,5输入 a 全选n 取消)"));
console.log("");
let selectableIdx = 0;
for (const section of sections) {
if (section.locked) {
console.log(` ${section.title} ${"[always included]"}`);
for (const item of section.items) {
console.log(`${item.label}${item.hint ? ` ${item.hint}` : ""}`);
}
} else {
console.log(` ${section.title}`);
for (const item of section.items) {
selectableIdx++;
console.log(` [${selectableIdx.toString().padStart(2, " ")}] ${item.label}${item.hint ? ` ${item.hint}` : ""}`);
}
}
}
console.log("");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question("请选择: ", (answer) => {
rl.close();
console.log("");
const trimmed = answer.trim().toLowerCase();
if (trimmed === "n" || trimmed === "no") {
resolve(null);
return;
}
const selected: string[] = [];
const parts = trimmed.split(",").map((s) => s.trim());
selectableIdx = 0;
for (const section of sections) {
if (section.locked) {
selected.push(...section.items.map((i) => i.value));
} else {
for (const item of section.items) {
selectableIdx++;
if (parts.includes(selectableIdx.toString())) {
selected.push(item.value);
}
}
}
}
if (selected.length === 0) {
console.log("未选择任何项");
resolve(null);
return;
}
resolve(selected);
});
});
}
export const cancelSymbol = Symbol("cancel");
export interface InteractiveSelectOptions {
message: string;
items: SelectItem[];
initialSelected?: string[];
lockedSection?: SelectSection;
hint?: string;
}
export async function interactiveMultiSelect(
options: InteractiveSelectOptions
): Promise<string[] | typeof cancelSymbol> {
const {
message,
items,
initialSelected = [],
lockedSection,
hint = "↑↓ move, space select, enter confirm",
} = options;
const selectableItems = items;
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: silentOutput,
terminal: false,
});
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
readline.emitKeypressEvents(process.stdin, rl);
let query = "";
let cursor = 0;
const selected = new Set<string>(initialSelected);
let lastRenderHeight = 0;
const lockedValues = lockedSection ? lockedSection.items.map((i) => i.value) : [];
const filter = (item: SelectItem, q: string): boolean => {
if (!q) return true;
const lowerQ = q.toLowerCase();
return (
item.label.toLowerCase().includes(lowerQ) ||
item.value.toLowerCase().includes(lowerQ)
);
};
const getFiltered = (): SelectItem[] => {
return selectableItems.filter((item) => filter(item, query));
};
const render = (state: "active" | "submit" | "cancel" = "active"): void => {
clearRender(lastRenderHeight);
const lines: string[] = [];
const filtered = getFiltered();
const icon =
state === "active" ? S_STEP_ACTIVE : state === "cancel" ? S_STEP_CANCEL : S_STEP_SUBMIT;
lines.push(`${icon} ${bold(message)}`);
if (state === "active") {
if (lockedSection && lockedSection.items.length > 0) {
lines.push(`${S_BAR}`);
const lockedTitle = `${bold(lockedSection.title)} ${dim("── always included")}`;
lines.push(`${S_BAR} ${S_BAR_H}${S_BAR_H} ${lockedTitle} ${S_BAR_H.repeat(12)}`);
for (const item of lockedSection.items) {
lines.push(`${S_BAR} ${S_BULLET} ${bold(item.label)}`);
}
lines.push(`${S_BAR}`);
lines.push(
`${S_BAR} ${S_BAR_H}${S_BAR_H} ${bold("Additional agents")} ${S_BAR_H.repeat(29)}`
);
}
const searchLine = `${S_BAR} ${dim("Search:")} ${query}${S_INVERSE} ${S_RESET}`;
lines.push(searchLine);
lines.push(`${S_BAR} ${dim(hint)}`);
lines.push(`${S_BAR}`);
const maxVisible = 10;
const visibleStart = Math.max(
0,
Math.min(cursor - Math.floor(maxVisible / 2), filtered.length - maxVisible)
);
const visibleEnd = Math.min(filtered.length, visibleStart + maxVisible);
const visibleItems = filtered.slice(visibleStart, visibleEnd);
if (filtered.length === 0) {
lines.push(`${S_BAR} ${dim("No matches found")}`);
} else {
for (let i = 0; i < visibleItems.length; i++) {
const item = visibleItems[i]!;
const actualIndex = visibleStart + i;
const isSelected = selected.has(item.value);
const isCursor = actualIndex === cursor;
const radio = isSelected ? S_RADIO_ACTIVE : S_RADIO_INACTIVE;
const label = isCursor ? `${S_UNDERLINE}${item.label}${S_RESET}` : item.label;
const hintStr = item.hint ? dim(` (${item.hint})`) : "";
const prefix = isCursor ? `${cyan("")}` : " ";
lines.push(`${S_BAR} ${prefix} ${radio} ${label}${hintStr}`);
}
const hiddenBefore = visibleStart;
const hiddenAfter = filtered.length - visibleEnd;
if (hiddenBefore > 0 || hiddenAfter > 0) {
const parts: string[] = [];
if (hiddenBefore > 0) parts.push(`${hiddenBefore} more`);
if (hiddenAfter > 0) parts.push(`${hiddenAfter} more`);
lines.push(`${S_BAR} ${dim(parts.join(" "))}`);
}
}
lines.push(`${S_BAR}`);
const allSelectedLabels = [
...(lockedSection ? lockedSection.items.map((i) => i.label) : []),
...items.filter((item) => selected.has(item.value)).map((item) => item.label),
];
if (allSelectedLabels.length === 0) {
lines.push(`${S_BAR} ${dim("Selected: (none)")}`);
} else {
const summary =
allSelectedLabels.length <= 3
? allSelectedLabels.join(", ")
: `${allSelectedLabels.slice(0, 3).join(", ")} +${allSelectedLabels.length - 3} more`;
lines.push(`${S_BAR} ${green("Selected:")} ${summary}`);
}
lines.push(`${dim("└")}`);
} else if (state === "submit") {
const allSelectedLabels = [
...(lockedSection ? lockedSection.items.map((i) => i.label) : []),
...items.filter((item) => selected.has(item.value)).map((item) => item.label),
];
lines.push(`${S_BAR} ${dim(allSelectedLabels.join(", "))}`);
} else if (state === "cancel") {
lines.push(`${S_BAR} ${red("Cancelled")}`);
}
process.stdout.write(lines.join("\n") + "\n");
lastRenderHeight = lines.length;
};
const cleanup = (): void => {
process.stdin.removeListener("keypress", keypressHandler);
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
rl.close();
};
const submit = (): void => {
render("submit");
cleanup();
process.stdout.write("\n");
resolve([...lockedValues, ...Array.from(selected)]);
};
const cancel = (): void => {
render("cancel");
cleanup();
process.stdout.write("\n");
resolve(cancelSymbol);
};
const keypressHandler = (_str: string, key: readline.Key): void => {
if (!key) return;
const filtered = getFiltered();
if (key.name === "return") {
submit();
return;
}
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
cancel();
return;
}
if (key.name === "up") {
cursor = Math.max(0, cursor - 1);
render();
return;
}
if (key.name === "down") {
cursor = Math.min(filtered.length - 1, cursor + 1);
render();
return;
}
if (key.name === "space") {
const item = filtered[cursor];
if (item) {
if (selected.has(item.value)) {
selected.delete(item.value);
} else {
selected.add(item.value);
}
}
render();
return;
}
if (key.name === "backspace") {
query = query.slice(0, -1);
cursor = 0;
render();
return;
}
if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) {
query += key.sequence;
cursor = 0;
render();
return;
}
};
process.stdin.on("keypress", keypressHandler);
render();
});
}
export async function interactiveSelect(
message: string,
items: SelectItem[]
): Promise<string | typeof cancelSymbol> {
const options: InteractiveSelectOptions = {
message,
items,
};
const result = await interactiveMultiSelect(options);
if (result === cancelSymbol) {
return cancelSymbol;
}
if (result.length === 0) {
return cancelSymbol;
}
return result[0];
}

View file

@ -0,0 +1,297 @@
import * as readline from 'readline';
import { Writable } from 'stream';
import pc from 'picocolors';
// Silent writable stream to prevent readline from echoing input
const silentOutput = new Writable({
write(_chunk, _encoding, callback) {
callback();
},
});
export interface SearchItem<T> {
value: T;
label: string;
hint?: string;
}
export interface LockedSection<T> {
title: string;
items: SearchItem<T>[];
}
export interface SearchMultiselectOptions<T> {
message: string;
items: SearchItem<T>[];
maxVisible?: number;
initialSelected?: T[];
/** If true, require at least one item to be selected before submitting */
required?: boolean;
/** Locked section shown above the searchable list - items are always selected and can't be toggled */
lockedSection?: LockedSection<T>;
}
const S_STEP_ACTIVE = pc.green('◆');
const S_STEP_CANCEL = pc.red('■');
const S_STEP_SUBMIT = pc.green('◇');
const S_RADIO_ACTIVE = pc.green('●');
const S_RADIO_INACTIVE = pc.dim('○');
const S_CHECKBOX_LOCKED = pc.green('✓');
const S_BULLET = pc.green('•');
const S_BAR = pc.dim('│');
const S_BAR_H = pc.dim('─');
export const cancelSymbol = Symbol('cancel');
/**
* Interactive search multiselect prompt.
* Allows users to filter a long list by typing and select multiple items.
* Optionally supports a "locked" section that displays always-selected items.
*/
export async function searchMultiselect<T>(
options: SearchMultiselectOptions<T>
): Promise<T[] | symbol> {
const {
message,
items,
maxVisible = 8,
initialSelected = [],
required = false,
lockedSection,
} = options;
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: silentOutput,
terminal: false,
});
// Enable raw mode for keypress detection
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
readline.emitKeypressEvents(process.stdin, rl);
let query = '';
let cursor = 0;
const selected = new Set<T>(initialSelected);
let lastRenderHeight = 0;
// Locked items are always included in the result
const lockedValues = lockedSection ? lockedSection.items.map((i) => i.value) : [];
const filter = (item: SearchItem<T>, q: string): boolean => {
if (!q) return true;
const lowerQ = q.toLowerCase();
return (
item.label.toLowerCase().includes(lowerQ) ||
String(item.value).toLowerCase().includes(lowerQ)
);
};
const getFiltered = (): SearchItem<T>[] => {
return items.filter((item) => filter(item, query));
};
const clearRender = (): void => {
if (lastRenderHeight > 0) {
// Move up and clear each line
process.stdout.write(`\x1b[${lastRenderHeight}A`);
for (let i = 0; i < lastRenderHeight; i++) {
process.stdout.write('\x1b[2K\x1b[1B');
}
process.stdout.write(`\x1b[${lastRenderHeight}A`);
}
};
const render = (state: 'active' | 'submit' | 'cancel' = 'active'): void => {
clearRender();
const lines: string[] = [];
const filtered = getFiltered();
// Header
const icon =
state === 'active' ? S_STEP_ACTIVE : state === 'cancel' ? S_STEP_CANCEL : S_STEP_SUBMIT;
lines.push(`${icon} ${pc.bold(message)}`);
if (state === 'active') {
// Locked section (universal agents)
if (lockedSection && lockedSection.items.length > 0) {
lines.push(`${S_BAR}`);
const lockedTitle = `${pc.bold(lockedSection.title)} ${pc.dim('── always included')}`;
lines.push(`${S_BAR} ${S_BAR_H}${S_BAR_H} ${lockedTitle} ${S_BAR_H.repeat(12)}`);
for (const item of lockedSection.items) {
lines.push(`${S_BAR} ${S_BULLET} ${pc.bold(item.label)}`);
}
lines.push(`${S_BAR}`);
lines.push(
`${S_BAR} ${S_BAR_H}${S_BAR_H} ${pc.bold('Additional agents')} ${S_BAR_H.repeat(29)}`
);
}
// Search input
const searchLine = `${S_BAR} ${pc.dim('Search:')} ${query}${pc.inverse(' ')}`;
lines.push(searchLine);
// Hint
lines.push(`${S_BAR} ${pc.dim('↑↓ move, space select, enter confirm')}`);
lines.push(`${S_BAR}`);
// Items
const visibleStart = Math.max(
0,
Math.min(cursor - Math.floor(maxVisible / 2), filtered.length - maxVisible)
);
const visibleEnd = Math.min(filtered.length, visibleStart + maxVisible);
const visibleItems = filtered.slice(visibleStart, visibleEnd);
if (filtered.length === 0) {
lines.push(`${S_BAR} ${pc.dim('No matches found')}`);
} else {
for (let i = 0; i < visibleItems.length; i++) {
const item = visibleItems[i]!;
const actualIndex = visibleStart + i;
const isSelected = selected.has(item.value);
const isCursor = actualIndex === cursor;
const radio = isSelected ? S_RADIO_ACTIVE : S_RADIO_INACTIVE;
const label = isCursor ? pc.underline(item.label) : item.label;
const hint = item.hint ? pc.dim(` (${item.hint})`) : '';
const prefix = isCursor ? pc.cyan('') : ' ';
lines.push(`${S_BAR} ${prefix} ${radio} ${label}${hint}`);
}
// Show count if more items
const hiddenBefore = visibleStart;
const hiddenAfter = filtered.length - visibleEnd;
if (hiddenBefore > 0 || hiddenAfter > 0) {
const parts: string[] = [];
if (hiddenBefore > 0) parts.push(`${hiddenBefore} more`);
if (hiddenAfter > 0) parts.push(`${hiddenAfter} more`);
lines.push(`${S_BAR} ${pc.dim(parts.join(' '))}`);
}
}
// Selected summary (include locked items)
lines.push(`${S_BAR}`);
const allSelectedLabels = [
...(lockedSection ? lockedSection.items.map((i) => i.label) : []),
...items.filter((item) => selected.has(item.value)).map((item) => item.label),
];
if (allSelectedLabels.length === 0) {
lines.push(`${S_BAR} ${pc.dim('Selected: (none)')}`);
} else {
const summary =
allSelectedLabels.length <= 3
? allSelectedLabels.join(', ')
: `${allSelectedLabels.slice(0, 3).join(', ')} +${allSelectedLabels.length - 3} more`;
lines.push(`${S_BAR} ${pc.green('Selected:')} ${summary}`);
}
lines.push(`${pc.dim('└')}`);
} else if (state === 'submit') {
// Final state - show what was selected (including locked)
const allSelectedLabels = [
...(lockedSection ? lockedSection.items.map((i) => i.label) : []),
...items.filter((item) => selected.has(item.value)).map((item) => item.label),
];
lines.push(`${S_BAR} ${pc.dim(allSelectedLabels.join(', '))}`);
} else if (state === 'cancel') {
lines.push(`${S_BAR} ${pc.strikethrough(pc.dim('Cancelled'))}`);
}
process.stdout.write(lines.join('\n') + '\n');
lastRenderHeight = lines.length;
};
const cleanup = (): void => {
process.stdin.removeListener('keypress', keypressHandler);
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
rl.close();
};
const submit = (): void => {
// If required and no locked items, don't allow submitting with no selection
if (required && selected.size === 0 && lockedValues.length === 0) {
return;
}
render('submit');
cleanup();
// Include locked values in the result
resolve([...lockedValues, ...Array.from(selected)]);
};
const cancel = (): void => {
render('cancel');
cleanup();
resolve(cancelSymbol);
};
// Handle keypresses
const keypressHandler = (_str: string, key: readline.Key): void => {
if (!key) return;
const filtered = getFiltered();
if (key.name === 'return') {
submit();
return;
}
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
cancel();
return;
}
if (key.name === 'up') {
cursor = Math.max(0, cursor - 1);
render();
return;
}
if (key.name === 'down') {
cursor = Math.min(filtered.length - 1, cursor + 1);
render();
return;
}
if (key.name === 'space') {
const item = filtered[cursor];
if (item) {
if (selected.has(item.value)) {
selected.delete(item.value);
} else {
selected.add(item.value);
}
}
render();
return;
}
if (key.name === 'backspace') {
query = query.slice(0, -1);
cursor = 0;
render();
return;
}
// Regular character input
if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) {
query += key.sequence;
cursor = 0;
render();
return;
}
};
process.stdin.on('keypress', keypressHandler);
// Initial render
render();
});
}

View file

@ -0,0 +1,99 @@
/**
* Telemetry utility for anonymous usage tracking.
*
* Respects the following environment variables:
* - DISABLE_TELEMETRY=1 (or 'true')
* - DO_NOT_TRACK=1
*
* Telemetry is automatically disabled in CI environments.
*/
export interface TelemetryEvent {
command: string;
args?: string[];
options?: Record<string, unknown>;
}
export interface TelemetryConfig {
enabled: boolean;
reason?: string;
}
/**
* Check if telemetry should be disabled.
* Respects user preferences and CI environments.
*/
export function isTelemetryDisabled(): TelemetryConfig {
// Check CI environment
if (process.env.CI === 'true' || process.env.CONTINUOUS_INTEGRATION === 'true') {
return { enabled: false, reason: 'CI environment' };
}
// Check explicit opt-out flags
const disableTelemetry = process.env.DISABLE_TELEMETRY;
const doNotTrack = process.env.DO_NOT_TRACK;
if (disableTelemetry === '1' || disableTelemetry === 'true') {
return { enabled: false, reason: 'DISABLE_TELEMETRY is set' };
}
if (doNotTrack === '1' || doNotTrack === 'true') {
return { enabled: false, reason: 'DO_NOT_TRACK is set' };
}
// Check Node.js built-in doNotTrack
if (process.env.NODE_OPTIONS?.includes('do-not-track')) {
return { enabled: false, reason: 'NODE_OPTIONS includes do-not-track' };
}
return { enabled: true };
}
/**
* Track a command execution event.
* Currently a stub - actual tracking implementation would send to a telemetry endpoint.
*/
export function trackEvent(event: TelemetryEvent): void {
const { enabled, reason } = isTelemetryDisabled();
if (!enabled) {
// Silently skip tracking
return;
}
// TODO: Implement actual telemetry tracking
// For now, this is a stub that could be expanded to:
// - Send events to a configured endpoint
// - Batch events and send periodically
// - Store events locally if offline
//
// Example implementation:
// if (process.env.SKILLHUB_TELEMETRY_URL) {
// fetch(process.env.SKILLHUB_TELEMETRY_URL, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ event, timestamp: Date.now() })
// });
// }
}
/**
* Track a command execution.
* Call this at the start of each command.
*/
export function trackCommand(command: string, args?: string[], options?: Record<string, unknown>): void {
trackEvent({ command, args, options });
}
/**
* Get telemetry status for display (e.g., in --help or version output).
*/
export function getTelemetryStatus(): string {
const { enabled, reason } = isTelemetryDisabled();
if (!enabled) {
return `Telemetry: Disabled (${reason})`;
}
return 'Telemetry: Enabled (anonymous usage collection)';
}

View file

@ -0,0 +1,179 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("undici", () => ({
request: vi.fn(),
FormData: class FormData {
private _data = new Map<string, string>();
set(k: string, v: string) { this._data.set(k, v); }
append(k: string, v: unknown) { this._data.set(k, String(v)); }
},
}));
import { ApiClient, ApiError } from "../src/core/api-client.js";
import { request } from "undici";
const mockRequest = request as ReturnType<typeof vi.fn>;
function mockResponse(statusCode: number, body: unknown) {
mockRequest.mockResolvedValueOnce({
statusCode,
body: { json: async () => body },
});
}
describe("ApiClient", () => {
let client: ApiClient;
beforeEach(() => {
vi.clearAllMocks();
client = new ApiClient({ baseUrl: "http://localhost:8080" });
});
describe("ApiResponse unwrapping", () => {
it("unwraps Native API success response", async () => {
mockResponse(200, {
code: 0,
msg: "success",
data: { id: 1, name: "test" },
timestamp: "2026-01-01T00:00:00Z",
});
const result = await client.get<{ id: number; name: string }>("/api/v1/test");
expect(result).toEqual({ id: 1, name: "test" });
expect(mockRequest).toHaveBeenCalledTimes(1);
});
it("throws ApiError on Native API error response", async () => {
mockResponse(200, {
code: 403,
msg: "Forbidden",
data: null,
timestamp: "2026-01-01T00:00:00Z",
});
await expect(client.get("/api/v1/test")).rejects.toThrow(ApiError);
});
it("returns raw response for Compat layer (no code/data)", async () => {
mockResponse(200, {
user: { handle: "test-user", displayName: "Test", image: null },
});
const result = await client.get<{ user: { handle: string } }>("/api/v1/whoami");
expect(result).toEqual({
user: { handle: "test-user", displayName: "Test", image: null },
});
});
it("returns raw response for Compat search results", async () => {
mockResponse(200, {
results: [
{ slug: "test-skill", displayName: "Test", summary: "A test", version: "1.0.0" },
],
});
const result = await client.get<{ results: Array<{ slug: string }> }>("/api/v1/search");
expect(result.results).toHaveLength(1);
expect(result.results[0].slug).toBe("test-skill");
});
it("returns raw response for Compat publish result", async () => {
mockResponse(200, { ok: true, skillId: "1", versionId: "1" });
const result = await client.postForm<{ ok: boolean; skillId: string }>("/api/v1/skills", {} as any);
expect(result.ok).toBe(true);
expect(result.skillId).toBe("1");
});
});
describe("HTTP error handling", () => {
it("throws ApiError on HTTP 404", async () => {
mockResponse(404, { code: 404, msg: "Not found", data: null });
await expect(client.get("/api/v1/nonexistent")).rejects.toThrow(ApiError);
});
it("throws ApiError on HTTP 500", async () => {
mockResponse(500, { code: 500, msg: "Internal error", data: null });
await expect(client.get("/api/v1/test")).rejects.toThrow(ApiError);
});
});
describe("Authorization header", () => {
it("includes Bearer token when provided", async () => {
mockResponse(200, { code: 0, data: {}, msg: "ok" });
const clientWithToken = new ApiClient({
baseUrl: "http://localhost:8080",
token: "sk_test123",
});
await clientWithToken.get("/api/v1/test");
expect(mockRequest).toHaveBeenCalledWith(
"http://localhost:8080/api/v1/test",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer sk_test123",
}),
})
);
});
it("omits Authorization header when no token", async () => {
mockResponse(200, { results: [] });
await client.get("/api/v1/search");
const callArgs = mockRequest.mock.calls[0][1];
expect(callArgs.headers).not.toHaveProperty("Authorization");
});
});
describe("POST method", () => {
it("unwraps Native API POST response", async () => {
mockResponse(200, {
code: 0,
data: { id: 1 },
msg: "created",
});
const result = await client.post<{ id: number }>("/api/v1/test", { body: "{}" });
expect(result).toEqual({ id: 1 });
});
it("returns raw Compat POST response", async () => {
mockResponse(200, { ok: true, skillId: "2" });
const result = await client.post<{ ok: boolean }>("/api/v1/skills", { body: "{}" });
expect(result.ok).toBe(true);
});
});
describe("PUT method", () => {
it("unwraps Native API PUT response", async () => {
mockResponse(200, { code: 0, data: { updated: true }, msg: "ok" });
const result = await client.put<{ updated: boolean }>("/api/v1/test", { body: "{}" });
expect(result.updated).toBe(true);
});
});
describe("DELETE method", () => {
it("unwraps Native API DELETE response", async () => {
mockResponse(200, { code: 0, data: { deleted: true }, msg: "ok" });
const result = await client.delete<{ deleted: boolean }>("/api/v1/test");
expect(result.deleted).toBe(true);
});
});
});

View file

@ -0,0 +1,191 @@
import { describe, it, expect } from "vitest";
import { Command } from "commander";
import { registerInspect } from "../src/commands/inspect.js";
import { registerWhoami } from "../src/commands/whoami.js";
import { registerLogin } from "../src/commands/login.js";
import { registerPublish } from "../src/commands/publish.js";
import { registerMe } from "../src/commands/me.js";
import { registerVersions } from "../src/commands/versions.js";
import { registerNotifications } from "../src/commands/notifications.js";
import { registerReviews } from "../src/commands/reviews.js";
import { registerNamespaces } from "../src/commands/namespaces.js";
import { registerResolve } from "../src/commands/resolve.js";
import { registerRating, registerRate } from "../src/commands/rating.js";
import { registerStar } from "../src/commands/star.js";
import { registerDelete } from "../src/commands/delete.js";
import { registerArchive } from "../src/commands/archive.js";
import { registerReport } from "../src/commands/report.js";
import { registerSearch } from "../src/commands/search.js";
import { registerInstall } from "../src/commands/install.js";
import { registerDownload } from "../src/commands/download.js";
import { registerInit } from "../src/commands/init.js";
import { registerList } from "../src/commands/list.js";
import { registerLogout } from "../src/commands/logout.js";
import { registerUninstall } from "../src/commands/uninstall.js";
import { registerSync } from "../src/commands/sync.js";
describe("Command registrations", () => {
function getCommandNames(program: Command): string[] {
return program.commands.map((c) => c.name());
}
it("registers inspect command", () => {
const program = new Command();
registerInspect(program);
const names = getCommandNames(program);
expect(names).toContain("inspect");
});
it("registers whoami command", () => {
const program = new Command();
registerWhoami(program);
expect(getCommandNames(program)).toContain("whoami");
});
it("registers login command", () => {
const program = new Command();
registerLogin(program);
expect(getCommandNames(program)).toContain("login");
});
it("registers publish command with correct options", () => {
const program = new Command();
registerPublish(program);
const cmd = program.commands.find((c) => c.name() === "publish");
expect(cmd).toBeDefined();
const opts = cmd!.options.map((o) => o.flags);
expect(opts).toContain("--namespace <ns>");
expect(opts).toContain("--slug <slug>");
expect(opts).toContain("-v, --skill-version <ver>");
expect(opts).not.toContain("--version <ver>");
});
it("registers me command with skills and stars subcommands", () => {
const program = new Command();
registerMe(program);
const cmd = program.commands.find((c) => c.name() === "me");
expect(cmd).toBeDefined();
const subNames = cmd!.commands.map((c) => c.name());
expect(subNames).toContain("skills");
expect(subNames).toContain("stars");
});
it("registers versions command", () => {
const program = new Command();
registerVersions(program);
expect(getCommandNames(program)).toContain("versions");
});
it("registers notifications command with subcommands", () => {
const program = new Command();
registerNotifications(program);
const cmd = program.commands.find((c) => c.name() === "notifications");
expect(cmd).toBeDefined();
const subNames = cmd!.commands.map((c) => c.name());
expect(subNames).toContain("list");
expect(subNames).toContain("read");
expect(subNames).toContain("read-all");
});
it("registers reviews command with subcommands", () => {
const program = new Command();
registerReviews(program);
const cmd = program.commands.find((c) => c.name() === "reviews");
expect(cmd).toBeDefined();
const subNames = cmd!.commands.map((c) => c.name());
expect(subNames).toContain("my");
});
it("registers namespaces command", () => {
const program = new Command();
registerNamespaces(program);
expect(getCommandNames(program)).toContain("namespaces");
});
it("registers resolve command", () => {
const program = new Command();
registerResolve(program);
expect(getCommandNames(program)).toContain("resolve");
});
it("registers rating and rate commands", () => {
const program = new Command();
registerRating(program);
registerRate(program);
const names = getCommandNames(program);
expect(names).toContain("rating");
expect(names).toContain("rate");
});
it("registers star command", () => {
const program = new Command();
registerStar(program);
expect(getCommandNames(program)).toContain("star");
});
it("registers delete command", () => {
const program = new Command();
registerDelete(program);
expect(getCommandNames(program)).toContain("delete");
});
it("registers archive command", () => {
const program = new Command();
registerArchive(program);
expect(getCommandNames(program)).toContain("archive");
});
it("registers report command", () => {
const program = new Command();
registerReport(program);
expect(getCommandNames(program)).toContain("report");
});
it("registers search command", () => {
const program = new Command();
registerSearch(program);
expect(getCommandNames(program)).toContain("search");
});
it("registers install command", () => {
const program = new Command();
registerInstall(program);
expect(getCommandNames(program)).toContain("install");
});
it("registers download command", () => {
const program = new Command();
registerDownload(program);
expect(getCommandNames(program)).toContain("download");
});
it("registers init command", () => {
const program = new Command();
registerInit(program);
expect(getCommandNames(program)).toContain("init");
});
it("registers list command", () => {
const program = new Command();
registerList(program);
expect(getCommandNames(program)).toContain("list");
});
it("registers uninstall command", () => {
const program = new Command();
registerUninstall(program);
expect(getCommandNames(program)).toContain("uninstall");
});
it("registers logout command", () => {
const program = new Command();
registerLogout(program);
expect(getCommandNames(program)).toContain("logout");
});
it("registers sync command", () => {
const program = new Command();
registerSync(program);
expect(getCommandNames(program)).toContain("sync");
});
});

View file

@ -0,0 +1,224 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const mockSuccess = vi.fn();
const mockError = vi.fn();
const mockInfo = vi.fn();
vi.mock("../src/utils/logger.js", () => ({
success: mockSuccess,
error: mockError,
info: mockInfo,
dim: vi.fn(),
}));
describe("install command", () => {
let tempDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), "install-test-" + Date.now());
mkdirSync(tempDir, { recursive: true });
mockSuccess.mockClear();
mockError.mockClear();
mockInfo.mockClear();
});
afterEach(() => {
try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
vi.restoreAllMocks();
});
describe("source auto-detection", () => {
it("should detect git source: owner/repo format", () => {
const source = "vercel-labs/agent-skills";
const isGitSource = /^[\w-]+\/[\w-]+$/.test(source);
expect(isGitSource).toBe(true);
});
it("should detect git source: GitHub URL", () => {
const source = "https://github.com/vercel-labs/agent-skills";
expect(source.includes("github.com")).toBe(true);
});
it("should detect git source: GitLab URL", () => {
const source = "https://gitlab.com/vercel-labs/agent-skills";
expect(source.includes("gitlab.com")).toBe(true);
});
it("should detect local source: relative path", () => {
const source = "./my-skill";
const isLocal = source.startsWith(".") || source.startsWith("/") || source.startsWith("~");
expect(isLocal).toBe(true);
});
it("should detect local source: absolute path", () => {
const source = "/Users/me/skills/my-skill";
expect(source.startsWith("/")).toBe(true);
});
it("should detect registry source: plain slug", () => {
const source = "my-skill";
const isGit = /^[\w-]+\/[\w-]+$/.test(source) || source.includes("github.com") || source.includes("gitlab.com");
const isLocal = source.startsWith(".") || source.startsWith("/") || source.startsWith("~");
expect(isGit || isLocal).toBe(false);
});
it("should detect registry source: namespace--slug format", () => {
const source = "global--my-skill";
const isScoped = source.includes("--");
expect(isScoped).toBe(true);
});
});
describe("--list option", () => {
it("should list skills without installing", () => {
const skills = [
{ name: "skill-one", description: "First skill" },
{ name: "skill-two", description: "Second skill" },
];
expect(skills.length).toBe(2);
expect(skills[0].name).toBe("skill-one");
});
});
describe("registry install", () => {
it("should construct correct download URL", () => {
const ns = "global";
const slug = "my-skill";
const downloadUrl = `/api/v1/skills/${ns}/${slug}/download`;
expect(downloadUrl).toBe("/api/v1/skills/global/my-skill/download");
});
it("should use default namespace when not specified", () => {
const defaultNs = "global";
expect(defaultNs).toBe("global");
});
});
describe("git install", () => {
it("should parse owner/repo correctly", () => {
const input = "vercel-labs/skills";
const parts = input.split("/");
expect(parts[0]).toBe("vercel-labs");
expect(parts[1]).toBe("skills");
});
it("should construct GitHub clone URL", () => {
const owner = "vercel-labs";
const repo = "skills";
const cloneUrl = `https://github.com/${owner}/${repo}.git`;
expect(cloneUrl).toBe("https://github.com/vercel-labs/skills.git");
});
});
describe("agent selection", () => {
it("should detect installed agents", () => {
const allAgents = [
{ key: "claude-code", name: "Claude Code" },
{ key: "cursor", name: "Cursor" },
];
expect(allAgents.length).toBe(2);
});
it("should filter by specified agent keys", () => {
const allAgents = [
{ key: "claude-code", name: "Claude Code" },
{ key: "cursor", name: "Cursor" },
];
const selected = allAgents.filter((a) => ["claude-code"].includes(a.key));
expect(selected.length).toBe(1);
expect(selected[0].key).toBe("claude-code");
});
});
describe("install modes", () => {
it("should support symlink mode (default)", () => {
const mode = "symlink";
expect(mode).toBe("symlink");
});
it("should support copy mode with --copy flag", () => {
const useCopy = true;
const mode = useCopy ? "copy" : "symlink";
expect(mode).toBe("copy");
});
it("should support global scope with --global flag", () => {
const isGlobal = true;
expect(isGlobal).toBe(true);
});
it("should support project scope (default)", () => {
const isGlobal = false;
expect(isGlobal).toBe(false);
});
});
});
describe("--skill option", () => {
it("should select all skills when '*' is specified", () => {
const allSkills = [
{ name: "skill-one", description: "First" },
{ name: "skill-two", description: "Second" },
{ name: "skill-three", description: "Third" },
];
const skillNames = ["*"] as string[];
let selectedSkills;
if (skillNames.includes("*")) {
selectedSkills = allSkills;
}
expect(selectedSkills).toEqual(allSkills);
expect(selectedSkills.length).toBe(3);
});
it("should filter skills by exact name match", () => {
const allSkills = [
{ name: "skill-one", description: "First" },
{ name: "skill-two", description: "Second" },
{ name: "skill-three", description: "Third" },
];
const skillNames = ["skill-one", "skill-three"] as string[];
const selectedSkills = allSkills.filter((s) => skillNames.includes(s.name));
expect(selectedSkills.length).toBe(2);
expect(selectedSkills[0].name).toBe("skill-one");
expect(selectedSkills[1].name).toBe("skill-three");
});
it("should return empty array when no skills match", () => {
const allSkills = [
{ name: "skill-one", description: "First" },
{ name: "skill-two", description: "Second" },
];
const skillNames = ["non-existent"] as string[];
const selectedSkills = allSkills.filter((s) => skillNames.includes(s.name));
expect(selectedSkills.length).toBe(0);
});
it("should handle case-sensitive name matching", () => {
const allSkills = [
{ name: "OpenSpec", description: "OpenSpec skill" },
{ name: "openspec", description: "lowercase" },
];
const skillNames = ["openspec"] as string[];
const selectedSkills = allSkills.filter((s) => skillNames.includes(s.name));
expect(selectedSkills.length).toBe(1);
expect(selectedSkills[0].name).toBe("openspec");
});
});
describe("add command alias", () => {
it("should be equivalent to install --source git", () => {
const installSource = "git";
expect(installSource).toBe("git");
});
});

View file

@ -0,0 +1,35 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { installSkill } from "../src/core/installer.js";
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
describe("installSkill", () => {
let tempDir: string;
let skillDir: string;
let targetDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), "installer-test-" + Date.now());
skillDir = join(tempDir, "source-skill");
targetDir = tempDir;
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), "# Test Skill\n");
});
afterEach(() => {
try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
});
it("should return correct mode when mode is 'symlink'", () => {
const result = installSkill(skillDir, "test-skill", "claude-code", targetDir, "symlink", false);
expect(result.mode).toBe("symlink");
expect(result.success).toBe(true);
});
it("should return correct mode when mode is 'copy'", () => {
const result = installSkill(skillDir, "test-skill", "claude-code", targetDir, "copy", false);
expect(result.mode).toBe("copy");
expect(result.success).toBe(true);
});
});

View file

@ -0,0 +1,45 @@
import { describe, it, expect, vi } from "vitest";
describe("multiSelect parsing", () => {
it("parses comma-separated numbers", () => {
const input = "1,3,5";
const parts = input.split(",").map((s) => s.trim());
const indices = parts.map((p) => parseInt(p, 10) - 1);
const items = [
{ value: "a", label: "A" },
{ value: "b", label: "B" },
{ value: "c", label: "C" },
{ value: "d", label: "D" },
{ value: "e", label: "E" },
];
const selected = indices.filter((i) => i >= 0 && i < items.length).map((i) => items[i].value);
expect(selected).toEqual(["a", "c", "e"]);
});
it("parses 'a' for all", () => {
const trimmed = "a";
const items = [{ value: "a", label: "A" }, { value: "b", label: "B" }];
if (trimmed === "a" || trimmed === "all") {
const selected = items.map((i) => i.value);
expect(selected).toEqual(["a", "b"]);
}
});
it("parses 'n' for none", () => {
const trimmed = "n";
let result: string[] | null = null;
if (trimmed === "n" || trimmed === "no") {
result = null;
}
expect(result).toBe(null);
});
it("ignores out-of-range numbers", () => {
const input = "1,10,2";
const parts = input.split(",").map((s) => s.trim());
const items = [{ value: "a", label: "A" }, { value: "b", label: "B" }];
const indices = parts.map((p) => parseInt(p, 10) - 1);
const selected = indices.filter((i) => i >= 0 && i < items.length).map((i) => items[i].value);
expect(selected).toEqual(["a", "b"]);
});
});

View file

@ -0,0 +1,223 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const LOCK_FILE_VERSION = 1;
interface SkillLockEntry {
source: string;
sourceType: "git" | "registry" | "local";
sourceUrl: string;
ref?: string;
namespace: string;
slug: string;
version: string;
fingerprint?: string;
installedAt: string;
updatedAt: string;
}
interface SkillLockFile {
version: number;
skills: Record<string, SkillLockEntry>;
lastSelectedAgents?: string[];
}
function getSkillLockPath(dir: string): string {
return join(dir, "lock.json");
}
async function readSkillLock(dir: string): Promise<SkillLockFile> {
const lockPath = getSkillLockPath(dir);
if (!existsSync(lockPath)) {
return { version: LOCK_FILE_VERSION, skills: {} };
}
const content = readFileSync(lockPath, "utf-8");
return JSON.parse(content);
}
async function writeSkillLock(dir: string, lock: SkillLockFile): Promise<void> {
const lockPath = getSkillLockPath(dir);
mkdirSync(dir, { recursive: true });
writeFileSync(lockPath, JSON.stringify(lock, null, 2));
}
async function addToLock(dir: string, name: string, entry: SkillLockEntry): Promise<void> {
const lock = await readSkillLock(dir);
const now = new Date().toISOString();
const existing = lock.skills[name];
lock.skills[name] = {
...entry,
installedAt: existing?.installedAt ?? entry.installedAt ?? now,
updatedAt: now,
};
await writeSkillLock(dir, lock);
}
async function removeFromLock(dir: string, name: string): Promise<boolean> {
const lock = await readSkillLock(dir);
if (!(name in lock.skills)) return false;
delete lock.skills[name];
await writeSkillLock(dir, lock);
return true;
}
async function getFromLock(dir: string, name: string): Promise<SkillLockEntry | null> {
const lock = await readSkillLock(dir);
return lock.skills[name] ?? null;
}
describe("skill-lock", () => {
let tempDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), "skill-lock-test-" + Date.now());
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
});
describe("read/write", () => {
it("should read empty lock file", async () => {
const lock = await readSkillLock(tempDir);
expect(lock.version).toBe(LOCK_FILE_VERSION);
expect(lock.skills).toEqual({});
});
it("should write and read lock file", async () => {
const entry: SkillLockEntry = {
source: "owner/repo",
sourceType: "git",
sourceUrl: "https://github.com/owner/repo",
namespace: "global",
slug: "my-skill",
version: "1.0.0",
installedAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
};
await addToLock(tempDir, "my-skill", entry);
const lock = await readSkillLock(tempDir);
expect(Object.keys(lock.skills)).toContain("my-skill");
expect(lock.skills["my-skill"].source).toBe("owner/repo");
});
});
describe("addToLock", () => {
it("should add new entry", async () => {
const entry: SkillLockEntry = {
source: "owner/repo",
sourceType: "git",
sourceUrl: "https://github.com/owner/repo",
namespace: "global",
slug: "new-skill",
version: "1.0.0",
installedAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
};
await addToLock(tempDir, "new-skill", entry);
const result = await getFromLock(tempDir, "new-skill");
expect(result).not.toBeNull();
expect(result!.slug).toBe("new-skill");
});
it("should preserve installedAt on update", async () => {
const entry1: SkillLockEntry = {
source: "owner/repo",
sourceType: "git",
sourceUrl: "https://github.com/owner/repo",
namespace: "global",
slug: "skill",
version: "1.0.0",
installedAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
};
await addToLock(tempDir, "skill", entry1);
const savedFirst = await getFromLock(tempDir, "skill");
expect(savedFirst!.installedAt).toBe("2024-01-01T00:00:00Z");
const entry2: SkillLockEntry = {
...entry1,
version: "1.1.0",
};
await addToLock(tempDir, "skill", entry2);
const result = await getFromLock(tempDir, "skill");
expect(result!.installedAt).toBe("2024-01-01T00:00:00Z");
expect(result!.version).toBe("1.1.0");
expect(result!.updatedAt).not.toBe("2024-01-01T00:00:00Z");
});
});
describe("removeFromLock", () => {
it("should remove existing entry", async () => {
const entry: SkillLockEntry = {
source: "owner/repo",
sourceType: "git",
sourceUrl: "https://github.com/owner/repo",
namespace: "global",
slug: "to-remove",
version: "1.0.0",
installedAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
};
await addToLock(tempDir, "to-remove", entry);
const removed = await removeFromLock(tempDir, "to-remove");
expect(removed).toBe(true);
const result = await getFromLock(tempDir, "to-remove");
expect(result).toBeNull();
});
it("should return false for non-existent entry", async () => {
const removed = await removeFromLock(tempDir, "non-existent");
expect(removed).toBe(false);
});
});
describe("getFromLock", () => {
it("should return null for non-existent key", async () => {
const result = await getFromLock(tempDir, "non-existent");
expect(result).toBeNull();
});
it("should return entry for existing key", async () => {
const entry: SkillLockEntry = {
source: "global/my-skill",
sourceType: "registry",
sourceUrl: "https://registry.example.com/global/my-skill",
namespace: "global",
slug: "my-skill",
version: "2.0.0",
installedAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
};
await addToLock(tempDir, "my-skill", entry);
const result = await getFromLock(tempDir, "my-skill");
expect(result).not.toBeNull();
expect(result!.version).toBe("2.0.0");
expect(result!.sourceType).toBe("registry");
});
});
describe("lastSelectedAgents", () => {
it("should persist lastSelectedAgents", async () => {
const lock: SkillLockFile = {
version: LOCK_FILE_VERSION,
skills: {},
lastSelectedAgents: ["claude-code", "cursor"],
};
await writeSkillLock(tempDir, lock);
const read = await readSkillLock(tempDir);
expect(read.lastSelectedAgents).toEqual(["claude-code", "cursor"]);
});
});
});

View file

@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { parseSkillName } from "../src/core/skill-name.js";
describe("parseSkillName", () => {
it("should parse namespace/slug format", () => {
const result = parseSkillName("global/test");
expect(result.namespace).toBe("global");
expect(result.slug).toBe("test");
});
it("should use default namespace for plain slug", () => {
const result = parseSkillName("test");
expect(result.namespace).toBe("global");
expect(result.slug).toBe("test");
});
it("should allow custom default namespace", () => {
const result = parseSkillName("test", "vision2group");
expect(result.namespace).toBe("vision2group");
expect(result.slug).toBe("test");
});
it("should handle team/namespace format", () => {
const result = parseSkillName("vision2group/test-publish");
expect(result.namespace).toBe("vision2group");
expect(result.slug).toBe("test-publish");
});
it("should handle slug with multiple slashes (use first two parts)", () => {
const result = parseSkillName("a/b/c");
expect(result.namespace).toBe("a");
expect(result.slug).toBe("b/c");
});
});

View file

@ -0,0 +1,84 @@
import { describe, it, expect, vi } from "vitest";
// local fs mock
vi.mock("node:fs", () => ({
existsSync: vi.fn(),
}));
it("parses local path", async () => {
vi.resetModules();
vi.doMock("node:fs", () => ({ existsSync: vi.fn().mockReturnValue(true) }));
const mod = await import("../src/core/source-parser");
const res = mod.parseSource("/abs/path");
expect(res.type).toBe("local");
expect(res.localPath).toBe("/abs/path");
});
it("parses github url from github.com", async () => {
vi.resetModules();
const mod = await import("../src/core/source-parser");
const res = mod.parseSource("https://github.com/owner/repo.git");
expect(res.type).toBe("github");
expect(res.owner).toBe("owner");
expect(res.repo).toBe("repo");
expect(res.cloneUrl).toBe("https://github.com/owner/repo.git");
});
it("parses shorthand owner/repo", async () => {
vi.resetModules();
const mod = await import("../src/core/source-parser");
const res = mod.parseSource("alice/awesome");
expect(res.type).toBe("github");
expect(res.owner).toBe("alice");
expect(res.repo).toBe("awesome");
});
it("throws on invalid source format", async () => {
vi.resetModules();
const mod = await import("../src/core/source-parser");
expect(() => mod.parseSource("invalid")).toThrow();
});
it("getCloneUrl uses cloneUrl when provided", async () => {
vi.resetModules();
const mod = await import("../src/core/source-parser");
const url = mod.getCloneUrl({ type: "github", owner: "a", repo: "b", cloneUrl: "https://example.com/a/b.git" } as any);
expect(url).toBe("https://example.com/a/b.git");
});
it("getCloneUrl builds default for github without cloneUrl", async () => {
vi.resetModules();
const mod = await import("../src/core/source-parser");
const url = mod.getCloneUrl({ type: "github", owner: "x", repo: "y" } as any);
expect(url).toBe("https://github.com/x/y.git");
});
it("parses @skill syntax: owner/repo@skillname", async () => {
vi.resetModules();
const mod = await import("../src/core/source-parser");
const res = mod.parseSource("vercel-labs/skills@openspec");
expect(res.type).toBe("github");
expect(res.owner).toBe("vercel-labs");
expect(res.repo).toBe("skills");
expect(res.skillFilter).toBe("openspec");
});
it("parses @skill syntax with branch: owner/repo@skillname", async () => {
vi.resetModules();
const mod = await import("../src/core/source-parser");
const res = mod.parseSource("owner/repo@something");
expect(res.type).toBe("github");
expect(res.owner).toBe("owner");
expect(res.repo).toBe("repo");
expect(res.skillFilter).toBe("something");
});
it("does not confuse @ in path with @skill syntax", async () => {
vi.resetModules();
const mod = await import("../src/core/source-parser");
const res = mod.parseSource("https://github.com/owner/repo");
expect(res.type).toBe("github");
expect(res.owner).toBe("owner");
expect(res.repo).toBe("repo");
expect(res.skillFilter).toBeUndefined();
});

View file

@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const mockSuccess = vi.fn();
const mockError = vi.fn();
const mockInfo = vi.fn();
vi.mock("../src/utils/logger.js", () => ({
success: mockSuccess,
error: mockError,
info: mockInfo,
dim: vi.fn(),
}));
describe("uninstall command", () => {
let tempDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), "uninstall-test-" + Date.now());
mkdirSync(tempDir, { recursive: true });
mockSuccess.mockClear();
mockError.mockClear();
mockInfo.mockClear();
});
afterEach(() => {
try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
vi.restoreAllMocks();
});
describe("removeDir utility", () => {
it("should handle file removal", () => {
const testFile = join(tempDir, "test-file.txt");
writeFileSync(testFile, "content");
expect(existsSync(testFile)).toBe(true);
});
it("should handle directory removal recursively", () => {
const testSubDir = join(tempDir, "subdir", "nested");
mkdirSync(testSubDir, { recursive: true });
writeFileSync(join(testSubDir, "file.txt"), "content");
expect(existsSync(testSubDir)).toBe(true);
});
});
describe("--all flag", () => {
it("should discover all installed skills", async () => {
const skill1 = join(tempDir, ".claude", "skills", "skill-one");
const skill2 = join(tempDir, ".claude", "skills", "skill-two");
mkdirSync(skill1, { recursive: true });
mkdirSync(skill2, { recursive: true });
writeFileSync(join(skill1, "SKILL.md"), "# Skill One\n");
writeFileSync(join(skill2, "SKILL.md"), "# Skill Two\n");
expect(existsSync(skill1)).toBe(true);
expect(existsSync(skill2)).toBe(true);
});
});
describe("--agent filter", () => {
it("should filter by specific agent", () => {
const claudeDir = join(tempDir, ".claude", "skills", "shared-skill");
const cursorDir = join(tempDir, ".agents", "skills", "shared-skill");
mkdirSync(claudeDir, { recursive: true });
mkdirSync(cursorDir, { recursive: true });
writeFileSync(join(claudeDir, "SKILL.md"), "# Shared Skill\n");
writeFileSync(join(cursorDir, "SKILL.md"), "# Shared Skill\n");
expect(existsSync(claudeDir)).toBe(true);
expect(existsSync(cursorDir)).toBe(true);
});
});
describe("--global flag", () => {
it("should target global scope only", () => {
const globalSkill = join(tempDir, ".claude", "skills", "global-skill");
mkdirSync(globalSkill, { recursive: true });
writeFileSync(join(globalSkill, "SKILL.md"), "# Global Skill\n");
expect(existsSync(globalSkill)).toBe(true);
});
});
});
describe("source parser", () => {
describe("parseSource", () => {
it("should identify git source: owner/repo", () => {
const source = "vercel-labs/agent-skills";
const pattern = /^[\w-]+\/[\w-]+/;
expect(pattern.test(source)).toBe(true);
});
it("should identify git source: GitHub URL", () => {
const source = "https://github.com/vercel-labs/agent-skills";
expect(source.startsWith("https://github.com/")).toBe(true);
});
it("should identify registry source: slug", () => {
const source = "my-skill";
const isGit = /^[\w-]+\/[\w-]+/.test(source) || source.startsWith("https://github.com/");
expect(isGit).toBe(false);
});
it("should identify registry source: namespace--slug", () => {
const source = "global--my-skill";
const parts = source.split("--");
expect(parts.length >= 2).toBe(true);
});
});
});

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "tests"]
}

View file

@ -0,0 +1,10 @@
import { defineBuildConfig } from "unbuild";
export default defineBuildConfig({
entries: ["src/cli"],
outDir: "dist",
clean: true,
rollup: {
emitCJS: false,
},
});

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
},
});