roster-cli 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -83,9 +83,11 @@ Once installed:
83
83
  (overlap, missing harness/tools, routing ambiguity, cost); runs the bundled
84
84
  CLI and explains how to read the findings.
85
85
  - **`roster-drift.sh` hook** (`SessionStart`) — on each session, diffs
86
- `.claude/agents/` against a cached snapshot and prints a short advisory if
86
+ `.claude/agents/` against a cached snapshot and emits a short advisory if
87
87
  agents were added/removed/changed (`ROSTER_DRIFT_DISABLE=1` to opt out).
88
- Advisory only never blocks a session.
88
+ The advisory goes to both Claude's session context (so it can offer an
89
+ audit proactively) and stderr (so you see it in the terminal). Advisory
90
+ only — never blocks a session.
89
91
 
90
92
  ## Contributing
91
93
 
package/dist/cli.js CHANGED
@@ -169,5 +169,8 @@ export async function main(argv) {
169
169
  }
170
170
  const isDirectRun = process.argv[1] !== undefined && import.meta.url === `file://${process.argv[1]}`;
171
171
  if (isDirectRun) {
172
- main(process.argv.slice(2)).then((code) => process.exit(code));
172
+ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
173
+ console.error(err instanceof Error ? err.message : String(err));
174
+ process.exit(2);
175
+ });
173
176
  }
