From c14b844c106a30448beb113b1a0beea452fcb6db Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:58:07 +0800 Subject: [PATCH] refactor(cli): merge list/check commands, unify skill status display - Merge list and check into unified list command with status filtering - Add skill-status.ts core module for shared discovery logic - Unify interaction flow: scope -> agent -> status -> display - Replace -g/-p/-a flags with --scope option - Simplify check as alias for list --status managed,missing - Fix uninstall to properly handle 'All' scope selection - Display orphaned skills with [orphaned] marker in uninstall - Update help text and examples BREAKING CHANGE: Removed -g/--global, -p/--project, -a/--all flags from list command. Use --scope global|project|all instead. --- skillhub-cli/src/cli.ts | 51 ++-- skillhub-cli/src/commands/check.ts | 281 +-------------------- skillhub-cli/src/commands/list.ts | 328 ++++++++++++++----------- skillhub-cli/src/commands/uninstall.ts | 79 +++--- skillhub-cli/src/core/skill-status.ts | 133 ++++++++++ 5 files changed, 381 insertions(+), 491 deletions(-) create mode 100644 skillhub-cli/src/core/skill-status.ts diff --git a/skillhub-cli/src/cli.ts b/skillhub-cli/src/cli.ts index 6ff6a7bac..d11f32bab 100644 --- a/skillhub-cli/src/cli.ts +++ b/skillhub-cli/src/cli.ts @@ -47,21 +47,6 @@ function buildTopLevelHelp(version: string): string { sections.push(dim("CLI for SkillHub — publish, search, and manage agent skills")); sections.push(""); - sections.push(formatSection("Configuration", [ - { cmd: "config list", desc: "Show current registry configuration" }, - { cmd: "config set ", desc: "Set registry URL" }, - { cmd: "config get", desc: "Get current registry configuration" }, - { cmd: "config show-env-instructions", desc: "Show environment variable setup guide" }, - ])); - sections.push(""); - - sections.push(formatSection("Auth", [ - { cmd: "login", desc: "Authenticate with SkillHub registry" }, - { cmd: "logout", desc: "Remove stored authentication token" }, - { cmd: "whoami", desc: "Show current authenticated user" }, - ])); - sections.push(""); - sections.push(formatSection("Discovery", [ { cmd: "explore", desc: "Browse or search skills from the registry", alias: "find, find-skills, search" }, { cmd: "inspect ", desc: "View skill metadata and versions", alias: "info, view" }, @@ -79,15 +64,14 @@ function buildTopLevelHelp(version: string): string { ])); sections.push(""); - sections.push(formatSection("Social", [ - { cmd: "star ", desc: "Star or unstar a skill" }, - { cmd: "rating ", desc: "View your rating for a skill" }, - { cmd: "rate ", desc: "Rate a skill (1-5)" }, - { cmd: "report ", desc: "Report a skill for review" }, + sections.push(formatSection("My Skills", [ + { cmd: "me skills", desc: "List your published skills", alias: "me ls" }, + { cmd: "me stars", desc: "List your starred skills" }, + { cmd: "reviews", desc: "List your review submissions", alias: "reviews my, reviews submissions" }, ])); sections.push(""); - sections.push(formatSection("Publish & Manage", [ + sections.push(formatSection("Publish & Content", [ { cmd: "init [name]", desc: "Create a new SKILL.md template" }, { cmd: "publish [path]", desc: "Publish a skill to SkillHub registry" }, { cmd: "sync [path]", desc: "Scan and publish all skills from a directory" }, @@ -96,22 +80,33 @@ function buildTopLevelHelp(version: string): string { ])); sections.push(""); - sections.push(formatSection("Account", [ - { cmd: "me skills", desc: "List your published skills", alias: "me ls" }, - { cmd: "me stars", desc: "List your starred skills" }, - { cmd: "namespaces", desc: "List namespaces you have access to" }, - { cmd: "notifications", desc: "Manage notifications", alias: "notif" }, - { cmd: "reviews my", desc: "List your review submissions", alias: "reviews submissions" }, + sections.push(formatSection("Community", [ + { cmd: "star ", desc: "Star or unstar a skill" }, + { cmd: "rating ", desc: "View your rating for a skill" }, + { cmd: "rate ", desc: "Rate a skill (1-5)" }, + { cmd: "report ", desc: "Report a skill for review" }, ])); sections.push(""); - sections.push(formatSection("Admin", [ + sections.push(formatSection("Notifications & Admin", [ + { cmd: "notifications", desc: "Manage notifications", alias: "notif" }, + { cmd: "namespaces", desc: "List namespaces you have access to" }, { cmd: "hide ", desc: "Hide a skill (admin only)" }, { cmd: "unhide ", desc: "Unhide a skill (admin only)" }, { cmd: "transfer ", desc: "Transfer namespace ownership" }, ])); sections.push(""); + sections.push(formatSection("Configuration", [ + { cmd: "config list", desc: "Show current registry configuration" }, + { cmd: "config set ", desc: "Set registry URL" }, + { cmd: "config get", desc: "Get current registry configuration" }, + { cmd: "login", desc: "Authenticate with SkillHub registry" }, + { cmd: "logout", desc: "Remove stored authentication token" }, + { cmd: "whoami", desc: "Show current authenticated user" }, + ])); + sections.push(""); + sections.push(bold("Examples")); sections.push(dim(" skillhub install vision2group/fork-workflow Install a skill from registry")); sections.push(dim(" skillhub install find-skills --from https://... Install from GitHub or local path")); diff --git a/skillhub-cli/src/commands/check.ts b/skillhub-cli/src/commands/check.ts index 2240efd9e..3f8294f79 100644 --- a/skillhub-cli/src/commands/check.ts +++ b/skillhub-cli/src/commands/check.ts @@ -1,287 +1,24 @@ import { Command } from "commander"; -import { existsSync, readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; -import { getAllAgents, type AgentInfo } from "../core/agent-detector.js"; -import { getAllLockedSkills, getSkillLockPath } from "../core/skill-lock.js"; -import { success, error, info, warn, dim } from "../utils/logger.js"; -import * as p from "@clack/prompts"; -import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js"; - -interface CheckResult { - name: string; - status: "ok" | "missing" | "orphaned"; - source?: string; - location?: string; -} - -function findInstalledSkills( - scope: "local" | "global", - agents?: AgentInfo[] -): Map { - const skillsMap = new Map(); - const allAgents = getAllAgents(); - const targetAgents = agents || allAgents; - - for (const agent of targetAgents) { - 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; -} +import { listAction } from "./list.js"; export function registerCheck(program: Command) { program .command("check") - .description("Check installed skills against lock file") - .option("--global", "Check global scope skills") - .option("--local", "Check local (project) scope skills") - .option("--all", "Check both global and local scopes") + .description("Check installed skills (alias for 'list --status managed,missing')") + .option("--scope ", "Scope to check (global, project, all)") .option("--agent ", "Filter by specific agents") - .option("--status ", "Filter by status (ok, missing, orphaned)") .option("--json", "Output results as JSON") .action(async (opts: { - global?: boolean; - local?: boolean; - all?: boolean; + scope?: string; agent?: string[]; - status?: string[]; json?: boolean; }) => { - // Determine scopes - let scopes: ("local" | "global")[] = []; + console.log("Tip: Use 'skillhub list' for more options including orphaned skills"); + console.log(""); - if (opts.all) { - scopes = ["local", "global"]; - } else if (opts.global) { - scopes = ["global"]; - } else if (opts.local) { - scopes = ["local"]; - } else { - // Interactive scope selection - const scopeSelection = await p.select({ - message: "Which scope to check?", - options: [ - { value: "all", label: "All (global + project)" }, - { value: "global", label: "Global only" }, - { value: "local", label: "Project only" }, - ], - }); - - if (p.isCancel(scopeSelection)) { - console.log("Cancelled."); - return; - } - - if (scopeSelection === "all") { - scopes = ["local", "global"]; - } else if (scopeSelection === "global") { - scopes = ["global"]; - } else { - scopes = ["local"]; - } - } - - // Determine agents to check - let targetAgents: AgentInfo[] | undefined; - if (opts.agent && opts.agent.length > 0) { - const allAgents = getAllAgents(); - targetAgents = allAgents.filter((a) => opts.agent!.includes(a.key)); - } else if (!opts.agent) { - // Interactive agent selection - const allAgents = getAllAgents(); - const agentItems = allAgents - .map((a) => ({ - value: a.key, - label: a.name, - })) - .sort((a, b) => a.label.localeCompare(b.label)); - - const selected = await searchMultiselect({ - message: "Which agents to check?", - items: agentItems, - required: false, - }); - - if (selected === cancelSymbol) { - console.log("Cancelled."); - return; - } - - if (selected && selected.length > 0) { - targetAgents = allAgents.filter((a) => (selected as string[]).includes(a.key)); - } - } - - // Determine which statuses to show - let showOk = false; - let showMissing = false; - let showOrphaned = false; - - if (opts.status && opts.status.length > 0) { - // Command line flags - showOk = opts.status.includes("ok"); - showMissing = opts.status.includes("missing"); - showOrphaned = opts.status.includes("orphaned"); - } else { - // Interactive status selection (default: ok + missing only) - const statusSelection = await p.multiselect({ - message: "Which statuses to show?", - options: [ - { value: "ok", label: "OK (installed and in lock file)" }, - { value: "missing", label: "Missing (in lock file but not installed)" }, - { value: "orphaned", label: "Orphaned (installed but not in lock file)" }, - ], - required: false, - initialValues: ["ok", "missing"], - }); - - if (p.isCancel(statusSelection)) { - console.log("Cancelled."); - return; - } - - const selected = statusSelection as string[]; - showOk = selected.includes("ok"); - showMissing = selected.includes("missing"); - showOrphaned = selected.includes("orphaned"); - } - - 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 allResults: CheckResult[] = []; - - // Check each scope - for (const scope of scopes) { - const installedSkills = findInstalledSkills(scope, targetAgents); - - for (const [name, entry] of Object.entries(lockedSkills)) { - const installedLocations = installedSkills.get(name); - if (installedLocations && installedLocations.length > 0) { - allResults.push({ - name, - status: "ok", - source: entry.source, - location: `${scope}: ${installedLocations.sort((a, b) => a.localeCompare(b)).join(", ")}`, - }); - } - } - - for (const [name, locations] of installedSkills.entries()) { - if (!lockedSkills[name]) { - allResults.push({ - name, - status: "orphaned", - location: `${scope}: ${locations.sort((a, b) => a.localeCompare(b)).join(", ")}`, - }); - } - } - } - - // Mark missing skills (not found in any scope) - const checkedNames = new Set(); - for (const r of allResults) { - if (r.status !== "orphaned") { - checkedNames.add(r.name); - } - } - - for (const [name, entry] of Object.entries(lockedSkills)) { - if (!checkedNames.has(name)) { - allResults.push({ - name, - status: "missing", - source: entry.source, - }); - } - } - - // Sort results: ok → missing → orphaned, then alphabetically by name - allResults.sort((a, b) => { - const order = { ok: 0, missing: 1, orphaned: 2 }; - const diff = order[a.status] - order[b.status]; - return diff !== 0 ? diff : a.name.localeCompare(b.name); + await listAction({ + ...opts, + status: ["managed", "missing"], }); - - if (opts.json) { - console.log(JSON.stringify(allResults, null, 2)); - return; - } - - // Filter results by selected statuses - const filteredResults = allResults.filter((r) => { - if (r.status === "ok") return showOk; - if (r.status === "missing") return showMissing; - if (r.status === "orphaned") return showOrphaned; - return false; - }); - - const scopeLabel = scopes.length === 2 ? "all scopes" : `${scopes[0]} scope`; - const agentLabel = targetAgents - ? ` (${targetAgents.map((a) => a.name).sort((a, b) => a.localeCompare(b)).join(", ")})` - : ""; - - console.log(""); - info(`SkillHub Lock Check (${scopeLabel})${agentLabel}:`); - console.log(""); - - if (filteredResults.length === 0) { - dim(" No matching skills found."); - console.log(""); - return; - } - - let ok = 0, - missing = 0, - orphaned = 0; - - for (const r of filteredResults) { - 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(""); }); } diff --git a/skillhub-cli/src/commands/list.ts b/skillhub-cli/src/commands/list.ts index c9cf1fc58..b5d4f4877 100644 --- a/skillhub-cli/src/commands/list.ts +++ b/skillhub-cli/src/commands/list.ts @@ -1,169 +1,203 @@ import { Command } from "commander"; -import { existsSync, readdirSync, lstatSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; -import { getAllAgents, isUniversalForScope } from "../core/agent-detector.js"; -import { info, dim } from "../utils/logger.js"; +import { existsSync } from "node:fs"; +import { getAllAgents } from "../core/agent-detector.js"; +import { getSkillLockPath } from "../core/skill-lock.js"; +import { discoverInstalledSkills, filterSkillsByStatus, type DiscoveredSkill } from "../core/skill-status.js"; +import { success, error, info, warn, 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; + scope?: string; agent?: string[]; - all?: boolean; + status?: string[]; + json?: boolean; +} + +export async function listAction(opts: ListOptions) { + let scopes: ("local" | "global")[] = []; + + if (opts.scope) { + const scopeValue = opts.scope.toLowerCase(); + if (scopeValue === "all") { + scopes = ["local", "global"]; + } else if (scopeValue === "global") { + scopes = ["global"]; + } else if (scopeValue === "project" || scopeValue === "local") { + scopes = ["local"]; + } + } 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 === "all") { + scopes = ["local", "global"]; + } else if (scopeSelection === "global") { + scopes = ["global"]; + } else { + scopes = ["local"]; + } + } + + let targetAgents = opts.agent + ? getAllAgents().filter((a) => opts.agent!.includes(a.key)) + : undefined; + + if (!opts.agent) { + const allAgents = getAllAgents(); + const agentItems = allAgents + .map((a) => ({ value: a.key, label: a.name })) + .sort((a, b) => a.label.localeCompare(b.label)); + + const selected = await searchMultiselect({ + message: "Which agents to list from?", + items: agentItems, + required: false, + }); + + if (selected === cancelSymbol) { + console.log("Cancelled."); + return; + } + + if (selected && selected.length > 0) { + targetAgents = allAgents.filter((a) => (selected as string[]).includes(a.key)); + } + } + + let showManaged = false; + let showOrphaned = false; + let showMissing = false; + + if (opts.status && opts.status.length > 0) { + const statusSet = new Set(opts.status.map((s) => s.toLowerCase())); + if (statusSet.has("all")) { + showManaged = true; + showOrphaned = true; + showMissing = true; + } else { + showManaged = statusSet.has("managed"); + showOrphaned = statusSet.has("orphaned"); + showMissing = statusSet.has("missing"); + } + } else { + const statusSelection = await p.multiselect({ + message: "Which statuses to show?", + options: [ + { value: "managed", label: "managed", hint: "installed and in lock file" }, + { value: "orphaned", label: "orphaned", hint: "installed but not in lock file" }, + { value: "missing", label: "missing", hint: "in lock file but not installed" }, + ], + required: false, + initialValues: ["managed", "orphaned"], + }); + + if (p.isCancel(statusSelection)) { + console.log("Cancelled."); + return; + } + + const selected = statusSelection as string[]; + showManaged = selected.includes("managed"); + showOrphaned = selected.includes("orphaned"); + showMissing = selected.includes("missing"); + } + + const allSkills = await discoverInstalledSkills(scopes, targetAgents); + const filteredSkills = filterSkillsByStatus(allSkills, { + managed: showManaged, + orphaned: showOrphaned, + missing: showMissing, + }); + + if (opts.json) { + console.log(JSON.stringify(filteredSkills, null, 2)); + return; + } + + displaySkillList(filteredSkills, scopes, targetAgents); } 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)") + .description("List installed skills with status") + .option("--scope ", "Scope to list (global, project, all)") .option("--agent ", "Filter by specific agents") + .option("--status ", "Filter by status (managed, orphaned, missing, all)") + .option("--json", "Output as JSON") .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; - } - } - - // Determine scope for dynamic universal grouping - const isGlobal = scopeGlobal === true; - const allAgents = getAllAgents(); - - // All agents are selectable - no locked section - const selectableItems = allAgents - .map((a) => ({ - value: a.key, - label: a.name, - })) - .sort((a, b) => a.label.localeCompare(b.label)); - - const agentSelection = await searchMultiselect({ - message: "Which agents to list from?", - items: selectableItems, - }); - - if (agentSelection === cancelSymbol) { - console.log("Cancelled."); - return; - } - - const selectedAgents = agentSelection as string[]; - const agents = allAgents.filter((a) => selectedAgents.includes(a.key)); - - if (agents.length === 0) { - console.log("No agents selected."); - return; - } - - console.log(""); - - // Collect all skill entries grouped by (skillName, path) -> agentNames - const skillMap = new Map>(); - const home = homedir(); - const cwd = process.cwd(); - - for (const agent of agents) { - const showProject = scopeGlobal === null || scopeGlobal === false; - const showGlobal = scopeGlobal === null || scopeGlobal === true; - - if (showProject) { - const projectDir = join(cwd, agent.skillsDir); - collectSkills(skillMap, projectDir, agent.name, cwd, true); - } - - if (showGlobal && agent.globalSkillsDir) { - const globalDir = join(home, agent.globalSkillsDir); - collectSkills(skillMap, globalDir, agent.name, home, false); - } - } - - if (skillMap.size === 0) { - dim("No skills installed for selected agents and scope."); - } else { - // Output grouped by skill, then by path with agent names merged - const sortedSkills = [...skillMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); - for (const [skillName, pathGroups] of sortedSkills) { - info(`${skillName}`); - const sortedPaths = [...pathGroups.entries()].sort((a, b) => a[0].localeCompare(b[0])); - for (const [displayPath, agentNames] of sortedPaths) { - const sorted = agentNames.sort((a, b) => a.localeCompare(b)); - const label = sorted.length <= 5 - ? sorted.join(", ") - : sorted.slice(0, 5).join(", ") + ` ${pc.dim(`+${sorted.length - 5}`)}`; - dim(` ${pc.dim("→")} ${label}: ${displayPath}`); - } - } - } - - console.log(""); + await listAction(opts); }); } -/** - * Collect skills from a directory into the skillMap. - * skillMap: skillName -> (displayPath -> agentNames[]) - */ -function collectSkills( - skillMap: Map>, - dir: string, - agentName: string, - baseForRelative: string, - isProject: boolean, +function displaySkillList( + skills: DiscoveredSkill[], + scopes: ("local" | "global")[], + targetAgents?: import("../core/agent-detector.js").AgentInfo[] ) { - if (!existsSync(dir)) return; - const skills = getSkillsInDir(dir); - for (const skillName of skills) { - const displayPath = isProject - ? dir.replace(baseForRelative, ".") - : dir.replace(baseForRelative, "~"); - let pathGroups = skillMap.get(skillName); - if (!pathGroups) { - pathGroups = new Map(); - skillMap.set(skillName, pathGroups); - } - const agents = pathGroups.get(displayPath) || []; - agents.push(agentName); - pathGroups.set(displayPath, agents); - } -} + const scopeLabel = scopes.length === 2 ? "all scopes" : `${scopes[0]} scope`; + const agentLabel = targetAgents + ? ` (${targetAgents.map((a) => a.name).sort((a, b) => a.localeCompare(b)).join(", ")})` + : ""; -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; + console.log(""); + info(`Installed Skills (${scopeLabel})${agentLabel}:`); + console.log(""); + + if (skills.length === 0) { + dim(" No skills found."); + console.log(""); + return; + } + + let managed = 0, missing = 0, orphaned = 0; + + for (const skill of skills) { + if (skill.status === "managed") { + managed++; + success(` ✓ ${skill.name}`); + if (skill.source) { + dim(` Source: ${skill.source}`); + } + for (const loc of skill.locations) { + dim(` → ${loc.agent}: ${loc.path}`); + } + } else if (skill.status === "missing") { + missing++; + error(` ✗ ${skill.name}`); + if (skill.source) { + dim(` Source: ${skill.source}`); + } + dim(` Status: NOT INSTALLED`); + } else if (skill.status === "orphaned") { + orphaned++; + warn(` ! ${skill.name}`); + for (const loc of skill.locations) { + dim(` → ${loc.agent}: ${loc.path}`); + } + dim(` Status: NOT IN LOCK FILE`); } - }).sort((a, b) => a.localeCompare(b)); + } + + console.log(""); + const lockPath = getSkillLockPath(); + if (existsSync(lockPath)) { + dim(`Lock file: ${lockPath}`); + } + dim(`Summary: ${managed} managed, ${missing} missing, ${orphaned} orphaned`); + console.log(""); } diff --git a/skillhub-cli/src/commands/uninstall.ts b/skillhub-cli/src/commands/uninstall.ts index 9bc583b0d..827fae7ba 100644 --- a/skillhub-cli/src/commands/uninstall.ts +++ b/skillhub-cli/src/commands/uninstall.ts @@ -5,6 +5,7 @@ import { homedir } from "node:os"; import { getAllAgents, isUniversalForScope, type AgentInfo } from "../core/agent-detector.js"; import { success, info, dim } from "../utils/logger.js"; import { removeFromLock } from "../core/skill-lock.js"; +import { discoverInstalledSkills as discoverSkillsWithStatus, type DiscoveredSkill } from "../core/skill-status.js"; import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js"; import * as p from "@clack/prompts"; import pc from "picocolors"; @@ -82,28 +83,6 @@ function getSkillPath(skillName: string, agent: AgentInfo, scope: "global" | "lo 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)].sort((a, b) => a.localeCompare(b)); -} - function findAgentsWithSkill(skillName: string, scope: "global" | "local", agents: AgentInfo[]): AgentInfo[] { return agents.filter((a) => getSkillPath(skillName, a, scope) !== null); } @@ -147,18 +126,25 @@ export function registerUninstall(program: Command) { const allAgents = getAllAgents(); const isGlobal = scope === "global"; + const searchScopes = scopeAll ? ["local", "global"] : [scope]; + + const discoveredSkills = await discoverSkillsWithStatus(searchScopes); + const installedSkills = discoveredSkills.filter( + (s) => s.status === "managed" || s.status === "orphaned" + ); if (opts.all) { - const skills = discoverInstalledSkills(scope); - - if (skills.length === 0) { + if (installedSkills.length === 0) { dim("No skills installed."); return; } const selected = await searchMultiselect({ message: "Select skills to uninstall", - items: skills.map((s) => ({ value: s, label: s })), + items: installedSkills.map((s) => ({ + value: s.name, + label: s.status === "orphaned" ? `${s.name} [orphaned]` : s.name, + })), required: true, }); @@ -170,14 +156,16 @@ export function registerUninstall(program: Command) { const selectedSkills = selected as string[]; const results: { skill: string; agent: string; path: string; ok: boolean }[] = []; - for (const skill of selectedSkills) { - const agentsWithSkill = findAgentsWithSkill(skill, scope, allAgents); - for (const agent of agentsWithSkill) { - const ok = await uninstallSkill(skill, agent, scope, true); - const skillPath = getSkillPath(skill, agent, scope); - results.push({ skill, agent: agent.name, path: skillPath || "", ok }); + for (const skillName of selectedSkills) { + for (const searchScope of searchScopes) { + const agentsWithSkill = findAgentsWithSkill(skillName, searchScope as "global" | "local", allAgents); + for (const agent of agentsWithSkill) { + const ok = await uninstallSkill(skillName, agent, searchScope as "global" | "local", true); + const skillPath = getSkillPath(skillName, agent, searchScope as "global" | "local"); + results.push({ skill: skillName, agent: agent.name, path: skillPath || "", ok }); + } } - await removeFromLock(skill); + await removeFromLock(skillName); } printUninstallResults(results); @@ -185,16 +173,17 @@ export function registerUninstall(program: Command) { } if (!name) { - const skills = discoverInstalledSkills(scope); - - if (skills.length === 0) { + if (installedSkills.length === 0) { dim("No skills installed."); return; } const selected = await searchMultiselect({ message: "Select skills to uninstall", - items: skills.map((s) => ({ value: s, label: s })), + items: installedSkills.map((s) => ({ + value: s.name, + label: s.status === "orphaned" ? `${s.name} [orphaned]` : s.name, + })), required: true, }); @@ -206,14 +195,16 @@ export function registerUninstall(program: Command) { const selectedSkills = selected as string[]; const results: { skill: string; agent: string; path: string; ok: boolean }[] = []; - 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); - const skillPath = getSkillPath(skill, agent, scope); - results.push({ skill, agent: agent.name, path: skillPath || "", ok }); + for (const skillName of selectedSkills) { + for (const searchScope of searchScopes) { + const agentsWithSkill = findAgentsWithSkill(skillName, searchScope as "global" | "local", allAgents); + for (const agent of agentsWithSkill) { + const ok = await uninstallSkill(skillName, agent, searchScope as "global" | "local", !!opts.yes); + const skillPath = getSkillPath(skillName, agent, searchScope as "global" | "local"); + results.push({ skill: skillName, agent: agent.name, path: skillPath || "", ok }); + } } - await removeFromLock(skill); + await removeFromLock(skillName); } printUninstallResults(results); diff --git a/skillhub-cli/src/core/skill-status.ts b/skillhub-cli/src/core/skill-status.ts new file mode 100644 index 000000000..785d0f273 --- /dev/null +++ b/skillhub-cli/src/core/skill-status.ts @@ -0,0 +1,133 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { getAllAgents, type AgentInfo } from "./agent-detector.js"; +import { getAllLockedSkills } from "./skill-lock.js"; + +export interface SkillLocation { + agent: string; + path: string; + scope: "local" | "global"; +} + +export interface DiscoveredSkill { + name: string; + status: "managed" | "orphaned" | "missing"; + source?: string; + locations: SkillLocation[]; +} + +function findInstalledSkills( + scope: "local" | "global", + agents?: AgentInfo[] +): Map { + const skillsMap = new Map(); + const allAgents = getAllAgents(); + const targetAgents = agents || allAgents; + + for (const agent of targetAgents) { + 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) || []; + const displayPath = scope === "global" + ? baseDir.replace(homedir(), "~") + : baseDir.replace(process.cwd(), "."); + existing.push({ + agent: agent.name, + path: `${displayPath}/${entry}`, + scope, + }); + skillsMap.set(entry, existing); + } + } + } catch {} + } + + return skillsMap; +} + +export async function discoverInstalledSkills( + scopes: ("local" | "global")[], + agents?: AgentInfo[] +): Promise { + const lockedSkills = await getAllLockedSkills(); + const allInstalled = new Map(); + + for (const scope of scopes) { + const installed = findInstalledSkills(scope, agents); + for (const [name, locations] of installed) { + const existing = allInstalled.get(name) || []; + existing.push(...locations); + allInstalled.set(name, existing); + } + } + + const results: DiscoveredSkill[] = []; + const checkedNames = new Set(); + + for (const [name, entry] of Object.entries(lockedSkills)) { + const locations = allInstalled.get(name); + if (locations && locations.length > 0) { + results.push({ + name, + status: "managed", + source: entry.source, + locations, + }); + } else { + results.push({ + name, + status: "missing", + source: entry.source, + locations: [], + }); + } + checkedNames.add(name); + } + + for (const [name, locations] of allInstalled) { + if (!checkedNames.has(name)) { + results.push({ + name, + status: "orphaned", + locations, + }); + } + } + + const order = { managed: 0, missing: 1, orphaned: 2 }; + results.sort((a, b) => { + const diff = order[a.status] - order[b.status]; + return diff !== 0 ? diff : a.name.localeCompare(b.name); + }); + + return results; +} + +export function filterSkillsByStatus( + skills: DiscoveredSkill[], + options: { + managed?: boolean; + orphaned?: boolean; + missing?: boolean; + } +): DiscoveredSkill[] { + const showManaged = options.managed ?? true; + const showOrphaned = options.orphaned ?? true; + const showMissing = options.missing ?? false; + + return skills.filter((s) => { + if (s.status === "managed" && showManaged) return true; + if (s.status === "orphaned" && showOrphaned) return true; + if (s.status === "missing" && showMissing) return true; + return false; + }); +}