@node9/proxy 1.5.2 → 1.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -168,12 +168,21 @@ var SmartRuleSchema = z.object({
168
168
  verdict: z.enum(["allow", "review", "block"], {
169
169
  errorMap: () => ({ message: "verdict must be one of: allow, review, block" })
170
170
  }),
171
- reason: z.string().optional()
171
+ reason: z.string().optional(),
172
+ // Unknown predicate names are filtered out rather than failing the whole rule.
173
+ // Failing the whole z.array() would cause sanitizeConfig to drop the entire
174
+ // `policy` top-level key, silently disabling ALL smart rules in the config.
175
+ dependsOnState: z.array(z.string()).transform(
176
+ (arr) => arr.filter(
177
+ (p) => p === "no_test_passed_since_last_edit"
178
+ )
179
+ ).optional(),
180
+ recoveryCommand: z.string().optional()
172
181
  });
173
182
  var ConfigFileSchema = z.object({
174
183
  version: z.string().optional(),
175
184
  settings: z.object({
176
- mode: z.enum(["standard", "strict", "audit"]).optional(),
185
+ mode: z.enum(["standard", "strict", "audit", "observe"]).optional(),
177
186
  autoStartDaemon: z.boolean().optional(),
178
187
  enableUndo: z.boolean().optional(),
179
188
  enableHookLogDebug: z.boolean().optional(),
@@ -542,7 +551,9 @@ var DEFAULT_CONFIG = {
542
551
  {
543
552
  field: "command",
544
553
  op: "matches",
545
- value: "rm\\b.*(-[rRfF]*[rR][rRfF]*|--recursive)"
554
+ // Require the recursive flag to be preceded by whitespace so that
555
+ // filenames containing "-r" (e.g. "ai-review.yml") don't false-positive.
556
+ value: "rm\\b.*\\s(-[rRfF]*[rR][rRfF]*|--recursive)(\\s|$)"
546
557
  },
547
558
  {
548
559
  field: "command",
@@ -786,12 +797,17 @@ function getConfig(cwd) {
786
797
  if (s.approvalTimeoutSeconds !== void 0 && s.approvalTimeoutMs === void 0)
787
798
  mergedSettings.approvalTimeoutMs = s.approvalTimeoutSeconds * 1e3;
788
799
  if (s.environment !== void 0) mergedSettings.environment = s.environment;
800
+ if (s.hud !== void 0) mergedSettings.hud = { ...mergedSettings.hud, ...s.hud };
789
801
  if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
790
802
  if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
791
803
  if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
792
804
  if (p.toolInspection)
793
805
  mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
794
- if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
806
+ if (p.smartRules) {
807
+ const defaultBlocks = mergedPolicy.smartRules.filter((r) => r.verdict === "block");
808
+ const defaultNonBlocks = mergedPolicy.smartRules.filter((r) => r.verdict !== "block");
809
+ mergedPolicy.smartRules = [...defaultBlocks, ...p.smartRules, ...defaultNonBlocks];
810
+ }
795
811
  if (p.snapshot) {
796
812
  const s2 = p.snapshot;
797
813
  if (s2.tools) mergedPolicy.snapshot.tools.push(...s2.tools);
@@ -1718,7 +1734,13 @@ async function evaluatePolicy(toolName, args, agent, cwd) {
1718
1734
  blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
1719
1735
  reason: matchedRule.reason,
1720
1736
  tier: 2,
1721
- ruleName: matchedRule.name ?? matchedRule.tool
1737
+ ruleName: matchedRule.name ?? matchedRule.tool,
1738
+ ...matchedRule.verdict === "block" && matchedRule.dependsOnState?.length && {
1739
+ dependsOnStatePredicates: matchedRule.dependsOnState
1740
+ },
1741
+ ...matchedRule.verdict === "block" && matchedRule.recoveryCommand && {
1742
+ recoveryCommand: matchedRule.recoveryCommand
1743
+ }
1722
1744
  };
1723
1745
  }
1724
1746
  }
@@ -1950,9 +1972,39 @@ function getPersistentDecision(toolName) {
1950
1972
 
1951
1973
  // src/auth/daemon.ts
1952
1974
  import fs8 from "fs";
1975
+ import net from "net";
1953
1976
  import path10 from "path";
1954
1977
  import os7 from "os";
1955
1978
  import { spawnSync } from "child_process";
1979
+ var ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : path10.join(os7.tmpdir(), "node9-activity.sock");
1980
+ function notifyActivitySocket(data) {
1981
+ return new Promise((resolve) => {
1982
+ try {
1983
+ const payload = JSON.stringify(data);
1984
+ const sock = net.createConnection(ACTIVITY_SOCKET_PATH);
1985
+ sock.on("connect", () => {
1986
+ sock.on("close", resolve);
1987
+ sock.end(payload);
1988
+ });
1989
+ sock.on("error", resolve);
1990
+ } catch {
1991
+ resolve();
1992
+ }
1993
+ });
1994
+ }
1995
+ async function checkStatePredicates(predicates) {
1996
+ if (predicates.length === 0) return {};
1997
+ try {
1998
+ const qs = predicates.map(encodeURIComponent).join(",");
1999
+ const res = await fetch(`http://${DAEMON_HOST}:${DAEMON_PORT}/state/check?predicates=${qs}`, {
2000
+ signal: AbortSignal.timeout(100)
2001
+ });
2002
+ if (!res.ok) return null;
2003
+ return await res.json();
2004
+ } catch {
2005
+ return null;
2006
+ }
2007
+ }
1956
2008
  var DAEMON_PORT = 7391;
1957
2009
  var DAEMON_HOST = "127.0.0.1";
1958
2010
  function getInternalToken() {
@@ -1988,7 +2040,7 @@ function isDaemonRunning() {
1988
2040
  return false;
1989
2041
  }
1990
2042
  }
1991
- async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityId, cwd) {
2043
+ async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityId, cwd, recoveryCommand, skipBackgroundAuth, viewOnly) {
1992
2044
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
1993
2045
  const ctrl = new AbortController();
1994
2046
  const timer = setTimeout(() => ctrl.abort(), 5e3);
@@ -2006,7 +2058,10 @@ async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityI
2006
2058
  // activity-result as the CLI used for the pending activity event.
2007
2059
  activityId,
2008
2060
  ...riskMetadata && { riskMetadata },
2009
- ...cwd && { cwd }
2061
+ ...cwd && { cwd },
2062
+ ...recoveryCommand && { recoveryCommand },
2063
+ ...skipBackgroundAuth && { skipBackgroundAuth: true },
2064
+ ...viewOnly && { viewOnly: true }
2010
2065
  }),
2011
2066
  signal: ctrl.signal
2012
2067
  });
@@ -2026,10 +2081,10 @@ async function waitForDaemonDecision(id, signal) {
2026
2081
  try {
2027
2082
  const waitRes = await fetch(`${base}/wait/${id}`, { signal: waitCtrl.signal });
2028
2083
  if (!waitRes.ok) return { decision: "deny" };
2029
- const { decision, source } = await waitRes.json();
2084
+ const { decision, source, reason } = await waitRes.json();
2030
2085
  if (decision === "allow") return { decision: "allow", source };
2031
2086
  if (decision === "abandoned") return { decision: "abandoned", source };
2032
- return { decision: "deny", source };
2087
+ return { decision: "deny", source, reason };
2033
2088
  } finally {
2034
2089
  clearTimeout(waitTimer);
2035
2090
  if (signal) signal.removeEventListener("abort", onAbort);
@@ -2104,9 +2159,6 @@ async function resolveViaDaemon(id, decision, internalToken, source) {
2104
2159
  }
2105
2160
 
2106
2161
  // src/auth/orchestrator.ts
2107
- import net from "net";
2108
- import path13 from "path";
2109
- import os9 from "os";
2110
2162
  import { randomUUID } from "crypto";
2111
2163
 
2112
2164
  // src/ui/native.ts
@@ -2436,6 +2488,7 @@ init_audit();
2436
2488
  init_audit();
2437
2489
  import fs9 from "fs";
2438
2490
  import os8 from "os";
2491
+ import path13 from "path";
2439
2492
  function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
2440
2493
  return fetch(`${creds.apiUrl}/audit`, {
2441
2494
  method: "POST",
@@ -2460,6 +2513,33 @@ function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
2460
2513
  async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
2461
2514
  const controller = new AbortController();
2462
2515
  const timeout = setTimeout(() => controller.abort(), 1e4);
2516
+ if (!creds.apiKey) throw new Error("Node9 API Key is missing");
2517
+ let ciContext;
2518
+ if (process.env.CI) {
2519
+ try {
2520
+ const ciContextPath = path13.join(os8.homedir(), ".node9", "ci-context.json");
2521
+ const stats = fs9.statSync(ciContextPath);
2522
+ if (stats.size > 1e4) throw new Error("ci-context.json exceeds 10 KB");
2523
+ const raw = fs9.readFileSync(ciContextPath, "utf8");
2524
+ const parsed = JSON.parse(raw);
2525
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
2526
+ throw new Error("ci-context.json is not a plain object");
2527
+ }
2528
+ const p = parsed;
2529
+ ciContext = {
2530
+ tests_after: p["tests_after"],
2531
+ files_changed: p["files_changed"],
2532
+ issues_found: p["issues_found"],
2533
+ issues_fixed: p["issues_fixed"],
2534
+ github_repository: p["github_repository"],
2535
+ github_head_ref: p["github_head_ref"],
2536
+ iteration: p["iteration"],
2537
+ draft_pr_number: p["draft_pr_number"],
2538
+ draft_pr_url: p["draft_pr_url"]
2539
+ };
2540
+ } catch {
2541
+ }
2542
+ }
2463
2543
  try {
2464
2544
  const response = await fetch(creds.apiUrl, {
2465
2545
  method: "POST",
@@ -2474,7 +2554,8 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
2474
2554
  cwd: process.cwd(),
2475
2555
  platform: os8.platform()
2476
2556
  },
2477
- ...riskMetadata && { riskMetadata }
2557
+ ...riskMetadata && { riskMetadata },
2558
+ ...ciContext && { ciContext }
2478
2559
  }),
2479
2560
  signal: controller.signal
2480
2561
  });
@@ -2500,12 +2581,17 @@ async function pollNode9SaaS(requestId, creds, signal) {
2500
2581
  });
2501
2582
  clearTimeout(pollTimer);
2502
2583
  if (!statusRes.ok) continue;
2503
- const { status, reason } = await statusRes.json();
2584
+ const statusBody = await statusRes.json();
2585
+ const { status } = statusBody;
2504
2586
  if (status === "APPROVED") {
2505
- return { approved: true, reason };
2587
+ return { approved: true, reason: statusBody.reason };
2506
2588
  }
2507
2589
  if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
2508
- return { approved: false, reason };
2590
+ return { approved: false, reason: statusBody.reason };
2591
+ }
2592
+ if (status === "FIX") {
2593
+ const feedbackText = statusBody.feedbackText ?? statusBody.reason ?? "Run again with feedback.";
2594
+ return { approved: false, reason: feedbackText };
2509
2595
  }
2510
2596
  } catch {
2511
2597
  }
@@ -2589,21 +2675,8 @@ function isNetworkTool(toolName, args) {
2589
2675
  }
2590
2676
  return false;
2591
2677
  }
2592
- var ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : path13.join(os9.tmpdir(), "node9-activity.sock");
2593
2678
  function notifyActivity(data) {
2594
- return new Promise((resolve) => {
2595
- try {
2596
- const payload = JSON.stringify(data);
2597
- const sock = net.createConnection(ACTIVITY_SOCKET_PATH);
2598
- sock.on("connect", () => {
2599
- sock.on("close", resolve);
2600
- sock.end(payload);
2601
- });
2602
- sock.on("error", resolve);
2603
- } catch {
2604
- resolve();
2605
- }
2606
- });
2679
+ return notifyActivitySocket(data);
2607
2680
  }
2608
2681
  async function authorizeHeadless(toolName, args, meta, options) {
2609
2682
  if (!options?.calledFromDaemon) {
@@ -2620,7 +2693,9 @@ async function authorizeHeadless(toolName, args, meta, options) {
2620
2693
  tool: toolName,
2621
2694
  ts: actTs,
2622
2695
  status: result.approved ? "allow" : result.blockedByLabel?.includes("DLP") ? "dlp" : result.blockedByLabel?.includes("Taint") ? "taint" : "block",
2623
- label: result.blockedByLabel
2696
+ label: result.blockedByLabel,
2697
+ ruleHit: result.ruleHit,
2698
+ observeWouldBlock: result.observeWouldBlock
2624
2699
  });
2625
2700
  }
2626
2701
  return result;
@@ -2647,10 +2722,12 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2647
2722
  appendHookDebug(toolName, args, meta, hashAuditArgs);
2648
2723
  }
2649
2724
  const isManual = meta?.agent === "Terminal";
2725
+ const isObserveMode = config.settings.mode === "observe";
2650
2726
  let explainableLabel = "Local Config";
2651
2727
  let policyMatchedField;
2652
2728
  let policyMatchedWord;
2653
2729
  let riskMetadata;
2730
+ let statefulRecoveryCommand;
2654
2731
  let taintWarning = null;
2655
2732
  if (isNetworkTool(toolName, args)) {
2656
2733
  const filePaths = extractFilePaths(toolName, args);
@@ -2671,10 +2748,26 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2671
2748
  if (dlpMatch) {
2672
2749
  const dlpReason = `\u{1F6A8} DATA LOSS PREVENTION: ${dlpMatch.patternName} detected in field "${dlpMatch.fieldPath}" (${dlpMatch.redactedSample})`;
2673
2750
  if (dlpMatch.severity === "block") {
2674
- if (!isManual) appendLocalAudit(toolName, args, "deny", "dlp-block", meta, true);
2751
+ if (!isManual)
2752
+ appendLocalAudit(
2753
+ toolName,
2754
+ args,
2755
+ "deny",
2756
+ isObserveMode ? "observe-mode-dlp-would-block" : "dlp-block",
2757
+ meta,
2758
+ true
2759
+ );
2675
2760
  if (isWriteTool(toolName) && filePath) {
2676
2761
  await notifyTaint(filePath, `DLP:${dlpMatch.patternName}`);
2677
2762
  }
2763
+ if (isObserveMode) {
2764
+ return {
2765
+ approved: true,
2766
+ checkedBy: "audit",
2767
+ observeWouldBlock: true,
2768
+ blockedByLabel: "\u{1F6A8} Node9 DLP (Secret Detected)"
2769
+ };
2770
+ }
2678
2771
  return {
2679
2772
  approved: false,
2680
2773
  reason: dlpReason,
@@ -2687,6 +2780,31 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2687
2780
  explainableLabel = "\u{1F6A8} Node9 DLP (Credential Review)";
2688
2781
  }
2689
2782
  }
2783
+ if (isObserveMode) {
2784
+ if (!isIgnoredTool(toolName)) {
2785
+ const policyResult = await evaluatePolicy(toolName, args, meta?.agent, options?.cwd);
2786
+ const wouldBlock = policyResult.decision === "block";
2787
+ if (!isManual)
2788
+ appendLocalAudit(
2789
+ toolName,
2790
+ args,
2791
+ "allow",
2792
+ wouldBlock ? "observe-mode-would-block" : "observe-mode",
2793
+ meta,
2794
+ hashAuditArgs
2795
+ );
2796
+ return {
2797
+ approved: true,
2798
+ checkedBy: "audit",
2799
+ ...wouldBlock && {
2800
+ observeWouldBlock: true,
2801
+ blockedByLabel: policyResult.blockedByLabel,
2802
+ ruleHit: policyResult.ruleName
2803
+ }
2804
+ };
2805
+ }
2806
+ return { approved: true, checkedBy: "audit" };
2807
+ }
2690
2808
  if (config.settings.mode === "audit") {
2691
2809
  if (!isIgnoredTool(toolName)) {
2692
2810
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent, options?.cwd);
@@ -2709,19 +2827,46 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2709
2827
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
2710
2828
  if (policyResult.decision === "allow") {
2711
2829
  if (approvers.cloud && creds?.apiKey)
2712
- auditLocalAllow(toolName, args, "local-policy", creds, meta);
2830
+ await auditLocalAllow(toolName, args, "local-policy", creds, meta);
2713
2831
  if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta, hashAuditArgs);
2714
2832
  return { approved: true, checkedBy: "local-policy" };
2715
2833
  }
2716
2834
  if (policyResult.decision === "block") {
2717
- if (!isManual)
2718
- appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta, hashAuditArgs);
2719
- return {
2720
- approved: false,
2721
- reason: policyResult.reason ?? "Action explicitly blocked by Smart Policy.",
2722
- blockedBy: "local-config",
2723
- blockedByLabel: policyResult.blockedByLabel
2724
- };
2835
+ if (policyResult.dependsOnStatePredicates?.length) {
2836
+ const stateResults = await checkStatePredicates(policyResult.dependsOnStatePredicates);
2837
+ const predicatesMet = stateResults !== null && policyResult.dependsOnStatePredicates.every((p) => stateResults[p] === true);
2838
+ if (stateResults === null && !isManual) {
2839
+ appendToLog(HOOK_DEBUG_LOG, {
2840
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2841
+ event: "state-check-fail-open",
2842
+ tool: toolName,
2843
+ rule: policyResult.ruleName,
2844
+ predicates: policyResult.dependsOnStatePredicates,
2845
+ reason: "daemon unreachable or /state/check timed out \u2014 block rule downgraded to review"
2846
+ });
2847
+ }
2848
+ if (predicatesMet && policyResult.recoveryCommand) {
2849
+ statefulRecoveryCommand = policyResult.recoveryCommand;
2850
+ }
2851
+ } else if (isDaemonRunning() && !isTestEnv2) {
2852
+ if (!isManual)
2853
+ appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta, hashAuditArgs);
2854
+ if (approvers.cloud && creds?.apiKey)
2855
+ auditLocalAllow(toolName, args, "smart-rule-block", creds, meta);
2856
+ } else {
2857
+ if (!isManual)
2858
+ appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta, hashAuditArgs);
2859
+ if (approvers.cloud && creds?.apiKey)
2860
+ auditLocalAllow(toolName, args, "smart-rule-block", creds, meta);
2861
+ return {
2862
+ approved: false,
2863
+ reason: policyResult.reason ?? "Action explicitly blocked by Smart Policy.",
2864
+ blockedBy: "local-config",
2865
+ blockedByLabel: policyResult.blockedByLabel,
2866
+ ruleHit: policyResult.ruleName,
2867
+ ...policyResult.recoveryCommand && { recoveryCommand: policyResult.recoveryCommand }
2868
+ };
2869
+ }
2725
2870
  }
2726
2871
  explainableLabel = policyResult.blockedByLabel || "Local Config";
2727
2872
  policyMatchedField = policyResult.matchedField;
@@ -2828,7 +2973,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2828
2973
  meta,
2829
2974
  riskMetadata,
2830
2975
  options?.activityId,
2831
- options?.cwd
2976
+ options?.cwd,
2977
+ statefulRecoveryCommand
2832
2978
  );
2833
2979
  daemonEntryId = entry.id;
2834
2980
  daemonAllowCount = entry.allowCount;
@@ -2889,20 +3035,26 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2889
3035
  if (daemonEntryId && (approvers.browser || approvers.terminal)) {
2890
3036
  racePromises.push(
2891
3037
  (async () => {
2892
- const { decision: daemonDecision, source: decisionSource } = await waitForDaemonDecision(
2893
- daemonEntryId,
2894
- signal
2895
- );
3038
+ const {
3039
+ decision: daemonDecision,
3040
+ source: decisionSource,
3041
+ reason: daemonReason
3042
+ } = await waitForDaemonDecision(daemonEntryId, signal);
2896
3043
  if (daemonDecision === "abandoned") throw new Error("Abandoned");
2897
3044
  const isApproved = daemonDecision === "allow";
2898
- const src = decisionSource === "terminal" || decisionSource === "browser" ? decisionSource : approvers.browser ? "browser" : "terminal";
3045
+ const isRedirect = decisionSource === "terminal-redirect";
3046
+ const src = decisionSource === "terminal" || decisionSource === "terminal-redirect" || decisionSource === "browser" ? decisionSource === "browser" ? "browser" : "terminal" : approvers.browser ? "browser" : "terminal";
2899
3047
  const via = src === "terminal" ? "Terminal (node9 tail)" : "Browser Dashboard";
2900
3048
  return {
2901
3049
  approved: isApproved,
2902
- reason: isApproved ? void 0 : `The human user rejected this action via the Node9 ${via}.`,
3050
+ reason: isApproved ? void 0 : (
3051
+ // Use the redirect reason from the tail when choice [2] was selected;
3052
+ // otherwise fall back to the generic rejection message.
3053
+ isRedirect && daemonReason || `The human user rejected this action via the Node9 ${via}.`
3054
+ ),
2903
3055
  checkedBy: isApproved ? "daemon" : void 0,
2904
3056
  blockedBy: isApproved ? void 0 : "local-decision",
2905
- blockedByLabel: `User Decision (${via})`,
3057
+ blockedByLabel: isRedirect ? "Steered Redirect (Terminal)" : `User Decision (${via})`,
2906
3058
  decisionSource: src
2907
3059
  };
2908
3060
  })()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/proxy",
3
- "version": "1.5.2",
3
+ "version": "1.5.4",
4
4
  "description": "The Sudo Command for AI Agents. Execution Security for Claude Code & MCP.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -58,12 +58,12 @@
58
58
  "format:check": "prettier --check .",
59
59
  "fix": "npm run format && npm run lint:fix",
60
60
  "validate": "npm run format && npm run lint && npm run typecheck && npm run test && npm run test:e2e && npm run build",
61
- "test:e2e": "NODE9_TESTING=1 bash scripts/e2e.sh",
61
+ "test:e2e": "cross-env NODE9_TESTING=1 bash scripts/e2e.sh",
62
+ "test": "cross-env NODE9_TESTING=1 vitest --run",
63
+ "test:coverage": "cross-env NODE9_TESTING=1 vitest --run --coverage",
64
+ "test:watch": "cross-env NODE9_TESTING=1 vitest",
62
65
  "preuninstall": "node9 uninstall || echo 'node9 uninstall failed — remove hooks manually from ~/.claude/settings.json'",
63
66
  "prepublishOnly": "npm run validate",
64
- "test": "vitest --run",
65
- "test:coverage": "vitest --run --coverage",
66
- "test:watch": "vitest",
67
67
  "test:ui": "vitest --ui",
68
68
  "dev:tail": "node -e \"try{const d=JSON.parse(require('fs').readFileSync(require('os').homedir()+'/.node9/daemon.pid','utf8'));const pid=d.pid;if(Number.isInteger(pid)&&pid>0&&pid<4194304)process.kill(pid)}catch(e){if(e.code!=='ESRCH'&&e.code!=='ENOENT')process.stderr.write(e.message+'\\n')}\" && npm run build && node dist/cli.js tail"
69
69
  },
@@ -88,6 +88,7 @@
88
88
  "@types/node": "^25.3.1",
89
89
  "@types/picomatch": "^4.0.2",
90
90
  "@vitest/coverage-v8": "4.1.2",
91
+ "cross-env": "^10.1.0",
91
92
  "prettier": "^3.4.2",
92
93
  "semantic-release": "^25.0.3",
93
94
  "tsup": "^8.5.1",