@chimix/agentscan 0.2.1 → 0.4.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 +19 -11
  2. package/dist/cli.js +177 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -164,6 +164,7 @@ Flags for `check`:
164
164
  | `--json` | JSON report (alias for `--output json`) |
165
165
  | `--output <format>` | `human` (default) · `json` · `prompt` |
166
166
  | `--copy` | Also copy the report to the system clipboard |
167
+ | `--no-color` | Never colour, even on a terminal (`NO_COLOR=1` does the same) |
167
168
  | `--quiet` | Summary line only |
168
169
  | `--verbose` | Show KEEP + info-severity findings |
169
170
  | `--fail-on <level>` | `never` (default) · `warning` · `error` |
@@ -176,23 +177,30 @@ Flags for `check`:
176
177
  ## Sample output
177
178
 
178
179
  ```text
179
- agentscan v0.1.0 — touchagency
180
+ ┌─────┐ 40/100 broken · touchagency
181
+ │ ✕ ✕ │ ██████████░░░░░░░░░░░░░░░
182
+ │ ⌒ │ 6 warn · 4 info hidden (--verbose)
183
+ └─────┘
180
184
 
181
- Stack: 46 deps · 54 skills · 1 mcp · 2 agents · packageManager=bun
185
+ agentscan v0.4.0 — touchagency
182
186
 
183
- WARN hook:PreToolUse:.claude/hooks/guard-destructive-bash.js
184
- rule:hook.missing-script
185
- PreToolUse hook points at a script that does not exist: .claude/hooks/guard-destructive-bash.js
186
- evidence: hook PreToolUse @ …/.claude/settings.json · script .claude/hooks/guard-destructive-bash.js
187
+ Scanned: 46 deps · 54 skills · 1 mcp · 2 agents · packageManager=bun
187
188
 
188
- WARN hook:PreToolUse:.claude/hooks/protect-artists-json.js
189
- rule:hook.missing-script
190
- PreToolUse hook points at a script that does not exist: .claude/hooks/protect-artists-json.js
191
- evidence: hook PreToolUse @ …/.claude/settings.json · script .claude/hooks/protect-artists-json.js
189
+ WARN rule:hook.missing-script ×6
190
+ PreToolUse hook points at a script that does not exist
191
+ PostToolUse @ .claude/settings.json · .claude/hooks/notify-related-tests.js
192
+ PreToolUse @ .claude/settings.json · .claude/hooks/guard-destructive-bash.js
193
+ PreToolUse @ .claude/settings.json · .claude/hooks/protect-artists-json.js
194
+ PreToolUse @ .claude/settings.json · .claude/hooks/protect-env.js
192
195
 
193
- Summary: 6 warn · 4 info hidden (--verbose) · score 40/100
196
+ Summary: 6 warn · 4 info hidden (--verbose) · score 40/100 broken
194
197
  ```
195
198
 
199
+ On a terminal the header is coloured green, yellow or red and the face tracks
200
+ the score. Redirect or pipe it and you get exactly the text above, with no
201
+ escape sequences — the header box is dropped too, so `--json`, `--output
202
+ prompt`, CI logs and `--copy` are unchanged by any of this.
203
+
196
204
  The `Stack:` line is orientation only — which project, how big. The summary lists
197
205
  only actions that actually occurred; a clean project prints `Summary: no findings`.
198
206
 
package/dist/cli.js CHANGED
@@ -12534,6 +12534,45 @@ function score(findings) {
12534
12534
  }
12535
12535
  return Math.max(0, 100 - deduction);
12536
12536
  }
12537
+ function scoreLabel(points) {
12538
+ if (points === 100) {
12539
+ return "clean";
12540
+ }
12541
+ if (points >= 90) {
12542
+ return "good";
12543
+ }
12544
+ if (points >= 70) {
12545
+ return "needs work";
12546
+ }
12547
+ if (points >= 50) {
12548
+ return "poor";
12549
+ }
12550
+ return "broken";
12551
+ }
12552
+ function scoreTone(points) {
12553
+ if (points >= 90) {
12554
+ return "green";
12555
+ }
12556
+ if (points >= 50) {
12557
+ return "yellow";
12558
+ }
12559
+ return "red";
12560
+ }
12561
+ function scoreFace(points) {
12562
+ if (points === 100) {
12563
+ return ["◠ ◠", " ▽ "];
12564
+ }
12565
+ if (points >= 90) {
12566
+ return ["• •", " ‿ "];
12567
+ }
12568
+ if (points >= 70) {
12569
+ return ["• •", " ─ "];
12570
+ }
12571
+ if (points >= 50) {
12572
+ return ["◡ ◡", " ⌒ "];
12573
+ }
12574
+ return ["✕ ✕", " ⌒ "];
12575
+ }
12537
12576
 
