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.
This commit is contained in:
chenbaowang 2026-04-24 14:58:07 +08:00
parent d96fb008f9
commit c14b844c10
5 changed files with 381 additions and 491 deletions

View file

@ -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 <value>", 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 <skill>", 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 <skill>", desc: "Star or unstar a skill" },
{ cmd: "rating <skill>", desc: "View your rating for a skill" },
{ cmd: "rate <skill> <score>", desc: "Rate a skill (1-5)" },
{ cmd: "report <skill>", 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 <skill>", desc: "Star or unstar a skill" },
{ cmd: "rating <skill>", desc: "View your rating for a skill" },
{ cmd: "rate <skill> <score>", desc: "Rate a skill (1-5)" },
{ cmd: "report <skill>", 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 <skill>", desc: "Hide a skill (admin only)" },
{ cmd: "unhide <skill>", desc: "Unhide a skill (admin only)" },
{ cmd: "transfer <ns> <user>", desc: "Transfer namespace ownership" },
]));
sections.push("");
sections.push(formatSection("Configuration", [
{ cmd: "config list", desc: "Show current registry configuration" },
{ cmd: "config set <value>", 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"));

View file

@ -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<string, string[]> {
const skillsMap = new Map<string, string[]>();
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>", "Scope to check (global, project, all)")
.option("--agent <agents...>", "Filter by specific agents")
.option("--status <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<string>();
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("");
});
}

View file

@ -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>", "Scope to list (global, project, all)")
.option("--agent <agents...>", "Filter by specific agents")
.option("--status <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<string, Map<string, string[]>>();
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<string, Map<string, string[]>>,
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("");
}

View file

@ -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);

View file

@ -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<string, SkillLocation[]> {
const skillsMap = new Map<string, SkillLocation[]>();
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<DiscoveredSkill[]> {
const lockedSkills = await getAllLockedSkills();
const allInstalled = new Map<string, SkillLocation[]>();
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<string>();
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;
});
}