mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-17 23:51:11 +00:00
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:
parent
d96fb008f9
commit
c14b844c10
5 changed files with 381 additions and 491 deletions
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
133
skillhub-cli/src/core/skill-status.ts
Normal file
133
skillhub-cli/src/core/skill-status.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue