contextpruner 0.3.0 → 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 +90 -3
  2. package/dist/index.js +978 -124
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -16,6 +16,14 @@ actually in your tree and reports:
16
16
  It exits non-zero when it finds issues, so it drops straight into CI or a
17
17
  pre-commit hook.
18
18
 
19
+ This CLI is the check side of a larger loop. At
20
+ [contextpruner.app](https://contextpruner.app) you can generate all nine
21
+ config files free in your browser — four of them enforced by the agent's own
22
+ harness as a best-effort block — and the $9/month automation regenerates all
23
+ nine on every push, with your account's usage dashboard showing what the last
24
+ sync cut. Lint is how you prove, locally or in CI, that the configs still
25
+ match your tree.
26
+
19
27
  ## Usage
20
28
 
21
29
  ```sh
@@ -49,10 +57,86 @@ If the repo root has a committed `.contextpruner` file, its
49
57
  readable by agents, even untracked ones like docs inside `node_modules/`)
50
58
  drop any enforced rule covering them from the expected rule set, exactly as
51
59
  the generate automation does — so lint, `--fix`, and generation always agree.
52
- Unrecognized lines are reported as a warning and ignored.
60
+ A bare `prefer-tools` line (optionally `prefer-tools: true|on|yes`) is also
61
+ recognized — it turns on the runtime filter's "Prefer these tools" section
62
+ (written by `serve`, see below). Unrecognized lines are reported as a warning
63
+ and ignored.
53
64
 
54
65
  **Exit codes:** `0` clean · `1` issues found · `2` usage/auth error.
55
66
 
67
+ ## Filter search junk (`serve`)
68
+
69
+ ```sh
70
+ export CONTEXTPRUNER_API_KEY=cp_live_... # same key as lint
71
+ contextpruner serve
72
+ ```
73
+
74
+ `serve` (paid, same `cp_live_` key) installs shell shims for the common search
75
+ tools — `rg`, `grep`, `find`, `ls` — into `~/.contextpruner/bin`. Each shim runs
76
+ the real tool and pipes its output through ContextPruner, dropping junk paths
77
+ from broad fan-out results and printing a footer showing what it hid. When a
78
+ search actually pruned something, a savings note is also printed to stderr —
79
+ `contextpruner: this search — ~120 → ~30 tokens (~90 of junk pruned)` — so the
80
+ filtered results on stdout stay clean while you still see the tokens-per-turn
81
+ saving. The estimate is computed locally and never sent. `cat` gets
82
+ a passthrough shim (reading a named file is a pointed read, never filtered). It
83
+ verifies your key (no key or an invalid one exits `2`), writes the shims, and
84
+ prints the line to prepend to your `PATH`:
85
+
86
+ ```sh
87
+ export PATH="$HOME/.contextpruner/bin:$PATH"
88
+ ```
89
+
90
+ Once it's on the front of your `PATH`, broad `rg`/`grep`/`find`/`ls` searches
91
+ drop junk paths automatically. Set `CONTEXTPRUNER_ALL=1` to run a search
92
+ unfiltered. The shims are POSIX `sh` (macOS/Linux); Windows is not yet
93
+ supported.
94
+
95
+ After the shims, `serve` also writes (or merges into) each supported agent's
96
+ MCP config so an MCP-speaking agent — whose bundled search bypasses the `PATH`
97
+ shims — spawns ContextPruner's filtered-search server (`contextpruner __mcp`)
98
+ and gets the same junk-pruning through the `contextpruner_search` /
99
+ `contextpruner_glob` / `contextpruner_list` tools (pass `all: true` to see
100
+ everything). Claude Code's project `.mcp.json` is written unconditionally (the
101
+ proven target, and the generic project MCP config). The other three are
102
+ detection-gated — written only when that agent is in use here, so `serve` never
103
+ litters a config for an agent you don't have:
104
+
105
+ - **Cursor** — `.cursor/mcp.json` (project), when `.cursor/` exists.
106
+ - **Gemini CLI** — `.gemini/settings.json` (project), when `.gemini/` exists.
107
+ - **Windsurf** — `~/.codeium/mcp_config.json` (home; Windsurf has no
108
+ project-local MCP config), when `~/.codeium/` exists.
109
+
110
+ An undetected agent prints a one-line "not detected — re-run serve once it's set
111
+ up" hint. A fresh config file is created; an existing one is left untouched
112
+ except for splicing in the `contextpruner` server, preserving every other server
113
+ and key — and never overwritten if it can't be parsed safely (the note tells you
114
+ to add the server by hand). Restart the agent afterward, then prefer those tools
115
+ for broad searches.
116
+
117
+ Finally, `serve` marks the repo's `.contextpruner` file with a `prefer-tools`
118
+ directive (creating the file with a header if it doesn't exist, appending the
119
+ line if it does, or leaving it untouched if it's already there). **Commit it:**
120
+ the committed directive is what tells the config automation to add the "Prefer
121
+ these tools" section to your generated `AGENTS.md` / `CLAUDE.md` / etc., steering
122
+ agents toward the filtered tools. It's a committed marker — not local detection —
123
+ so the automation reads the same value locally and in CI and the section never
124
+ flip-flops.
125
+
126
+ ### Optional aggregate stats
127
+
128
+ The first time you run `serve` in an interactive terminal, it asks once whether
129
+ you'd share two aggregate numbers — how many files were assessed and how many
130
+ bytes were pruned — so we can show running totals on the website. **Only those
131
+ two integers are ever sent — never a path, never file contents.** A leading
132
+ `y`/`yes` opts in; anything else opts out, and a non-interactive or CI run is
133
+ never prompted and never sends. The answer is saved in
134
+ `~/.contextpruner/config.json`, so you're only asked once. If you opt in, each
135
+ filtered search adds to a local tally in `~/.contextpruner/stats.json`, and
136
+ `serve` flushes it best-effort — at most once a day — to `/api/stats/contribute`
137
+ with your `cp_live_` key. Opting out (or never being prompted) writes and sends
138
+ nothing.
139
+
56
140
  ## In CI
57
141
 
58
142
  Fail the build when a config drifts. Save this as
@@ -78,8 +162,11 @@ jobs:
78
162
  ## Privacy
79
163
 
80
164
  The pruning engine runs **entirely on your machine** — your file tree never
81
- leaves it. The only network call is to `/api/license/verify`, which sends just
82
- your API key to confirm an active subscription.
165
+ leaves it. `lint` makes a single network call, to `/api/license/verify`, which
166
+ sends just your API key to confirm an active subscription. The only other thing
167
+ that can ever leave your machine is the opt-in aggregate stats above: if — and
168
+ only if — you say yes at `serve`, two integers (files assessed, bytes pruned)
169
+ are POSTed to `/api/stats/contribute`. Never a path, never file contents.
83
170
 
84
171
  ## Development
85
172
 
package/dist/index.js CHANGED
@@ -1,57 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { execFileSync } from "node:child_process";
5
- import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
6
- import { join } from "node:path";
7
-
8
- // src/args.ts
9
- function parseArgs(argv) {
10
- const [command, ...rest] = argv;
11
- if (command === void 0 || command === "--help" || command === "-h") {
12
- return { command: "help", configs: [], json: false, fix: false };
13
- }
14
- if (command === "--version" || command === "-v") {
15
- return { command: "version", configs: [], json: false, fix: false };
16
- }
17
- if (command !== "lint") {
18
- return {
19
- command: "unknown",
20
- configs: [],
21
- json: false,
22
- fix: false,
23
- problem: `unknown command "${command}"`
24
- };
25
- }
26
- const configs = [];
27
- let json = false;
28
- let fix = false;
29
- for (let i = 0; i < rest.length; i++) {
30
- const arg = rest[i];
31
- if (arg === "--json") {
32
- json = true;
33
- } else if (arg === "--fix") {
34
- fix = true;
35
- } else if (arg === "--config") {
36
- const value = rest[++i];
37
- if (value === void 0) {
38
- return { command: "lint", configs, json, fix, problem: "--config needs a path" };
39
- }
40
- configs.push(value);
41
- } else if (arg === "--help" || arg === "-h") {
42
- return { command: "help", configs: [], json: false, fix: false };
43
- } else {
44
- return {
45
- command: "lint",
46
- configs,
47
- json,
48
- fix,
49
- problem: `unknown option "${arg}"`
50
- };
51
- }
52
- }
53
- return { command: "lint", configs, json, fix };
54
- }
4
+ import { execFile, execFileSync } from "node:child_process";
5
+ import {
6
+ chmodSync,
7
+ existsSync as existsSync2,
8
+ mkdirSync as mkdirSync2,
9
+ readFileSync as readFileSync2,
10
+ statSync,
11
+ writeFileSync as writeFileSync2
12
+ } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname as dirname2, join as join3 } from "node:path";
15
+ import { createInterface } from "node:readline";
55
16
 
56
17
  // ../../lib/shared/constants.ts
57
18
  var BYTES_PER_TOKEN_RATIO = 0.25;
@@ -282,6 +243,478 @@ function compileGlob(glob) {
282
243
  return (path) => re.test(path);
283
244
  }
284
245
 
246
+ // ../../lib/engine/filter.ts
247
+ function buildKeepMatcher(declarations) {
248
+ const predicates = [];
249
+ for (const declaration of declarations) {
250
+ if (declaration.length === 0) continue;
251
+ if (declaration.includes("*")) {
252
+ predicates.push(compileGlob(declaration));
253
+ } else {
254
+ predicates.push(compileGlob(`**/${declaration}`));
255
+ predicates.push(compileGlob(`**/${declaration}/**`));
256
+ }
257
+ }
258
+ if (predicates.length === 0) return () => false;
259
+ return (path) => predicates.some((match) => match(path));
260
+ }
261
+ function filterPaths(paths, options = {}) {
262
+ if (options.pointed) {
263
+ const kept2 = [...paths];
264
+ return {
265
+ kept: kept2,
266
+ pruned: [],
267
+ shown: kept2.length,
268
+ total: paths.length,
269
+ prunedCount: 0
270
+ };
271
+ }
272
+ const isKept = buildKeepMatcher(options.keep ?? []);
273
+ const { sizeOf } = options;
274
+ const kept = [];
275
+ const pruned = [];
276
+ for (const path of paths) {
277
+ if (isKept(path)) {
278
+ kept.push(path);
279
+ continue;
280
+ }
281
+ const sizeInBytes = sizeOf ? sizeOf(path) || 0 : 0;
282
+ if (classify({ path, sizeInBytes }).verdict === "PRUNE") {
283
+ pruned.push(path);
284
+ } else {
285
+ kept.push(path);
286
+ }
287
+ }
288
+ return {
289
+ kept,
290
+ pruned,
291
+ shown: kept.length,
292
+ total: paths.length,
293
+ prunedCount: pruned.length
294
+ };
295
+ }
296
+
297
+ // ../../lib/engine/filterStream.ts
298
+ var ENCODER = new TextEncoder();
299
+ var byteLength = (text) => ENCODER.encode(text).length;
300
+ function pathOfLine(line, format) {
301
+ if (format === "paths") {
302
+ const path2 = line.trim();
303
+ return path2 === "" ? null : path2;
304
+ }
305
+ const colon = line.indexOf(":");
306
+ const path = colon === -1 ? line.trim() : line.slice(0, colon);
307
+ return path === "" ? null : path;
308
+ }
309
+ var DEFAULT_BYPASS_HINT = "set CONTEXTPRUNER_ALL=1 to see them";
310
+ function footer(shown, total, pruned, bypassHint) {
311
+ return `showing ${shown} of ${total} matches; ${pruned} pruned as junk \u2014 ${bypassHint}`;
312
+ }
313
+ function filterSearchOutput(raw, options) {
314
+ const lines = raw.split("\n");
315
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
316
+ const uniquePathsOf = (ls) => [
317
+ ...new Set(
318
+ ls.map((line) => pathOfLine(line, options.format)).filter((path) => path !== null)
319
+ )
320
+ ];
321
+ if (options.all) {
322
+ const bytesTotal2 = lines.reduce((sum, line) => sum + byteLength(line), 0);
323
+ return {
324
+ output: raw,
325
+ shown: lines.length,
326
+ total: lines.length,
327
+ pruned: 0,
328
+ bytesTotal: bytesTotal2,
329
+ bytesPruned: 0,
330
+ pathsConsidered: uniquePathsOf(lines).length
331
+ };
332
+ }
333
+ const uniquePaths = uniquePathsOf(lines);
334
+ const { pruned: prunedPaths } = filterPaths(uniquePaths, options);
335
+ const isPruned = new Set(prunedPaths);
336
+ const keptLines = [];
337
+ let prunedCount = 0;
338
+ let bytesTotal = 0;
339
+ let bytesPruned = 0;
340
+ for (const line of lines) {
341
+ const bytes = byteLength(line);
342
+ bytesTotal += bytes;
343
+ const path = pathOfLine(line, options.format);
344
+ if (path !== null && isPruned.has(path)) {
345
+ prunedCount++;
346
+ bytesPruned += bytes;
347
+ } else {
348
+ keptLines.push(line);
349
+ }
350
+ }
351
+ const shown = keptLines.length;
352
+ const total = lines.length;
353
+ const body = keptLines.join("\n");
354
+ const bypassHint = options.bypassHint ?? DEFAULT_BYPASS_HINT;
355
+ const output = prunedCount === 0 ? body : body === "" ? footer(shown, total, prunedCount, bypassHint) : `${body}
356
+ ${footer(shown, total, prunedCount, bypassHint)}`;
357
+ return {
358
+ output,
359
+ shown,
360
+ total,
361
+ pruned: prunedCount,
362
+ bytesTotal,
363
+ bytesPruned,
364
+ pathsConsidered: uniquePaths.length
365
+ };
366
+ }
367
+
368
+ // ../../lib/engine/generators/mcpConfig.ts
369
+ var MCP_SERVER_NAME = "contextpruner";
370
+ function generateMcpConfig(entry) {
371
+ return `${JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: entry } }, null, 2)}
372
+ `;
373
+ }
374
+ function isPlainObject(value) {
375
+ return typeof value === "object" && value !== null && !Array.isArray(value);
376
+ }
377
+ function mergeMcpConfig(existing, entry) {
378
+ const unchanged = (error) => ({ content: existing, changed: false, error });
379
+ let root;
380
+ try {
381
+ root = JSON.parse(existing);
382
+ } catch {
383
+ return unchanged("existing MCP config is not valid JSON");
384
+ }
385
+ if (!isPlainObject(root)) {
386
+ return unchanged("existing MCP config is not a JSON object");
387
+ }
388
+ const servers = root.mcpServers ?? {};
389
+ if (!isPlainObject(servers)) {
390
+ return unchanged("existing `mcpServers` is not an object");
391
+ }
392
+ if (JSON.stringify(servers[MCP_SERVER_NAME]) === JSON.stringify(entry)) {
393
+ return { content: existing, changed: false };
394
+ }
395
+ root.mcpServers = { ...servers, [MCP_SERVER_NAME]: entry };
396
+ const content = `${JSON.stringify(root, null, 2)}
397
+ `;
398
+ return { content, changed: true };
399
+ }
400
+
401
+ // ../../lib/engine/overridesFile.ts
402
+ var OVERRIDES_FILENAME = ".contextpruner";
403
+ var KEEP_LINE = /^keep:\s*(.+)$/;
404
+ var PREFER_TOOLS_LINE = /^prefer-tools(?:\s*:\s*(?:true|on|yes))?$/i;
405
+ function isValidDeclaration(declaration) {
406
+ return declaration.length > 0 && declaration.length <= MAX_PATH_LENGTH && !declaration.includes("\0") && !declaration.includes("\\") && !declaration.startsWith("/") && declaration.split("/").every((seg) => seg !== "..");
407
+ }
408
+ function parseOverridesFile(source) {
409
+ const keep = [];
410
+ const seen = /* @__PURE__ */ new Set();
411
+ const unknownLines = [];
412
+ let preferTools = false;
413
+ for (const raw of source.split("\n")) {
414
+ const line = raw.trim();
415
+ if (line === "" || line.startsWith("#")) continue;
416
+ if (PREFER_TOOLS_LINE.test(line)) {
417
+ preferTools = true;
418
+ continue;
419
+ }
420
+ const match = KEEP_LINE.exec(line);
421
+ const declaration = match?.[1]?.trim();
422
+ if (declaration !== void 0 && isValidDeclaration(declaration)) {
423
+ if (!seen.has(declaration)) {
424
+ seen.add(declaration);
425
+ if (keep.length < MAX_OVERRIDE_DECLARATIONS) keep.push(declaration);
426
+ }
427
+ } else {
428
+ unknownLines.push(line);
429
+ }
430
+ }
431
+ return { keep, preferTools, unknownLines };
432
+ }
433
+ function representativePathsFor(declaration) {
434
+ const literal = declaration.replace(/\*+/g, "cpx");
435
+ return declaration.includes("*") ? [literal] : [literal, `${literal}/cpx`];
436
+ }
437
+ var PREFER_TOOLS_HEADER = [
438
+ "# ContextPruner overrides \u2014 https://contextpruner.app",
439
+ '# prefer-tools: emit the "Prefer these tools" section so agents route broad',
440
+ "# searches through the filtered tools `contextpruner serve` set up."
441
+ ];
442
+ function ensurePreferToolsDirective(existing) {
443
+ if (existing === null) {
444
+ return { content: `${[...PREFER_TOOLS_HEADER, "prefer-tools"].join("\n")}
445
+ `, changed: true };
446
+ }
447
+ if (parseOverridesFile(existing).preferTools) {
448
+ return { content: existing, changed: false };
449
+ }
450
+ const separator = existing === "" || existing.endsWith("\n") ? "" : "\n";
451
+ return { content: `${existing}${separator}prefer-tools
452
+ `, changed: true };
453
+ }
454
+
455
+ // src/args.ts
456
+ function base(command, problem) {
457
+ return { command, configs: [], json: false, fix: false, problem };
458
+ }
459
+ function parseArgs(argv) {
460
+ const [command, ...rest] = argv;
461
+ if (command === void 0 || command === "--help" || command === "-h") {
462
+ return base("help");
463
+ }
464
+ if (command === "--version" || command === "-v") {
465
+ return base("version");
466
+ }
467
+ if (command === "serve") {
468
+ for (const arg of rest) {
469
+ if (arg === "--help" || arg === "-h") return base("help");
470
+ return base("serve", `unknown option "${arg}"`);
471
+ }
472
+ return base("serve");
473
+ }
474
+ if (command === "__mcp") {
475
+ for (const arg of rest) {
476
+ return base("mcp", `unknown option "${arg}"`);
477
+ }
478
+ return base("mcp");
479
+ }
480
+ if (command === "__filter") {
481
+ let format;
482
+ let all = false;
483
+ for (let i = 0; i < rest.length; i++) {
484
+ const arg = rest[i];
485
+ if (arg === "--all") {
486
+ all = true;
487
+ } else if (arg === "--format") {
488
+ const value = rest[++i];
489
+ if (value !== "grep" && value !== "paths") {
490
+ return { ...base("filter", "--format must be grep or paths"), all };
491
+ }
492
+ format = value;
493
+ } else {
494
+ return { ...base("filter", `unknown option "${arg}"`), all };
495
+ }
496
+ }
497
+ if (format === void 0) {
498
+ return { ...base("filter", "--format is required"), all };
499
+ }
500
+ return { ...base("filter"), format, all };
501
+ }
502
+ if (command !== "lint") {
503
+ return base("unknown", `unknown command "${command}"`);
504
+ }
505
+ const configs = [];
506
+ let json = false;
507
+ let fix = false;
508
+ for (let i = 0; i < rest.length; i++) {
509
+ const arg = rest[i];
510
+ if (arg === "--json") {
511
+ json = true;
512
+ } else if (arg === "--fix") {
513
+ fix = true;
514
+ } else if (arg === "--config") {
515
+ const value = rest[++i];
516
+ if (value === void 0) {
517
+ return { command: "lint", configs, json, fix, problem: "--config needs a path" };
518
+ }
519
+ configs.push(value);
520
+ } else if (arg === "--help" || arg === "-h") {
521
+ return base("help");
522
+ } else {
523
+ return { command: "lint", configs, json, fix, problem: `unknown option "${arg}"` };
524
+ }
525
+ }
526
+ return { command: "lint", configs, json, fix };
527
+ }
528
+
529
+ // src/mcpTargets.ts
530
+ import { join } from "node:path";
531
+ function mcpConfigTargets(cwd, home) {
532
+ return [
533
+ { agent: "Claude Code", file: join(cwd, ".mcp.json"), presentDirs: [], always: true },
534
+ {
535
+ agent: "Cursor",
536
+ file: join(cwd, ".cursor", "mcp.json"),
537
+ presentDirs: [join(cwd, ".cursor")],
538
+ always: false
539
+ },
540
+ {
541
+ agent: "Gemini CLI",
542
+ file: join(cwd, ".gemini", "settings.json"),
543
+ presentDirs: [join(cwd, ".gemini")],
544
+ always: false
545
+ },
546
+ {
547
+ agent: "Windsurf",
548
+ file: join(home, ".codeium", "mcp_config.json"),
549
+ presentDirs: [join(home, ".codeium")],
550
+ always: false
551
+ }
552
+ ];
553
+ }
554
+
555
+ // src/mcp.ts
556
+ var MCP_TOOLS = [
557
+ {
558
+ name: "contextpruner_search",
559
+ description: "Search file contents across the repository for a text or regex pattern (a broad, repo-wide fan-out search). Prefer this over the built-in grep/search tool for whole-repo searches: it drops junk paths (lockfiles, fixtures, minified/compiled output, snapshots) from the results and reports how many matches it pruned.",
560
+ inputSchema: {
561
+ type: "object",
562
+ properties: {
563
+ query: { type: "string", description: "Text or regex to search for." },
564
+ path: { type: "string", description: "Directory to search under (default: the repo root)." },
565
+ all: { type: "boolean", description: "Set true to bypass junk filtering and see every match." }
566
+ },
567
+ required: ["query"]
568
+ }
569
+ },
570
+ {
571
+ name: "contextpruner_glob",
572
+ description: "Find files whose name matches a glob pattern (e.g. '*.ts', 'config.*') across the repository. Prefer this over the built-in file-finder for broad matches: junk paths are dropped and the pruned count is reported.",
573
+ inputSchema: {
574
+ type: "object",
575
+ properties: {
576
+ pattern: { type: "string", description: "Filename glob, e.g. '*.ts'." },
577
+ path: { type: "string", description: "Directory to search under (default: the repo root)." },
578
+ all: { type: "boolean", description: "Set true to bypass junk filtering and see every file." }
579
+ },
580
+ required: ["pattern"]
581
+ }
582
+ },
583
+ {
584
+ name: "contextpruner_list",
585
+ description: "List the files directly inside a directory. Prefer this over the built-in directory listing: junk entries are dropped and the pruned count is reported.",
586
+ inputSchema: {
587
+ type: "object",
588
+ properties: {
589
+ path: { type: "string", description: "Directory to list (default: the repo root)." },
590
+ all: { type: "boolean", description: "Set true to bypass junk filtering and see everything." }
591
+ },
592
+ required: []
593
+ }
594
+ }
595
+ ];
596
+ function planToolCall(name, args) {
597
+ const path = typeof args.path === "string" && args.path.trim() !== "" ? args.path : ".";
598
+ const all = args.all === true;
599
+ switch (name) {
600
+ case "contextpruner_search": {
601
+ if (typeof args.query !== "string" || args.query === "") {
602
+ return { error: "contextpruner_search needs a non-empty 'query'." };
603
+ }
604
+ return { tool: "search", pattern: args.query, path, all };
605
+ }
606
+ case "contextpruner_glob": {
607
+ if (typeof args.pattern !== "string" || args.pattern === "") {
608
+ return { error: "contextpruner_glob needs a non-empty 'pattern'." };
609
+ }
610
+ return { tool: "glob", pattern: args.pattern, path, all };
611
+ }
612
+ case "contextpruner_list":
613
+ return { tool: "list", path, all };
614
+ default:
615
+ return { error: `unknown tool: ${name}` };
616
+ }
617
+ }
618
+ function resolveCommand(plan, opts) {
619
+ switch (plan.tool) {
620
+ case "search":
621
+ return opts.hasRg ? {
622
+ file: "rg",
623
+ argv: ["--line-number", "--no-heading", "--color=never", "--", plan.pattern, plan.path],
624
+ format: "grep"
625
+ } : {
626
+ file: "grep",
627
+ argv: [
628
+ "-rn",
629
+ "--color=never",
630
+ "--exclude-dir=.git",
631
+ "--exclude-dir=node_modules",
632
+ "-e",
633
+ plan.pattern,
634
+ plan.path
635
+ ],
636
+ format: "grep"
637
+ };
638
+ case "glob":
639
+ return {
640
+ file: "find",
641
+ argv: [
642
+ plan.path,
643
+ "(",
644
+ "-name",
645
+ ".git",
646
+ "-o",
647
+ "-name",
648
+ "node_modules",
649
+ ")",
650
+ "-prune",
651
+ "-o",
652
+ "-name",
653
+ plan.pattern,
654
+ "-print"
655
+ ],
656
+ format: "paths"
657
+ };
658
+ case "list":
659
+ return {
660
+ file: "find",
661
+ argv: [plan.path, "-maxdepth", "1", "-mindepth", "1"],
662
+ format: "paths"
663
+ };
664
+ }
665
+ }
666
+ var MCP_BYPASS_HINT = "call this tool again with all: true to see them";
667
+ function buildToolResult(raw, opts, onStats) {
668
+ const { output, shown, total, pruned, pathsConsidered, bytesPruned } = filterSearchOutput(raw, {
669
+ format: opts.format,
670
+ all: opts.all,
671
+ bypassHint: MCP_BYPASS_HINT
672
+ });
673
+ onStats?.({ pathsConsidered, bytesPruned });
674
+ return {
675
+ content: [{ type: "text", text: output === "" ? "(no matches)" : output }],
676
+ structuredContent: { shown, total, pruned }
677
+ };
678
+ }
679
+ var PROTOCOL_VERSION = "2025-06-18";
680
+ async function handleMcpMessage(msg, deps) {
681
+ const { id, method, params } = msg;
682
+ const reply = (result) => ({ jsonrpc: "2.0", id, result });
683
+ const fail = (code, message) => ({
684
+ jsonrpc: "2.0",
685
+ id,
686
+ error: { code, message }
687
+ });
688
+ switch (method) {
689
+ case "initialize":
690
+ return reply({
691
+ protocolVersion: typeof params?.protocolVersion === "string" ? params.protocolVersion : PROTOCOL_VERSION,
692
+ capabilities: { tools: { listChanged: false } },
693
+ serverInfo: deps.serverInfo
694
+ });
695
+ case "notifications/initialized":
696
+ case "initialized":
697
+ return null;
698
+ // notification — no reply
699
+ case "tools/list":
700
+ return reply({ tools: MCP_TOOLS });
701
+ case "tools/call": {
702
+ const name = typeof params?.name === "string" ? params.name : "";
703
+ const args = params?.arguments ?? {};
704
+ const result = await deps.callTool(name, args);
705
+ return reply(result);
706
+ }
707
+ case "ping":
708
+ return reply({});
709
+ case "resources/list":
710
+ return reply({ resources: [] });
711
+ case "prompts/list":
712
+ return reply({ prompts: [] });
713
+ default:
714
+ return id === void 0 || id === null ? null : fail(-32601, `method not found: ${method}`);
715
+ }
716
+ }
717
+
285
718
  // ../../lib/engine/enforce.ts
286
719
  var UNENFORCED_BINARY_EXTENSIONS = /* @__PURE__ */ new Set(["svg", "pdf"]);
287
720
  var ENV_ENFORCED_GLOB = "**/.env*";
@@ -412,7 +845,7 @@ function generateClaudeSettings(input) {
412
845
  return `${JSON.stringify(settings, null, 2)}
413
846
  `;
414
847
  }
415
- function isPlainObject(value) {
848
+ function isPlainObject2(value) {
416
849
  return typeof value === "object" && value !== null && !Array.isArray(value);
417
850
  }
418
851
  function mergeClaudeSettings(existing, input) {
@@ -427,11 +860,11 @@ function mergeClaudeSettings(existing, input) {
427
860
  } catch {
428
861
  return unchanged("existing settings file is not valid JSON");
429
862
  }
430
- if (!isPlainObject(root)) {
863
+ if (!isPlainObject2(root)) {
431
864
  return unchanged("existing settings file is not a JSON object");
432
865
  }
433
866
  const permissions = root.permissions ?? {};
434
- if (!isPlainObject(permissions)) {
867
+ if (!isPlainObject2(permissions)) {
435
868
  return unchanged("existing `permissions` is not an object");
436
869
  }
437
870
  const deny = permissions.deny ?? [];
@@ -506,6 +939,18 @@ function formatStatsLine(stats) {
506
939
  var MANAGED_BLOCK_BEGIN = "<!-- contextpruner:begin -->";
507
940
  var MANAGED_BLOCK_END = "<!-- contextpruner:end -->";
508
941
  var MANAGED_BLOCK_NOTE = "<!-- generated block \u2014 edits inside are overwritten on regeneration -->";
942
+ function preferToolsSection() {
943
+ return [
944
+ "## Prefer these tools",
945
+ "",
946
+ "For a broad, repo-wide search \u2014 grep, glob, list, or an initial scan \u2014 use ContextPruner's filtered tools instead of the built-in ones. They run the same search on your machine and drop junk paths (lockfiles, minified or compiled output, fixtures, snapshots) from the results, so less noise reaches context:",
947
+ "",
948
+ "- MCP tools: `contextpruner_search` (file contents), `contextpruner_glob` (files by name), `contextpruner_list` (a directory's files).",
949
+ "- Shell shims, if `contextpruner serve` added them to your PATH: plain `grep`, `rg`, `find`, and `ls` already filter.",
950
+ "",
951
+ "Each result says what it hid, e.g. `showing 40 of 210 matches; 170 pruned as junk`. To see everything, call an MCP tool with `all: true`, or set `CONTEXTPRUNER_ALL=1` for the shims. Reading one named file is never filtered \u2014 open it directly."
952
+ ];
953
+ }
509
954
  function generateMarkdownRules(filename, input) {
510
955
  const lines = [
511
956
  MANAGED_BLOCK_BEGIN,
@@ -559,6 +1004,9 @@ function generateMarkdownRules(filename, input) {
559
1004
  lines.push(`- Prune (do not read): \`${path}\``);
560
1005
  }
561
1006
  }
1007
+ if (input.preferTools) {
1008
+ lines.push("", ...preferToolsSection());
1009
+ }
562
1010
  lines.push(
563
1011
  "",
564
1012
  MANAGED_BLOCK_END,
@@ -576,37 +1024,6 @@ function generateMarkdownRules(filename, input) {
576
1024
  return lines.join("\n");
577
1025
  }
578
1026
 
579
- // ../../lib/engine/overridesFile.ts
580
- var OVERRIDES_FILENAME = ".contextpruner";
581
- var KEEP_LINE = /^keep:\s*(.+)$/;
582
- function isValidDeclaration(declaration) {
583
- return declaration.length > 0 && declaration.length <= MAX_PATH_LENGTH && !declaration.includes("\0") && !declaration.includes("\\") && !declaration.startsWith("/") && declaration.split("/").every((seg) => seg !== "..");
584
- }
585
- function parseOverridesFile(source) {
586
- const keep = [];
587
- const seen = /* @__PURE__ */ new Set();
588
- const unknownLines = [];
589
- for (const raw of source.split("\n")) {
590
- const line = raw.trim();
591
- if (line === "" || line.startsWith("#")) continue;
592
- const match = KEEP_LINE.exec(line);
593
- const declaration = match?.[1]?.trim();
594
- if (declaration !== void 0 && isValidDeclaration(declaration)) {
595
- if (!seen.has(declaration)) {
596
- seen.add(declaration);
597
- if (keep.length < MAX_OVERRIDE_DECLARATIONS) keep.push(declaration);
598
- }
599
- } else {
600
- unknownLines.push(line);
601
- }
602
- }
603
- return { keep, unknownLines };
604
- }
605
- function representativePathsFor(declaration) {
606
- const literal = declaration.replace(/\*+/g, "cpx");
607
- return declaration.includes("*") ? [literal] : [literal, `${literal}/cpx`];
608
- }
609
-
610
1027
  // ../../lib/engine/parseConfig.ts
611
1028
  var GLOB_BULLET = /^- `(.+)`$/;
612
1029
  var OVERRIDE_BULLET = /^- (Keep|Skim|Prune) \([^)]*\): `(.+)`$/;
@@ -794,13 +1211,13 @@ function prune(files, options = {}) {
794
1211
  ...options.verdictOverrides
795
1212
  };
796
1213
  const classified = files.map((file) => {
797
- const base = classify(file);
1214
+ const base2 = classify(file);
798
1215
  const pinned = Object.hasOwn(overrides, file.path) ? overrides[file.path] : void 0;
799
- if (pinned === void 0 || pinned === base.verdict) {
800
- return base;
1216
+ if (pinned === void 0 || pinned === base2.verdict) {
1217
+ return base2;
801
1218
  }
802
1219
  return {
803
- ...base,
1220
+ ...base2,
804
1221
  verdict: pinned,
805
1222
  glob: null,
806
1223
  overridden: true
@@ -876,7 +1293,8 @@ function prune(files, options = {}) {
876
1293
  pruneOverridePaths: classified.filter((f) => f.overridden && f.verdict === "PRUNE").map((f) => f.path).sort(),
877
1294
  // Static-baseline + derived enforce-set; independent of extraExcludeGlobs
878
1295
  // (the baseline already names every canonical untracked junk dir).
879
- enforcedGlobs: enforced.globs
1296
+ enforcedGlobs: enforced.globs,
1297
+ preferTools: options.preferTools ?? false
880
1298
  };
881
1299
  const verdicts = classified.map(
882
1300
  ({ path, verdict, reason, sizeInBytes, overridden }) => ({
@@ -1099,18 +1517,18 @@ function enforcedFindings(classified, paths, enforced, pinnedPaths, keepDeclarat
1099
1517
  });
1100
1518
  }
1101
1519
  }
1102
- const base = enforced[0];
1103
- if (base) {
1104
- const baseSet = new Set(base.engineGlobs);
1520
+ const base2 = enforced[0];
1521
+ if (base2) {
1522
+ const baseSet = new Set(base2.engineGlobs);
1105
1523
  for (const other of enforced.slice(1)) {
1106
1524
  const otherSet = new Set(other.engineGlobs);
1107
- for (const glob of base.engineGlobs) {
1525
+ for (const glob of base2.engineGlobs) {
1108
1526
  if (!otherSet.has(glob)) {
1109
1527
  findings.push({
1110
1528
  kind: "drift",
1111
1529
  severity: "medium",
1112
1530
  glob,
1113
- message: `Enforced \`${glob}\` is in ${base.name} but not ${other.name}`
1531
+ message: `Enforced \`${glob}\` is in ${base2.name} but not ${other.name}`
1114
1532
  });
1115
1533
  }
1116
1534
  }
@@ -1120,7 +1538,7 @@ function enforcedFindings(classified, paths, enforced, pinnedPaths, keepDeclarat
1120
1538
  kind: "drift",
1121
1539
  severity: "medium",
1122
1540
  glob,
1123
- message: `Enforced \`${glob}\` is in ${other.name} but not ${base.name}`
1541
+ message: `Enforced \`${glob}\` is in ${other.name} but not ${base2.name}`
1124
1542
  });
1125
1543
  }
1126
1544
  }
@@ -1471,11 +1889,11 @@ var DEFAULT_CONFIG_PATHS = [
1471
1889
  ".claude/settings.json"
1472
1890
  ];
1473
1891
  function formatForPath(path) {
1474
- const base = path.split("/").pop() ?? path;
1475
- if (base === ".cursorignore" || base === ".geminiignore" || base === ".codeiumignore") {
1892
+ const base2 = path.split("/").pop() ?? path;
1893
+ if (base2 === ".cursorignore" || base2 === ".geminiignore" || base2 === ".codeiumignore") {
1476
1894
  return "gitignore";
1477
1895
  }
1478
- if (base === "settings.json") return "claudeSettings";
1896
+ if (base2 === "settings.json") return "claudeSettings";
1479
1897
  return "markdown";
1480
1898
  }
1481
1899
  var CREATE_KEY = "Set CONTEXTPRUNER_API_KEY (create one at https://contextpruner.app/account).";
@@ -1604,36 +2022,442 @@ async function runLint(deps) {
1604
2022
  return { text, exitCode: exitCodeFor(filtered), channel: "stdout" };
1605
2023
  }
1606
2024
 
2025
+ // src/shims.ts
2026
+ var SHIM_TOOLS = [
2027
+ { name: "rg", mode: "grep" },
2028
+ { name: "grep", mode: "grep" },
2029
+ { name: "find", mode: "paths" },
2030
+ { name: "ls", mode: "paths" },
2031
+ { name: "cat", mode: "passthrough" }
2032
+ ];
2033
+ function shSingleQuote(value) {
2034
+ return `'${value.replace(/'/g, `'\\''`)}'`;
2035
+ }
2036
+ function sanitizePathForRealTools(pathEnv, shimDir) {
2037
+ const normalized = shimDir.replace(/\/+$/, "");
2038
+ return pathEnv.split(":").filter((dir) => dir.replace(/\/+$/, "") !== normalized).join(":");
2039
+ }
2040
+ function resolveRealLines(name) {
2041
+ return [
2042
+ 'self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
2043
+ "real=",
2044
+ "IFS=:",
2045
+ "for d in $PATH; do",
2046
+ // Normalize each PATH entry to its physical (symlink-resolved) path before
2047
+ // comparing to self_dir — self_dir is already physical, so a symlinked
2048
+ // install dir (symlinked $HOME etc.) still string-matches and is skipped.
2049
+ // Because the chosen d_phys can never equal self_dir, the resolved real can
2050
+ // never live in the shim's own directory and thus can never be the shim,
2051
+ // which is what prevents the front-of-PATH self-recursion.
2052
+ ' d_phys=$(CDPATH= cd -- "$d" 2>/dev/null && pwd) || d_phys=$d',
2053
+ ' [ "$d_phys" = "$self_dir" ] && continue',
2054
+ // Belt-and-suspenders: even after the physical-dir skip, a duplicate shim
2055
+ // (a symlink/hardlink to this same file) reachable through a *different*
2056
+ // physical dir earlier on PATH would otherwise be chosen as `real` and
2057
+ // recurse. Skip any candidate that is the same file as this shim.
2058
+ ` [ "$d_phys/${name}" -ef "$0" ] && continue`,
2059
+ ` if [ -x "$d_phys/${name}" ]; then real="$d_phys/${name}"; break; fi`,
2060
+ "done",
2061
+ "unset IFS",
2062
+ `if [ -z "$real" ]; then echo "contextpruner: real ${name} not found on PATH" >&2; exit 127; fi`
2063
+ ];
2064
+ }
2065
+ function shimScript(tool, cpInvoke) {
2066
+ const lines = ["#!/bin/sh"];
2067
+ if (tool.mode === "passthrough") {
2068
+ lines.push(
2069
+ `# ContextPruner shim for ${tool.name} \u2014 a pointed read, never filtered.`,
2070
+ "# Managed by `contextpruner serve`; safe to delete.",
2071
+ ...resolveRealLines(tool.name),
2072
+ 'exec "$real" "$@"'
2073
+ );
2074
+ return `${lines.join("\n")}
2075
+ `;
2076
+ }
2077
+ lines.push(
2078
+ `# ContextPruner shim for ${tool.name} \u2014 drops junk paths from broad fan-out output.`,
2079
+ "# Managed by `contextpruner serve`; safe to delete.",
2080
+ `# CONTEXTPRUNER_ALL=1 runs ${tool.name} unfiltered.`,
2081
+ ...resolveRealLines(tool.name),
2082
+ 'if [ "${CONTEXTPRUNER_ALL:-}" = "1" ]; then exec "$real" "$@"; fi',
2083
+ // Buffer to a temp file so the real tool's exit code survives the pipe
2084
+ // (POSIX sh has no PIPESTATUS); the filter itself always exits 0.
2085
+ "tmp=$(mktemp)",
2086
+ `trap 'rm -f "$tmp"' EXIT`,
2087
+ '"$real" "$@" > "$tmp"',
2088
+ "rc=$?",
2089
+ // Fail open: if the filter invocation is dead (stale node/CLI path) or errors,
2090
+ // emit the raw buffered output rather than silently swallowing all results —
2091
+ // the feature degrades toward keeping, never toward losing output.
2092
+ `if ${cpInvoke} __filter --format ${tool.mode} < "$tmp"; then`,
2093
+ " :",
2094
+ "else",
2095
+ ' cat "$tmp"',
2096
+ "fi",
2097
+ "exit $rc"
2098
+ );
2099
+ return `${lines.join("\n")}
2100
+ `;
2101
+ }
2102
+ function shimInstallPlan(cpInvoke) {
2103
+ return SHIM_TOOLS.map((tool) => ({
2104
+ name: tool.name,
2105
+ content: shimScript(tool, cpInvoke),
2106
+ mode: 493
2107
+ }));
2108
+ }
2109
+
2110
+ // src/stats.ts
2111
+ import {
2112
+ existsSync,
2113
+ mkdirSync,
2114
+ readFileSync,
2115
+ renameSync,
2116
+ writeFileSync
2117
+ } from "node:fs";
2118
+ import { dirname, join as join2 } from "node:path";
2119
+ var DAY_MS = 24 * 60 * 60 * 1e3;
2120
+ var configPath = (dir) => join2(dir, "config.json");
2121
+ var tallyPath = (dir) => join2(dir, "stats.json");
2122
+ function writeJsonAtomic(file, value) {
2123
+ mkdirSync(dirname(file), { recursive: true });
2124
+ const tmp = `${file}.tmp`;
2125
+ writeFileSync(tmp, JSON.stringify(value), "utf8");
2126
+ renameSync(tmp, file);
2127
+ }
2128
+ function readJson(file, fallback) {
2129
+ try {
2130
+ if (!existsSync(file)) return fallback;
2131
+ return { ...fallback, ...JSON.parse(readFileSync(file, "utf8")) };
2132
+ } catch {
2133
+ return fallback;
2134
+ }
2135
+ }
2136
+ function readConfig(dir) {
2137
+ return readJson(configPath(dir), {});
2138
+ }
2139
+ function writeConfig(dir, config) {
2140
+ writeJsonAtomic(configPath(dir), config);
2141
+ }
2142
+ function readTally(dir) {
2143
+ const t = readJson(tallyPath(dir), {
2144
+ filesAssessed: 0,
2145
+ bytesSaved: 0
2146
+ });
2147
+ const clean = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : 0;
2148
+ return { filesAssessed: clean(t.filesAssessed), bytesSaved: clean(t.bytesSaved) };
2149
+ }
2150
+ function consentValue(dir) {
2151
+ return readConfig(dir).statsConsent;
2152
+ }
2153
+ var CONSENT_PROMPT = [
2154
+ "contextpruner: Would you mind sharing how many files were assessed and how",
2155
+ " many bytes saved? We'd love to display aggregate counts on the website.",
2156
+ " Only two numbers are ever sent \u2014 never a path, never file contents. [y/N] "
2157
+ ].join("\n");
2158
+ async function ensureConsent(dir, deps) {
2159
+ const existing = consentValue(dir);
2160
+ if (existing) return existing;
2161
+ if (!deps.isTTY) return void 0;
2162
+ deps.out(CONSENT_PROMPT);
2163
+ const answer = (await deps.ask()).trim();
2164
+ const consent = /^y(es)?$/i.test(answer) ? "yes" : "no";
2165
+ writeConfig(dir, { ...readConfig(dir), statsConsent: consent });
2166
+ deps.out(
2167
+ consent === "yes" ? "contextpruner: thanks! Two aggregate numbers will be shared, nothing else." : "contextpruner: no problem \u2014 nothing will be sent, and you won't be asked again."
2168
+ );
2169
+ return consent;
2170
+ }
2171
+ function accumulate(dir, pathsConsidered, bytesPruned) {
2172
+ if (consentValue(dir) !== "yes") return;
2173
+ try {
2174
+ const t = readTally(dir);
2175
+ writeJsonAtomic(tallyPath(dir), {
2176
+ filesAssessed: t.filesAssessed + Math.max(0, Math.trunc(pathsConsidered)),
2177
+ bytesSaved: t.bytesSaved + Math.max(0, Math.trunc(bytesPruned))
2178
+ });
2179
+ } catch {
2180
+ }
2181
+ }
2182
+ async function flushStats(dir, deps) {
2183
+ const config = readConfig(dir);
2184
+ if (config.statsConsent !== "yes") return "skipped";
2185
+ const now = deps.now ?? Date.now();
2186
+ const interval = deps.minIntervalMs ?? DAY_MS;
2187
+ if (config.statsLastFlushAt) {
2188
+ const last = Date.parse(config.statsLastFlushAt);
2189
+ if (Number.isFinite(last) && now - last < interval) return "skipped";
2190
+ }
2191
+ const tally = readTally(dir);
2192
+ if (tally.filesAssessed === 0 && tally.bytesSaved === 0) return "nothing";
2193
+ const doFetch = deps.fetchImpl ?? fetch;
2194
+ const controller = new AbortController();
2195
+ const timer = setTimeout(() => controller.abort(), deps.timeoutMs ?? 5e3);
2196
+ try {
2197
+ const res = await doFetch(`${deps.baseUrl}/api/stats/contribute`, {
2198
+ method: "POST",
2199
+ headers: {
2200
+ authorization: `Bearer ${deps.key}`,
2201
+ "content-type": "application/json"
2202
+ },
2203
+ body: JSON.stringify(tally),
2204
+ signal: controller.signal
2205
+ });
2206
+ if (!res.ok) return "failed";
2207
+ } catch {
2208
+ return "failed";
2209
+ } finally {
2210
+ clearTimeout(timer);
2211
+ }
2212
+ writeJsonAtomic(tallyPath(dir), { filesAssessed: 0, bytesSaved: 0 });
2213
+ writeConfig(dir, {
2214
+ ...readConfig(dir),
2215
+ statsLastFlushAt: new Date(now).toISOString()
2216
+ });
2217
+ return "sent";
2218
+ }
2219
+
1607
2220
  // src/index.ts
1608
- var VERSION = "0.3.0";
1609
- var HELP = `contextpruner lint \u2014 check your AI-context config against your repo
2221
+ var cpDir = () => join3(homedir(), ".contextpruner");
2222
+ var VERSION = "0.4.0";
2223
+ var HELP = `contextpruner \u2014 keep your AI-context config honest, and junk out of search
1610
2224
 
1611
2225
  Usage:
1612
- contextpruner lint [--config <path>]... [--json]
2226
+ contextpruner lint [--config <path>]... [--fix] [--json]
2227
+ contextpruner serve
2228
+
2229
+ Commands:
2230
+ lint Check your AI-context config against your repo (auto-detects the nine
2231
+ config files; --fix rewrites each managed block in place).
2232
+ serve (paid) Filter search junk. Installs the grep/rg/find/ls shims,
2233
+ writes each agent's filtered-search MCP config, and marks
2234
+ .contextpruner so generated configs prefer the filtered tools.
2235
+ Prints the PATH line to add.
1613
2236
 
1614
2237
  Options:
1615
- --config <path> A config to lint (repeatable). Default: auto-detect
1616
- AGENTS.md, CLAUDE.md, GEMINI.md, Copilot, and Cursor rules,
1617
- plus the enforced .cursorignore, .geminiignore,
1618
- .codeiumignore, and .claude/settings.json.
1619
- --fix Rewrite each config's managed block in place to fix the
1620
- issues \u2014 enforced files too, via their managed block. Your
1621
- own content outside the block is left alone.
1622
- --json Emit the full report as JSON.
2238
+ --config <path> A config to lint (repeatable). Default: auto-detect.
2239
+ --fix Rewrite each config's managed block in place.
2240
+ --json Emit the lint report as JSON.
1623
2241
  -h, --help Show this help.
1624
2242
  -v, --version Show the version.
1625
2243
 
1626
- Exceptions:
1627
- \`keep: <path-or-glob>\` lines in a committed .contextpruner file mark paths
1628
- that must stay readable by agents. Lint expects their covering enforced
1629
- rules to be absent, and --fix keeps those rules out.
1630
-
1631
- Auth:
2244
+ Auth (lint --fix and serve):
1632
2245
  export CONTEXTPRUNER_API_KEY=cp_live_... (create one at
1633
- https://contextpruner.app/account). Only your key is sent \u2014 the file tree
1634
- never leaves your machine.
2246
+ https://contextpruner.app/account). Your files and their contents never
2247
+ leave your machine; only your key is sent \u2014 plus, if you opt in at serve,
2248
+ two aggregate counts (files assessed, bytes saved).
1635
2249
 
1636
- Exit codes: 0 clean \xB7 1 issues found \xB7 2 usage/auth error.`;
2250
+ Exit codes: 0 clean/ok \xB7 1 issues found \xB7 2 usage/auth error.`;
2251
+ async function readStdin() {
2252
+ const chunks = [];
2253
+ for await (const chunk of process.stdin) chunks.push(chunk);
2254
+ return Buffer.concat(chunks).toString("utf8");
2255
+ }
2256
+ async function runServe(baseUrl) {
2257
+ const key = process.env.CONTEXTPRUNER_API_KEY;
2258
+ if (!key) {
2259
+ console.error(
2260
+ "contextpruner serve needs a key. export CONTEXTPRUNER_API_KEY=cp_live_... (create one at https://contextpruner.app/account)."
2261
+ );
2262
+ return 2;
2263
+ }
2264
+ const verify = await verifyKey({ key, baseUrl });
2265
+ if (!verify.ok) {
2266
+ console.error(verify.message);
2267
+ return 2;
2268
+ }
2269
+ const binDir = join3(homedir(), ".contextpruner", "bin");
2270
+ mkdirSync2(binDir, { recursive: true });
2271
+ const cliPath = process.argv[1] ?? "contextpruner";
2272
+ const cpOnPath = resolveOnPath("contextpruner");
2273
+ const cpInvoke = cpOnPath ? "contextpruner" : `node ${shSingleQuote(cliPath)}`;
2274
+ const plan = shimInstallPlan(cpInvoke);
2275
+ for (const file of plan) {
2276
+ const dest = join3(binDir, file.name);
2277
+ writeFileSync2(dest, file.content, { mode: file.mode });
2278
+ chmodSync(dest, file.mode);
2279
+ }
2280
+ const names = plan.map((f) => f.name).join(", ");
2281
+ console.log(`contextpruner: installed ${plan.length} search shims (${names}) to ${binDir}`);
2282
+ console.log(` shims filter via: ${cpInvoke}`);
2283
+ if (!cpOnPath) {
2284
+ console.log(
2285
+ " (note: moving or reinstalling the CLI to a new path needs a re-run of `contextpruner serve`)"
2286
+ );
2287
+ }
2288
+ console.log("");
2289
+ console.log("Add it to the front of your PATH so broad searches drop junk automatically:");
2290
+ console.log(` export PATH="${binDir}:$PATH"`);
2291
+ console.log("");
2292
+ console.log(
2293
+ "Then rg/grep/find/ls hide junk paths from fan-out results (set CONTEXTPRUNER_ALL=1 to see everything). cat is never filtered."
2294
+ );
2295
+ const mcpEntry = cpOnPath ? { command: "contextpruner", args: ["__mcp"] } : { command: "node", args: [cliPath, "__mcp"] };
2296
+ console.log("");
2297
+ for (const target of mcpConfigTargets(process.cwd(), homedir())) {
2298
+ if (!target.always && !target.presentDirs.some((dir2) => existsSync2(dir2))) {
2299
+ console.log(`contextpruner: ${target.agent} not detected \u2014 re-run serve once it's set up.`);
2300
+ continue;
2301
+ }
2302
+ writeMcpConfig(target, mcpEntry);
2303
+ }
2304
+ console.log(
2305
+ " Restart the agent, then prefer contextpruner_search / _glob / _list for broad searches (pass all:true to see everything)."
2306
+ );
2307
+ const overridesPath = join3(process.cwd(), OVERRIDES_FILENAME);
2308
+ const overrides = ensurePreferToolsDirective(
2309
+ existsSync2(overridesPath) ? readFileSync2(overridesPath, "utf8") : null
2310
+ );
2311
+ console.log("");
2312
+ if (overrides.changed) {
2313
+ writeFileSync2(overridesPath, overrides.content, "utf8");
2314
+ console.log(
2315
+ `contextpruner: marked ${overridesPath} with "prefer-tools" \u2014 commit it so the config automation adds the "Prefer these tools" section for your team.`
2316
+ );
2317
+ } else {
2318
+ console.log(`contextpruner: ${overridesPath} already has "prefer-tools".`);
2319
+ }
2320
+ const dir = cpDir();
2321
+ console.log("");
2322
+ try {
2323
+ await ensureConsent(dir, {
2324
+ isTTY: Boolean(process.stdin.isTTY),
2325
+ ask: readLine,
2326
+ out: (line) => console.log(line)
2327
+ });
2328
+ await flushStats(dir, { key, baseUrl });
2329
+ } catch {
2330
+ }
2331
+ return 0;
2332
+ }
2333
+ function readLine() {
2334
+ return new Promise((resolve) => {
2335
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2336
+ rl.question("", (answer) => {
2337
+ rl.close();
2338
+ resolve(answer);
2339
+ });
2340
+ });
2341
+ }
2342
+ function writeMcpConfig(target, entry) {
2343
+ mkdirSync2(dirname2(target.file), { recursive: true });
2344
+ if (!existsSync2(target.file)) {
2345
+ writeFileSync2(target.file, generateMcpConfig(entry), "utf8");
2346
+ console.log(`contextpruner: wrote ${target.file} (${target.agent}).`);
2347
+ return;
2348
+ }
2349
+ const merge = mergeMcpConfig(readFileSync2(target.file, "utf8"), entry);
2350
+ if (merge.error) {
2351
+ console.log(
2352
+ `contextpruner: left ${target.file} untouched (${merge.error}). Add a "contextpruner" server to its mcpServers by hand.`
2353
+ );
2354
+ } else if (merge.changed) {
2355
+ writeFileSync2(target.file, merge.content, "utf8");
2356
+ console.log(`contextpruner: updated ${target.file} (${target.agent}).`);
2357
+ } else {
2358
+ console.log(`contextpruner: ${target.file} already has the "contextpruner" MCP server.`);
2359
+ }
2360
+ }
2361
+ function resolveOnPath(name, pathEnv = process.env.PATH ?? "") {
2362
+ for (const dir of pathEnv.split(":")) {
2363
+ if (!dir) continue;
2364
+ const candidate = join3(dir, name);
2365
+ try {
2366
+ const st = statSync(candidate);
2367
+ if (st.isFile() && (st.mode & 73) !== 0) return candidate;
2368
+ } catch {
2369
+ }
2370
+ }
2371
+ return null;
2372
+ }
2373
+ function runSearch(file, argv, cwd, pathEnv) {
2374
+ return new Promise((resolve, reject) => {
2375
+ execFile(
2376
+ file,
2377
+ argv,
2378
+ { cwd, maxBuffer: 256 * 1024 * 1024, encoding: "utf8", env: { ...process.env, PATH: pathEnv } },
2379
+ (error, stdout) => {
2380
+ if (error && typeof error.code === "string") {
2381
+ reject(error);
2382
+ return;
2383
+ }
2384
+ resolve(stdout ?? "");
2385
+ }
2386
+ );
2387
+ });
2388
+ }
2389
+ async function runMcp(baseUrl) {
2390
+ const key = process.env.CONTEXTPRUNER_API_KEY;
2391
+ if (!key) {
2392
+ console.error(
2393
+ "contextpruner __mcp needs a key. export CONTEXTPRUNER_API_KEY=cp_live_... (create one at https://contextpruner.app/account)."
2394
+ );
2395
+ return 2;
2396
+ }
2397
+ const verify = await verifyKey({ key, baseUrl });
2398
+ if (!verify.ok && (verify.kind === "invalid_key" || verify.kind === "inactive")) {
2399
+ console.error(verify.message);
2400
+ return 2;
2401
+ }
2402
+ if (!verify.ok) {
2403
+ console.error(`contextpruner: ${verify.message} Starting anyway (filtering runs locally).`);
2404
+ }
2405
+ const cwd = process.cwd();
2406
+ const shimBinDir = join3(homedir(), ".contextpruner", "bin");
2407
+ const realToolPath = sanitizePathForRealTools(process.env.PATH ?? "", shimBinDir);
2408
+ const hasRg = resolveOnPath("rg", realToolPath) !== null;
2409
+ const callTool = async (name, args) => {
2410
+ const plan = planToolCall(name, args);
2411
+ if ("error" in plan) return { content: [{ type: "text", text: plan.error }], isError: true };
2412
+ const { file, argv, format } = resolveCommand(plan, { hasRg });
2413
+ try {
2414
+ const raw = await runSearch(file, argv, cwd, realToolPath);
2415
+ return buildToolResult(
2416
+ raw,
2417
+ { format, all: plan.all },
2418
+ (s) => accumulate(cpDir(), s.pathsConsidered, s.bytesPruned)
2419
+ );
2420
+ } catch (error) {
2421
+ const message = error instanceof Error ? error.message : String(error);
2422
+ return {
2423
+ content: [{ type: "text", text: `contextpruner: search failed \u2014 ${message}` }],
2424
+ isError: true
2425
+ };
2426
+ }
2427
+ };
2428
+ const deps = { callTool, serverInfo: { name: "contextpruner", version: VERSION } };
2429
+ const send = (msg) => process.stdout.write(`${JSON.stringify(msg)}
2430
+ `);
2431
+ let buffer = "";
2432
+ process.stdin.setEncoding("utf8");
2433
+ return new Promise((resolve) => {
2434
+ process.stdin.on("data", (chunk) => {
2435
+ buffer += chunk;
2436
+ let nl;
2437
+ while ((nl = buffer.indexOf("\n")) !== -1) {
2438
+ const line = buffer.slice(0, nl).trim();
2439
+ buffer = buffer.slice(nl + 1);
2440
+ if (line === "") continue;
2441
+ let parsed;
2442
+ try {
2443
+ parsed = JSON.parse(line);
2444
+ } catch {
2445
+ continue;
2446
+ }
2447
+ void handleMcpMessage(parsed, deps).then(
2448
+ (response) => {
2449
+ if (response !== null) send(response);
2450
+ },
2451
+ (error) => {
2452
+ const message = error instanceof Error ? error.message : String(error);
2453
+ console.error(`contextpruner: message handling failed \u2014 ${message}`);
2454
+ }
2455
+ );
2456
+ }
2457
+ });
2458
+ process.stdin.on("end", () => resolve(0));
2459
+ });
2460
+ }
1637
2461
  async function main() {
1638
2462
  const args = parseArgs(process.argv.slice(2));
1639
2463
  if (args.command === "help") {
@@ -1644,16 +2468,46 @@ async function main() {
1644
2468
  console.log(VERSION);
1645
2469
  return 0;
1646
2470
  }
1647
- if (args.command !== "lint" || args.problem) {
2471
+ if (args.problem || args.command === "unknown") {
1648
2472
  console.error(`contextpruner: ${args.problem ?? "invalid usage"}
1649
2473
 
1650
2474
  ${HELP}`);
1651
2475
  return 2;
1652
2476
  }
2477
+ const baseUrl = process.env.CONTEXTPRUNER_API_URL ?? "https://contextpruner.app";
2478
+ if (args.command === "filter") {
2479
+ if (!args.format) {
2480
+ console.error("contextpruner: --format is required");
2481
+ return 2;
2482
+ }
2483
+ const raw = await readStdin();
2484
+ const { output, bytesTotal, bytesPruned, pathsConsidered } = filterSearchOutput(raw, {
2485
+ format: args.format,
2486
+ all: args.all
2487
+ });
2488
+ if (output) process.stdout.write(`${output}
2489
+ `);
2490
+ if (bytesPruned > 0) {
2491
+ const before = Math.round(bytesTotal * BYTES_PER_TOKEN_RATIO);
2492
+ const saved = Math.round(bytesPruned * BYTES_PER_TOKEN_RATIO);
2493
+ process.stderr.write(
2494
+ `contextpruner: this search \u2014 ~${before} \u2192 ~${before - saved} tokens (~${saved} of junk pruned)
2495
+ `
2496
+ );
2497
+ }
2498
+ accumulate(cpDir(), pathsConsidered, bytesPruned);
2499
+ return 0;
2500
+ }
2501
+ if (args.command === "mcp") {
2502
+ return runMcp(baseUrl);
2503
+ }
2504
+ if (args.command === "serve") {
2505
+ return runServe(baseUrl);
2506
+ }
1653
2507
  const cwd = process.cwd();
1654
2508
  const { text, exitCode, channel } = await runLint({
1655
2509
  key: process.env.CONTEXTPRUNER_API_KEY,
1656
- baseUrl: process.env.CONTEXTPRUNER_API_URL ?? "https://contextpruner.app",
2510
+ baseUrl,
1657
2511
  configPaths: args.configs.length > 0 ? args.configs : DEFAULT_CONFIG_PATHS,
1658
2512
  json: args.json,
1659
2513
  fix: args.fix,
@@ -1661,9 +2515,9 @@ ${HELP}`);
1661
2515
  cwd,
1662
2516
  maxBuffer: 256 * 1024 * 1024
1663
2517
  }).toString("utf8").split("\0").filter(Boolean),
1664
- sizeOf: (path) => statSync(join(cwd, path)).size,
1665
- readConfig: (path) => existsSync(join(cwd, path)) ? readFileSync(join(cwd, path), "utf8") : null,
1666
- writeConfig: (path, content) => writeFileSync(join(cwd, path), content, "utf8")
2518
+ sizeOf: (path) => statSync(join3(cwd, path)).size,
2519
+ readConfig: (path) => existsSync2(join3(cwd, path)) ? readFileSync2(join3(cwd, path), "utf8") : null,
2520
+ writeConfig: (path, content) => writeFileSync2(join3(cwd, path), content, "utf8")
1667
2521
  });
1668
2522
  if (channel === "stderr") {
1669
2523
  console.error(text);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextpruner",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Lint your AI-context config files (AGENTS.md, CLAUDE.md, GEMINI.md, Cursor rules) against your repo — find missing, dead, drifting, and conflicting rules.",
5
5
  "keywords": [
6
6
  "AGENTS.md",