@chimix/agentscan 0.6.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 +31 -6
  2. package/dist/cli.js +319 -189
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p align="center">
8
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-235%20passing-111111?style=flat-square" alt="235 tests">
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">
@@ -62,7 +62,7 @@ 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
67
  3. Check 27 checks, each against one published spec line
68
68
  4. Report text · --json · --output prompt (handoff for a fixing agent)
@@ -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
@@ -191,7 +193,7 @@ Flags for `check`:
191
193
  │ ⌒ │ 6 error · 2 warning · 11 info hidden (--verbose)
192
194
  └─────┘
193
195
 
194
- agentscan v0.6.0 — touchagency
196
+ agentscan v0.7.0 — touchagency
195
197
 
196
198
  Scanned: 46 deps · 108 skills · 1 mcp · 2 agents · packageManager=bun
197
199
 
@@ -223,7 +225,7 @@ JSON shape (abridged):
223
225
 
224
226
  ```json
225
227
  {
226
- "version": "0.6.0",
228
+ "version": "0.7.0",
227
229
  "root": "/path/to/project",
228
230
  "factsSummary": {
229
231
  "packageManager": "bun",
@@ -417,7 +419,7 @@ Most validate one discovered item against its own file on disk. The four
417
419
  |----|----------|---------|
418
420
  | `config.unreadable` | error | A config file that is not valid JSON, so whatever it declares is silently not in effect |
419
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 |
420
- | `hook.missing-script` | error | A registered hook whose script does not exist — it never runs |
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) |
421
423
  | `hook.unknown-event` | error | A hook registered under an event name that is never dispatched |
422
424
  | `agent.missing-frontmatter` | error | An agent definition with no `---` block |
423
425
  | `agent.missing-description` | error | Agent frontmatter has no `description` |
@@ -457,6 +459,24 @@ references measured across 17 projects, 1645 resolved skill-relative and 12 only
457
459
  at the root. Fenced code blocks are stripped first: a path in an example is
458
460
  illustration, not a pointer.
459
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
+
460
480
  `hook.missing-script` and `mcp.command-missing` are deliberately conservative.
461
481
  A value is only resolved when it is path-like and the answer is certain; shell
462
482
  programs (`a && b`, `$(...)`, pipes), bare PATH binaries (`npx`, `uvx`), and
@@ -489,6 +509,11 @@ When adding a check, add its spec line there first.
489
509
  - **Runtime split.** Source development and the composite Action use Bun; the
490
510
  published `dist/cli.js` runs on Node 20.11+ or Bun. The source entrypoint uses
491
511
  Bun's `import.meta.dir` / `import.meta.main` and is not the Node entrypoint.
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.
492
517
  - **Bounded reads.** A `SKILL.md` is read to 64 KB and a policy file to 100 KB,
493
518
  so `skill.broken-reference` and `policyLines` see only that much of a larger
494
519
  file. This is reported — `scan.truncated`, at `info` — rather than left
package/dist/cli.js CHANGED
@@ -7259,10 +7259,11 @@ function checkHooks(facts) {
7259
7259
  action: "warn",
7260
7260
  severity: "error",
7261
7261
  message: `${hook.event ?? hook.name} hook points at a script that does not exist: ${hook.scriptPath}`,
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.",
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.",
7263
7263
  evidence: [
7264
7264
  { kind: "hook", value: `${hook.event ?? hook.name} @ ${hook.path}` },
7265
- { kind: "script", value: hook.scriptPath }
7265
+ { kind: "script", value: hook.scriptPath },
7266
+ ...hook.source === undefined || hook.source === "settings" ? [] : [{ kind: "source", value: hook.source }]
7266
7267
  ],
7267
7268
  suggest: `Restore ${hook.scriptPath} or remove the hook from ${hook.path}`
7268
7269
  }));
@@ -11703,11 +11704,16 @@ function loadConfig(root, configPath) {
11703
11704
 
11704
11705
  // src/discover/index.ts
11705
11706
  import { homedir as homedir3 } from "node:os";
11706
- import { join as join8, resolve as resolve3 } from "node:path";
11707
+ import { join as join9, resolve as resolve3 } from "node:path";
11707
11708
 
11708
11709
  // src/discover/agents.ts
11709
- import { existsSync as existsSync3, readdirSync, statSync } from "node:fs";
11710
- 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";
11711
11717
 
11712
11718
  // src/discover/shared.ts
11713
11719
  import { closeSync, existsSync as existsSync2, openSync, readFileSync as readFileSync2, readSync } from "node:fs";
@@ -11772,6 +11778,16 @@ function readCapped(path, cap) {
11772
11778
  closeSync(fd);
11773
11779
  }
11774
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
+ ]);
11775
11791
  var AGENT_CONFIG_SIGNALS = [
11776
11792
  ".claude",
11777
11793
  ".agents",
@@ -11899,86 +11915,19 @@ function readFrontmatter(skillMdPath, errors3) {
11899
11915
  if (description !== undefined) {
11900
11916
  out.description = description;
11901
11917
  }
11902
- return out;
11903
- }
11904
-
11905
- // src/discover/agents.ts
11906
- function agentFiles(dir, errors3) {
11907
- let entries;
11908
- try {
11909
- entries = readdirSync(dir, { withFileTypes: true }).map((e) => e.isDirectory() ? `${e.name}/` : e.name);
11910
- } catch (err) {
11911
- errors3.push({
11912
- path: dir,
11913
- kind: "unreadable",
11914
- detail: err instanceof Error ? err.message : String(err)
11915
- });
11916
- return [];
11917
- }
11918
- const out = [];
11919
- for (const entry of entries) {
11920
- if (entry.startsWith(".")) {
11921
- continue;
11922
- }
11923
- if (entry.endsWith("/")) {
11924
- out.push(...agentFiles(join3(dir, entry.slice(0, -1)), errors3).map((p) => join3(entry.slice(0, -1), p)));
11925
- continue;
11926
- }
11927
- out.push(entry);
11918
+ if (record.hooks !== undefined) {
11919
+ out.hooks = record.hooks;
11928
11920
  }
11929
11921
  return out;
11930
11922
  }
11931
- function discoverAgents(root, errors3) {
11932
- const dir = join3(root, ".claude", "agents");
11933
- if (!existsSync3(dir)) {
11934
- return [];
11935
- }
11936
- const entries = agentFiles(dir, errors3);
11937
- const facts = [];
11938
- for (const name of entries) {
11939
- if (!name.endsWith(".md")) {
11940
- continue;
11941
- }
11942
- const filePath = join3(dir, name);
11943
- try {
11944
- const st = statSync(filePath);
11945
- if (!st.isFile()) {
11946
- continue;
11947
- }
11948
- } catch {
11949
- continue;
11950
- }
11951
- const fm = readFrontmatter(filePath, errors3);
11952
- const fact = {
11953
- name: name.slice(0, -".md".length),
11954
- path: filePath,
11955
- hasFrontmatter: fm.hasFrontmatter
11956
- };
11957
- if (fm.unreadable === true) {
11958
- fact.unreadable = true;
11959
- }
11960
- if (fm.unparseable === true) {
11961
- fact.unparseableFrontmatter = true;
11962
- }
11963
- if (fm.name !== undefined) {
11964
- fact.frontmatterName = fm.name;
11965
- }
11966
- if (fm.description !== undefined) {
11967
- fact.description = fm.description;
11968
- }
11969
- facts.push(fact);
11970
- }
11971
- return facts;
11972
- }
11973
11923
 
11974
11924
  // src/discover/hooks.ts
11975
- import { existsSync as existsSync4, statSync as statSync2 } from "node:fs";
11976
- import { homedir } from "node:os";
11977
- import { isAbsolute, join as join4 } from "node:path";
11978
11925
  var INTERPRETERS = /^(?:bash|sh|zsh|python3?|node|bun|deno)$/;
11979
11926
  var INLINE_CODE_FLAGS = /^-(?:e|p|c)$/;
11980
11927
  var SHELL_METACHARS = /[|;&`]|\$\(|\|\||&&/;
11981
- 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 = {}) {
11982
11931
  const trimmed = command.trim();
11983
11932
  if (trimmed.length === 0 || SHELL_METACHARS.test(trimmed)) {
11984
11933
  return;
@@ -12013,39 +11962,106 @@ function hookScriptPath(command) {
12013
11962
  return;
12014
11963
  }
12015
11964
  if (candidate.startsWith("$")) {
12016
- 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;
12017
11969
  }
12018
11970
  if (candidate.startsWith("/") || candidate.startsWith("./") || candidate.startsWith("../") || candidate.startsWith("~/") || candidate.startsWith(".")) {
12019
11971
  return candidate;
12020
11972
  }
12021
11973
  return;
12022
11974
  }
12023
- function resolveHookScript(root, raw) {
12024
- 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);
12025
11984
  if (extracted === undefined) {
12026
11985
  return;
12027
11986
  }
12028
- let expanded = extracted;
12029
- if (expanded.startsWith("~/")) {
12030
- expanded = join4(homedir(), expanded.slice(2));
12031
- } else if (expanded.startsWith("$")) {
12032
- 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
+ };
12033
11992
  }
