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
This commit is contained in:
chenbaowang 2026-04-20 20:24:44 +08:00
parent 3cf4d64cf5
commit ee18161d56
2 changed files with 185 additions and 54 deletions

View file

@ -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<string, string[]> {
function findInstalledSkills(
scope: "local" | "global",
agents?: AgentInfo[]
): Map<string, string[]> {
const skillsMap = new Map<string, string[]>();
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 <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; 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<string>();
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}`);

View file

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