mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-12 23:01:05 +00:00
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:
parent
38ebb13133
commit
6c0d4f4f16
59 changed files with 9328 additions and 0 deletions
35
skillhub-cli/package.json
Normal file
35
skillhub-cli/package.json
Normal 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
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
126
skillhub-cli/src/cli.ts
Normal 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();
|
||||
39
skillhub-cli/src/commands/archive.ts
Normal file
39
skillhub-cli/src/commands/archive.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
135
skillhub-cli/src/commands/check.ts
Normal file
135
skillhub-cli/src/commands/check.ts
Normal 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("");
|
||||
});
|
||||
}
|
||||
40
skillhub-cli/src/commands/delete.ts
Normal file
40
skillhub-cli/src/commands/delete.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
59
skillhub-cli/src/commands/download.ts
Normal file
59
skillhub-cli/src/commands/download.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
278
skillhub-cli/src/commands/explore.ts
Normal file
278
skillhub-cli/src/commands/explore.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
88
skillhub-cli/src/commands/hide.ts
Normal file
88
skillhub-cli/src/commands/hide.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
46
skillhub-cli/src/commands/init.ts
Normal file
46
skillhub-cli/src/commands/init.ts
Normal 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}`);
|
||||
});
|
||||
}
|
||||
136
skillhub-cli/src/commands/inspect.ts
Normal file
136
skillhub-cli/src/commands/inspect.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
759
skillhub-cli/src/commands/install.ts
Normal file
759
skillhub-cli/src/commands/install.ts
Normal 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."));
|
||||
}
|
||||
142
skillhub-cli/src/commands/list.ts
Normal file
142
skillhub-cli/src/commands/list.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
34
skillhub-cli/src/commands/login.ts
Normal file
34
skillhub-cli/src/commands/login.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
13
skillhub-cli/src/commands/logout.ts
Normal file
13
skillhub-cli/src/commands/logout.ts
Normal 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");
|
||||
});
|
||||
}
|
||||
89
skillhub-cli/src/commands/me.ts
Normal file
89
skillhub-cli/src/commands/me.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
35
skillhub-cli/src/commands/namespaces.ts
Normal file
35
skillhub-cli/src/commands/namespaces.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
78
skillhub-cli/src/commands/notifications.ts
Normal file
78
skillhub-cli/src/commands/notifications.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
84
skillhub-cli/src/commands/publish.ts
Normal file
84
skillhub-cli/src/commands/publish.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
71
skillhub-cli/src/commands/rating.ts
Normal file
71
skillhub-cli/src/commands/rating.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
40
skillhub-cli/src/commands/report.ts
Normal file
40
skillhub-cli/src/commands/report.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
177
skillhub-cli/src/commands/resolve.ts
Normal file
177
skillhub-cli/src/commands/resolve.ts
Normal 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}`);
|
||||
}
|
||||
43
skillhub-cli/src/commands/reviews.ts
Normal file
43
skillhub-cli/src/commands/reviews.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
54
skillhub-cli/src/commands/search.ts
Normal file
54
skillhub-cli/src/commands/search.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
37
skillhub-cli/src/commands/star.ts
Normal file
37
skillhub-cli/src/commands/star.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
155
skillhub-cli/src/commands/sync.ts
Normal file
155
skillhub-cli/src/commands/sync.ts
Normal 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}`;
|
||||
}
|
||||
38
skillhub-cli/src/commands/transfer.ts
Normal file
38
skillhub-cli/src/commands/transfer.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
334
skillhub-cli/src/commands/uninstall.ts
Normal file
334
skillhub-cli/src/commands/uninstall.ts
Normal 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.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
99
skillhub-cli/src/commands/update.ts
Normal file
99
skillhub-cli/src/commands/update.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
101
skillhub-cli/src/commands/versions.ts
Normal file
101
skillhub-cli/src/commands/versions.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
30
skillhub-cli/src/commands/whoami.ts
Normal file
30
skillhub-cli/src/commands/whoami.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
89
skillhub-cli/src/core/agent-detector.ts
Normal file
89
skillhub-cli/src/core/agent-detector.ts
Normal 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));
|
||||
}
|
||||
131
skillhub-cli/src/core/api-client.ts
Normal file
131
skillhub-cli/src/core/api-client.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
39
skillhub-cli/src/core/auth-token.ts
Normal file
39
skillhub-cli/src/core/auth-token.ts
Normal 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;
|
||||
}
|
||||
34
skillhub-cli/src/core/config.ts
Normal file
34
skillhub-cli/src/core/config.ts
Normal 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));
|
||||
}
|
||||
144
skillhub-cli/src/core/installer.ts
Normal file
144
skillhub-cli/src/core/installer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
249
skillhub-cli/src/core/interactive-search.ts
Normal file
249
skillhub-cli/src/core/interactive-search.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
86
skillhub-cli/src/core/skill-discovery.ts
Normal file
86
skillhub-cli/src/core/skill-discovery.ts
Normal 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;
|
||||
}
|
||||
106
skillhub-cli/src/core/skill-lock.ts
Normal file
106
skillhub-cli/src/core/skill-lock.ts
Normal 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);
|
||||
}
|
||||
12
skillhub-cli/src/core/skill-name.ts
Normal file
12
skillhub-cli/src/core/skill-name.ts
Normal 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 };
|
||||
}
|
||||
69
skillhub-cli/src/core/source-parser.ts
Normal file
69
skillhub-cli/src/core/source-parser.ts
Normal 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");
|
||||
}
|
||||
46
skillhub-cli/src/schema/routes.ts
Normal file
46
skillhub-cli/src/schema/routes.ts
Normal 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
|
||||
}>;
|
||||
}
|
||||
253
skillhub-cli/src/utils/install-helpers.ts
Normal file
253
skillhub-cli/src/utils/install-helpers.ts
Normal 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}`;
|
||||
}
|
||||
25
skillhub-cli/src/utils/logger.ts
Normal file
25
skillhub-cli/src/utils/logger.ts
Normal 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));
|
||||
}
|
||||
449
skillhub-cli/src/utils/prompts.ts
Normal file
449
skillhub-cli/src/utils/prompts.ts
Normal 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];
|
||||
}
|
||||
297
skillhub-cli/src/utils/search-multiselect.ts
Normal file
297
skillhub-cli/src/utils/search-multiselect.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
99
skillhub-cli/src/utils/telemetry.ts
Normal file
99
skillhub-cli/src/utils/telemetry.ts
Normal 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)';
|
||||
}
|
||||
179
skillhub-cli/tests/api-client.test.ts
Normal file
179
skillhub-cli/tests/api-client.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
191
skillhub-cli/tests/commands.test.ts
Normal file
191
skillhub-cli/tests/commands.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
224
skillhub-cli/tests/install.test.ts
Normal file
224
skillhub-cli/tests/install.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
35
skillhub-cli/tests/installer.test.ts
Normal file
35
skillhub-cli/tests/installer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
45
skillhub-cli/tests/prompts.test.ts
Normal file
45
skillhub-cli/tests/prompts.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
223
skillhub-cli/tests/skill-lock.test.ts
Normal file
223
skillhub-cli/tests/skill-lock.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
34
skillhub-cli/tests/skill-name.test.ts
Normal file
34
skillhub-cli/tests/skill-name.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
84
skillhub-cli/tests/source-parser.test.ts
Normal file
84
skillhub-cli/tests/source-parser.test.ts
Normal 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();
|
||||
});
|
||||
112
skillhub-cli/tests/uninstall.test.ts
Normal file
112
skillhub-cli/tests/uninstall.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
18
skillhub-cli/tsconfig.json
Normal file
18
skillhub-cli/tsconfig.json
Normal 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"]
|
||||
}
|
||||
10
skillhub-cli/unbuild.config.ts
Normal file
10
skillhub-cli/unbuild.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineBuildConfig } from "unbuild";
|
||||
|
||||
export default defineBuildConfig({
|
||||
entries: ["src/cli"],
|
||||
outDir: "dist",
|
||||
clean: true,
|
||||
rollup: {
|
||||
emitCJS: false,
|
||||
},
|
||||
});
|
||||
8
skillhub-cli/vitest.config.ts
Normal file
8
skillhub-cli/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue