import { Command } from "commander"; import { existsSync } from "node:fs"; import { join } from "node:path"; import chalk from "chalk"; import ora from "ora"; import { apiRequest } from "../lib/api-client.js"; import { resolveInstallPath, detectAllTargets, ALL_TARGETS, parseTargets, getPlatform, type KnownTarget, } from "../lib/install-path.js"; import { addToManifest, readManifest } from "../lib/manifest.js"; import { buildSkillMarkdown, validateSlug, writeSkillFiles, } from "@skills-hub-ai/skill-installer"; import type { SkillDetail } from "@skills-hub-ai/shared"; /** Core install logic, reusable from update command */ export async function installSkill( slug: string, options: { version?: string; target?: string; team?: string }, ) { validateSlug(slug); const endpoint = options.team ? `/api/v1/orgs/${encodeURIComponent(options.team)}/skills/${encodeURIComponent(slug)}` : `/api/v1/skills/${encodeURIComponent(slug)}`; const skill = await apiRequest(endpoint); let instructions = skill.instructions; let version = skill.latestVersion; let contentHash: string | undefined; if (options.version) { const ver = await apiRequest<{ instructions: string; version: string; contentHash?: string | null; }>( `/api/v1/skills/${encodeURIComponent(slug)}/versions/${encodeURIComponent(options.version)}`, ); instructions = ver.instructions; version = ver.version; contentHash = ver.contentHash ?? undefined; } else { // Extract contentHash from the latest version in the skill detail const latestVer = skill.versions?.find( (v) => v.version === skill.latestVersion, ); contentHash = latestVer?.contentHash ?? undefined; } const target = resolveInstallPath(options.target); const skillDir = join(target.path, slug); const skillContent = buildSkillMarkdown( { name: skill.name, description: skill.description, version, category: skill.category.slug, }, instructions, ); writeSkillFiles(skillDir, [ { relativePath: "SKILL.md", content: skillContent }, ]); // Show permissions if any are declared if (skill.permissions && skill.permissions.length > 0) { console.log(chalk.yellow(` Permissions: ${skill.permissions.join(", ")}`)); } // Record install, don't fail if tracking fails await apiRequest(`/api/v1/skills/${encodeURIComponent(slug)}/install`, { method: "POST", body: JSON.stringify({ version, platform: target.type.toUpperCase().replace(/-/g, "_"), }), }).catch(() => {}); return { skill, version, skillDir, contentHash }; } interface ChildResult { slug: string; status: "installed" | "skipped" | "failed"; contentHash?: string; error?: string; } /** Install a skill and all its composition dependencies recursively */ export async function installWithDependencies( slug: string, options: { version?: string; target?: string; team?: string }, callbacks: { onChildStart?: (slug: string, index: number, total: number) => void; onChildSkip?: (slug: string) => void; onChildDone?: (slug: string) => void; onChildFail?: (slug: string, error: string) => void; } = {}, ) { const parent = await installSkill(slug, options); const children: ChildResult[] = []; if (!parent.skill.composition?.children.length) { return { parent, children }; } // Recursively collect all child slugs (BFS with cycle protection) const visited = new Set([slug]); const toInstall: string[] = []; async function collectChildren(detail: SkillDetail, depth: number) { if (depth > 5 || !detail.composition?.children.length) return; for (const child of detail.composition.children) { if (visited.has(child.skill.slug)) continue; visited.add(child.skill.slug); toInstall.push(child.skill.slug); // Check if this child is itself a composition, if fetch fails, skip recursion try { const childDetail = await apiRequest( `/api/v1/skills/${encodeURIComponent(child.skill.slug)}`, ); await collectChildren(childDetail, depth + 1); } catch { // Child will still be attempted during the install loop } } } await collectChildren(parent.skill, 0); // Determine install target path for "already installed" checks const target = resolveInstallPath(options.target); const targetPath = target.path; for (let i = 0; i < toInstall.length; i++) { const childSlug = toInstall[i]; if (existsSync(join(targetPath, childSlug, "SKILL.md"))) { callbacks.onChildSkip?.(childSlug); children.push({ slug: childSlug, status: "skipped" }); continue; } callbacks.onChildStart?.(childSlug, i, toInstall.length); try { const result = await installSkill(childSlug, { target: options.target }); callbacks.onChildDone?.(childSlug); children.push({ slug: childSlug, status: "installed", contentHash: result.contentHash, }); } catch (err) { const msg = err instanceof Error ? err.message : "Unknown error"; callbacks.onChildFail?.(childSlug, msg); children.push({ slug: childSlug, status: "failed", error: msg }); } } return { parent, children }; } /** Install all skills listed in .skills.json manifest */ async function installFromManifest(): Promise { const manifest = readManifest(); const slugs = Object.keys(manifest.skills); if (slugs.length === 0) { console.log(chalk.yellow("No .skills.json found or no skills listed.")); return; } const spinner = ora( `Installing ${slugs.length} skill(s) from .skills.json...`, ).start(); let installed = 0; let failed = 0; for (const slug of slugs) { const entry = manifest.skills[slug]; spinner.text = `Installing ${slug} v${entry.version}...`; try { const result = await installSkill(slug, { version: entry.version, target: entry.platform || "claude-code", }); addToManifest( slug, result.version, entry.platform || "claude-code", result.contentHash, ); installed++; } catch (err) { spinner.warn( chalk.yellow( ` ${slug}: ${err instanceof Error ? err.message : "failed"}`, ), ); spinner.start(); failed++; } } if (failed > 0) { spinner.warn( `Installed ${installed}/${slugs.length} skills (${failed} failed)`, ); process.exitCode = 1; } else { spinner.succeed(`All ${installed} skill(s) installed from .skills.json`); } } /** * Resolve which platforms to install to based on CLI flags. * Priority order: * 1. --all → every supported platform in the registry * 2. --target a,b,c → explicit list (multi-target supported) * 3. auto-detect → every detected platform on this machine * 4. fallback → single target via legacy resolveInstallPath() */ function resolveTargets(options: { target?: string; all?: boolean; }): { id: KnownTarget; path: string }[] { if (options.all) { return ALL_TARGETS.map((id) => ({ id, path: getPlatform(id)!.paths[0], })); } if (options.target) { const { known, unknown } = parseTargets(options.target); if (unknown.length > 0) { console.warn( chalk.yellow(` Unknown target(s) ignored: ${unknown.join(", ")}`), ); } if (known.length > 0) { return known.map((id) => ({ id, path: getPlatform(id)!.paths[0] })); } } // No explicit target, auto-detect every installed platform. const detected = detectAllTargets(); if (detected.length > 0) { return detected.map((d) => ({ id: d.type as KnownTarget, path: d.path })); } // Nothing detected, fall back to default (claude-code). const fallback = resolveInstallPath(undefined); return [{ id: fallback.type as KnownTarget, path: fallback.path }]; } export const installCommand = new Command("install") .description( "Install a skill from skills-hub.ai, or restore all from .skills.json", ) .argument("[slug]", "Skill slug or name (omit to install from .skills.json)") .option("-v, --version ", "Install a specific version") .option( "-t, --target ", `Install target(s), comma-separated: ${ALL_TARGETS.join(", ")}`, ) .option("--all", "Install to every supported platform in the registry") .option("--team ", "Install from an organization") .option("--no-deps", "Skip installing composition dependencies") .option("--no-save", "Do not update .skills.json manifest") .option("--dry-run", "Preview what would be installed without writing files") .action(async (slug: string | undefined, options) => { if (!slug) { await installFromManifest(); return; } const targets = resolveTargets(options); // Dry run: show what would be installed without writing files if (options.dryRun) { const spinner = ora(`Resolving ${slug}...`).start(); try { const endpoint = options.team ? `/api/v1/orgs/${encodeURIComponent(options.team)}/skills/${encodeURIComponent(slug)}` : `/api/v1/skills/${encodeURIComponent(slug)}`; const skill = await apiRequest(endpoint); const deps = skill.composition?.children.map((c) => c.skill.slug) ?? []; spinner.succeed(`Dry run for ${chalk.bold(skill.name)}:`); for (const t of targets) { console.log( ` ${chalk.cyan(slug)} v${skill.latestVersion} → ${t.path}/${slug}/ ${chalk.dim(`[${t.id}]`)}`, ); } if (deps.length > 0) { console.log(` Dependencies (${deps.length}):`); for (const dep of deps) { const installed = targets.every((t) => existsSync(join(t.path, dep, "SKILL.md")), ); console.log( ` ${installed ? chalk.dim("(installed)") : chalk.green("(new)")} ${dep}`, ); } } console.log(`\n Run without ${chalk.yellow("--dry-run")} to install.`); } catch (err) { spinner.fail( chalk.red(err instanceof Error ? err.message : "Resolve failed"), ); process.exit(1); } return; } const spinner = ora(`Installing ${slug}...`).start(); const results: { target: KnownTarget; path: string; ok: boolean }[] = []; let lastSkill: SkillDetail | undefined; let lastVersion: string | undefined; let lastChildSummary: string | undefined; let anyChildFailed = false; try { for (const t of targets) { spinner.text = `Installing ${slug} → ${t.id}...`; try { if (options.deps === false) { const { skill, version, skillDir, contentHash } = await installSkill(slug, { ...options, target: t.id }); if (options.save !== false) addToManifest(slug, version, t.id, contentHash); lastSkill = skill; lastVersion = version; results.push({ target: t.id, path: skillDir, ok: true }); continue; } const { parent, children } = await installWithDependencies( slug, { ...options, target: t.id }, { onChildStart: (childSlug, index, total) => { spinner.text = `[${t.id}] dep ${index + 1}/${total}: ${childSlug}...`; }, onChildSkip: (childSlug) => { spinner.info(` [${t.id}] ${childSlug} already installed`); spinner.start(); }, onChildFail: (childSlug, error) => { spinner.warn(` [${t.id}] ${childSlug} failed: ${error}`); spinner.start(); }, }, ); if (options.save !== false) { addToManifest(slug, parent.version, t.id, parent.contentHash); for (const child of children) { if (child.status === "installed") { addToManifest(child.slug, "latest", t.id, child.contentHash); } } } lastSkill = parent.skill; lastVersion = parent.version; results.push({ target: t.id, path: parent.skillDir, ok: true }); if (children.length > 0) { const installedCount = children.filter( (c) => c.status === "installed", ).length; const skipped = children.filter( (c) => c.status === "skipped", ).length; const failedCount = children.filter( (c) => c.status === "failed", ).length; const parts: string[] = []; if (installedCount > 0) parts.push(`${installedCount} installed`); if (skipped > 0) parts.push(`${skipped} already installed`); if (failedCount > 0) parts.push(chalk.yellow(`${failedCount} failed`)); lastChildSummary = parts.join(", "); if (failedCount > 0) anyChildFailed = true; } } catch (err) { const msg = err instanceof Error ? err.message : "Install failed"; spinner.warn(chalk.yellow(` [${t.id}] ${msg}`)); spinner.start(); results.push({ target: t.id, path: t.path, ok: false }); } } const ok = results.filter((r) => r.ok); if (ok.length === 0) { spinner.fail(chalk.red(`Failed to install ${slug} to any target`)); process.exit(1); } spinner.succeed( `${chalk.bold(lastSkill?.name ?? slug)} v${lastVersion ?? "?"} installed to ${ok.length} target(s)`, ); for (const r of ok) { console.log(` ${chalk.dim(r.target)} → ${chalk.cyan(r.path)}`); } if (lastChildSummary) { console.log(` Dependencies: ${lastChildSummary}`); } console.log(` Use: ${chalk.yellow(`/${slug}`)} in your AI coding tool`); if (anyChildFailed || results.some((r) => !r.ok)) { process.exitCode = 1; } } catch (err) { spinner.fail( chalk.red(err instanceof Error ? err.message : "Install failed"), ); process.exit(1); } });