roster-cli 0.1.3 → 0.3.0
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 +83 -12
- package/dist/cli.js +28 -3
- package/dist/doccheck.js +215 -0
- package/dist/sources/plugin-cache.js +16 -2
- package/dist/usage.js +224 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,21 +25,73 @@ npx roster-cli audit <dir>
|
|
|
25
25
|
|
|
26
26
|
## Usage
|
|
27
27
|
|
|
28
|
+
roster is one binary with three commands: `audit`, `doccheck`, and `usage`.
|
|
29
|
+
|
|
28
30
|
```sh
|
|
29
31
|
roster audit <dir>
|
|
30
32
|
roster audit <dir> --user
|
|
31
33
|
roster audit <dir> --repo owner/name
|
|
32
34
|
roster audit <dir> --html report.html
|
|
35
|
+
roster audit --plugin --enabled-only
|
|
36
|
+
roster doccheck README.md
|
|
37
|
+
roster usage --days 14 --user
|
|
33
38
|
```
|
|
34
39
|
|
|
35
|
-
Full flag surface
|
|
40
|
+
Full flag surface for `audit`:
|
|
36
41
|
|
|
37
42
|
```
|
|
38
43
|
roster audit <dir> [--json] [--html <out>] [--user] [--plugin [name]]
|
|
39
|
-
[--repo <owner/name[@ref]>] [--top <n>]
|
|
44
|
+
[--enabled-only] [--repo <owner/name[@ref]>] [--top <n>]
|
|
40
45
|
[--fail-above <s>] [--no-fail]
|
|
41
46
|
```
|
|
42
47
|
|
|
48
|
+
`--enabled-only` (with `--plugin`) restricts the plugin-cache source to entries
|
|
49
|
+
active for the current project.[^enabled-only]
|
|
50
|
+
|
|
51
|
+
[^enabled-only]: Activation is judged purely by installation **scope**
|
|
52
|
+
(user-scope entries are always active; local/project-scope entries are
|
|
53
|
+
active only when the cwd is inside the pinning project). It does not read
|
|
54
|
+
per-project enable/disable state from `settings.json`.
|
|
55
|
+
|
|
56
|
+
## doccheck
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
roster doccheck README.md
|
|
60
|
+
roster doccheck docs/
|
|
61
|
+
roster doccheck # defaults to README.md + docs/**/*.md
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Scans fenced `sh`/`bash`/`shell` code blocks in markdown docs for commands
|
|
65
|
+
that would fail if a reader copy-pasted them: dead relative paths, missing
|
|
66
|
+
`npm run <script>` scripts, and scripts that exist on disk but lack the
|
|
67
|
+
executable bit.
|
|
68
|
+
|
|
69
|
+
To keep false positives at zero, it skips anything it can't verify cheaply:
|
|
70
|
+
absolute paths (`/plugin ...`), `npx ...` invocations, and bare global
|
|
71
|
+
binaries (`node`, `git`, ...) with no path separator.
|
|
72
|
+
|
|
73
|
+
Exits `1` if any finding is reported, `0` otherwise (`--json` for machine-
|
|
74
|
+
readable output).
|
|
75
|
+
|
|
76
|
+
## usage
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
roster usage
|
|
80
|
+
roster usage --days 14
|
|
81
|
+
roster usage --user
|
|
82
|
+
roster usage --plugin --json
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Aggregates how often each `subagent_type` was invoked (via the Agent/Task
|
|
86
|
+
tool) across Claude Code transcript files under `~/.claude` (override with
|
|
87
|
+
`ROSTER_CLAUDE_DIR`), within the last `--days` (default `30`).
|
|
88
|
+
|
|
89
|
+
Joining with `--user` and/or `--plugin` also reports:
|
|
90
|
+
- **unused** — agents present in the roster with zero observed invocations
|
|
91
|
+
- **ghosts** — invoked `subagent_type` values that don't match any roster agent
|
|
92
|
+
|
|
93
|
+
Always exits `0` — this is a reporting tool, not a gate.
|
|
94
|
+
|
|
43
95
|
## Rules
|
|
44
96
|
|
|
45
97
|
| Rule | Description | Status |
|
|
@@ -55,11 +107,21 @@ roster audit <dir> [--json] [--html <out>] [--user] [--plugin [name]]
|
|
|
55
107
|
`roster audit --repo` run against well-known public agent rosters (SHA-pinned,
|
|
56
108
|
reproducible via `scripts/bench.sh`). Full reports: `docs/benchmarks/`.
|
|
57
109
|
|
|
58
|
-
|
|
110
|
+
A weekly cron re-runs the benchmark suite against each roster's latest
|
|
111
|
+
upstream HEAD and opens a refresh PR with any changes.[^leaderboard-cron]
|
|
112
|
+
|
|
113
|
+
<!-- bench:start -->
|
|
114
|
+
| Repo | Agents | Top overlap pair | No-tools % | Fixed cost |
|
|
59
115
|
| --- | --- | --- | --- | --- |
|
|
60
|
-
| [
|
|
61
|
-
| [
|
|
62
|
-
| [
|
|
116
|
+
| [contains-studio/agents](https://github.com/contains-studio/agents) | 37 | content-creator <-> instagram-curator (0.565) | 16.2% | ~8421 tokens/turn |
|
|
117
|
+
| [msitarzewski/agency-agents](https://github.com/msitarzewski/agency-agents) | 277 | Backend Architect <-> Backend Architect (0.878) | 93.9% | ~14024 tokens/turn |
|
|
118
|
+
| [wshobson/agents](https://github.com/wshobson/agents) | 691 | api-scaffolding-backend-architect <-> backend-api-security-backend-architect (1.000) | 97.8% | ~24853 tokens/turn |
|
|
119
|
+
<!-- bench:end -->
|
|
120
|
+
|
|
121
|
+
[^leaderboard-cron]: Runs every Monday via `.github/workflows/leaderboard.yml`.
|
|
122
|
+
GitHub automatically disables scheduled workflows after 60 days of repo
|
|
123
|
+
inactivity — re-enable with a manual `workflow_dispatch` run if that
|
|
124
|
+
happens.
|
|
63
125
|
|
|
64
126
|
Several top pairs score at or near 1.000 similarity (e.g. wshobson/agents has
|
|
65
127
|
five pairs at a perfect 1.000) — these are near-duplicate agent files (same
|
|
@@ -82,12 +144,21 @@ Once installed:
|
|
|
82
144
|
- **`roster-audit` skill** — triggers when you ask to audit an agent roster
|
|
83
145
|
(overlap, missing harness/tools, routing ambiguity, cost); runs the bundled
|
|
84
146
|
CLI and explains how to read the findings.
|
|
85
|
-
- **`roster-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
147
|
+
- **`roster-cleanup` skill** — triggers when you ask to clean up, prune, or
|
|
148
|
+
merge agents. Audits, classifies findings into concrete actions (delete /
|
|
149
|
+
move / merge / rename / uninstall / narrow tools), asks you to approve each
|
|
150
|
+
destructive step, executes only what you approved, then re-audits and
|
|
151
|
+
reports the delta.
|
|
152
|
+
- **`roster-drift.sh` hook** (`SessionStart`) — on each session, diffs the
|
|
153
|
+
watched agent-md dir(s) against a cached snapshot and emits a short
|
|
154
|
+
advisory if agents were added/removed/changed
|
|
155
|
+
(`ROSTER_DRIFT_DISABLE=1` to opt out). By default it watches
|
|
156
|
+
`.claude/agents` plus, in a plugin-layout repo (one with a top-level
|
|
157
|
+
`.claude-plugin/plugin.json`), the root `agents/` dir; override with
|
|
158
|
+
`ROSTER_DRIFT_DIR` (colon-separated dir list). The advisory goes to both
|
|
159
|
+
Claude's session context (so it can offer an audit proactively) and
|
|
160
|
+
stderr (so you see it in the terminal). Advisory only — never blocks a
|
|
161
|
+
session.
|
|
91
162
|
|
|
92
163
|
## Contributing
|
|
93
164
|
|
package/dist/cli.js
CHANGED
|
@@ -3,13 +3,21 @@ import { writeFileSync } from 'node:fs';
|
|
|
3
3
|
import { sources } from './sources/index.js';
|
|
4
4
|
import { rules } from './rules/index.js';
|
|
5
5
|
import { renderers } from './render/index.js';
|
|
6
|
-
|
|
6
|
+
import { run as runDoccheck } from './doccheck.js';
|
|
7
|
+
import { run as runUsage } from './usage.js';
|
|
8
|
+
const HELP_TEXT = `Usage: roster <command> [<dir>] [options]
|
|
9
|
+
|
|
10
|
+
Commands:
|
|
11
|
+
audit Audit a roster for overlaps and issues
|
|
12
|
+
doccheck Check docs for drift (not yet implemented)
|
|
13
|
+
usage Report agent usage stats (not yet implemented)
|
|
7
14
|
|
|
8
15
|
Options:
|
|
9
16
|
--json Output machine-readable JSON
|
|
10
17
|
--html <out> Write an HTML report to <out>
|
|
11
18
|
--user Load agents from the user-level agent directory
|
|
12
19
|
--plugin [name] Load agents from the plugin cache
|
|
20
|
+
--enabled-only With --plugin, only include entries active for the current project
|
|
13
21
|
--repo <owner/name[@ref]> Load agents from a GitHub repo
|
|
14
22
|
--top <n> Number of top overlapping pairs to report (default 10)
|
|
15
23
|
--fail-above <score> Mark overlap findings above <score> as critical
|
|
@@ -22,6 +30,7 @@ function parseArgs(argv) {
|
|
|
22
30
|
user: false,
|
|
23
31
|
plugin: false,
|
|
24
32
|
noFail: false,
|
|
33
|
+
enabledOnly: false,
|
|
25
34
|
help: false,
|
|
26
35
|
};
|
|
27
36
|
let i = 0;
|
|
@@ -71,6 +80,10 @@ function parseArgs(argv) {
|
|
|
71
80
|
result.noFail = true;
|
|
72
81
|
i += 1;
|
|
73
82
|
break;
|
|
83
|
+
case '--enabled-only':
|
|
84
|
+
result.enabledOnly = true;
|
|
85
|
+
i += 1;
|
|
86
|
+
break;
|
|
74
87
|
default:
|
|
75
88
|
if (result.dir === undefined && !arg.startsWith('--')) {
|
|
76
89
|
result.dir = arg;
|
|
@@ -82,11 +95,15 @@ function parseArgs(argv) {
|
|
|
82
95
|
return result;
|
|
83
96
|
}
|
|
84
97
|
export async function main(argv) {
|
|
85
|
-
if (argv.length === 0 || argv
|
|
98
|
+
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
|
86
99
|
console.log(HELP_TEXT);
|
|
87
100
|
return 0;
|
|
88
101
|
}
|
|
89
102
|
const [cmd, ...rest] = argv;
|
|
103
|
+
if (cmd === 'doccheck')
|
|
104
|
+
return runDoccheck(rest);
|
|
105
|
+
if (cmd === 'usage')
|
|
106
|
+
return runUsage(rest);
|
|
90
107
|
if (cmd !== 'audit') {
|
|
91
108
|
console.error(`unknown command: ${cmd}`);
|
|
92
109
|
console.error(HELP_TEXT);
|
|
@@ -111,6 +128,9 @@ export async function main(argv) {
|
|
|
111
128
|
console.error('audit requires a <dir> argument or one of --user / --plugin / --repo');
|
|
112
129
|
return 1;
|
|
113
130
|
}
|
|
131
|
+
if (parsed.enabledOnly && !parsed.plugin) {
|
|
132
|
+
console.error('--enabled-only has no effect without --plugin');
|
|
133
|
+
}
|
|
114
134
|
let rendererId = 'cli';
|
|
115
135
|
if (parsed.json)
|
|
116
136
|
rendererId = 'json';
|
|
@@ -141,7 +161,12 @@ export async function main(argv) {
|
|
|
141
161
|
console.error(`not implemented: source '${sourceId}'`);
|
|
142
162
|
return 2;
|
|
143
163
|
}
|
|
144
|
-
agents.push(...(await source.load({
|
|
164
|
+
agents.push(...(await source.load({
|
|
165
|
+
dir: parsed.dir,
|
|
166
|
+
pluginName: parsed.pluginName,
|
|
167
|
+
repo: parsed.repo,
|
|
168
|
+
enabledOnly: parsed.enabledOnly,
|
|
169
|
+
})));
|
|
145
170
|
}
|
|
146
171
|
const findings = [];
|
|
147
172
|
for (const rule of Object.values(rules)) {
|
package/dist/doccheck.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { accessSync, constants, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const SHELL_LANGS = new Set(['sh', 'bash', 'shell']);
|
|
4
|
+
const FENCE_OPEN_RE = /^\s*(`{3,}|~{3,})\s*([A-Za-z0-9_+-]*)\s*$/;
|
|
5
|
+
const HELP_TEXT = `Usage: roster doccheck [<file|dir> ...] [options]
|
|
6
|
+
|
|
7
|
+
Scans fenced sh/bash/shell code blocks in markdown docs for commands that
|
|
8
|
+
would fail if run: dead relative paths, missing npm run scripts, and
|
|
9
|
+
scripts that exist but lack the executable bit.
|
|
10
|
+
|
|
11
|
+
Arguments:
|
|
12
|
+
<file|dir> ... Markdown files or directories to scan (directories are
|
|
13
|
+
scanned recursively for *.md). Defaults to README.md and
|
|
14
|
+
docs/**/*.md, whichever exist, when omitted.
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
--json Output findings as JSON: { findings: [...] }
|
|
18
|
+
--help, -h Show this help text
|
|
19
|
+
`;
|
|
20
|
+
export async function run(argv) {
|
|
21
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
22
|
+
console.log(HELP_TEXT);
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
const jsonMode = argv.includes('--json');
|
|
26
|
+
const positional = argv.filter((a) => a !== '--json');
|
|
27
|
+
const targets = resolveTargets(positional);
|
|
28
|
+
const scripts = loadPackageScripts();
|
|
29
|
+
const findings = [];
|
|
30
|
+
for (const target of targets) {
|
|
31
|
+
findings.push(...checkFile(target, scripts));
|
|
32
|
+
}
|
|
33
|
+
if (jsonMode) {
|
|
34
|
+
console.log(JSON.stringify({ findings }));
|
|
35
|
+
}
|
|
36
|
+
else if (findings.length === 0) {
|
|
37
|
+
console.log('doccheck: no findings');
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
for (const f of findings) {
|
|
41
|
+
console.log(`${f.file}:${f.line} ${f.command} ${f.reason}`);
|
|
42
|
+
}
|
|
43
|
+
console.log(`doccheck: ${findings.length} finding(s)`);
|
|
44
|
+
}
|
|
45
|
+
return findings.length > 0 ? 1 : 0;
|
|
46
|
+
}
|
|
47
|
+
function resolveTargets(positional) {
|
|
48
|
+
if (positional.length === 0) {
|
|
49
|
+
const targets = [];
|
|
50
|
+
if (isFile('README.md'))
|
|
51
|
+
targets.push('README.md');
|
|
52
|
+
if (isDir('docs'))
|
|
53
|
+
targets.push(...collectMarkdownFiles('docs'));
|
|
54
|
+
return targets;
|
|
55
|
+
}
|
|
56
|
+
const targets = [];
|
|
57
|
+
for (const target of positional) {
|
|
58
|
+
if (isDir(target)) {
|
|
59
|
+
targets.push(...collectMarkdownFiles(target));
|
|
60
|
+
}
|
|
61
|
+
else if (isFile(target)) {
|
|
62
|
+
targets.push(target);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return targets;
|
|
66
|
+
}
|
|
67
|
+
function collectMarkdownFiles(dir) {
|
|
68
|
+
const result = [];
|
|
69
|
+
let entries;
|
|
70
|
+
try {
|
|
71
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const full = path.join(dir, entry.name);
|
|
78
|
+
if (entry.isDirectory()) {
|
|
79
|
+
result.push(...collectMarkdownFiles(full));
|
|
80
|
+
}
|
|
81
|
+
else if (entry.isFile() && path.extname(entry.name).toLowerCase() === '.md') {
|
|
82
|
+
result.push(full);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
function loadPackageScripts() {
|
|
88
|
+
try {
|
|
89
|
+
const raw = readFileSync(path.join(process.cwd(), 'package.json'), 'utf8');
|
|
90
|
+
const pkg = JSON.parse(raw);
|
|
91
|
+
return new Set(Object.keys(pkg.scripts ?? {}));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return new Set();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function checkFile(filePath, scripts) {
|
|
98
|
+
let content;
|
|
99
|
+
try {
|
|
100
|
+
content = readFileSync(filePath, 'utf8');
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
const findings = [];
|
|
106
|
+
for (const block of extractShellBlocks(content)) {
|
|
107
|
+
for (const { text, lineNumber } of block) {
|
|
108
|
+
const reason = checkCommandLine(text, scripts);
|
|
109
|
+
if (reason) {
|
|
110
|
+
findings.push({ file: filePath, line: lineNumber, command: text.trim(), reason });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return findings;
|
|
115
|
+
}
|
|
116
|
+
function extractShellBlocks(content) {
|
|
117
|
+
const lines = content.split(/\r?\n/);
|
|
118
|
+
const blocks = [];
|
|
119
|
+
let i = 0;
|
|
120
|
+
while (i < lines.length) {
|
|
121
|
+
const openMatch = FENCE_OPEN_RE.exec(lines[i]);
|
|
122
|
+
if (!openMatch) {
|
|
123
|
+
i += 1;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const fenceChar = openMatch[1][0];
|
|
127
|
+
const fenceLen = openMatch[1].length;
|
|
128
|
+
const lang = openMatch[2].toLowerCase();
|
|
129
|
+
const closeRe = new RegExp(`^\\s*[${fenceChar}]{${fenceLen},}\\s*$`);
|
|
130
|
+
let j = i + 1;
|
|
131
|
+
while (j < lines.length && !closeRe.test(lines[j])) {
|
|
132
|
+
j += 1;
|
|
133
|
+
}
|
|
134
|
+
if (SHELL_LANGS.has(lang)) {
|
|
135
|
+
const blockLines = [];
|
|
136
|
+
for (let k = i + 1; k < j; k += 1) {
|
|
137
|
+
blockLines.push({ text: lines[k], lineNumber: k + 1 });
|
|
138
|
+
}
|
|
139
|
+
blocks.push(blockLines);
|
|
140
|
+
}
|
|
141
|
+
i = j + 1;
|
|
142
|
+
}
|
|
143
|
+
return blocks;
|
|
144
|
+
}
|
|
145
|
+
function checkCommandLine(rawLine, scripts) {
|
|
146
|
+
const trimmed = rawLine.trim();
|
|
147
|
+
if (trimmed === '' || trimmed.startsWith('#'))
|
|
148
|
+
return null;
|
|
149
|
+
let tokens = trimmed.split(/\s+/).filter(Boolean);
|
|
150
|
+
while (tokens.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=\S*$/.test(tokens[0])) {
|
|
151
|
+
tokens = tokens.slice(1);
|
|
152
|
+
}
|
|
153
|
+
if (tokens.length === 0)
|
|
154
|
+
return null;
|
|
155
|
+
const cmd = tokens[0];
|
|
156
|
+
if (cmd === 'npm') {
|
|
157
|
+
if (tokens[1] === 'run' && tokens[2]) {
|
|
158
|
+
const scriptName = tokens[2];
|
|
159
|
+
if (!scripts.has(scriptName)) {
|
|
160
|
+
return `npm script not found: ${scriptName}`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
if (cmd === 'npx')
|
|
166
|
+
return null;
|
|
167
|
+
if (cmd.startsWith('/'))
|
|
168
|
+
return null; // absolute path or a slash-command like /plugin
|
|
169
|
+
if (cmd.startsWith('./') || cmd.includes('/')) {
|
|
170
|
+
const resolved = path.join(process.cwd(), cmd);
|
|
171
|
+
if (!pathExists(resolved)) {
|
|
172
|
+
return `relative path not found: ${cmd}`;
|
|
173
|
+
}
|
|
174
|
+
if (!isExecutable(resolved)) {
|
|
175
|
+
return `not executable: ${cmd}`;
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
// No path separator and not npm/npx/absolute — treat as a global binary, skip.
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
function isFile(p) {
|
|
183
|
+
try {
|
|
184
|
+
return statSync(p).isFile();
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function isDir(p) {
|
|
191
|
+
try {
|
|
192
|
+
return statSync(p).isDirectory();
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function pathExists(p) {
|
|
199
|
+
try {
|
|
200
|
+
statSync(p);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function isExecutable(p) {
|
|
208
|
+
try {
|
|
209
|
+
accessSync(p, constants.X_OK);
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -38,7 +38,18 @@ function pickPreferred(a, b, cwd) {
|
|
|
38
38
|
return aUser ? a : b;
|
|
39
39
|
return compareVersions(a.version, b.version) >= 0 ? a : b;
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
// --enabled-only: user-scope (or scope absent) entries are always active; a
|
|
42
|
+
// local/project-scope entry is only active for the project that pinned it —
|
|
43
|
+
// i.e. cwd is that project's directory or a subdirectory of it.
|
|
44
|
+
function passesEnabledOnly(entry, cwd) {
|
|
45
|
+
if (entry.scope !== 'local' && entry.scope !== 'project')
|
|
46
|
+
return true;
|
|
47
|
+
if (!entry.projectPath)
|
|
48
|
+
return false;
|
|
49
|
+
const projectPath = path.resolve(entry.projectPath);
|
|
50
|
+
return cwd === projectPath || cwd.startsWith(projectPath + path.sep);
|
|
51
|
+
}
|
|
52
|
+
function resolveActivePlugins(data, cwd, enabledOnly) {
|
|
42
53
|
const byName = new Map();
|
|
43
54
|
const skipped = [];
|
|
44
55
|
for (const [key, entries] of Object.entries(data.plugins ?? {})) {
|
|
@@ -48,6 +59,8 @@ function resolveActivePlugins(data, cwd) {
|
|
|
48
59
|
for (const entry of entries) {
|
|
49
60
|
if (!entry.installPath)
|
|
50
61
|
continue;
|
|
62
|
+
if (enabledOnly && !passesEnabledOnly(entry, cwd))
|
|
63
|
+
continue;
|
|
51
64
|
const candidate = {
|
|
52
65
|
name,
|
|
53
66
|
marketplace,
|
|
@@ -122,7 +135,8 @@ export const pluginCacheSource = {
|
|
|
122
135
|
}
|
|
123
136
|
const cwd = path.resolve(opts?.cwd ?? process.cwd());
|
|
124
137
|
const pluginNameFilter = opts?.pluginName;
|
|
125
|
-
const
|
|
138
|
+
const enabledOnly = opts?.enabledOnly ?? false;
|
|
139
|
+
const activePlugins = resolveActivePlugins(data, cwd, enabledOnly).filter((p) => !pluginNameFilter || p.name === pluginNameFilter);
|
|
126
140
|
const agentLists = await Promise.all(activePlugins.map(async (plugin) => {
|
|
127
141
|
const agentsDir = path.join(plugin.installPath, 'agents');
|
|
128
142
|
if (!(await dirExists(agentsDir)))
|
package/dist/usage.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { sources } from './sources/index.js';
|
|
7
|
+
const HELP_TEXT = `Usage: roster usage [options]
|
|
8
|
+
|
|
9
|
+
Reports how often each subagent_type has been invoked (via the Agent/Task
|
|
10
|
+
tool) across Claude Code transcript files.
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--days <n> Only count invocations from the last <n> days (default 30)
|
|
14
|
+
--json Output machine-readable JSON
|
|
15
|
+
--user Join against the user-level agent roster (~/.claude/agents)
|
|
16
|
+
--plugin Join against the installed plugin-cache roster
|
|
17
|
+
--help, -h Show this help text
|
|
18
|
+
|
|
19
|
+
Env:
|
|
20
|
+
ROSTER_CLAUDE_DIR Transcripts root (default ~/.claude)
|
|
21
|
+
`;
|
|
22
|
+
function parseArgs(argv) {
|
|
23
|
+
const result = {
|
|
24
|
+
days: 30,
|
|
25
|
+
json: false,
|
|
26
|
+
user: false,
|
|
27
|
+
plugin: false,
|
|
28
|
+
help: false,
|
|
29
|
+
};
|
|
30
|
+
let i = 0;
|
|
31
|
+
while (i < argv.length) {
|
|
32
|
+
const arg = argv[i];
|
|
33
|
+
switch (arg) {
|
|
34
|
+
case '--help':
|
|
35
|
+
case '-h':
|
|
36
|
+
result.help = true;
|
|
37
|
+
i += 1;
|
|
38
|
+
break;
|
|
39
|
+
case '--json':
|
|
40
|
+
result.json = true;
|
|
41
|
+
i += 1;
|
|
42
|
+
break;
|
|
43
|
+
case '--user':
|
|
44
|
+
result.user = true;
|
|
45
|
+
i += 1;
|
|
46
|
+
break;
|
|
47
|
+
case '--plugin':
|
|
48
|
+
result.plugin = true;
|
|
49
|
+
i += 1;
|
|
50
|
+
break;
|
|
51
|
+
case '--days': {
|
|
52
|
+
const n = Number(argv[i + 1]);
|
|
53
|
+
if (Number.isFinite(n) && n > 0)
|
|
54
|
+
result.days = n;
|
|
55
|
+
i += 2;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
default:
|
|
59
|
+
i += 1;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Pure join between observed invocation counts and a roster's agent names.
|
|
67
|
+
* - unused: in the roster, but zero observed invocations.
|
|
68
|
+
* - ghosts: observed invocations for a subagent_type that isn't in the roster.
|
|
69
|
+
*/
|
|
70
|
+
export function computeJoin(counts, rosterNames) {
|
|
71
|
+
const rosterSet = new Set(rosterNames);
|
|
72
|
+
const unused = rosterNames.filter((name) => !counts[name]);
|
|
73
|
+
const ghosts = Object.keys(counts).filter((name) => !rosterSet.has(name));
|
|
74
|
+
return { unused, ghosts };
|
|
75
|
+
}
|
|
76
|
+
async function findJsonlFiles(root) {
|
|
77
|
+
const projectsDir = path.join(root, 'projects');
|
|
78
|
+
let projectEntries;
|
|
79
|
+
try {
|
|
80
|
+
projectEntries = await readdir(projectsDir, { withFileTypes: true });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
const files = [];
|
|
86
|
+
for (const projectEntry of projectEntries) {
|
|
87
|
+
if (!projectEntry.isDirectory())
|
|
88
|
+
continue;
|
|
89
|
+
const projectDir = path.join(projectsDir, projectEntry.name);
|
|
90
|
+
let fileEntries;
|
|
91
|
+
try {
|
|
92
|
+
fileEntries = await readdir(projectDir, { withFileTypes: true });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
for (const fileEntry of fileEntries) {
|
|
98
|
+
if (fileEntry.isFile() && fileEntry.name.endsWith('.jsonl')) {
|
|
99
|
+
files.push(path.join(projectDir, fileEntry.name));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return files;
|
|
104
|
+
}
|
|
105
|
+
async function countAgentInvocations(filePath, cutoff, counts) {
|
|
106
|
+
const rl = createInterface({
|
|
107
|
+
input: createReadStream(filePath, { encoding: 'utf8' }),
|
|
108
|
+
crlfDelay: Infinity,
|
|
109
|
+
});
|
|
110
|
+
for await (const line of rl) {
|
|
111
|
+
if (!line.trim())
|
|
112
|
+
continue;
|
|
113
|
+
let record;
|
|
114
|
+
try {
|
|
115
|
+
record = JSON.parse(line);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (typeof record !== 'object' || record === null)
|
|
121
|
+
continue;
|
|
122
|
+
const obj = record;
|
|
123
|
+
const timestamp = obj.timestamp;
|
|
124
|
+
if (typeof timestamp !== 'string')
|
|
125
|
+
continue;
|
|
126
|
+
const ts = new Date(timestamp);
|
|
127
|
+
if (Number.isNaN(ts.getTime()) || ts < cutoff)
|
|
128
|
+
continue;
|
|
129
|
+
if (obj.type !== 'assistant')
|
|
130
|
+
continue;
|
|
131
|
+
const message = obj.message;
|
|
132
|
+
const content = message?.content;
|
|
133
|
+
if (!Array.isArray(content))
|
|
134
|
+
continue;
|
|
135
|
+
for (const block of content) {
|
|
136
|
+
if (block &&
|
|
137
|
+
typeof block === 'object' &&
|
|
138
|
+
block.type === 'tool_use' &&
|
|
139
|
+
(block.name === 'Agent' || block.name === 'Task')) {
|
|
140
|
+
const input = block.input;
|
|
141
|
+
const subagentType = input?.subagent_type;
|
|
142
|
+
if (typeof subagentType === 'string') {
|
|
143
|
+
counts[subagentType] = (counts[subagentType] ?? 0) + 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function loadRosterNames(user, plugin) {
|
|
150
|
+
const names = new Set();
|
|
151
|
+
if (user) {
|
|
152
|
+
const agents = await sources.user.load();
|
|
153
|
+
for (const agent of agents)
|
|
154
|
+
names.add(agent.name);
|
|
155
|
+
}
|
|
156
|
+
if (plugin) {
|
|
157
|
+
const agents = await sources['plugin-cache'].load();
|
|
158
|
+
for (const agent of agents)
|
|
159
|
+
names.add(agent.name);
|
|
160
|
+
}
|
|
161
|
+
return [...names];
|
|
162
|
+
}
|
|
163
|
+
function renderHuman(days, counts, unused, ghosts) {
|
|
164
|
+
const lines = [];
|
|
165
|
+
const rows = Object.entries(counts).sort((a, b) => b[1] - a[1]);
|
|
166
|
+
if (rows.length === 0) {
|
|
167
|
+
lines.push(`No agent invocations found in the last ${days} day(s).`);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
lines.push(`Agent usage (last ${days} day(s)):`);
|
|
171
|
+
for (const [name, count] of rows) {
|
|
172
|
+
lines.push(` ${count}\t${name}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (unused.length > 0) {
|
|
176
|
+
lines.push('Unused (in roster, 0 invocations):');
|
|
177
|
+
for (const name of unused)
|
|
178
|
+
lines.push(` ${name}`);
|
|
179
|
+
}
|
|
180
|
+
if (ghosts.length > 0) {
|
|
181
|
+
lines.push('Ghost subagent_type (invoked, not in roster):');
|
|
182
|
+
for (const name of ghosts)
|
|
183
|
+
lines.push(` ${name}`);
|
|
184
|
+
}
|
|
185
|
+
return lines.join('\n');
|
|
186
|
+
}
|
|
187
|
+
export async function run(argv) {
|
|
188
|
+
const parsed = parseArgs(argv);
|
|
189
|
+
if (parsed.help) {
|
|
190
|
+
console.log(HELP_TEXT);
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
const root = process.env.ROSTER_CLAUDE_DIR ?? path.join(os.homedir(), '.claude');
|
|
194
|
+
const cutoff = new Date(Date.now() - parsed.days * 24 * 60 * 60 * 1000);
|
|
195
|
+
const files = await findJsonlFiles(root);
|
|
196
|
+
const counts = {};
|
|
197
|
+
for (const filePath of files) {
|
|
198
|
+
let stats;
|
|
199
|
+
try {
|
|
200
|
+
stats = await stat(filePath);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (stats.mtime < cutoff)
|
|
206
|
+
continue;
|
|
207
|
+
await countAgentInvocations(filePath, cutoff, counts);
|
|
208
|
+
}
|
|
209
|
+
let unused = [];
|
|
210
|
+
let ghosts = [];
|
|
211
|
+
if (parsed.user || parsed.plugin) {
|
|
212
|
+
const rosterNames = await loadRosterNames(parsed.user, parsed.plugin);
|
|
213
|
+
const joined = computeJoin(counts, rosterNames);
|
|
214
|
+
unused = joined.unused;
|
|
215
|
+
ghosts = joined.ghosts;
|
|
216
|
+
}
|
|
217
|
+
if (parsed.json) {
|
|
218
|
+
console.log(JSON.stringify({ days: parsed.days, counts, unused, ghosts }));
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
console.log(renderHuman(parsed.days, counts, unused, ghosts));
|
|
222
|
+
}
|
|
223
|
+
return 0;
|
|
224
|
+
}
|