From ee18161d5674581b0b8cc61c34b40bcc82702ae6 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:24:44 +0800 Subject: [PATCH] feat(cli): improve list and check commands with better filtering list.ts: - Remove locked universal section, all agents are now equal - Users manually select which agents to list from - No default selections, cleaner UX check.ts: - Add agent filtering via --agent flag or interactive selection - Add status filtering via --status flag or interactive selection - Default to showing only OK + Missing (not Orphaned) - Interactive prompts for scope, agents, and statuses - Show agent names and scope prefix in output - Smart defaults reduce noise from orphaned skills --- skillhub-cli/src/commands/check.ts | 215 ++++++++++++++++++++++++----- skillhub-cli/src/commands/list.ts | 24 +--- 2 files changed, 185 insertions(+), 54 deletions(-) diff --git a/skillhub-cli/src/commands/check.ts b/skillhub-cli/src/commands/check.ts index 9580d8b4..2240efd9 100644 --- a/skillhub-cli/src/commands/check.ts +++ b/skillhub-cli/src/commands/check.ts @@ -2,9 +2,11 @@ import { Command } from "commander"; import { existsSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; -import { getAllAgents } from "../core/agent-detector.js"; +import { 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; @@ -13,11 +15,15 @@ interface CheckResult { location?: string; } -function findInstalledSkills(scope: "local" | "global"): Map { +function findInstalledSkills( + scope: "local" | "global", + agents?: AgentInfo[] +): Map { const skillsMap = new Map(); - const agents = getAllAgents(); + const allAgents = getAllAgents(); + const targetAgents = agents || allAgents; - for (const agent of agents) { + for (const agent of targetAgents) { const baseDir = scope === "global" ? join(homedir(), agent.globalSkillsDir || agent.skillsDir) : join(process.cwd(), agent.skillsDir); @@ -44,9 +50,118 @@ export function registerCheck(program: Command) { .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") + .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; json?: boolean }) => { - const scope = opts.global ? "global" : "local"; + .action(async (opts: { + global?: boolean; + local?: boolean; + all?: boolean; + agent?: string[]; + status?: string[]; + json?: boolean; + }) => { + // Determine scopes + let scopes: ("local" | "global")[] = []; + + 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)) { @@ -59,21 +174,46 @@ export function registerCheck(program: Command) { } const lockedSkills = await getAllLockedSkills(); - const installedSkills = findInstalledSkills(scope); + const allResults: CheckResult[] = []; - const results: 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)) { - const installedLocations = installedSkills.get(name); - if (installedLocations && installedLocations.length > 0) { - results.push({ - name, - status: "ok", - source: entry.source, - location: installedLocations.sort((a, b) => a.localeCompare(b)).join(", "), - }); - } else { - results.push({ + if (!checkedNames.has(name)) { + allResults.push({ name, status: "missing", source: entry.source, @@ -81,41 +221,46 @@ export function registerCheck(program: Command) { } } - for (const [name, locations] of installedSkills.entries()) { - if (!lockedSkills[name]) { - results.push({ - name, - status: "orphaned", - location: locations.sort((a, b) => a.localeCompare(b)).join(", "), - }); - } - } - // Sort results: ok → missing → orphaned, then alphabetically by name - results.sort((a, b) => { + 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); }); if (opts.json) { - console.log(JSON.stringify(results, null, 2)); + 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 (${scope} scope):`); + info(`SkillHub Lock Check (${scopeLabel})${agentLabel}:`); console.log(""); - if (results.length === 0) { - dim(" No skills found."); + if (filteredResults.length === 0) { + dim(" No matching skills found."); console.log(""); return; } - let ok = 0, missing = 0, orphaned = 0; + let ok = 0, + missing = 0, + orphaned = 0; - for (const r of results) { + for (const r of filteredResults) { if (r.status === "ok") { ok++; success(` ✓ ${r.name}`); diff --git a/skillhub-cli/src/commands/list.ts b/skillhub-cli/src/commands/list.ts index 85a4f8be..c9cf1fc5 100644 --- a/skillhub-cli/src/commands/list.ts +++ b/skillhub-cli/src/commands/list.ts @@ -57,31 +57,17 @@ export function registerList(program: Command) { const isGlobal = scopeGlobal === true; const allAgents = getAllAgents(); - const universalAgents = allAgents - .filter((a) => isUniversalForScope(a, isGlobal) && a.showInUniversalList !== false) - .sort((a, b) => a.name.localeCompare(b.name)); - const nonUniversalAgents = allAgents - .filter((a) => !isUniversalForScope(a, isGlobal)) - .sort((a, b) => a.name.localeCompare(b.name)); - - const canonicalLabel = isGlobal ? "Universal (~/.agents/skills)" : "Universal (.agents/skills)"; - const universalSection = { - title: canonicalLabel, - items: universalAgents.map((a) => ({ + // All agents are selectable - no locked section + const selectableItems = allAgents + .map((a) => ({ value: a.key, label: a.name, - })), - }; - - const selectableItems = nonUniversalAgents.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, - lockedSection: universalSection, }); if (agentSelection === cancelSymbol) {