12034
- const abs = isAbsolute(expanded) ? expanded : join4(root, expanded);
12035
- try {
12036
- return { scriptPath: extracted, exists: statSync2(abs).isFile() };
12037
- } catch {
12038
- 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) };
12039
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
+ }
12054
+ }
12055
+ return facts;
12040
12056
  }
12041
12057
  function discoverHooks(root, errors3) {
12042
12058
  const files = [
12043
- join4(root, ".claude", "settings.json"),
12044
- join4(root, ".claude", "settings.local.json")
12059
+ join3(root, ".claude", "settings.json"),
12060
+ join3(root, ".claude", "settings.local.json")
12045
12061
  ];
12046
12062
  const facts = [];
12047
12063
  for (const filePath of files) {
12048
- if (!existsSync4(filePath)) {
12064
+ if (!existsSync3(filePath)) {
12049
12065
  continue;
12050
12066
  }
12051
12067
  const raw = readJsonConfig(filePath, errors3);
@@ -12064,49 +12080,7 @@ function discoverHooks(root, errors3) {
12064
12080
  if (hooks === undefined) {
12065
12081
  continue;
12066
12082
  }
12067
- if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) {
12068
- errors3.push({
12069
- path: filePath,
12070
- kind: "unexpected-shape",
12071
- detail: "`hooks` is not an object"
12072
- });
12073
- continue;
12074
- }
12075
- for (const [event, groups] of Object.entries(hooks)) {
12076
- const malformed = !Array.isArray(groups) || groups.some((group) => {
12077
- if (group === null || typeof group !== "object" || Array.isArray(group)) {
12078
- return true;
12079
- }
12080
- const inner = group.hooks;
12081
- if (!Array.isArray(inner))
12082
- return true;
12083
- return inner.some((entry) => {
12084
- if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
12085
- return true;
12086
- }
12087
- const item = entry;
12088
- return item.command !== undefined && (typeof item.command !== "string" || item.command.length === 0) || item.type === "command" && typeof item.command !== "string";
12089
- });
12090
- });
12091
- if (malformed) {
12092
- errors3.push({ path: filePath, kind: "unexpected-shape", detail: `invalid hook groups for ${event}` });
12093
- continue;
12094
- }
12095
- const commands = collectHookCommands(groups);
12096
- if (commands.length === 0) {
12097
- facts.push({ name: event, path: filePath, event });
12098
- continue;
12099
- }
12100
- for (const command of commands) {
12101
- const fact = { name: event, path: filePath, event, command };
12102
- const resolved = resolveHookScript(root, command);
12103
- if (resolved !== undefined) {
12104
- fact.scriptPath = resolved.scriptPath;
12105
- fact.scriptExists = resolved.exists;
12106
- }
12107
- facts.push(fact);
12108
- }
12109
- }
12083
+ facts.push(...hooksFromObject(hooks, filePath, "settings", { project: root }, errors3));
12110
12084
  }
12111
12085
  return facts;
12112
12086
  }
