@chimix/agentscan 0.5.0 → 0.7.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 +66 -25
- package/dist/cli.js +370 -207
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<img src="https://img.shields.io/badge/checks-
|
|
9
|
-
<img src="https://img.shields.io/badge/tests-
|
|
8
|
+
<img src="https://img.shields.io/badge/checks-27-111111?style=flat-square" alt="27 checks">
|
|
9
|
+
<img src="https://img.shields.io/badge/tests-250%20passing-111111?style=flat-square" alt="250 tests">
|
|
10
10
|
<img src="https://img.shields.io/badge/network-none-111111?style=flat-square" alt="No network">
|
|
11
11
|
<img src="https://img.shields.io/badge/writes-none-111111?style=flat-square" alt="Writes nothing">
|
|
12
12
|
<img src="https://img.shields.io/badge/runs%20on-node%20%C2%B7%20bun-111111?style=flat-square" alt="Node or Bun">
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
</p>
|
|
16
16
|
|
|
17
17
|
<p align="center">
|
|
18
|
-
<strong>
|
|
18
|
+
<strong>27 checks · 4.2k lines · 0 network calls · every check sourced to a published spec line</strong><br>
|
|
19
19
|
<sub>Alpha. An earlier build reported 37 findings across 17 real projects of which <strong>25 were false</strong> — two checks had been written from what real projects looked like instead of from the spec. Both were deleted, and every check that survived is recorded in <a href="docs/spec/">docs/spec/</a> with the URL it came from and the date it was read. That story is the reason this tool exists in its current shape.</sub>
|
|
20
20
|
</p>
|
|
21
21
|
|
|
@@ -62,9 +62,9 @@ of the filesystem**, where nothing else in your stack will ever tell you.
|
|
|
62
62
|
No AI, no network, no heuristics. Read the config, read the disk, compare:
|
|
63
63
|
|
|
64
64
|
```
|
|
65
|
-
1. Discover .claude/ .agents/ .mcp.json AGENTS.md skills-lock.json
|
|
65
|
+
1. Discover .claude/ .agents/ .mcp.json AGENTS.md skills-lock.json plugin hooks/
|
|
66
66
|
2. Extract immutable facts — never re-read during checking
|
|
67
|
-
3. Check
|
|
67
|
+
3. Check 27 checks, each against one published spec line
|
|
68
68
|
4. Report text · --json · --output prompt (handoff for a fixing agent)
|
|
69
69
|
```
|
|
70
70
|
|
|
@@ -127,7 +127,9 @@ bunx @chimix/agentscan explain hook.missing-script:hook:PreToolUse:./x.js ~/your
|
|
|
127
127
|
```
|
|
128
128
|
|
|
129
129
|
`check` never opens a socket and never writes to the tree it scans. Contributing
|
|
130
|
-
needs **Bun** 1.
|
|
130
|
+
needs **Bun** 1.4+ — the version CI pins and every gate is verified on; the
|
|
131
|
+
text `bun.lock` this repo ships cannot be read by 1.1 at all. The repo runs
|
|
132
|
+
TypeScript directly and `bun run build`
|
|
131
133
|
bundles it for Node.
|
|
132
134
|
|
|
133
135
|
### In CI
|
|
@@ -156,7 +158,7 @@ bunx agentscan check # text report (cwd)
|
|
|
156
158
|
bunx agentscan check ./my-app # explicit root
|
|
157
159
|
bunx agentscan check --json # machine-readable
|
|
158
160
|
bunx agentscan check --quiet # summary only
|
|
159
|
-
bunx agentscan check --verbose # include KEEP and
|
|
161
|
+
bunx agentscan check --verbose # include KEEP, info findings, and each finding's id
|
|
160
162
|
bunx agentscan check --fail-on warning
|
|
161
163
|
bunx agentscan check --global # also scan global skill dirs
|
|
162
164
|
|
|
@@ -175,7 +177,7 @@ Flags for `check`:
|
|
|
175
177
|
| `--copy` | Also copy the report to the system clipboard |
|
|
176
178
|
| `--no-color` | Never colour, even on a terminal (`NO_COLOR=1` does the same) |
|
|
177
179
|
| `--quiet` | Summary line only |
|
|
178
|
-
| `--verbose` | Show KEEP + info-severity findings |
|
|
180
|
+
| `--verbose` | Show KEEP + info-severity findings, and print each finding's id |
|
|
179
181
|
| `--fail-on <level>` | `never` (default) · `warning` · `error` |
|
|
180
182
|
| `--fail-under <0-100>` | Fail when the score drops below this floor |
|
|
181
183
|
| `--global` | Also scan `~/.claude/skills` and `~/.codex/skills` (see below) |
|
|
@@ -186,14 +188,14 @@ Flags for `check`:
|
|
|
186
188
|
## Sample output
|
|
187
189
|
|
|
188
190
|
```text
|
|
189
|
-
┌─────┐
|
|
190
|
-
│ ✕ ✕ │
|
|
191
|
-
│ ⌒ │ 6 error ·
|
|
191
|
+
┌─────┐ 34/100 broken · touchagency
|
|
192
|
+
│ ✕ ✕ │ █████████░░░░░░░░░░░░░░░░
|
|
193
|
+
│ ⌒ │ 6 error · 2 warning · 11 info hidden (--verbose)
|
|
192
194
|
└─────┘
|
|
193
195
|
|
|
194
|
-
agentscan v0.
|
|
196
|
+
agentscan v0.7.0 — touchagency
|
|
195
197
|
|
|
196
|
-
Scanned: 46 deps ·
|
|
198
|
+
Scanned: 46 deps · 108 skills · 1 mcp · 2 agents · packageManager=bun
|
|
197
199
|
|
|
198
200
|
ERROR rule:hook.missing-script ×6
|
|
199
201
|
Hook points at a script that does not exist
|
|
@@ -202,7 +204,12 @@ ERROR rule:hook.missing-script ×6
|
|
|
202
204
|
PreToolUse @ .claude/settings.json · .claude/hooks/protect-artists-json.js
|
|
203
205
|
PreToolUse @ .claude/settings.json · .claude/hooks/protect-env.js
|
|
204
206
|
|
|
205
|
-
|
|
207
|
+
WARN rule:agent.invalid-name ×2
|
|
208
|
+
2 findings
|
|
209
|
+
.claude/agents/marketing-seo-specialist.md
|
|
210
|
+
.claude/agents/testing-accessibility-auditor.md
|
|
211
|
+
|
|
212
|
+
Summary: 6 error · 2 warning · 11 info hidden (--verbose) · score 34/100 broken
|
|
206
213
|
```
|
|
207
214
|
|
|
208
215
|
On a terminal the header is coloured green, yellow or red and the face tracks
|
|
@@ -218,7 +225,7 @@ JSON shape (abridged):
|
|
|
218
225
|
|
|
219
226
|
```json
|
|
220
227
|
{
|
|
221
|
-
"version": "0.
|
|
228
|
+
"version": "0.7.0",
|
|
222
229
|
"root": "/path/to/project",
|
|
223
230
|
"factsSummary": {
|
|
224
231
|
"packageManager": "bun",
|
|
@@ -389,8 +396,9 @@ This is the only place agentscan starts a subprocess, and it runs only under
|
|
|
389
396
|
|
|
390
397
|
`ignoreRules` disables a check project-wide, which is too blunt when a single
|
|
391
398
|
hook uses an exotic launcher. `ignoreFindings` takes exact finding ids — the
|
|
392
|
-
same strings `agentscan explain` accepts
|
|
393
|
-
|
|
399
|
+
same strings `agentscan explain` accepts. `check --verbose` prints the id under
|
|
400
|
+
each finding, and `--json` carries it as `.findings[].id`, so the value you copy
|
|
401
|
+
out of the report is the value you paste:
|
|
394
402
|
|
|
395
403
|
```json
|
|
396
404
|
{ "ignoreFindings": ["hook.missing-script:hook:PreToolUse:./bin/wrapper"] }
|
|
@@ -401,21 +409,22 @@ reading the report is the value you paste:
|
|
|
401
409
|
Every check lives in `src/checks/` and runs on every `check`. `agentscan rules`
|
|
402
410
|
lists all of them with their ids; `agentscan explain <id>` details any finding.
|
|
403
411
|
|
|
404
|
-
Most validate one discovered item against its own file on disk. The
|
|
405
|
-
`budget.*` entries at the bottom judge aggregate size instead
|
|
406
|
-
|
|
407
|
-
are retuned through `thresholds` in
|
|
408
|
-
off.
|
|
412
|
+
Most validate one discovered item against its own file on disk. The four
|
|
413
|
+
`budget.*` entries at the bottom judge aggregate size instead, as does
|
|
414
|
+
`skill.description-budget` — all five are **info**, all five are sourced to
|
|
415
|
+
`docs/spec/thresholds.md`, and all five are retuned through `thresholds` in
|
|
416
|
+
`.agentscanrc.json` rather than switched off.
|
|
409
417
|
|
|
410
418
|
| id | Severity | Catches |
|
|
411
419
|
|----|----------|---------|
|
|
412
420
|
| `config.unreadable` | error | A config file that is not valid JSON, so whatever it declares is silently not in effect |
|
|
413
|
-
| `
|
|
421
|
+
| `scan.truncated` | info | A file past the scan cap, so the checks that read its body saw only a prefix — about this tool's reach, not about your project |
|
|
422
|
+
| `hook.missing-script` | error | A registered hook whose script does not exist — it never runs. Reads all four in-tree registration sites (see below) |
|
|
414
423
|
| `hook.unknown-event` | error | A hook registered under an event name that is never dispatched |
|
|
415
424
|
| `agent.missing-frontmatter` | error | An agent definition with no `---` block |
|
|
416
425
|
| `agent.missing-description` | error | Agent frontmatter has no `description` |
|
|
417
426
|
| `agent.missing-name` | error | Agent frontmatter has no `name` |
|
|
418
|
-
| `agent.invalid-name` |
|
|
427
|
+
| `agent.invalid-name` | warning | Agent name is not lowercase letters, numbers, and hyphens |
|
|
419
428
|
| `agent.duplicate-name` | error | Multiple agent files declare the same name |
|
|
420
429
|
| `mcp.no-launch` | error | An MCP server with neither `command` nor `url` — unlaunchable by schema |
|
|
421
430
|
| `mcp.command-missing` | error | An MCP `command` that is a path-like value whose file does not exist on disk |
|
|
@@ -431,6 +440,10 @@ off.
|
|
|
431
440
|
| `skill.not-in-lock` | info | A skill on disk that the lockfile does not track — local and unpinned |
|
|
432
441
|
| `skill.description-budget` | info | Skill names + descriptions exceed the startup character budget |
|
|
433
442
|
| `skill.no-lockfile` | info | Skills present with no lockfile at all (only with `requireLock`) |
|
|
443
|
+
| `budget.agents-md` | info | `AGENTS.md` past the point where added lines stop helping (>150) |
|
|
444
|
+
| `budget.claude-md` | info | `CLAUDE.md` past the instruction count a model reliably follows (>200) |
|
|
445
|
+
| `budget.agents` | info | More agent definitions than a focused set (>8 in `.claude/agents`) |
|
|
446
|
+
| `budget.mcp` | info | More MCP servers than the point where tool selection degrades (>5) |
|
|
434
447
|
|
|
435
448
|
Agent definitions are checked for structure only. An agent's frontmatter `name`
|
|
436
449
|
is not compared to the filename; it must simply be a valid lowercase identifier
|
|
@@ -446,6 +459,24 @@ references measured across 17 projects, 1645 resolved skill-relative and 12 only
|
|
|
446
459
|
at the root. Fenced code blocks are stripped first: a path in an example is
|
|
447
460
|
illustration, not a pointer.
|
|
448
461
|
|
|
462
|
+
### Where it looks for hooks
|
|
463
|
+
|
|
464
|
+
The hooks reference lists seven places a hook can be registered. agentscan reads
|
|
465
|
+
the four that live inside the scanned project:
|
|
466
|
+
|
|
467
|
+
| Registered in | Script paths resolve against |
|
|
468
|
+
|---|---|
|
|
469
|
+
| `.claude/settings.json` · `.claude/settings.local.json` | project root, `${CLAUDE_PROJECT_DIR}` |
|
|
470
|
+
| A plugin's `hooks/hooks.json` (a directory with `.claude-plugin/plugin.json`, or the scan root itself) | the plugin root, `${CLAUDE_PLUGIN_ROOT}` |
|
|
471
|
+
| `SKILL.md` frontmatter | the skill's own directory, then the project root |
|
|
472
|
+
| `.claude/agents/*.md` frontmatter | the agent file's directory, then the project root |
|
|
473
|
+
|
|
474
|
+
`${CLAUDE_PLUGIN_ROOT}` is expanded **only** for a hook that came from a plugin.
|
|
475
|
+
In a settings file it names nothing, so the path is skipped rather than guessed
|
|
476
|
+
at — measured across 17 installed plugins, 31 of 33 plugin hook commands use it
|
|
477
|
+
and none of them are settings hooks. `hook.unknown-event` covers all four sites
|
|
478
|
+
at no extra cost. Sources and measurements: [docs/spec/hook-sources.md](docs/spec/hook-sources.md).
|
|
479
|
+
|
|
449
480
|
`hook.missing-script` and `mcp.command-missing` are deliberately conservative.
|
|
450
481
|
A value is only resolved when it is path-like and the answer is certain; shell
|
|
451
482
|
programs (`a && b`, `$(...)`, pipes), bare PATH binaries (`npx`, `uvx`), and
|
|
@@ -478,7 +509,17 @@ When adding a check, add its spec line there first.
|
|
|
478
509
|
- **Runtime split.** Source development and the composite Action use Bun; the
|
|
479
510
|
published `dist/cli.js` runs on Node 20.11+ or Bun. The source entrypoint uses
|
|
480
511
|
Bun's `import.meta.dir` / `import.meta.main` and is not the Node entrypoint.
|
|
481
|
-
-
|
|
512
|
+
- **Three hook locations are unread.** `~/.claude/settings.json`, managed policy
|
|
513
|
+
settings, and installed marketplace plugins under `~/.claude/plugins` all sit
|
|
514
|
+
outside the scanned project, and the docs say a plugin's install directory
|
|
515
|
+
changes on every update. A hook registered in one of those, pointing at a
|
|
516
|
+
missing script, is not reported.
|
|
517
|
+
- **Bounded reads.** A `SKILL.md` is read to 64 KB and a policy file to 100 KB,
|
|
518
|
+
so `skill.broken-reference` and `policyLines` see only that much of a larger
|
|
519
|
+
file. This is reported — `scan.truncated`, at `info` — rather than left
|
|
520
|
+
silent, and it says nothing about the file being valid: it is, and the tools
|
|
521
|
+
that read it see all of it. Reporting the cap as `config.unreadable` at
|
|
522
|
+
severity error is a defect this tool shipped and [plan 019](plans/019-findings-say-true-things.md) fixed.
|
|
482
523
|
|
|
483
524
|
## Development
|
|
484
525
|
|
package/dist/cli.js
CHANGED
|
@@ -7120,9 +7120,9 @@ function checkAgents(facts) {
|
|
|
7120
7120
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(agent.frontmatterName)) {
|
|
7121
7121
|
out.push(make("agent.invalid-name", `agent:${agent.name}`, {
|
|
7122
7122
|
action: "warn",
|
|
7123
|
-
severity: "
|
|
7123
|
+
severity: "warning",
|
|
7124
7124
|
message: `Agent name "${agent.frontmatterName}" is not a valid identifier`,
|
|
7125
|
-
reason: "
|
|
7125
|
+
reason: "The subagent reference specifies a unique identifier in lowercase letters and hyphens. A name outside that shape is not documented to load reliably, and a `:` in it is documented not to load at all. See docs/spec/agents.md.",
|
|
7126
7126
|
evidence: [{ kind: "agent", value: agent.path }],
|
|
7127
7127
|
suggest: "Use lowercase letters, numbers, and hyphens in the `name` field"
|
|
7128
7128
|
}));
|
|
@@ -7156,6 +7156,18 @@ function safeDetail(detail) {
|
|
|
7156
7156
|
}
|
|
7157
7157
|
function checkConfigErrors(facts) {
|
|
7158
7158
|
return facts.configErrors.map((err) => {
|
|
7159
|
+
if (err.kind === "truncated") {
|
|
7160
|
+
return make("scan.truncated", `scan:${err.path}`, {
|
|
7161
|
+
action: "warn",
|
|
7162
|
+
severity: "info",
|
|
7163
|
+
message: `${basename2(err.path)} is larger than the scan cap — only its first bytes were read`,
|
|
7164
|
+
reason: "The file itself is fine; agentscan reads a bounded prefix of it. Checks that read the body — skill.broken-reference, and the AGENTS.md / CLAUDE.md line counts — see only that prefix, so they can undercount on this file.",
|
|
7165
|
+
evidence: [
|
|
7166
|
+
{ kind: "config", value: err.path },
|
|
7167
|
+
{ kind: "detail", value: err.detail }
|
|
7168
|
+
]
|
|
7169
|
+
});
|
|
7170
|
+
}
|
|
7159
7171
|
const what = err.kind === "invalid-json" ? "is not valid JSON" : err.kind === "unreadable" ? "could not be read" : "has an unexpected shape";
|
|
7160
7172
|
return make("config.unreadable", `config:${err.path}#${err.kind}:${safeDetail(err.detail)}`, {
|
|
7161
7173
|
action: "warn",
|
|
@@ -7247,10 +7259,11 @@ function checkHooks(facts) {
|
|
|
7247
7259
|
action: "warn",
|
|
7248
7260
|
severity: "error",
|
|
7249
7261
|
message: `${hook.event ?? hook.name} hook points at a script that does not exist: ${hook.scriptPath}`,
|
|
7250
|
-
reason: "The hook is registered but its script is missing, so it never runs. A guard hook that silently does nothing is worse than no hook — the config claims a protection that is not in effect.",
|
|
7262
|
+
reason: "The hook is registered but its script is missing, so it never runs. A guard hook that silently does nothing is worse than no hook — the config claims a protection that is not in effect. A relative path is looked for beside the file that declared it and at the project root; it is at neither.",
|
|
7251
7263
|
evidence: [
|
|
7252
7264
|
{ kind: "hook", value: `${hook.event ?? hook.name} @ ${hook.path}` },
|
|
7253
|
-
{ kind: "script", value: hook.scriptPath }
|
|
7265
|
+
{ kind: "script", value: hook.scriptPath },
|
|
7266
|
+
...hook.source === undefined || hook.source === "settings" ? [] : [{ kind: "source", value: hook.source }]
|
|
7254
7267
|
],
|
|
7255
7268
|
suggest: `Restore ${hook.scriptPath} or remove the hook from ${hook.path}`
|
|
7256
7269
|
}));
|
|
@@ -7335,6 +7348,7 @@ function checkMcp(facts) {
|
|
|
7335
7348
|
}
|
|
7336
7349
|
|
|
7337
7350
|
// src/checks/skills.ts
|
|
7351
|
+
import { dirname } from "node:path";
|
|
7338
7352
|
function skillEvidence(skill, value) {
|
|
7339
7353
|
const evidence = [{ kind: "skill", value }];
|
|
7340
7354
|
if (skill.source === "global") {
|
|
@@ -7345,6 +7359,9 @@ function skillEvidence(skill, value) {
|
|
|
7345
7359
|
function skillSubject(skill) {
|
|
7346
7360
|
return `skill:${skill.instanceId ?? skill.id}`;
|
|
7347
7361
|
}
|
|
7362
|
+
function skillRoot(skill) {
|
|
7363
|
+
return dirname(skill.path.replaceAll("\\", "/"));
|
|
7364
|
+
}
|
|
7348
7365
|
function checkSkillStructure(facts) {
|
|
7349
7366
|
const out = [];
|
|
7350
7367
|
for (const skill of facts.skills) {
|
|
@@ -7467,9 +7484,7 @@ function checkDuplicateDescriptions(facts) {
|
|
|
7467
7484
|
if (skill.runtime === "agents") {
|
|
7468
7485
|
continue;
|
|
7469
7486
|
}
|
|
7470
|
-
const
|
|
7471
|
-
const namespace = path.includes(".claude/skills/") ? "claude" : path.includes(".agents/skills/") ? "agents" : skill.source;
|
|
7472
|
-
const key = `${namespace}\x00${skill.description.trim().replace(/\s+/g, " ").toLowerCase()}`;
|
|
7487
|
+
const key = `${skillRoot(skill)}\x00${skill.description.trim().replace(/\s+/g, " ").toLowerCase()}`;
|
|
7473
7488
|
if (key.length === 0) {
|
|
7474
7489
|
continue;
|
|
7475
7490
|
}
|
|
@@ -7502,23 +7517,36 @@ function checkDescriptionBudget(facts, options) {
|
|
|
7502
7517
|
return [];
|
|
7503
7518
|
}
|
|
7504
7519
|
const project = facts.skills.filter((s) => s.source === "project" && s.hasSkillMd && s.description !== undefined);
|
|
7505
|
-
const
|
|
7506
|
-
|
|
7507
|
-
|
|
7520
|
+
const byRoot = new Map;
|
|
7521
|
+
for (const s of project) {
|
|
7522
|
+
const root = skillRoot(s);
|
|
7523
|
+
const bucket = byRoot.get(root);
|
|
7524
|
+
if (bucket === undefined)
|
|
7525
|
+
byRoot.set(root, [s]);
|
|
7526
|
+
else
|
|
7527
|
+
bucket.push(s);
|
|
7508
7528
|
}
|
|
7509
|
-
|
|
7510
|
-
|
|
7529
|
+
const out = [];
|
|
7530
|
+
for (const [root, skills] of byRoot) {
|
|
7531
|
+
const bytes = skills.reduce((sum, s) => sum + Buffer.byteLength(s.id) + Buffer.byteLength(s.description ?? ""), 0);
|
|
7532
|
+
if (bytes <= ceiling) {
|
|
7533
|
+
continue;
|
|
7534
|
+
}
|
|
7535
|
+
const where = root.startsWith(`${facts.root}/`) ? root.slice(facts.root.length + 1) : root;
|
|
7536
|
+
const subject = byRoot.size === 1 ? "skills:description-budget" : `skills:description-budget:${where}`;
|
|
7537
|
+
out.push(make("skill.description-budget", subject, {
|
|
7511
7538
|
action: "warn",
|
|
7512
7539
|
severity: "info",
|
|
7513
|
-
message: `Skill descriptions total ${bytes} bytes across ${
|
|
7540
|
+
message: `Skill descriptions total ${bytes} bytes across ${skills.length} skills in ${where} (over ${ceiling})`,
|
|
7514
7541
|
reason: "Names and descriptions for every skill are loaded at startup, within a budget of roughly 1-2% of the context window. Past it they are truncated, and a skill whose description is cut short stops being matched for the tasks it was written for.",
|
|
7515
7542
|
evidence: [
|
|
7516
7543
|
{ kind: "count", value: `descriptionBytes=${bytes}` },
|
|
7517
7544
|
{ kind: "threshold", value: String(ceiling) }
|
|
7518
7545
|
],
|
|
7519
7546
|
suggest: "Shorten the longest descriptions, or remove skills this project does not use"
|
|
7520
|
-
})
|
|
7521
|
-
|
|
7547
|
+
}));
|
|
7548
|
+
}
|
|
7549
|
+
return out;
|
|
7522
7550
|
}
|
|
7523
7551
|
// src/checks/registry.ts
|
|
7524
7552
|
var STRUCTURAL_CHECKS = [
|
|
@@ -7526,6 +7554,10 @@ var STRUCTURAL_CHECKS = [
|
|
|
7526
7554
|
id: "config.unreadable",
|
|
7527
7555
|
description: "Config file is not valid JSON or cannot be read"
|
|
7528
7556
|
},
|
|
7557
|
+
{
|
|
7558
|
+
id: "scan.truncated",
|
|
7559
|
+
description: "File is larger than the scan cap, so body checks read only a prefix of it"
|
|
7560
|
+
},
|
|
7529
7561
|
{
|
|
7530
7562
|
id: "hook.unknown-event",
|
|
7531
7563
|
description: "Hook registered under an event name that is never dispatched"
|
|
@@ -11672,11 +11704,16 @@ function loadConfig(root, configPath) {
|
|
|
11672
11704
|
|
|
11673
11705
|
// src/discover/index.ts
|
|
11674
11706
|
import { homedir as homedir3 } from "node:os";
|
|
11675
|
-
import { join as
|
|
11707
|
+
import { join as join9, resolve as resolve3 } from "node:path";
|
|
11676
11708
|
|
|
11677
11709
|
// src/discover/agents.ts
|
|
11678
|
-
import { existsSync as
|
|
11679
|
-
import { join as
|
|
11710
|
+
import { existsSync as existsSync4, readdirSync, statSync as statSync2 } from "node:fs";
|
|
11711
|
+
import { dirname as dirname2, join as join4 } from "node:path";
|
|
11712
|
+
|
|
11713
|
+
// src/discover/hooks.ts
|
|
11714
|
+
import { existsSync as existsSync3, statSync } from "node:fs";
|
|
11715
|
+
import { homedir } from "node:os";
|
|
11716
|
+
import { isAbsolute, join as join3 } from "node:path";
|
|
11680
11717
|
|
|
11681
11718
|
// src/discover/shared.ts
|
|
11682
11719
|
import { closeSync, existsSync as existsSync2, openSync, readFileSync as readFileSync2, readSync } from "node:fs";
|
|
@@ -11741,6 +11778,16 @@ function readCapped(path, cap) {
|
|
|
11741
11778
|
closeSync(fd);
|
|
11742
11779
|
}
|
|
11743
11780
|
}
|
|
11781
|
+
var NESTED_DISCOVERY_MAX_DEPTH = 8;
|
|
11782
|
+
var NESTED_DISCOVERY_SKIP = new Set([
|
|
11783
|
+
".git",
|
|
11784
|
+
"node_modules",
|
|
11785
|
+
".next",
|
|
11786
|
+
"dist",
|
|
11787
|
+
"build",
|
|
11788
|
+
"coverage",
|
|
11789
|
+
"vendor"
|
|
11790
|
+
]);
|
|
11744
11791
|
var AGENT_CONFIG_SIGNALS = [
|
|
11745
11792
|
".claude",
|
|
11746
11793
|
".agents",
|
|
@@ -11812,8 +11859,7 @@ function readFrontmatter(skillMdPath, errors3) {
|
|
|
11812
11859
|
text = text.replace(/\r\n/g, `
|
|
11813
11860
|
`);
|
|
11814
11861
|
if (truncated) {
|
|
11815
|
-
errors3.push({ path: skillMdPath, kind: "
|
|
11816
|
-
return { hasFrontmatter: true, unparseable: true };
|
|
11862
|
+
errors3.push({ path: skillMdPath, kind: "truncated", detail: `file exceeds ${SKILL_MD_CAP} byte scan cap` });
|
|
11817
11863
|
}
|
|
11818
11864
|
} catch (err) {
|
|
11819
11865
|
errors3.push({
|
|
@@ -11869,86 +11915,19 @@ function readFrontmatter(skillMdPath, errors3) {
|
|
|
11869
11915
|
if (description !== undefined) {
|
|
11870
11916
|
out.description = description;
|
|
11871
11917
|
}
|
|
11872
|
-
|
|
11873
|
-
|
|
11874
|
-
|
|
11875
|
-
// src/discover/agents.ts
|
|
11876
|
-
function agentFiles(dir, errors3) {
|
|
11877
|
-
let entries;
|
|
11878
|
-
try {
|
|
11879
|
-
entries = readdirSync(dir, { withFileTypes: true }).map((e) => e.isDirectory() ? `${e.name}/` : e.name);
|
|
11880
|
-
} catch (err) {
|
|
11881
|
-
errors3.push({
|
|
11882
|
-
path: dir,
|
|
11883
|
-
kind: "unreadable",
|
|
11884
|
-
detail: err instanceof Error ? err.message : String(err)
|
|
11885
|
-
});
|
|
11886
|
-
return [];
|
|
11887
|
-
}
|
|
11888
|
-
const out = [];
|
|
11889
|
-
for (const entry of entries) {
|
|
11890
|
-
if (entry.startsWith(".")) {
|
|
11891
|
-
continue;
|
|
11892
|
-
}
|
|
11893
|
-
if (entry.endsWith("/")) {
|
|
11894
|
-
out.push(...agentFiles(join3(dir, entry.slice(0, -1)), errors3).map((p) => join3(entry.slice(0, -1), p)));
|
|
11895
|
-
continue;
|
|
11896
|
-
}
|
|
11897
|
-
out.push(entry);
|
|
11918
|
+
if (record.hooks !== undefined) {
|
|
11919
|
+
out.hooks = record.hooks;
|
|
11898
11920
|
}
|
|
11899
11921
|
return out;
|
|
11900
11922
|
}
|
|
11901
|
-
function discoverAgents(root, errors3) {
|
|
11902
|
-
const dir = join3(root, ".claude", "agents");
|
|
11903
|
-
if (!existsSync3(dir)) {
|
|
11904
|
-
return [];
|
|
11905
|
-
}
|
|
11906
|
-
const entries = agentFiles(dir, errors3);
|
|
11907
|
-
const facts = [];
|
|
11908
|
-
for (const name of entries) {
|
|
11909
|
-
if (!name.endsWith(".md")) {
|
|
11910
|
-
continue;
|
|
11911
|
-
}
|
|
11912
|
-
const filePath = join3(dir, name);
|
|
11913
|
-
try {
|
|
11914
|
-
const st = statSync(filePath);
|
|
11915
|
-
if (!st.isFile()) {
|
|
11916
|
-
continue;
|
|
11917
|
-
}
|
|
11918
|
-
} catch {
|
|
11919
|
-
continue;
|
|
11920
|
-
}
|
|
11921
|
-
const fm = readFrontmatter(filePath, errors3);
|
|
11922
|
-
const fact = {
|
|
11923
|
-
name: name.slice(0, -".md".length),
|
|
11924
|
-
path: filePath,
|
|
11925
|
-
hasFrontmatter: fm.hasFrontmatter
|
|
11926
|
-
};
|
|
11927
|
-
if (fm.unreadable === true) {
|
|
11928
|
-
fact.unreadable = true;
|
|
11929
|
-
}
|
|
11930
|
-
if (fm.unparseable === true) {
|
|
11931
|
-
fact.unparseableFrontmatter = true;
|
|
11932
|
-
}
|
|
11933
|
-
if (fm.name !== undefined) {
|
|
11934
|
-
fact.frontmatterName = fm.name;
|
|
11935
|
-
}
|
|
11936
|
-
if (fm.description !== undefined) {
|
|
11937
|
-
fact.description = fm.description;
|
|
11938
|
-
}
|
|
11939
|
-
facts.push(fact);
|
|
11940
|
-
}
|
|
11941
|
-
return facts;
|
|
11942
|
-
}
|
|
11943
11923
|
|
|
11944
11924
|
// src/discover/hooks.ts
|
|
11945
|
-
import { existsSync as existsSync4, statSync as statSync2 } from "node:fs";
|
|
11946
|
-
import { homedir } from "node:os";
|
|
11947
|
-
import { isAbsolute, join as join4 } from "node:path";
|
|
11948
11925
|
var INTERPRETERS = /^(?:bash|sh|zsh|python3?|node|bun|deno)$/;
|
|
11949
11926
|
var INLINE_CODE_FLAGS = /^-(?:e|p|c)$/;
|
|
11950
11927
|
var SHELL_METACHARS = /[|;&`]|\$\(|\|\||&&/;
|
|
11951
|
-
|
|
11928
|
+
var PROJECT_DIR = /^\$(?:CLAUDE_PROJECT_DIR\b|\{CLAUDE_PROJECT_DIR\})/;
|
|
11929
|
+
var PLUGIN_ROOT = /^\$(?:CLAUDE_PLUGIN_ROOT\b|\{CLAUDE_PLUGIN_ROOT\})/;
|
|
11930
|
+
function hookScriptPath(command, bases = {}) {
|
|
11952
11931
|
const trimmed = command.trim();
|
|
11953
11932
|
if (trimmed.length === 0 || SHELL_METACHARS.test(trimmed)) {
|
|
11954
11933
|
return;
|
|
@@ -11983,39 +11962,106 @@ function hookScriptPath(command) {
|
|
|
11983
11962
|
return;
|
|
11984
11963
|
}
|
|
11985
11964
|
if (candidate.startsWith("$")) {
|
|
11986
|
-
|
|
11965
|
+
if (PROJECT_DIR.test(candidate)) {
|
|
11966
|
+
return candidate;
|
|
11967
|
+
}
|
|
11968
|
+
return bases.plugin !== undefined && PLUGIN_ROOT.test(candidate) ? candidate : undefined;
|
|
11987
11969
|
}
|
|
11988
11970
|
if (candidate.startsWith("/") || candidate.startsWith("./") || candidate.startsWith("../") || candidate.startsWith("~/") || candidate.startsWith(".")) {
|
|
11989
11971
|
return candidate;
|
|
11990
11972
|
}
|
|
11991
11973
|
return;
|
|
11992
11974
|
}
|
|
11993
|
-
function
|
|
11994
|
-
|
|
11975
|
+
function isFile(path) {
|
|
11976
|
+
try {
|
|
11977
|
+
return statSync(path).isFile();
|
|
11978
|
+
} catch {
|
|
11979
|
+
return false;
|
|
11980
|
+
}
|
|
11981
|
+
}
|
|
11982
|
+
function resolveHookScript(bases, raw) {
|
|
11983
|
+
const extracted = hookScriptPath(raw, bases);
|
|
11995
11984
|
if (extracted === undefined) {
|
|
11996
11985
|
return;
|
|
11997
11986
|
}
|
|
11998
|
-
|
|
11999
|
-
|
|
12000
|
-
|
|
12001
|
-
|
|
12002
|
-
|
|
11987
|
+
if (extracted.startsWith("~/")) {
|
|
11988
|
+
return {
|
|
11989
|
+
scriptPath: extracted,
|
|
11990
|
+
exists: isFile(join3(homedir(), extracted.slice(2)))
|
|
11991
|
+
};
|
|
12003
11992
|
}
|
|
12004
|
-
|
|
12005
|
-
|
|
12006
|
-
return { scriptPath: extracted, exists:
|
|
12007
|
-
}
|
|
12008
|
-
|
|
11993
|
+
if (PROJECT_DIR.test(extracted)) {
|
|
11994
|
+
const rel = extracted.replace(/^\$(?:CLAUDE_PROJECT_DIR|\{CLAUDE_PROJECT_DIR\})\/?/, "");
|
|
11995
|
+
return { scriptPath: extracted, exists: isFile(join3(bases.project, rel)) };
|
|
11996
|
+
}
|
|
11997
|
+
if (bases.plugin !== undefined && PLUGIN_ROOT.test(extracted)) {
|
|
11998
|
+
const rel = extracted.replace(/^\$(?:CLAUDE_PLUGIN_ROOT|\{CLAUDE_PLUGIN_ROOT\})\/?/, "");
|
|
11999
|
+
return { scriptPath: extracted, exists: isFile(join3(bases.plugin, rel)) };
|
|
12000
|
+
}
|
|
12001
|
+
if (isAbsolute(extracted)) {
|
|
12002
|
+
return { scriptPath: extracted, exists: isFile(extracted) };
|
|
12003
|
+
}
|
|
12004
|
+
const candidates = [
|
|
12005
|
+
...bases.own === undefined ? [] : [join3(bases.own, extracted)],
|
|
12006
|
+
join3(bases.project, extracted)
|
|
12007
|
+
];
|
|
12008
|
+
return { scriptPath: extracted, exists: candidates.some(isFile) };
|
|
12009
|
+
}
|
|
12010
|
+
function hooksFromObject(hooks, filePath, source, bases, errors3) {
|
|
12011
|
+
if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) {
|
|
12012
|
+
errors3.push({
|
|
12013
|
+
path: filePath,
|
|
12014
|
+
kind: "unexpected-shape",
|
|
12015
|
+
detail: "`hooks` is not an object"
|
|
12016
|
+
});
|
|
12017
|
+
return [];
|
|
12018
|
+
}
|
|
12019
|
+
const facts = [];
|
|
12020
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
12021
|
+
const malformed = !Array.isArray(groups) || groups.some((group) => {
|
|
12022
|
+
if (group === null || typeof group !== "object" || Array.isArray(group)) {
|
|
12023
|
+
return true;
|
|
12024
|
+
}
|
|
12025
|
+
const inner = group.hooks;
|
|
12026
|
+
if (!Array.isArray(inner))
|
|
12027
|
+
return true;
|
|
12028
|
+
return inner.some((entry) => {
|
|
12029
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
12030
|
+
return true;
|
|
12031
|
+
}
|
|
12032
|
+
const item = entry;
|
|
12033
|
+
return item.command !== undefined && (typeof item.command !== "string" || item.command.length === 0) || item.type === "command" && typeof item.command !== "string";
|
|
12034
|
+
});
|
|
12035
|
+
});
|
|
12036
|
+
if (malformed) {
|
|
12037
|
+
errors3.push({ path: filePath, kind: "unexpected-shape", detail: `invalid hook groups for ${event}` });
|
|
12038
|
+
continue;
|
|
12039
|
+
}
|
|
12040
|
+
const commands = collectHookCommands(groups);
|
|
12041
|
+
if (commands.length === 0) {
|
|
12042
|
+
facts.push({ name: event, path: filePath, event, source });
|
|
12043
|
+
continue;
|
|
12044
|
+
}
|
|
12045
|
+
for (const command of commands) {
|
|
12046
|
+
const fact = { name: event, path: filePath, event, source, command };
|
|
12047
|
+
const resolved = resolveHookScript(bases, command);
|
|
12048
|
+
if (resolved !== undefined) {
|
|
12049
|
+
fact.scriptPath = resolved.scriptPath;
|
|
12050
|
+
fact.scriptExists = resolved.exists;
|
|
12051
|
+
}
|
|
12052
|
+
facts.push(fact);
|
|
12053
|
+
}
|
|
12009
12054
|
}
|
|
12055
|
+
return facts;
|
|
12010
12056
|
}
|
|
12011
12057
|
function discoverHooks(root, errors3) {
|
|
12012
12058
|
const files = [
|
|
12013
|
-
|
|
12014
|
-
|
|
12059
|
+
join3(root, ".claude", "settings.json"),
|
|
12060
|
+
join3(root, ".claude", "settings.local.json")
|
|
12015
12061
|
];
|
|
12016
12062
|
const facts = [];
|
|
12017
12063
|
for (const filePath of files) {
|
|
12018
|
-
if (!
|
|
12064
|
+
if (!existsSync3(filePath)) {
|
|
12019
12065
|
continue;
|
|
12020
12066
|
}
|
|
12021
12067
|
const raw = readJsonConfig(filePath, errors3);
|
|
@@ -12034,49 +12080,7 @@ function discoverHooks(root, errors3) {
|
|
|
12034
12080
|
if (hooks === undefined) {
|
|
12035
12081
|
continue;
|
|
12036
12082
|
}
|
|
12037
|
-
|
|
12038
|
-
errors3.push({
|
|
12039
|
-
path: filePath,
|
|
12040
|
-
kind: "unexpected-shape",
|
|
12041
|
-
detail: "`hooks` is not an object"
|
|
12042
|
-
});
|
|
12043
|
-
continue;
|
|
12044
|
-
}
|
|
12045
|
-
for (const [event, groups] of Object.entries(hooks)) {
|
|
12046
|
-
const malformed = !Array.isArray(groups) || groups.some((group) => {
|
|
12047
|
-
if (group === null || typeof group !== "object" || Array.isArray(group)) {
|
|
12048
|
-
return true;
|
|
12049
|
-
}
|
|
12050
|
-
const inner = group.hooks;
|
|
12051
|
-
if (!Array.isArray(inner))
|
|
12052
|
-
return true;
|
|
12053
|
-
return inner.some((entry) => {
|
|
12054
|
-
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
12055
|
-
return true;
|
|
12056
|
-
}
|
|
12057
|
-
const item = entry;
|
|
12058
|
-
return item.command !== undefined && (typeof item.command !== "string" || item.command.length === 0) || item.type === "command" && typeof item.command !== "string";
|
|
12059
|
-
});
|
|
12060
|
-
});
|
|
12061
|
-
if (malformed) {
|
|
12062
|
-
errors3.push({ path: filePath, kind: "unexpected-shape", detail: `invalid hook groups for ${event}` });
|
|
12063
|
-
continue;
|
|
12064
|
-
}
|
|
12065
|
-
const commands = collectHookCommands(groups);
|
|
12066
|
-
if (commands.length === 0) {
|
|
12067
|
-
facts.push({ name: event, path: filePath, event });
|
|
12068
|
-
continue;
|
|
12069
|
-
}
|
|
12070
|
-
for (const command of commands) {
|
|
12071
|
-
const fact = { name: event, path: filePath, event, command };
|
|
12072
|
-
const resolved = resolveHookScript(root, command);
|
|
12073
|
-
if (resolved !== undefined) {
|
|
12074
|
-
fact.scriptPath = resolved.scriptPath;
|
|
12075
|
-
fact.scriptExists = resolved.exists;
|
|
12076
|
-
}
|
|
12077
|
-
facts.push(fact);
|
|
12078
|
-
}
|
|
12079
|
-
}
|
|
12083
|
+
facts.push(...hooksFromObject(hooks, filePath, "settings", { project: root }, errors3));
|
|
12080
12084
|
}
|
|
12081
12085
|
return facts;
|
|
12082
12086
|
}
|
|
@@ -12106,6 +12110,84 @@ function collectHookCommands(groups) {
|
|
|
12106
12110
|
return out;
|
|
12107
12111
|
}
|
|
12108
12112
|
|
|
12113
|
+
// src/discover/agents.ts
|
|
12114
|
+
function agentFiles(dir, errors3) {
|
|
12115
|
+
let entries;
|
|
12116
|
+
try {
|
|
12117
|
+
entries = readdirSync(dir, { withFileTypes: true }).map((e) => e.isDirectory() ? `${e.name}/` : e.name);
|
|
12118
|
+
} catch (err) {
|
|
12119
|
+
errors3.push({
|
|
12120
|
+
path: dir,
|
|
12121
|
+
kind: "unreadable",
|
|
12122
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
12123
|
+
});
|
|
12124
|
+
return [];
|
|
12125
|
+
}
|
|
12126
|
+
const out = [];
|
|
12127
|
+
for (const entry of entries) {
|
|
12128
|
+
if (entry.startsWith(".")) {
|
|
12129
|
+
continue;
|
|
12130
|
+
}
|
|
12131
|
+
if (entry.endsWith("/")) {
|
|
12132
|
+
out.push(...agentFiles(join4(dir, entry.slice(0, -1)), errors3).map((p) => join4(entry.slice(0, -1), p)));
|
|
12133
|
+
continue;
|
|
12134
|
+
}
|
|
12135
|
+
out.push(entry);
|
|
12136
|
+
}
|
|
12137
|
+
return out;
|
|
12138
|
+
}
|
|
12139
|
+
function discoverAgents(root, errors3) {
|
|
12140
|
+
const dir = join4(root, ".claude", "agents");
|
|
12141
|
+
if (!existsSync4(dir)) {
|
|
12142
|
+
return [];
|
|
12143
|
+
}
|
|
12144
|
+
const entries = agentFiles(dir, errors3);
|
|
12145
|
+
const facts = [];
|
|
12146
|
+
for (const name of entries) {
|
|
12147
|
+
if (!name.endsWith(".md")) {
|
|
12148
|
+
continue;
|
|
12149
|
+
}
|
|
12150
|
+
const filePath = join4(dir, name);
|
|
12151
|
+
try {
|
|
12152
|
+
const st = statSync2(filePath);
|
|
12153
|
+
if (!st.isFile()) {
|
|
12154
|
+
continue;
|
|
12155
|
+
}
|
|
12156
|
+
} catch {
|
|
12157
|
+
continue;
|
|
12158
|
+
}
|
|
12159
|
+
const fm = readFrontmatter(filePath, errors3);
|
|
12160
|
+
const fact = {
|
|
12161
|
+
name: name.slice(0, -".md".length),
|
|
12162
|
+
path: filePath,
|
|
12163
|
+
hasFrontmatter: fm.hasFrontmatter
|
|
12164
|
+
};
|
|
12165
|
+
if (fm.unreadable === true) {
|
|
12166
|
+
fact.unreadable = true;
|
|
12167
|
+
}
|
|
12168
|
+
if (fm.unparseable === true) {
|
|
12169
|
+
fact.unparseableFrontmatter = true;
|
|
12170
|
+
}
|
|
12171
|
+
if (fm.name !== undefined) {
|
|
12172
|
+
fact.frontmatterName = fm.name;
|
|
12173
|
+
}
|
|
12174
|
+
if (fm.description !== undefined) {
|
|
12175
|
+
fact.description = fm.description;
|
|
12176
|
+
}
|
|
12177
|
+
if (fm.hooks !== undefined) {
|
|
12178
|
+
const hooks = hooksFromObject(fm.hooks, filePath, "agent", {
|
|
12179
|
+
project: root,
|
|
12180
|
+
own: dirname2(filePath)
|
|
12181
|
+
}, errors3);
|
|
12182
|
+
if (hooks.length > 0) {
|
|
12183
|
+
fact.frontmatterHooks = hooks;
|
|
12184
|
+
}
|
|
12185
|
+
}
|
|
12186
|
+
facts.push(fact);
|
|
12187
|
+
}
|
|
12188
|
+
return facts;
|
|
12189
|
+
}
|
|
12190
|
+
|
|
12109
12191
|
// src/discover/mcp.ts
|
|
12110
12192
|
import { existsSync as existsSync5, statSync as statSync3 } from "node:fs";
|
|
12111
12193
|
import { homedir as homedir2 } from "node:os";
|
|
@@ -12240,14 +12322,87 @@ function discoverMcp(root, mcpPaths, errors3) {
|
|
|
12240
12322
|
return facts;
|
|
12241
12323
|
}
|
|
12242
12324
|
|
|
12243
|
-
// src/discover/
|
|
12244
|
-
import { existsSync as existsSync6 } from "node:fs";
|
|
12325
|
+
// src/discover/plugins.ts
|
|
12326
|
+
import { existsSync as existsSync6, readdirSync as readdirSync2 } from "node:fs";
|
|
12245
12327
|
import { join as join6 } from "node:path";
|
|
12328
|
+
function findPluginRoots(root, errors3) {
|
|
12329
|
+
const found = [];
|
|
12330
|
+
if (existsSync6(join6(root, "hooks", "hooks.json"))) {
|
|
12331
|
+
found.push(root);
|
|
12332
|
+
}
|
|
12333
|
+
const walk = (dir, depth) => {
|
|
12334
|
+
let entries;
|
|
12335
|
+
try {
|
|
12336
|
+
entries = readdirSync2(dir, { withFileTypes: true });
|
|
12337
|
+
} catch {
|
|
12338
|
+
errors3.push({
|
|
12339
|
+
path: dir,
|
|
12340
|
+
kind: "unreadable",
|
|
12341
|
+
detail: "could not read directories while looking for plugins"
|
|
12342
|
+
});
|
|
12343
|
+
return;
|
|
12344
|
+
}
|
|
12345
|
+
for (const entry of entries) {
|
|
12346
|
+
if (!entry.isDirectory() || NESTED_DISCOVERY_SKIP.has(entry.name)) {
|
|
12347
|
+
continue;
|
|
12348
|
+
}
|
|
12349
|
+
if (entry.name.startsWith(".") && entry.name !== ".claude") {
|
|
12350
|
+
continue;
|
|
12351
|
+
}
|
|
12352
|
+
const child = join6(dir, entry.name);
|
|
12353
|
+
if (existsSync6(join6(child, ".claude-plugin", "plugin.json"))) {
|
|
12354
|
+
found.push(child);
|
|
12355
|
+
continue;
|
|
12356
|
+
}
|
|
12357
|
+
if (depth >= NESTED_DISCOVERY_MAX_DEPTH) {
|
|
12358
|
+
continue;
|
|
12359
|
+
}
|
|
12360
|
+
walk(child, depth + 1);
|
|
12361
|
+
}
|
|
12362
|
+
};
|
|
12363
|
+
walk(root, 0);
|
|
12364
|
+
return found;
|
|
12365
|
+
}
|
|
12366
|
+
function discoverPluginHooks(root, errors3) {
|
|
12367
|
+
const facts = [];
|
|
12368
|
+
for (const pluginRoot of findPluginRoots(root, errors3)) {
|
|
12369
|
+
const filePath = join6(pluginRoot, "hooks", "hooks.json");
|
|
12370
|
+
if (!existsSync6(filePath)) {
|
|
12371
|
+
continue;
|
|
12372
|
+
}
|
|
12373
|
+
const raw = readJsonConfig(filePath, errors3);
|
|
12374
|
+
if (raw === undefined) {
|
|
12375
|
+
continue;
|
|
12376
|
+
}
|
|
12377
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
12378
|
+
errors3.push({
|
|
12379
|
+
path: filePath,
|
|
12380
|
+
kind: "unexpected-shape",
|
|
12381
|
+
detail: "hooks.json is not a JSON object"
|
|
12382
|
+
});
|
|
12383
|
+
continue;
|
|
12384
|
+
}
|
|
12385
|
+
const hooks = raw.hooks;
|
|
12386
|
+
if (hooks === undefined) {
|
|
12387
|
+
continue;
|
|
12388
|
+
}
|
|
12389
|
+
facts.push(...hooksFromObject(hooks, filePath, "plugin", {
|
|
12390
|
+
project: root,
|
|
12391
|
+
plugin: pluginRoot,
|
|
12392
|
+
own: pluginRoot
|
|
12393
|
+
}, errors3));
|
|
12394
|
+
}
|
|
12395
|
+
return facts;
|
|
12396
|
+
}
|
|
12397
|
+
|
|
12398
|
+
// src/discover/policy.ts
|
|
12399
|
+
import { existsSync as existsSync7 } from "node:fs";
|
|
12400
|
+
import { join as join7 } from "node:path";
|
|
12246
12401
|
function discoverPolicyFiles(root, policyFiles, errors3) {
|
|
12247
12402
|
const out = [];
|
|
12248
12403
|
for (const rel of policyFiles) {
|
|
12249
|
-
const filePath =
|
|
12250
|
-
if (!
|
|
12404
|
+
const filePath = join7(root, rel);
|
|
12405
|
+
if (!existsSync7(filePath)) {
|
|
12251
12406
|
continue;
|
|
12252
12407
|
}
|
|
12253
12408
|
try {
|
|
@@ -12255,7 +12410,7 @@ function discoverPolicyFiles(root, policyFiles, errors3) {
|
|
|
12255
12410
|
const text = result.buf.subarray(0, POLICY_CAP).toString("utf8");
|
|
12256
12411
|
out.push({ path: filePath, text });
|
|
12257
12412
|
if (result.truncated)
|
|
12258
|
-
errors3.push({ path: filePath, kind: "
|
|
12413
|
+
errors3.push({ path: filePath, kind: "truncated", detail: `file exceeds ${POLICY_CAP} byte scan cap` });
|
|
12259
12414
|
} catch (err) {
|
|
12260
12415
|
errors3.push({
|
|
12261
12416
|
path: filePath,
|
|
@@ -12267,8 +12422,8 @@ function discoverPolicyFiles(root, policyFiles, errors3) {
|
|
|
12267
12422
|
return out;
|
|
12268
12423
|
}
|
|
12269
12424
|
function discoverSkillsLock(root, errors3) {
|
|
12270
|
-
const filePath =
|
|
12271
|
-
if (!
|
|
12425
|
+
const filePath = join7(root, "skills-lock.json");
|
|
12426
|
+
if (!existsSync7(filePath)) {
|
|
12272
12427
|
return { locked: [], present: false };
|
|
12273
12428
|
}
|
|
12274
12429
|
const raw = readJsonConfig(filePath, errors3);
|
|
@@ -12313,8 +12468,8 @@ function discoverSkillsLock(root, errors3) {
|
|
|
12313
12468
|
}
|
|
12314
12469
|
|
|
12315
12470
|
// src/discover/skills.ts
|
|
12316
|
-
import { existsSync as
|
|
12317
|
-
import { join as
|
|
12471
|
+
import { existsSync as existsSync8, readdirSync as readdirSync3, statSync as statSync4 } from "node:fs";
|
|
12472
|
+
import { join as join8, resolve as resolve2 } from "node:path";
|
|
12318
12473
|
var BUNDLED = "scripts|references|assets|templates|examples";
|
|
12319
12474
|
var REFERENCE_RE = new RegExp(`(?<![\\w/.-])(?:\\./)?((?:${BUNDLED})/[A-Za-z0-9_./-]+\\.[A-Za-z0-9]{1,10})(?![A-Za-z0-9_.-])`, "g");
|
|
12320
12475
|
var FENCE_BLOCK = /^[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:^[ \t]*\1[ \t]*$|$)/gm;
|
|
@@ -12335,22 +12490,22 @@ function skillReferences(body) {
|
|
|
12335
12490
|
return out;
|
|
12336
12491
|
}
|
|
12337
12492
|
function brokenReferences(body, skillDir, root) {
|
|
12338
|
-
const
|
|
12493
|
+
const isFile2 = (p) => {
|
|
12339
12494
|
try {
|
|
12340
12495
|
return statSync4(p).isFile();
|
|
12341
12496
|
} catch {
|
|
12342
12497
|
return false;
|
|
12343
12498
|
}
|
|
12344
12499
|
};
|
|
12345
|
-
return skillReferences(body).filter((rel) => !
|
|
12500
|
+
return skillReferences(body).filter((rel) => !isFile2(join8(skillDir, rel)) && !isFile2(join8(root, rel)));
|
|
12346
12501
|
}
|
|
12347
12502
|
function discoverSkillsInDir(dir, source, errors3, root) {
|
|
12348
|
-
if (!
|
|
12503
|
+
if (!existsSync8(dir)) {
|
|
12349
12504
|
return [];
|
|
12350
12505
|
}
|
|
12351
12506
|
let entries;
|
|
12352
12507
|
try {
|
|
12353
|
-
entries =
|
|
12508
|
+
entries = readdirSync3(dir);
|
|
12354
12509
|
} catch (err) {
|
|
12355
12510
|
errors3.push({
|
|
12356
12511
|
path: dir,
|
|
@@ -12366,7 +12521,7 @@ function discoverSkillsInDir(dir, source, errors3, root) {
|
|
|
12366
12521
|
if (name.startsWith(".") || name === "node_modules") {
|
|
12367
12522
|
continue;
|
|
12368
12523
|
}
|
|
12369
|
-
const skillDir =
|
|
12524
|
+
const skillDir = join8(dir, name);
|
|
12370
12525
|
let st;
|
|
12371
12526
|
try {
|
|
12372
12527
|
st = statSync4(skillDir);
|
|
@@ -12376,10 +12531,10 @@ function discoverSkillsInDir(dir, source, errors3, root) {
|
|
|
12376
12531
|
if (!st.isDirectory()) {
|
|
12377
12532
|
continue;
|
|
12378
12533
|
}
|
|
12379
|
-
const skillMd =
|
|
12534
|
+
const skillMd = join8(skillDir, "SKILL.md");
|
|
12380
12535
|
let dirReadable = true;
|
|
12381
12536
|
try {
|
|
12382
|
-
|
|
12537
|
+
readdirSync3(skillDir);
|
|
12383
12538
|
} catch (err) {
|
|
12384
12539
|
dirReadable = false;
|
|
12385
12540
|
errors3.push({
|
|
@@ -12388,7 +12543,7 @@ function discoverSkillsInDir(dir, source, errors3, root) {
|
|
|
12388
12543
|
detail: err instanceof Error ? err.message : String(err)
|
|
12389
12544
|
});
|
|
12390
12545
|
}
|
|
12391
|
-
const hasSkillMd = dirReadable &&
|
|
12546
|
+
const hasSkillMd = dirReadable && existsSync8(skillMd);
|
|
12392
12547
|
const fm = hasSkillMd ? readFrontmatter(skillMd, errors3) : { hasFrontmatter: false };
|
|
12393
12548
|
const fact = {
|
|
12394
12549
|
id: name,
|
|
@@ -12416,26 +12571,25 @@ function discoverSkillsInDir(dir, source, errors3, root) {
|
|
|
12416
12571
|
if (fm.name !== undefined) {
|
|
12417
12572
|
fact.frontmatterName = fm.name;
|
|
12418
12573
|
}
|
|
12574
|
+
if (fm.hooks !== undefined) {
|
|
12575
|
+
const hooks = hooksFromObject(fm.hooks, skillMd, "skill", {
|
|
12576
|
+
project: root,
|
|
12577
|
+
own: skillDir
|
|
12578
|
+
}, errors3);
|
|
12579
|
+
if (hooks.length > 0) {
|
|
12580
|
+
fact.frontmatterHooks = hooks;
|
|
12581
|
+
}
|
|
12582
|
+
}
|
|
12419
12583
|
skills.push(fact);
|
|
12420
12584
|
}
|
|
12421
12585
|
return skills;
|
|
12422
12586
|
}
|
|
12423
|
-
var NESTED_DISCOVERY_MAX_DEPTH = 8;
|
|
12424
|
-
var NESTED_DISCOVERY_SKIP = new Set([
|
|
12425
|
-
".git",
|
|
12426
|
-
"node_modules",
|
|
12427
|
-
".next",
|
|
12428
|
-
"dist",
|
|
12429
|
-
"build",
|
|
12430
|
-
"coverage",
|
|
12431
|
-
"vendor"
|
|
12432
|
-
]);
|
|
12433
12587
|
function discoverNestedClaudeSkills(root, configuredRoots, errors3, options = {}) {
|
|
12434
12588
|
const out = [];
|
|
12435
12589
|
const read = (dir) => {
|
|
12436
12590
|
options.onDirectoryRead?.();
|
|
12437
12591
|
try {
|
|
12438
|
-
return
|
|
12592
|
+
return readdirSync3(dir, { withFileTypes: true });
|
|
12439
12593
|
} catch {
|
|
12440
12594
|
errors3.push({ path: dir, kind: "unreadable", detail: "could not read nested skill directories" });
|
|
12441
12595
|
return;
|
|
@@ -12448,7 +12602,7 @@ function discoverNestedClaudeSkills(root, configuredRoots, errors3, options = {}
|
|
|
12448
12602
|
for (const entry of entries) {
|
|
12449
12603
|
if (!entry.isDirectory() || entry.name !== "skills")
|
|
12450
12604
|
continue;
|
|
12451
|
-
const child =
|
|
12605
|
+
const child = join8(dir, entry.name);
|
|
12452
12606
|
if (configuredRoots.has(resolve2(child)))
|
|
12453
12607
|
continue;
|
|
12454
12608
|
out.push(...discoverSkillsInDir(child, "project", errors3, root));
|
|
@@ -12463,7 +12617,7 @@ function discoverNestedClaudeSkills(root, configuredRoots, errors3, options = {}
|
|
|
12463
12617
|
continue;
|
|
12464
12618
|
if (entry.name.startsWith(".") && entry.name !== ".claude")
|
|
12465
12619
|
continue;
|
|
12466
|
-
const child =
|
|
12620
|
+
const child = join8(dir, entry.name);
|
|
12467
12621
|
if (entry.name === ".claude") {
|
|
12468
12622
|
inspectClaude(child);
|
|
12469
12623
|
continue;
|
|
@@ -12493,7 +12647,7 @@ function discoverAgentSurface(root, config, opts) {
|
|
|
12493
12647
|
const configErrors = [];
|
|
12494
12648
|
const skills = [];
|
|
12495
12649
|
for (const rel of config.skillPaths) {
|
|
12496
|
-
skills.push(...discoverSkillsInDir(
|
|
12650
|
+
skills.push(...discoverSkillsInDir(join9(root, rel), "project", configErrors, root));
|
|
12497
12651
|
}
|
|
12498
12652
|
const configuredRoots = new Set(config.skillPaths.map((rel) => resolve3(root, rel)));
|
|
12499
12653
|
skills.push(...discoverNestedClaudeSkills(root, configuredRoots, configErrors, {
|
|
@@ -12501,14 +12655,20 @@ function discoverAgentSurface(root, config, opts) {
|
|
|
12501
12655
|
}));
|
|
12502
12656
|
if (opts.includeGlobal) {
|
|
12503
12657
|
const home = homedir3();
|
|
12504
|
-
skills.push(...discoverSkillsInDir(
|
|
12505
|
-
skills.push(...discoverSkillsInDir(
|
|
12658
|
+
skills.push(...discoverSkillsInDir(join9(home, ".claude", "skills"), "global", configErrors, root));
|
|
12659
|
+
skills.push(...discoverSkillsInDir(join9(home, ".codex", "skills"), "global", configErrors, root));
|
|
12506
12660
|
}
|
|
12507
12661
|
const lock = discoverSkillsLock(root, configErrors);
|
|
12662
|
+
const agents = discoverAgents(root, configErrors);
|
|
12508
12663
|
return {
|
|
12509
12664
|
skills: disambiguateSkills(skills, root),
|
|
12510
|
-
agents
|
|
12511
|
-
hooks:
|
|
12665
|
+
agents,
|
|
12666
|
+
hooks: [
|
|
12667
|
+
...discoverHooks(root, configErrors),
|
|
12668
|
+
...discoverPluginHooks(root, configErrors),
|
|
12669
|
+
...skills.flatMap((s) => s.frontmatterHooks ?? []),
|
|
12670
|
+
...agents.flatMap((a) => a.frontmatterHooks ?? [])
|
|
12671
|
+
],
|
|
12512
12672
|
mcp: discoverMcp(root, config.mcpPaths, configErrors),
|
|
12513
12673
|
policyFiles: discoverPolicyFiles(root, config.policyFiles, configErrors),
|
|
12514
12674
|
lockedSkills: lock.locked,
|
|
@@ -12519,11 +12679,11 @@ function discoverAgentSurface(root, config, opts) {
|
|
|
12519
12679
|
}
|
|
12520
12680
|
|
|
12521
12681
|
// src/facts/extract.ts
|
|
12522
|
-
import { existsSync as
|
|
12523
|
-
import { join as
|
|
12682
|
+
import { existsSync as existsSync9, readFileSync as readFileSync3 } from "node:fs";
|
|
12683
|
+
import { join as join10 } from "node:path";
|
|
12524
12684
|
function readPackageJson(root, errors3) {
|
|
12525
|
-
const path =
|
|
12526
|
-
if (!
|
|
12685
|
+
const path = join10(root, "package.json");
|
|
12686
|
+
if (!existsSync9(path)) {
|
|
12527
12687
|
return {};
|
|
12528
12688
|
}
|
|
12529
12689
|
let text;
|
|
@@ -12621,16 +12781,16 @@ function inferPackageManager(root, pkg) {
|
|
|
12621
12781
|
return prefix;
|
|
12622
12782
|
}
|
|
12623
12783
|
}
|
|
12624
|
-
if (
|
|
12784
|
+
if (existsSync9(join10(root, "bun.lock")) || existsSync9(join10(root, "bun.lockb"))) {
|
|
12625
12785
|
return "bun";
|
|
12626
12786
|
}
|
|
12627
|
-
if (
|
|
12787
|
+
if (existsSync9(join10(root, "pnpm-lock.yaml"))) {
|
|
12628
12788
|
return "pnpm";
|
|
12629
12789
|
}
|
|
12630
|
-
if (
|
|
12790
|
+
if (existsSync9(join10(root, "yarn.lock"))) {
|
|
12631
12791
|
return "yarn";
|
|
12632
12792
|
}
|
|
12633
|
-
if (
|
|
12793
|
+
if (existsSync9(join10(root, "package-lock.json"))) {
|
|
12634
12794
|
return "npm";
|
|
12635
12795
|
}
|
|
12636
12796
|
return "unknown";
|
|
@@ -12969,7 +13129,7 @@ function renderText(args) {
|
|
|
12969
13129
|
lines.push(ink.dim(`Scanned: ${formatStack(facts)}`));
|
|
12970
13130
|
lines.push("");
|
|
12971
13131
|
for (const group of groupByRule(visible)) {
|
|
12972
|
-
lines.push(...formatGroup(group, facts.root, ink));
|
|
13132
|
+
lines.push(...formatGroup(group, facts.root, ink, verbose));
|
|
12973
13133
|
lines.push("");
|
|
12974
13134
|
}
|
|
12975
13135
|
lines.push(summary);
|
|
@@ -13029,7 +13189,7 @@ var SEVERITY_TONE = {
|
|
|
13029
13189
|
warning: "yellow",
|
|
13030
13190
|
info: undefined
|
|
13031
13191
|
};
|
|
13032
|
-
function formatGroup(group, root, ink) {
|
|
13192
|
+
function formatGroup(group, root, ink, verbose) {
|
|
13033
13193
|
const first = group[0];
|
|
13034
13194
|
const count = group.length;
|
|
13035
13195
|
const out = [];
|
|
@@ -13041,6 +13201,9 @@ function formatGroup(group, root, ink) {
|
|
|
13041
13201
|
const where = f.evidence.filter((e) => e.kind !== "count" && e.kind !== "threshold").map((e) => shortPath(e.value, root));
|
|
13042
13202
|
const location = where.length > 0 ? where.join(" · ") : f.subject;
|
|
13043
13203
|
out.push(ink.dim(` ${safe(shortPath(location, root))}`));
|
|
13204
|
+
if (verbose) {
|
|
13205
|
+
out.push(ink.dim(` id: ${safe(f.id)}`));
|
|
13206
|
+
}
|
|
13044
13207
|
}
|
|
13045
13208
|
return out;
|
|
13046
13209
|
}
|
|
@@ -13102,7 +13265,7 @@ function formatSummary(findings, verbose, ink) {
|
|
|
13102
13265
|
}
|
|
13103
13266
|
|
|
13104
13267
|
// src/version.ts
|
|
13105
|
-
var VERSION = "0.
|
|
13268
|
+
var VERSION = "0.7.0";
|
|
13106
13269
|
|
|
13107
13270
|
// src/commands/check.ts
|
|
13108
13271
|
async function runCheck(options) {
|
|
@@ -13149,13 +13312,13 @@ import {
|
|
|
13149
13312
|
writeFileSync
|
|
13150
13313
|
} from "node:fs";
|
|
13151
13314
|
import { tmpdir } from "node:os";
|
|
13152
|
-
import { join as
|
|
13315
|
+
import { join as join11 } from "node:path";
|
|
13153
13316
|
async function runDemo() {
|
|
13154
|
-
const root = mkdtempSync(
|
|
13317
|
+
const root = mkdtempSync(join11(tmpdir(), "agentscan-demo-"));
|
|
13155
13318
|
try {
|
|
13156
|
-
const claude =
|
|
13319
|
+
const claude = join11(root, ".claude");
|
|
13157
13320
|
mkdirSync(claude, { recursive: true });
|
|
13158
|
-
writeFileSync(
|
|
13321
|
+
writeFileSync(join11(claude, "settings.json"), `${JSON.stringify({
|
|
13159
13322
|
hooks: {
|
|
13160
13323
|
PreToolUse: [
|
|
13161
13324
|
{
|
|
@@ -13229,12 +13392,12 @@ function formatExplain(f) {
|
|
|
13229
13392
|
}
|
|
13230
13393
|
|
|
13231
13394
|
// src/commands/init.ts
|
|
13232
|
-
import { existsSync as
|
|
13233
|
-
import { join as
|
|
13395
|
+
import { existsSync as existsSync10, writeFileSync as writeFileSync2 } from "node:fs";
|
|
13396
|
+
import { join as join12, resolve as resolve5 } from "node:path";
|
|
13234
13397
|
async function runInit(dir, options = {}) {
|
|
13235
13398
|
const root = resolve5(dir ?? process.cwd());
|
|
13236
|
-
const path =
|
|
13237
|
-
if (
|
|
13399
|
+
const path = join12(root, ".agentscanrc.json");
|
|
13400
|
+
if (existsSync10(path) && !options.force) {
|
|
13238
13401
|
return {
|
|
13239
13402
|
exitCode: 2,
|
|
13240
13403
|
stdout: "",
|
|
@@ -13270,7 +13433,7 @@ async function runRulesCommand(options = {}) {
|
|
|
13270
13433
|
// src/report/clipboard.ts
|
|
13271
13434
|
import { spawn } from "node:child_process";
|
|
13272
13435
|
import { accessSync, constants } from "node:fs";
|
|
13273
|
-
import { delimiter, join as
|
|
13436
|
+
import { delimiter, join as join13 } from "node:path";
|
|
13274
13437
|
var TOOLS = [
|
|
13275
13438
|
["wl-copy"],
|
|
13276
13439
|
["xclip", "-selection", "clipboard"],
|
|
@@ -13287,7 +13450,7 @@ function resolve6(name) {
|
|
|
13287
13450
|
if (dir.length === 0) {
|
|
13288
13451
|
continue;
|
|
13289
13452
|
}
|
|
13290
|
-
const candidate =
|
|
13453
|
+
const candidate = join13(dir, name);
|
|
13291
13454
|
try {
|
|
13292
13455
|
accessSync(candidate, constants.X_OK);
|
|
13293
13456
|
return candidate;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chimix/agentscan",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Find broken agent configuration in a project — hooks whose scripts are gone, MCP servers misconfigured by schema, skills that disagree with their lockfile",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"engines": {
|
|
46
46
|
"node": ">=20.11.0",
|
|
47
|
-
"bun": ">=1.
|
|
47
|
+
"bun": ">=1.4.0"
|
|
48
48
|
},
|
|
49
49
|
"license": "MIT",
|
|
50
50
|
"dependencies": {
|