12538
12577
  // src/report/exit-code.ts
12539
12578
  function exitCode(findings, failOn, failUnder) {
@@ -12658,6 +12697,50 @@ function headingFor(severity) {
12658
12697
 
12659
12698
  // src/report/text.ts
12660
12699
  import { basename as basename4 } from "node:path";
12700
+
12701
+ // src/report/ansi.ts
12702
+ var CODES = {
12703
+ green: "\x1B[32m",
12704
+ yellow: "\x1B[33m",
12705
+ red: "\x1B[31m",
12706
+ dim: "\x1B[2m",
12707
+ bold: "\x1B[1m",
12708
+ reset: "\x1B[0m"
12709
+ };
12710
+ var PLAIN = {
12711
+ tone: (_tone, text) => text,
12712
+ strong: (_tone, text) => text,
12713
+ dim: (text) => text,
12714
+ bold: (text) => text,
12715
+ enabled: false
12716
+ };
12717
+ function paint(enabled) {
12718
+ if (!enabled) {
12719
+ return PLAIN;
12720
+ }
12721
+ return {
12722
+ tone: (tone, text) => `${CODES[tone]}${text}${CODES.reset}`,
12723
+ strong: (tone, text) => `${CODES.bold}${CODES[tone]}${text}${CODES.reset}`,
12724
+ dim: (text) => `${CODES.dim}${text}${CODES.reset}`,
12725
+ bold: (text) => `${CODES.bold}${text}${CODES.reset}`,
12726
+ enabled: true
12727
+ };
12728
+ }
12729
+ function shouldColour(args) {
12730
+ if (args.noColorFlag === true) {
12731
+ return false;
12732
+ }
12733
+ const { NO_COLOR, FORCE_COLOR } = args.env;
12734
+ if (NO_COLOR !== undefined && NO_COLOR !== "") {
12735
+ return false;
12736
+ }
12737
+ if (FORCE_COLOR !== undefined && FORCE_COLOR !== "" && FORCE_COLOR !== "0") {
12738
+ return true;
12739
+ }
12740
+ return args.isTTY;
12741
+ }
12742
+
12743
+ // src/report/text.ts
12661
12744
  var ACTION_LABEL = {
12662
12745
  keep: "KEEP",
12663
12746
  delete: "DELETE",
@@ -12671,24 +12754,28 @@ function actionColumn(action) {
12671
12754
  }
12672
12755
  function renderText(args) {
12673
12756
  const { version, facts, verbose, quiet } = args;
12757
+ const ink = paint(args.colour === true);
12674
12758
  const sorted = sortFindings(args.findings);
12675
12759
  const visible = verbose ? sorted : sorted.filter((f) => f.action !== "keep" && f.severity !== "info");
12676
- const summary = formatSummary(sorted, verbose);
12760
+ const summary = formatSummary(sorted, verbose, ink);
12677
12761
  if (quiet) {
12678
12762
  return `${summary}
12679
12763
  `;
12680
12764
  }
12681
12765
  const lines = [];
12682
12766
  const name = projectLabel(facts.root);
12683
- lines.push(`agentscan v${version} — ${safe(name)}`);
12767
+ if (ink.enabled) {
12768
+ lines.push(...formatHeaderBox(sorted, verbose, safe(name), ink));
12769
+ }
12770
+ lines.push(ink.dim(`agentscan v${version} — ${safe(name)}`));
12684
12771
  if (args.resolvedFrom !== undefined) {
12685
12772
  lines.push(` no package.json in ${safe(args.resolvedFrom)} — scanned ${safe(facts.root)}`);
12686
12773
  }
12687
12774
  lines.push("");
12688
- lines.push(`Stack: ${formatStack(facts)}`);
12775
+ lines.push(ink.dim(`Scanned: ${formatStack(facts)}`));
12689
12776
  lines.push("");
12690
- for (const f of visible) {
12691
- lines.push(...formatFinding(f));
12777
+ for (const group of groupByRule(visible)) {
12778
+ lines.push(...formatGroup(group, facts.root, ink));
12692
12779
  lines.push("");
12693
12780
  }
12694
12781
  lines.push(summary);
@@ -12696,6 +12783,22 @@ function renderText(args) {
12696
12783
  return lines.join(`
12697
12784
  `);
12698
12785
  }
12786
+ var BAR_CELLS = 25;
12787
+ function formatHeaderBox(findings, verbose, name, ink) {
12788
+ const points = score(findings);
12789
+ const tone = scoreTone(points);
12790
+ const [eyes, mouth] = scoreFace(points);
12791
+ const filled = Math.round(points / 100 * BAR_CELLS);
12792
+ const bar = ink.tone(tone, "█".repeat(filled)) + ink.dim("░".repeat(BAR_CELLS - filled));
12793
+ const verdict = `${ink.strong(tone, `${points}/100`)} ${ink.tone(tone, scoreLabel(points))}${ink.dim(` · ${name}`)}`;
12794
+ return [
12795
+ ` ${ink.tone(tone, "┌─────┐")} ${verdict}`,
12796
+ ` ${ink.tone(tone, `│ ${eyes} │`)} ${bar}`,
12797
+ ` ${ink.tone(tone, `│ ${mouth} │`)} ${ink.dim(countsLine(findings, verbose))}`,
12798
+ ` ${ink.tone(tone, "└─────┘")}`,
12799
+ ""
12800
+ ];
12801
+ }
12699
12802
  function projectLabel(root) {
12700
12803
  const base = basename4(root);
12701
12804
  return base.length > 0 ? base : root;
@@ -12712,18 +12815,52 @@ function formatStack(facts) {
12712
12815
  `packageManager=${facts.packageManager}`
12713
12816
  ].join(" · ");
12714
12817
  }
12715
- function formatFinding(f) {
12818
+ function groupByRule(findings) {
12819
+ const byRule = new Map;
12820
+ for (const f of findings) {
12821
+ const bucket = byRule.get(f.ruleId);
12822
+ if (bucket === undefined) {
12823
+ byRule.set(f.ruleId, [f]);
12824
+ } else {
12825
+ bucket.push(f);
12826
+ }
12827
+ }
12828
+ return [...byRule.values()];
12829
+ }
12830
+ function shortPath(value, root) {
12831
+ return value.split(`${root}/`).join("");
12832
+ }
12833
+ var ACTION_TONE = {
12834
+ delete: "red",
12835
+ warn: "yellow",
12836
+ drift: "yellow",
12837
+ add: "green",
12838
+ refresh: "green"
12839
+ };
12840
+ function formatGroup(group, root, ink) {
12841
+ const first = group[0];
12842
+ const count = group.length;
12716
12843
  const out = [];
12717
- out.push(`${actionColumn(f.action)} ${safe(f.subject)}`);
12718
- out.push(` rule:${safe(f.ruleId)}`);
12719
- out.push(` ${safe(f.message)}`);
12720
- if (f.evidence.length > 0) {
12721
- const ev = f.evidence.map((e) => `${safe(e.kind)} ${safe(e.value)}`).join(" · ");
12722
- out.push(` evidence: ${ev}`);
12844
+ const tone = ACTION_TONE[first.action];
12845
+ const label = actionColumn(first.action);
12846
+ out.push(`${tone === undefined ? ink.dim(label) : ink.tone(tone, label)} ${ink.dim(`rule:${safe(first.ruleId)}`)}${count > 1 ? ink.bold(` ×${count}`) : ""}`);
12847
+ out.push(` ${safe(count === 1 ? first.message : ruleHeadline(first))}`);
12848
+ for (const f of group) {
12849
+ const where = f.evidence.filter((e) => e.kind !== "count" && e.kind !== "threshold").map((e) => shortPath(e.value, root));
12850
+ const location = where.length > 0 ? where.join(" · ") : f.subject;
12851
+ out.push(ink.dim(` ${safe(shortPath(location, root))}`));
12723
12852
  }
12724
12853
  return out;
12725
12854
  }
12726
- function formatSummary(findings, verbose) {
12855
+ function ruleHeadline(f) {
12856
+ const cut = f.message.indexOf(": ");
12857
+ return cut === -1 ? f.message : f.message.slice(0, cut);
12858
+ }
12859
+ function countsLine(findings, verbose) {
12860
+ const parts = actionParts(findings, verbose);
12861
+ return parts.length === 0 ? "no findings" : parts.join(" · ");
12862
+ }
12863
+ function actionParts(findings, verbose) {
12727
12864
  const counts = {
12728
12865
  keep: 0,
12729
12866
  delete: 0,
@@ -12745,15 +12882,20 @@ function formatSummary(findings, verbose) {
12745
12882
  if (!verbose && infoHidden > 0) {
12746
12883
  parts.push(`${infoHidden} info hidden (--verbose)`);
12747
12884
  }
12885
+ return parts;
12886
+ }
12887
+ function formatSummary(findings, verbose, ink) {
12888
+ const parts = actionParts(findings, verbose);
12748
12889
  const points = score(findings);
12890
+ const scored = `score ${ink.tone(scoreTone(points), `${points}/100 ${scoreLabel(points)}`)}`;
12749
12891
  if (parts.length === 0) {
12750
- return `Summary: no findings · score ${points}/100`;
12892
+ return `Summary: no findings · ${scored}`;
12751
12893
  }
12752
- return `Summary: ${parts.join(" · ")} · score ${points}/100`;
12894
+ return `Summary: ${parts.join(" · ")} · ${scored}`;
12753
12895
  }
12754
12896
 
12755
12897
  // src/version.ts
12756
- var VERSION = "0.2.1";
12898
+ var VERSION = "0.4.0";
12757
12899
 
12758
12900
  // src/commands/check.ts
12759
12901
  async function runCheck(options) {
@@ -12765,23 +12907,30 @@ async function runCheck(options) {
12765
12907
  });
12766
12908
  const format = options.output ?? (options.json === true ? "json" : "human");
12767
12909
  let stdout;
12910
+ let plain;
12768
12911
  if (format === "json") {
12769
12912
  stdout = renderJson({ version: VERSION, root, facts, findings, resolvedFrom });
12770
12913
  } else if (format === "prompt") {
12771
12914
  stdout = renderPrompt({ version: VERSION, facts, findings });
12772
12915
  } else {
12773
- stdout = renderText({
12916
+ const text = (colour) => renderText({
12774
12917
  version: VERSION,
12775
12918
  facts,
12776
12919
  findings,
12777
12920
  resolvedFrom,
12778
12921
  verbose: options.verbose ?? false,
12779
- quiet: options.quiet ?? false
12922
+ quiet: options.quiet ?? false,
12923
+ colour
12780
12924
  });
12925
+ stdout = text(options.colour === true);
12926
+ if (options.colour === true) {
12927
+ plain = text(false);
12928
+ }
12781
12929
  }
12782
12930
  return {
12783
12931
  exitCode: exitCode(findings, config.failOn, options.failUnder),
12784
- stdout
12932
+ stdout,
12933
+ ...plain === undefined ? {} : { plain }
12785
12934
  };
12786
12935
  }
12787
12936
 
@@ -12950,6 +13099,7 @@ check options:
12950
13099
  --output <format> human (default) | json | prompt
12951
13100
  prompt: a paste-ready handoff for a fixing agent
12952
13101
  --copy Also copy the report to the system clipboard
13102
+ --no-color Never colour, even on a terminal (also: NO_COLOR=1)
12953
13103
  --quiet Summary only
12954
13104
  --verbose Show KEEP findings
12955
13105
  --fail-on <level> never | warning | error (default: never)
@@ -12979,6 +13129,7 @@ async function main(argv) {
12979
13129
  json: { type: "boolean", default: false },
12980
13130
  output: { type: "string" },
12981
13131
  copy: { type: "boolean", default: false },
13132
+ "no-color": { type: "boolean", default: false },
12982
13133
  quiet: { type: "boolean", default: false },
12983
13134
  verbose: { type: "boolean", default: false },
12984
13135
  "fail-on": { type: "string" },
@@ -13045,11 +13196,16 @@ async function main(argv) {
13045
13196
  failOn: failOnRaw,
13046
13197
  ...failUnder === undefined ? {} : { failUnder },
13047
13198
  global: values.global,
13048
- configPath: values.config
13199
+ configPath: values.config,
13200
+ colour: shouldColour({
13201
+ isTTY: process.stdout.isTTY === true,
13202
+ env: process.env,
13203
+ noColorFlag: values["no-color"]
13204
+ })
13049
13205
  });
13050
13206
  process.stdout.write(result.stdout);
13051
13207
  if (values.copy === true) {
13052
- const copied = await copyToClipboard(result.stdout);
13208
+ const copied = await copyToClipboard(result.plain ?? result.stdout);
13053
13209
  process.stderr.write(copied.ok ? `Copied to clipboard via ${copied.tool}.
13054
13210
  ` : `Could not copy: ${safe(copied.reason)}. Redirect instead: agentscan check > report.md
13055
13211
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chimix/agentscan",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Find broken agent configuration in a project — hooks whose scripts are gone, MCP servers that cannot start, skills that disagree with their lockfile",
5
5
  "type": "module",
6
6
  "bin": {