@@ -12136,6 +12110,84 @@ function collectHookCommands(groups) {
12136
12110
  return out;
12137
12111
  }
12138
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
+
12139
12191
  // src/discover/mcp.ts
12140
12192
  import { existsSync as existsSync5, statSync as statSync3 } from "node:fs";
12141
12193
  import { homedir as homedir2 } from "node:os";
@@ -12270,14 +12322,87 @@ function discoverMcp(root, mcpPaths, errors3) {
12270
12322
  return facts;
12271
12323
  }
12272
12324
 
12273
- // src/discover/policy.ts
12274
- import { existsSync as existsSync6 } from "node:fs";
12325
+ // src/discover/plugins.ts
12326
+ import { existsSync as existsSync6, readdirSync as readdirSync2 } from "node:fs";
12275
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";
12276
12401
  function discoverPolicyFiles(root, policyFiles, errors3) {
12277
12402
  const out = [];
12278
12403
  for (const rel of policyFiles) {
12279
- const filePath = join6(root, rel);
12280
- if (!existsSync6(filePath)) {
12404
+ const filePath = join7(root, rel);
12405
+ if (!existsSync7(filePath)) {
12281
12406
  continue;
12282
12407
  }
12283
12408
  try {
@@ -12297,8 +12422,8 @@ function discoverPolicyFiles(root, policyFiles, errors3) {
12297
12422
  return out;
12298
12423
  }
12299
12424
  function discoverSkillsLock(root, errors3) {
12300
- const filePath = join6(root, "skills-lock.json");
12301
- if (!existsSync6(filePath)) {
12425
+ const filePath = join7(root, "skills-lock.json");
12426
+ if (!existsSync7(filePath)) {
12302
12427
  return { locked: [], present: false };
12303
12428
  }
12304
12429
  const raw = readJsonConfig(filePath, errors3);
@@ -12343,8 +12468,8 @@ function discoverSkillsLock(root, errors3) {
12343
12468
  }
12344
12469
 
12345
12470
  // src/discover/skills.ts
12346
- import { existsSync as existsSync7, readdirSync as readdirSync2, statSync as statSync4 } from "node:fs";
12347
- 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";
12348
12473
  var BUNDLED = "scripts|references|assets|templates|examples";
12349
12474
  var REFERENCE_RE = new RegExp(`(?<![\\w/.-])(?:\\./)?((?:${BUNDLED})/[A-Za-z0-9_./-]+\\.[A-Za-z0-9]{1,10})(?![A-Za-z0-9_.-])`, "g");
12350
12475
  var FENCE_BLOCK = /^[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:^[ \t]*\1[ \t]*$|$)/gm;
@@ -12365,22 +12490,22 @@ function skillReferences(body) {
12365
12490
  return out;
12366
12491
  }
12367
12492
  function brokenReferences(body, skillDir, root) {
12368
- const isFile = (p) => {
12493
+ const isFile2 = (p) => {
12369
12494
  try {
12370
12495
  return statSync4(p).isFile();
12371
12496
  } catch {
12372
12497
  return false;
12373
12498
  }
12374
12499
  };
12375
- 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)));
12376
12501
  }
12377
12502
  function discoverSkillsInDir(dir, source, errors3, root) {
12378
- if (!existsSync7(dir)) {
12503
+ if (!existsSync8(dir)) {
12379
12504
  return [];
12380
12505
  }
12381
12506
  let entries;
12382
12507
  try {
12383
- entries = readdirSync2(dir);
12508
+ entries = readdirSync3(dir);
12384
12509
  } catch (err) {
12385
12510
  errors3.push({
12386
12511
  path: dir,
@@ -12396,7 +12521,7 @@ function discoverSkillsInDir(dir, source, errors3, root) {
12396
12521
  if (name.startsWith(".") || name === "node_modules") {
12397
12522
  continue;
12398
12523
  }
12399
- const skillDir = join7(dir, name);
12524
+ const skillDir = join8(dir, name);
12400
12525
  let st;
12401
12526
  try {
12402
12527
  st = statSync4(skillDir);
@@ -12406,10 +12531,10 @@ function discoverSkillsInDir(dir, source, errors3, root) {
12406
12531
  if (!st.isDirectory()) {
12407
12532
  continue;
12408
12533
  }
12409
- const skillMd = join7(skillDir, "SKILL.md");
12534
+ const skillMd = join8(skillDir, "SKILL.md");
12410
12535
  let dirReadable = true;
12411
12536
  try {
12412
- readdirSync2(skillDir);
12537
+ readdirSync3(skillDir);
12413
12538
  } catch (err) {
12414
12539
  dirReadable = false;
12415
12540
  errors3.push({
@@ -12418,7 +12543,7 @@ function discoverSkillsInDir(dir, source, errors3, root) {
12418
12543
  detail: err instanceof Error ? err.message : String(err)
12419
12544
  });
12420
12545
  }
12421
- const hasSkillMd = dirReadable && existsSync7(skillMd);
12546
+ const hasSkillMd = dirReadable && existsSync8(skillMd);
12422
12547
  const fm = hasSkillMd ? readFrontmatter(skillMd, errors3) : { hasFrontmatter: false };
12423
12548
  const fact = {
12424
12549
  id: name,
@@ -12446,26 +12571,25 @@ function discoverSkillsInDir(dir, source, errors3, root) {
12446
12571
  if (fm.name !== undefined) {
12447
12572
  fact.frontmatterName = fm.name;
12448
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
+ }
12449
12583
  skills.push(fact);
12450
12584
  }
12451
12585
  return skills;
12452
12586
  }
12453
- var NESTED_DISCOVERY_MAX_DEPTH = 8;
12454
- var NESTED_DISCOVERY_SKIP = new Set([
12455
- ".git",
12456
- "node_modules",
12457
- ".next",
12458
- "dist",
12459
- "build",
12460
- "coverage",
12461
- "vendor"
12462
- ]);
12463
12587
  function discoverNestedClaudeSkills(root, configuredRoots, errors3, options = {}) {
12464
12588
  const out = [];
12465
12589
  const read = (dir) => {
12466
12590
  options.onDirectoryRead?.();
12467
12591
  try {
12468
- return readdirSync2(dir, { withFileTypes: true });
12592
+ return readdirSync3(dir, { withFileTypes: true });
12469
12593
  } catch {
12470
12594
  errors3.push({ path: dir, kind: "unreadable", detail: "could not read nested skill directories" });
12471
12595
  return;
@@ -12478,7 +12602,7 @@ function discoverNestedClaudeSkills(root, configuredRoots, errors3, options = {}
12478
12602
  for (const entry of entries) {
12479
12603
  if (!entry.isDirectory() || entry.name !== "skills")
12480
12604
  continue;
12481
- const child = join7(dir, entry.name);
12605
+ const child = join8(dir, entry.name);
12482
12606
  if (configuredRoots.has(resolve2(child)))
12483
12607
  continue;
12484
12608
  out.push(...discoverSkillsInDir(child, "project", errors3, root));
@@ -12493,7 +12617,7 @@ function discoverNestedClaudeSkills(root, configuredRoots, errors3, options = {}
12493
12617
  continue;
12494
12618
  if (entry.name.startsWith(".") && entry.name !== ".claude")
12495
12619
  continue;
12496
- const child = join7(dir, entry.name);
12620
+ const child = join8(dir, entry.name);
12497
12621
  if (entry.name === ".claude") {
12498
12622
  inspectClaude(child);
12499
12623
  continue;
@@ -12523,7 +12647,7 @@ function discoverAgentSurface(root, config, opts) {
12523
12647
  const configErrors = [];
12524
12648
  const skills = [];
12525
12649
  for (const rel of config.skillPaths) {
12526
- skills.push(...discoverSkillsInDir(join8(root, rel), "project", configErrors, root));
12650
+ skills.push(...discoverSkillsInDir(join9(root, rel), "project", configErrors, root));
12527
12651
  }
12528
12652
  const configuredRoots = new Set(config.skillPaths.map((rel) => resolve3(root, rel)));
12529
12653
  skills.push(...discoverNestedClaudeSkills(root, configuredRoots, configErrors, {
@@ -12531,14 +12655,20 @@ function discoverAgentSurface(root, config, opts) {
12531
12655
  }));
12532
12656
  if (opts.includeGlobal) {
12533
12657
  const home = homedir3();
12534
- skills.push(...discoverSkillsInDir(join8(home, ".claude", "skills"), "global", configErrors, root));
12535
- 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));
12536
12660
  }
12537
12661
  const lock = discoverSkillsLock(root, configErrors);
12662
+ const agents = discoverAgents(root, configErrors);
12538
12663
  return {
12539
12664
  skills: disambiguateSkills(skills, root),
12540
- agents: discoverAgents(root, configErrors),
12541
- 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
+ ],
12542
12672
  mcp: discoverMcp(root, config.mcpPaths, configErrors),
12543
12673
  policyFiles: discoverPolicyFiles(root, config.policyFiles, configErrors),
12544
12674
  lockedSkills: lock.locked,
@@ -12549,11 +12679,11 @@ function discoverAgentSurface(root, config, opts) {
12549
12679
  }
12550
12680
 
12551
12681
  // src/facts/extract.ts
12552
- import { existsSync as existsSync8, readFileSync as readFileSync3 } from "node:fs";
12553
- 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";
12554
12684
  function readPackageJson(root, errors3) {
12555
- const path = join9(root, "package.json");
12556
- if (!existsSync8(path)) {
12685
+ const path = join10(root, "package.json");
12686
+ if (!existsSync9(path)) {
12557
12687
  return {};
12558
12688
  }
12559
12689
  let text;
@@ -12651,16 +12781,16 @@ function inferPackageManager(root, pkg) {
12651
12781
  return prefix;
12652
12782
  }
12653
12783
  }
12654
- if (existsSync8(join9(root, "bun.lock")) || existsSync8(join9(root, "bun.lockb"))) {
12784
+ if (existsSync9(join10(root, "bun.lock")) || existsSync9(join10(root, "bun.lockb"))) {
12655
12785
  return "bun";
12656
12786
  }
12657
- if (existsSync8(join9(root, "pnpm-lock.yaml"))) {
12787
+ if (existsSync9(join10(root, "pnpm-lock.yaml"))) {
12658
12788
  return "pnpm";
12659
12789
  }
12660
- if (existsSync8(join9(root, "yarn.lock"))) {
12790
+ if (existsSync9(join10(root, "yarn.lock"))) {
12661
12791
  return "yarn";
12662
12792
  }
12663
- if (existsSync8(join9(root, "package-lock.json"))) {
12793
+ if (existsSync9(join10(root, "package-lock.json"))) {
12664
12794
  return "npm";
12665
12795
  }
12666
12796
  return "unknown";
@@ -13135,7 +13265,7 @@ function formatSummary(findings, verbose, ink) {
13135
13265
  }
13136
13266
 
13137
13267
  // src/version.ts
13138
- var VERSION = "0.6.0";
13268
+ var VERSION = "0.7.0";
13139
13269
 
13140
13270
  // src/commands/check.ts
13141
13271
  async function runCheck(options) {
@@ -13182,13 +13312,13 @@ import {
13182
13312
  writeFileSync
13183
13313
  } from "node:fs";
13184
13314
  import { tmpdir } from "node:os";
13185
- import { join as join10 } from "node:path";
13315
+ import { join as join11 } from "node:path";
13186
13316
  async function runDemo() {
13187
- const root = mkdtempSync(join10(tmpdir(), "agentscan-demo-"));
13317
+ const root = mkdtempSync(join11(tmpdir(), "agentscan-demo-"));
13188
13318
  try {
13189
- const claude = join10(root, ".claude");
13319
+ const claude = join11(root, ".claude");
13190
13320
  mkdirSync(claude, { recursive: true });
13191
- writeFileSync(join10(claude, "settings.json"), `${JSON.stringify({
13321
+ writeFileSync(join11(claude, "settings.json"), `${JSON.stringify({
13192
13322
  hooks: {
13193
13323
  PreToolUse: [
13194
13324
  {
@@ -13262,12 +13392,12 @@ function formatExplain(f) {
13262
13392
  }
13263
13393
 
13264
13394
  // src/commands/init.ts
13265
- import { existsSync as existsSync9, writeFileSync as writeFileSync2 } from "node:fs";
13266
- 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";
13267
13397
  async function runInit(dir, options = {}) {
13268
13398
  const root = resolve5(dir ?? process.cwd());
13269
- const path = join11(root, ".agentscanrc.json");
13270
- if (existsSync9(path) && !options.force) {
13399
+ const path = join12(root, ".agentscanrc.json");
13400
+ if (existsSync10(path) && !options.force) {
13271
13401
  return {
13272
13402
  exitCode: 2,
13273
13403
  stdout: "",
@@ -13303,7 +13433,7 @@ async function runRulesCommand(options = {}) {
13303
13433
  // src/report/clipboard.ts
13304
13434
  import { spawn } from "node:child_process";
13305
13435
  import { accessSync, constants } from "node:fs";
13306
- import { delimiter, join as join12 } from "node:path";
13436
+ import { delimiter, join as join13 } from "node:path";
13307
13437
  var TOOLS = [
13308
13438
  ["wl-copy"],
13309
13439
  ["xclip", "-selection", "clipboard"],
@@ -13320,7 +13450,7 @@ function resolve6(name) {
13320
13450
  if (dir.length === 0) {
13321
13451
  continue;
13322
13452
  }
13323
- const candidate = join12(dir, name);
13453
+ const candidate = join13(dir, name);
13324
13454
  try {
13325
13455
  accessSync(candidate, constants.X_OK);
13326
13456
  return candidate;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chimix/agentscan",
3
- "version": "0.6.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": {