@@ -67,8 +67,12 @@ export const overlapRule = {
67
67
  pairs.sort((a, b) => b.score - a.score);
68
68
  const topPairs = pairs.slice(0, top);
69
69
  return topPairs.map(({ i, j, score }) => {
70
- const a = agents[i].name;
71
- const b = agents[j].name;
70
+ // Same name from two sources (e.g. a repo audited via dir that is also
71
+ // installed as a plugin) — disambiguate with the source label so the
72
+ // pair never renders as "x <-> x".
73
+ const sameName = agents[i].name === agents[j].name;
74
+ const a = sameName ? `${agents[i].name} (${agents[i].sourceLabel})` : agents[i].name;
75
+ const b = sameName ? `${agents[j].name} (${agents[j].sourceLabel})` : agents[j].name;
72
76
  const critical = failAbove !== undefined && score > failAbove;
73
77
  return {
74
78
  ruleId: 'overlap',
@@ -40,7 +40,17 @@ export const dirSource = {
40
40
  if (!dir) {
41
41
  throw new Error('sources/dir: opts.dir is required');
42
42
  }
43
- const files = await collectMarkdownFiles(dir);
43
+ let files;
44
+ try {
45
+ files = await collectMarkdownFiles(dir);
46
+ }
47
+ catch (err) {
48
+ const code = err.code;
49
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
50
+ throw new Error(`sources/dir: "${dir}" is not a readable directory`);
51
+ }
52
+ throw err;
53
+ }
44
54
  const agents = await Promise.all(files.map(async (filePath) => {
45
55
  const raw = await readFile(filePath, 'utf8');
46
56
  return parseAgentMarkdown(raw, filePath, `dir:${dir}`);
@@ -12,21 +12,70 @@ async function readInstalledPlugins(pluginsRoot) {
12
12
  return undefined;
13
13
  }
14
14
  }
15
- function resolveActivePlugins(data) {
16
- const active = [];
17
- const seenInstallPaths = new Set();
15
+ function compareVersions(a, b) {
16
+ const pa = a.split('.').map((n) => parseInt(n, 10));
17
+ const pb = b.split('.').map((n) => parseInt(n, 10));
18
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
19
+ const na = pa[i];
20
+ const nb = pb[i];
21
+ if (Number.isNaN(na ?? NaN) || Number.isNaN(nb ?? NaN)) {
22
+ // Non-numeric segment (git SHAs etc.) — fall back to string compare.
23
+ return a < b ? -1 : a > b ? 1 : 0;
24
+ }
25
+ if ((na ?? 0) !== (nb ?? 0))
26
+ return (na ?? 0) - (nb ?? 0);
27
+ }
28
+ return 0;
29
+ }
30
+ function pickPreferred(a, b, cwd) {
31
+ const aCwd = a.projectPath !== undefined && path.resolve(a.projectPath) === cwd;
32
+ const bCwd = b.projectPath !== undefined && path.resolve(b.projectPath) === cwd;
33
+ if (aCwd !== bCwd)
34
+ return aCwd ? a : b;
35
+ const aUser = a.scope === 'user';
36
+ const bUser = b.scope === 'user';
37
+ if (aUser !== bUser)
38
+ return aUser ? a : b;
39
+ return compareVersions(a.version, b.version) >= 0 ? a : b;
40
+ }
41
+ function resolveActivePlugins(data, cwd) {
42
+ const byName = new Map();
43
+ const skipped = [];
18
44
  for (const [key, entries] of Object.entries(data.plugins ?? {})) {
19
45
  const sepIndex = key.indexOf('@');
20
46
  const name = sepIndex === -1 ? key : key.slice(0, sepIndex);
21
47
  const marketplace = sepIndex === -1 ? '' : key.slice(sepIndex + 1);
22
48
  for (const entry of entries) {
23
- if (!entry.installPath || seenInstallPaths.has(entry.installPath))
49
+ if (!entry.installPath)
24
50
  continue;
25
- seenInstallPaths.add(entry.installPath);
26
- active.push({ name, marketplace, version: entry.version, installPath: entry.installPath });
51
+ const candidate = {
52
+ name,
53
+ marketplace,
54
+ version: entry.version,
55
+ installPath: entry.installPath,
56
+ scope: entry.scope,
57
+ projectPath: entry.projectPath,
58
+ };
59
+ const current = byName.get(name);
60
+ if (!current) {
61
+ byName.set(name, candidate);
62
+ }
63
+ else if (current.installPath !== candidate.installPath) {
64
+ const winner = pickPreferred(current, candidate, cwd);
65
+ const loser = winner === current ? candidate : current;
66
+ byName.set(name, winner);
67
+ skipped.push(loser);
68
+ }
69
+ }
70
+ }
71
+ for (const loser of skipped) {
72
+ const winner = byName.get(loser.name);
73
+ if (winner && winner.installPath !== loser.installPath) {
74
+ console.error(`sources/plugin-cache: skipped ${loser.name}@${loser.version}` +
75
+ `${loser.projectPath ? ` (pinned by ${loser.projectPath})` : ''} — using ${winner.version}`);
27
76
  }
28
77
  }
29
- return active;
78
+ return [...byName.values()];
30
79
  }
31
80
  async function collectMarkdownFiles(root) {
32
81
  let entries;
@@ -71,8 +120,9 @@ export const pluginCacheSource = {
71
120
  console.error(`sources/plugin-cache: ${pluginsRoot}/installed_plugins.json not found — returning empty roster`);
72
121
  return [];
73
122
  }
123
+ const cwd = path.resolve(opts?.cwd ?? process.cwd());
74
124
  const pluginNameFilter = opts?.pluginName;
75
- const activePlugins = resolveActivePlugins(data).filter((p) => !pluginNameFilter || p.name === pluginNameFilter);
125
+ const activePlugins = resolveActivePlugins(data, cwd).filter((p) => !pluginNameFilter || p.name === pluginNameFilter);
76
126
  const agentLists = await Promise.all(activePlugins.map(async (plugin) => {
77
127
  const agentsDir = path.join(plugin.installPath, 'agents');
78
128
  if (!(await dirExists(agentsDir)))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roster-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Zero-runtime-dep static auditor for Claude Code agent rosters \u2014 overlap, harness gaps, routing, cost.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,8 @@
15
15
  "scripts": {
16
16
  "build": "tsc",
17
17
  "test": "vitest run",
18
- "prepublishOnly": "npm run build"
18
+ "prepublishOnly": "npm run build",
19
+ "prepare": "npm run build"
19
20
  },
20
21
  "devDependencies": {
21
22
  "typescript": "^5.6.3",