@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.
Files changed (3) hide show
  1. package/README.md +66 -25
  2. package/dist/cli.js +370 -207
  3. 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-26-111111?style=flat-square" alt="26 checks">
9
- <img src="https://img.shields.io/badge/tests-226%20passing-111111?style=flat-square" alt="226 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>26 checks &middot; 4.1k lines &middot; 0 network calls &middot; every check sourced to a published spec line</strong><br>
18
+ <strong>27 checks &middot; 4.2k lines &middot; 0 network calls &middot; 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 26 checks, each against one published spec line
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.1+ — the repo runs TypeScript directly and `bun run build`
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 info-severity findings
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
- ┌─────┐ 40/100 broken · touchagency
190
- │ ✕ ✕ │ ██████████░░░░░░░░░░░░░░░
191
- │ ⌒ │ 6 error · 4 info hidden (--verbose)
191
+ ┌─────┐ 34/100 broken · touchagency
192
+ │ ✕ ✕ │ █████████░░░░░░░░░░░░░░░░
193
+ │ ⌒ │ 6 error · 2 warning · 11 info hidden (--verbose)
192
194
  └─────┘
193
195
 
194
- agentscan v0.5.0 — touchagency
196
+ agentscan v0.7.0 — touchagency
195
197
 
196
- Scanned: 46 deps · 54 skills · 1 mcp · 2 agents · packageManager=bun
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
- Summary: 6 error · 4 info hidden (--verbose) · score 40/100 broken
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.5.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, so the value you already have from
393
- reading the report is the value you paste:
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 five
405
- `budget.*` entries at the bottom judge aggregate size instead —
406
- they are all **info**, they are sourced to `docs/spec/thresholds.md`, and they
407
- are retuned through `thresholds` in `.agentscanrc.json` rather than switched
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
- | `hook.missing-script` | error | A registered hook whose script does not exist — it never runs |
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` | error | Agent name is not lowercase letters, numbers, and hyphens |
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
- - Policy files are read up to 100 KB, so `policyLines` undercounts past that.
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: "error",
7123
+ severity: "warning",
7124
7124
  message: `Agent name "${agent.frontmatterName}" is not a valid identifier`,
7125
- reason: "Agent names use lowercase letters, numbers, and hyphens so dispatch can address them reliably.",
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 path = skill.path.replaceAll("\\", "/");
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 bytes = project.reduce((sum, s) => sum + Buffer.byteLength(s.id) + Buffer.byteLength(s.description ?? ""), 0);
7506
- if (bytes <= ceiling) {
7507
- return [];
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
- return [
7510
- make("skill.description-budget", "skills:description-budget", {
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 ${project.length} skills (over ${ceiling})`,
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 join8, resolve as resolve3 } from "node:path";
11707
+ import { join as join9, resolve as resolve3 } from "node:path";
11676
11708
 
11677
11709
  // src/discover/agents.ts
