feat(cli): add scope-aware universal grouping and improve output formatting

- list.ts: use isUniversalForScope for dynamic grouping based on scope
- list.ts: group output by skill name to avoid duplicate path listings
- uninstall.ts: use isUniversalForScope for dynamic grouping
- uninstall.ts: add printUninstallResults for consistent formatted output
- .npmignore: exclude development files from npm package
- package.json: bump version to 1.1.1
This commit is contained in:
chenbaowang 2026-04-20 19:46:21 +08:00
parent e5da7397fa
commit 04bc62fed4
4 changed files with 151 additions and 44 deletions

View file

@ -5,3 +5,10 @@ unbuild.config.ts
tsconfig.json
*.test.ts
src/
.claude/
.omc/
AGENTS.md
README.md
Makefile
docs/
scripts/

View file

@ -1,6 +1,6 @@
{
"name": "motovis-skillhub",
"version": "1.1.0",
"version": "1.1.1",
"type": "module",
"description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间",
"bin": {

View file

@ -2,7 +2,7 @@ import { Command } from "commander";
import { existsSync, readdirSync, lstatSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { getAllAgents, getUniversalAgents, getNonUniversalAgents } from "../core/agent-detector.js";
import { getAllAgents, isUniversalForScope } from "../core/agent-detector.js";
import { info, dim } from "../utils/logger.js";
import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js";
import * as p from "@clack/prompts";
@ -53,11 +53,20 @@ export function registerList(program: Command) {
}
}
const universalAgents = getUniversalAgents();
const nonUniversalAgents = getNonUniversalAgents();
// Determine scope for dynamic universal grouping
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: "Universal (.agents/skills)",
title: canonicalLabel,
items: universalAgents.map((a) => ({
value: a.key,
label: a.name,
@ -81,7 +90,7 @@ export function registerList(program: Command) {
}
const selectedAgents = agentSelection as string[];
const agents = getAllAgents().filter((a) => selectedAgents.includes(a.key));
const agents = allAgents.filter((a) => selectedAgents.includes(a.key));
if (agents.length === 0) {
console.log("No agents selected.");
@ -90,44 +99,76 @@ export function registerList(program: Command) {
console.log("");
let found = false;
// 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(process.cwd(), agent.skillsDir);
const skills = getSkillsInDir(projectDir);
if (skills.length > 0) {
found = true;
info(`\n${agent.name} (project):`);
for (const s of skills) {
dim(` ${s}`);
}
}
const projectDir = join(cwd, agent.skillsDir);
collectSkills(skillMap, projectDir, agent.name, cwd, true);
}
if (showGlobal && agent.globalSkillsDir) {
const globalDir = join(homedir(), agent.globalSkillsDir);
const skills = getSkillsInDir(globalDir);
if (skills.length > 0) {
found = true;
info(`\n${agent.name} (global):`);
for (const s of skills) {
dim(` ${s}`);
}
}
const globalDir = join(home, agent.globalSkillsDir);
collectSkills(skillMap, globalDir, agent.name, home, false);
}
}
if (!found) {
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("");
});
}
/**
* 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,
) {
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);
}
}
function getSkillsInDir(dir: string): string[] {
if (!existsSync(dir)) return [];
return readdirSync(dir).filter((f) => {
@ -138,5 +179,5 @@ function getSkillsInDir(dir: string): string[] {
} catch {
return false;
}
});
}).sort((a, b) => a.localeCompare(b));
}

View file

@ -2,11 +2,12 @@ import { Command } from "commander";
import { existsSync, readdirSync, statSync, unlinkSync, rmdirSync, lstatSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { getAllAgents, isUniversalForScope, getUniversalAgents, getNonUniversalAgents, type AgentInfo } from "../core/agent-detector.js";
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 { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js";
import * as p from "@clack/prompts";
import pc from "picocolors";
function removeDir(path: string) {
try {
@ -100,7 +101,7 @@ function discoverInstalledSkills(scope: "local" | "global", agent?: AgentInfo):
} catch {}
}
return [...new Set(skills)];
return [...new Set(skills)].sort((a, b) => a.localeCompare(b));
}
function findAgentsWithSkill(skillName: string, scope: "global" | "local", agents: AgentInfo[]): AgentInfo[] {
@ -145,6 +146,7 @@ export function registerUninstall(program: Command) {
}
const allAgents = getAllAgents();
const isGlobal = scope === "global";
if (opts.all) {
const skills = discoverInstalledSkills(scope);
@ -166,18 +168,19 @@ export function registerUninstall(program: Command) {
}
const selectedSkills = selected as string[];
let uninstalled = 0;
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);
if (ok) uninstalled++;
const skillPath = getSkillPath(skill, agent, scope);
results.push({ skill, agent: agent.name, path: skillPath || "", ok });
}
await removeFromLock(skill);
}
success(`Uninstalled ${uninstalled} skill(s).`);
printUninstallResults(results);
return;
}
@ -201,18 +204,19 @@ export function registerUninstall(program: Command) {
}
const selectedSkills = selected as string[];
let uninstalled = 0;
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);
if (ok) uninstalled++;
const skillPath = getSkillPath(skill, agent, scope);
results.push({ skill, agent: agent.name, path: skillPath || "", ok });
}
await removeFromLock(skill);
}
success(`Uninstalled ${uninstalled} skill(s).`);
printUninstallResults(results);
return;
}
@ -240,11 +244,17 @@ export function registerUninstall(program: Command) {
}
}
const universalAgents = getUniversalAgents();
const nonUniversalAgents = getNonUniversalAgents();
// Dynamic universal grouping based on scope
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: "Universal (.agents/skills)",
title: canonicalLabel,
items: universalAgents
.filter((a) => agentsWithSkill.some((w) => w.key === a.key))
.map((a) => ({
@ -317,12 +327,13 @@ export function registerUninstall(program: Command) {
if (pathToAgents.size > 0) {
const lines: string[] = [];
for (const [path, agents] of pathToAgents) {
if (agents.length > 1) {
lines.push(` ${agents.join(", ")} (${path})`);
} else {
lines.push(` ${agents[0]} (${path})`);
}
const sortedEntries = [...pathToAgents.entries()].sort((a, b) => a[0].localeCompare(b[0]));
for (const [path, agents] of sortedEntries) {
const sorted = agents.sort((a, b) => a.localeCompare(b));
const label = sorted.length <= 5
? sorted.join(", ")
: sorted.slice(0, 5).join(", ") + ` ${pc.dim(`+${sorted.length - 5}`)}`;
lines.push(` ${pc.dim("→")} ${label}: ${pc.dim(path)}`);
}
success(`Uninstalled ${name} from ${selectedAgentKeys.length} agent(s):`);
console.log(lines.join("\n"));
@ -332,3 +343,51 @@ export function registerUninstall(program: Command) {
}
});
}
/**
* Print uninstall results grouped by skill and path (consistent with install output format).
*/
function printUninstallResults(results: { skill: string; agent: string; path: string; ok: boolean }[]) {
const successful = results.filter((r) => r.ok);
if (successful.length === 0) {
info("No skills were uninstalled.");
return;
}
// Group by skill
const skillGroups = new Map<string, { agent: string; path: string }[]>();
for (const r of successful) {
let group = skillGroups.get(r.skill);
if (!group) {
group = [];
skillGroups.set(r.skill, group);
}
group.push({ agent: r.agent, path: r.path });
}
const lines: string[] = [];
const sortedSkills = [...skillGroups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
for (const [skillName, entries] of sortedSkills) {
lines.push(`${pc.green("✓")} ${skillName}`);
// Group by path
const pathGroups = new Map<string, string[]>();
for (const e of entries) {
const agents = pathGroups.get(e.path) || [];
agents.push(e.agent);
pathGroups.set(e.path, agents);
}
const sortedPaths = [...pathGroups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
for (const [path, agents] of sortedPaths) {
const sorted = agents.sort((a, b) => a.localeCompare(b));
const label = sorted.length <= 5
? sorted.join(", ")
: sorted.slice(0, 5).join(", ") + ` ${pc.dim(`+${sorted.length - 5}`)}`;
lines.push(` ${pc.dim("→")} ${label}: ${pc.dim(path)}`);
}
}
p.note(lines.join("\n"), `Uninstalled ${successful.length} skill(s)`);
}