11678
- import { existsSync as existsSync3, readdirSync, statSync } from "node:fs";
11679
- import { join as join3 } from "node:path";
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: "unexpected-shape", detail: `file exceeds ${SKILL_MD_CAP} byte scan cap` });
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
- return out;
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
- function hookScriptPath(command) {
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
- return /^\$(?:CLAUDE_PROJECT_DIR\b|\{CLAUDE_PROJECT_DIR\})/.test(candidate) ? candidate : undefined;
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 resolveHookScript(root, raw) {
11994
- const extracted = hookScriptPath(raw);
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
- let expanded = extracted;
11999
- if (expanded.startsWith("~/")) {
12000
- expanded = join4(homedir(), expanded.slice(2));
12001
- } else if (expanded.startsWith("$")) {
12002
- expanded = expanded.replace(/^\$(?:CLAUDE_PROJECT_DIR|\{CLAUDE_PROJECT_DIR\})\/?/, "");
11987
+ if (extracted.startsWith("~/")) {
11988
+ return {
11989
+ scriptPath: extracted,
11990
+ exists: isFile(join3(homedir(), extracted.slice(2)))
11991
+ };
12003
11992
  }
12004
- const abs = isAbsolute(expanded) ? expanded : join4(root, expanded);
12005
- try {
12006
- return { scriptPath: extracted, exists: statSync2(abs).isFile() };
12007
- } catch {
12008
- return { scriptPath: extracted, exists: false };
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
- join4(root, ".claude", "settings.json"),
12014
- join4(root, ".claude", "settings.local.json")
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 (!existsSync4(filePath)) {
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
- if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) {
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/policy.ts
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 = join6(root, rel);
12250
- if (!existsSync6(filePath)) {
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: "unexpected-shape", detail: `file exceeds ${POLICY_CAP} byte scan cap` });
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 = join6(root, "skills-lock.json");
12271
- if (!existsSync6(filePath)) {
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 existsSync7, readdirSync as readdirSync2, statSync as statSync4 } from "node:fs";
12317
- import { join as join7, resolve as resolve2 } from "node:path";
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 isFile = (p) => {
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) => !isFile(join7(skillDir, rel)) && !isFile(join7(root, 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 (!existsSync7(dir)) {
12503
+ if (!existsSync8(dir)) {
12349
12504
  return [];
12350
12505
  }
12351
12506
  let entries;
12352
12507
  try {
12353
- entries = readdirSync2(dir);
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 = join7(dir, name);
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 = join7(skillDir, "SKILL.md");
12534
+ const skillMd = join8(skillDir, "SKILL.md");
12380
12535
  let dirReadable = true;
12381
12536
  try {
12382
- readdirSync2(skillDir);
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 && existsSync7(skillMd);
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 readdirSync2(dir, { withFileTypes: true });
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 = join7(dir, entry.name);
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 = join7(dir, entry.name);
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(join8(root, rel), "project", configErrors, root));
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(join8(home, ".claude", "skills"), "global", configErrors, root));
12505
- skills.push(...discoverSkillsInDir(join8(home, ".codex", "skills"), "global", configErrors, root));
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: discoverAgents(root, configErrors),
12511
- hooks: discoverHooks(root, configErrors),
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 existsSync8, readFileSync as readFileSync3 } from "node:fs";
12523
- import { join as join9 } from "node:path";
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 = join9(root, "package.json");
12526
- if (!existsSync8(path)) {
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 (existsSync8(join9(root, "bun.lock")) || existsSync8(join9(root, "bun.lockb"))) {
12784
+ if (existsSync9(join10(root, "bun.lock")) || existsSync9(join10(root, "bun.lockb"))) {
12625
12785
  return "bun";
12626
12786
  }
12627
- if (existsSync8(join9(root, "pnpm-lock.yaml"))) {
12787
+ if (existsSync9(join10(root, "pnpm-lock.yaml"))) {
12628
12788
  return "pnpm";
12629
12789
  }
12630
- if (existsSync8(join9(root, "yarn.lock"))) {
12790
+ if (existsSync9(join10(root, "yarn.lock"))) {
12631
12791
  return "yarn";
12632
12792
  }
12633
- if (existsSync8(join9(root, "package-lock.json"))) {
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.5.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 join10 } from "node:path";
13315
+ import { join as join11 } from "node:path";
13153
13316
  async function runDemo() {
13154
- const root = mkdtempSync(join10(tmpdir(), "agentscan-demo-"));
13317
+ const root = mkdtempSync(join11(tmpdir(), "agentscan-demo-"));
13155
13318
  try {
13156
- const claude = join10(root, ".claude");
13319
+ const claude = join11(root, ".claude");
13157
13320
  mkdirSync(claude, { recursive: true });
13158
- writeFileSync(join10(claude, "settings.json"), `${JSON.stringify({
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 existsSync9, writeFileSync as writeFileSync2 } from "node:fs";
13233
- import { join as join11, resolve as resolve5 } from "node:path";
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 = join11(root, ".agentscanrc.json");
13237
- if (existsSync9(path) && !options.force) {
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 join12 } from "node:path";
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 = join12(dir, name);
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.5.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.1.0"
47
+ "bun": ">=1.4.0"
48
48
  },
49
49
  "license": "MIT",
50
50
  "dependencies": {