contextpruner 0.2.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 +98 -3
  2. package/dist/index.js +1055 -103
  3. package/package.json +1 -1
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;
@@ -63,6 +24,8 @@ var CONTEXT_SLICE_FACTOR = 0.05;
63
24
  var DEFAULT_CONTEXT_WINDOW_TOKENS = 1e6;
64
25
  var MONTHLY_SAVINGS_CAP_USD = 200;
65
26
  var MAX_MANIFEST_FILES = 2e4;
27
+ var MAX_OVERRIDE_DECLARATIONS = 200;
28
+ var MAX_PATH_LENGTH = 1024;
66
29
  var OVERSIZED_FILE_BYTES = 1048576;
67
30
 
68
31
  // ../../lib/engine/rules.ts
@@ -280,6 +243,478 @@ function compileGlob(glob) {
280
243
  return (path) => re.test(path);
281
244
  }
282
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
+
283
718
  // ../../lib/engine/enforce.ts
284
719
  var UNENFORCED_BINARY_EXTENSIONS = /* @__PURE__ */ new Set(["svg", "pdf"]);
285
720
  var ENV_ENFORCED_GLOB = "**/.env*";
@@ -410,7 +845,7 @@ function generateClaudeSettings(input) {
410
845
  return `${JSON.stringify(settings, null, 2)}
411
846
  `;
412
847
  }
413
- function isPlainObject(value) {
848
+ function isPlainObject2(value) {
414
849
  return typeof value === "object" && value !== null && !Array.isArray(value);
415
850
  }
416
851
  function mergeClaudeSettings(existing, input) {
@@ -425,11 +860,11 @@ function mergeClaudeSettings(existing, input) {
425
860
  } catch {
426
861
  return unchanged("existing settings file is not valid JSON");
427
862
  }
428
- if (!isPlainObject(root)) {
863
+ if (!isPlainObject2(root)) {
429
864
  return unchanged("existing settings file is not a JSON object");
430
865
  }
431
866
  const permissions = root.permissions ?? {};
432
- if (!isPlainObject(permissions)) {
867
+ if (!isPlainObject2(permissions)) {
433
868
  return unchanged("existing `permissions` is not an object");
434
869
  }
435
870
  const deny = permissions.deny ?? [];
@@ -504,6 +939,18 @@ function formatStatsLine(stats) {
504
939
  var MANAGED_BLOCK_BEGIN = "<!-- contextpruner:begin -->";
505
940
  var MANAGED_BLOCK_END = "<!-- contextpruner:end -->";
506
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
+ }
507
954
  function generateMarkdownRules(filename, input) {
508
955
  const lines = [
509
956
  MANAGED_BLOCK_BEGIN,
@@ -557,6 +1004,9 @@ function generateMarkdownRules(filename, input) {
557
1004
  lines.push(`- Prune (do not read): \`${path}\``);
558
1005
  }
559
1006
  }
1007
+ if (input.preferTools) {
1008
+ lines.push("", ...preferToolsSection());
1009
+ }
560
1010
  lines.push(
561
1011
  "",
562
1012
  MANAGED_BLOCK_END,
@@ -753,15 +1203,21 @@ function computeSavings(bytesTotal, bytesKept, options = {}) {
753
1203
  // ../../lib/engine/prune.ts
754
1204
  var MAX_PRIORITY_GLOBS = 5;
755
1205
  function prune(files, options = {}) {
756
- const overrides = options.verdictOverrides ?? {};
1206
+ const declarations = options.keepDeclarations ?? [];
1207
+ const overrides = {
1208
+ ...Object.fromEntries(
1209
+ declarations.filter((declaration) => !declaration.includes("*")).map((path) => [path, "KEEP"])
1210
+ ),
1211
+ ...options.verdictOverrides
1212
+ };
757
1213
  const classified = files.map((file) => {
758
- const base = classify(file);
1214
+ const base2 = classify(file);
759
1215
  const pinned = Object.hasOwn(overrides, file.path) ? overrides[file.path] : void 0;
760
- if (pinned === void 0 || pinned === base.verdict) {
761
- return base;
1216
+ if (pinned === void 0 || pinned === base2.verdict) {
1217
+ return base2;
762
1218
  }
763
1219
  return {
764
- ...base,
1220
+ ...base2,
765
1221
  verdict: pinned,
766
1222
  glob: null,
767
1223
  overridden: true
@@ -816,7 +1272,15 @@ function prune(files, options = {}) {
816
1272
  windowClamped: pinnedSavings.windowClamped
817
1273
  };
818
1274
  for (const glob of options.extraExcludeGlobs ?? []) excludeGlobs.add(glob);
819
- const enforced = deriveEnforcedRules(classified);
1275
+ const pinDemoted = deriveEnforcedRules(classified);
1276
+ const declDemoted = demoteEnforcedGlobs(
1277
+ pinDemoted.globs,
1278
+ declarations.flatMap(representativePathsFor)
1279
+ );
1280
+ const enforced = {
1281
+ globs: declDemoted.globs,
1282
+ demoted: [...pinDemoted.demoted, ...declDemoted.demoted]
1283
+ };
820
1284
  const priorityGlobs = [...keptBytesByDir.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_PRIORITY_GLOBS).map(([dir]) => `${dir}/**`);
821
1285
  const generatorInput = {
822
1286
  generatedAt: (options.now ?? /* @__PURE__ */ new Date()).toISOString(),
@@ -829,7 +1293,8 @@ function prune(files, options = {}) {
829
1293
  pruneOverridePaths: classified.filter((f) => f.overridden && f.verdict === "PRUNE").map((f) => f.path).sort(),
830
1294
  // Static-baseline + derived enforce-set; independent of extraExcludeGlobs
831
1295
  // (the baseline already names every canonical untracked junk dir).
832
- enforcedGlobs: enforced.globs
1296
+ enforcedGlobs: enforced.globs,
1297
+ preferTools: options.preferTools ?? false
833
1298
  };
834
1299
  const verdicts = classified.map(
835
1300
  ({ path, verdict, reason, sizeInBytes, overridden }) => ({
@@ -914,11 +1379,14 @@ var IGNORE_GENERATOR_BY_TITLE = {
914
1379
  ".codeiumignore": generateCodeiumIgnore
915
1380
  };
916
1381
  var IGNORE_TITLE_RE = /^# (\.[a-z]+ignore) — enforced AI context rules/m;
917
- function buildFixedEnforcedConfig(files, userConfig, format, pins = { keep: [], skim: [] }) {
1382
+ function buildFixedEnforcedConfig(files, userConfig, format, pins = { keep: [], skim: [] }, keepDeclarations = []) {
918
1383
  const classified = files.map(classify);
919
1384
  const enforcedGlobs = demoteEnforcedGlobs(
920
1385
  deriveEnforcedRules(classified).globs,
921
- effectivePinnedPaths(classified, pins)
1386
+ [
1387
+ ...effectivePinnedPaths(classified, pins),
1388
+ ...keepDeclarations.flatMap(representativePathsFor)
1389
+ ]
922
1390
  ).globs;
923
1391
  if (format === "claudeSettings") {
924
1392
  if (!parseEnforcedConfig(userConfig, "claudeSettings").recognized) {
@@ -983,10 +1451,16 @@ function dialectLine(glob, format) {
983
1451
  function expectedEnforcedGlobs(classified, pinnedPaths) {
984
1452
  return demoteEnforcedGlobs(deriveEnforcedRules(classified).globs, pinnedPaths).globs;
985
1453
  }
986
- function enforcedFindings(classified, paths, enforced, pinnedPaths) {
1454
+ function enforcedFindings(classified, paths, enforced, pinnedPaths, keepDeclarations) {
987
1455
  const findings = [];
988
- const expected = expectedEnforcedGlobs(classified, pinnedPaths);
1456
+ const expected = expectedEnforcedGlobs(classified, [
1457
+ ...pinnedPaths,
1458
+ ...keepDeclarations.flatMap(representativePathsFor)
1459
+ ]);
989
1460
  const expectedSet = new Set(expected);
1461
+ const declarationReps = keepDeclarations.map(
1462
+ (declaration) => [declaration, representativePathsFor(declaration)]
1463
+ );
990
1464
  for (const config of enforced) {
991
1465
  const have = new Set(config.engineGlobs);
992
1466
  for (const glob of expected) {
@@ -1023,6 +1497,17 @@ function enforcedFindings(classified, paths, enforced, pinnedPaths) {
1023
1497
  });
1024
1498
  }
1025
1499
  }
1500
+ for (const [declaration, representatives] of declarationReps) {
1501
+ if (representatives.some(matches)) {
1502
+ findings.push({
1503
+ kind: "conflict",
1504
+ severity: "high",
1505
+ glob,
1506
+ path: declaration,
1507
+ message: `\`${declaration}\` is declared keep in ${OVERRIDES_FILENAME} but ${config.name} still hard-blocks it via \`${dialectLine(glob, config.format)}\``
1508
+ });
1509
+ }
1510
+ }
1026
1511
  }
1027
1512
  for (const line of config.foreignLines) {
1028
1513
  findings.push({
@@ -1032,18 +1517,18 @@ function enforcedFindings(classified, paths, enforced, pinnedPaths) {
1032
1517
  });
1033
1518
  }
1034
1519
  }
1035
- const base = enforced[0];
1036
- if (base) {
1037
- const baseSet = new Set(base.engineGlobs);
1520
+ const base2 = enforced[0];
1521
+ if (base2) {
1522
+ const baseSet = new Set(base2.engineGlobs);
1038
1523
  for (const other of enforced.slice(1)) {
1039
1524
  const otherSet = new Set(other.engineGlobs);
1040
- for (const glob of base.engineGlobs) {
1525
+ for (const glob of base2.engineGlobs) {
1041
1526
  if (!otherSet.has(glob)) {
1042
1527
  findings.push({
1043
1528
  kind: "drift",
1044
1529
  severity: "medium",
1045
1530
  glob,
1046
- message: `Enforced \`${glob}\` is in ${base.name} but not ${other.name}`
1531
+ message: `Enforced \`${glob}\` is in ${base2.name} but not ${other.name}`
1047
1532
  });
1048
1533
  }
1049
1534
  }
@@ -1053,7 +1538,7 @@ function enforcedFindings(classified, paths, enforced, pinnedPaths) {
1053
1538
  kind: "drift",
1054
1539
  severity: "medium",
1055
1540
  glob,
1056
- message: `Enforced \`${glob}\` is in ${other.name} but not ${base.name}`
1541
+ message: `Enforced \`${glob}\` is in ${other.name} but not ${base2.name}`
1057
1542
  });
1058
1543
  }
1059
1544
  }
@@ -1101,14 +1586,25 @@ function reconcile(files, configs, options = {}) {
1101
1586
  keep: keepPins,
1102
1587
  skim: skimPins
1103
1588
  });
1589
+ const declarations = options.keepDeclarations ?? [];
1590
+ const demotionPaths = [
1591
+ ...pinnedPaths,
1592
+ ...declarations.flatMap(representativePathsFor)
1593
+ ];
1104
1594
  if ((primary.format ?? "markdown") !== "markdown") {
1105
1595
  const parsedPrimary = parseEnforcedConfig(
1106
1596
  primary.source,
1107
1597
  primary.format
1108
1598
  );
1109
1599
  if (!parsedPrimary.recognized) return empty2;
1110
- const findings2 = enforcedFindings(classified, paths, enforced, pinnedPaths);
1111
- const expected = expectedEnforcedGlobs(classified, pinnedPaths);
1600
+ const findings2 = enforcedFindings(
1601
+ classified,
1602
+ paths,
1603
+ enforced,
1604
+ pinnedPaths,
1605
+ declarations
1606
+ );
1607
+ const expected = expectedEnforcedGlobs(classified, demotionPaths);
1112
1608
  const have = new Set(parsedPrimary.engineGlobs);
1113
1609
  const present = expected.filter((glob) => have.has(glob)).length;
1114
1610
  const coveragePct2 = expected.length === 0 ? 100 : Math.floor(present / expected.length * 100);
@@ -1241,7 +1737,9 @@ function reconcile(files, configs, options = {}) {
1241
1737
  }
1242
1738
  }
1243
1739
  }
1244
- findings.push(...enforcedFindings(classified, paths, enforced, pinnedPaths));
1740
+ findings.push(
1741
+ ...enforcedFindings(classified, paths, enforced, pinnedPaths, declarations)
1742
+ );
1245
1743
  let junkBytes = 0;
1246
1744
  let prunedBytes = 0;
1247
1745
  for (const file of classified) {
@@ -1391,11 +1889,11 @@ var DEFAULT_CONFIG_PATHS = [
1391
1889
  ".claude/settings.json"
1392
1890
  ];
1393
1891
  function formatForPath(path) {
1394
- const base = path.split("/").pop() ?? path;
1395
- if (base === ".cursorignore" || base === ".geminiignore" || base === ".codeiumignore") {
1892
+ const base2 = path.split("/").pop() ?? path;
1893
+ if (base2 === ".cursorignore" || base2 === ".geminiignore" || base2 === ".codeiumignore") {
1396
1894
  return "gitignore";
1397
1895
  }
1398
- if (base === "settings.json") return "claudeSettings";
1896
+ if (base2 === "settings.json") return "claudeSettings";
1399
1897
  return "markdown";
1400
1898
  }
1401
1899
  var CREATE_KEY = "Set CONTEXTPRUNER_API_KEY (create one at https://contextpruner.app/account).";
@@ -1454,6 +1952,11 @@ async function runLint(deps) {
1454
1952
  if (files.length === 0) {
1455
1953
  return { text: "No tracked files found.", exitCode: 2, channel: "stderr" };
1456
1954
  }
1955
+ const overridesSource = deps.readConfig(OVERRIDES_FILENAME);
1956
+ const parsedOverrides = parseOverridesFile(overridesSource ?? "");
1957
+ const overridesWarning = parsedOverrides.unknownLines.length > 0 ? `
1958
+ \u26A0 ${OVERRIDES_FILENAME}: ignored ${parsedOverrides.unknownLines.length} unrecognized line${parsedOverrides.unknownLines.length === 1 ? "" : "s"} (${parsedOverrides.unknownLines.map((l) => `"${l}"`).join(", ")}) \u2014 only \`keep: <path-or-glob>\` lines are read.
1959
+ ` : "";
1457
1960
  if (deps.fix) {
1458
1961
  const pins = { keep: [], skim: [] };
1459
1962
  for (const c of configs) {
@@ -1474,7 +1977,13 @@ async function runLint(deps) {
1474
1977
  });
1475
1978
  result = buildFixedConfig(files, source, void 0, spared);
1476
1979
  } else {
1477
- result = buildFixedEnforcedConfig(files, source, format, pins);
1980
+ result = buildFixedEnforcedConfig(
1981
+ files,
1982
+ source,
1983
+ format,
1984
+ pins,
1985
+ parsedOverrides.keep
1986
+ );
1478
1987
  }
1479
1988
  if (!result.fixable) {
1480
1989
  unfixable.push(name);
@@ -1485,57 +1994,470 @@ async function runLint(deps) {
1485
1994
  }
1486
1995
  if (fixed.length > 0) {
1487
1996
  return {
1488
- text: `Fixed ${fixed.join(", ")}. Re-run without --fix to confirm.`,
1997
+ text: `${overridesWarning}Fixed ${fixed.join(", ")}. Re-run without --fix to confirm.`,
1489
1998
  exitCode: 0,
1490
1999
  channel: "stdout"
1491
2000
  };
1492
2001
  }
1493
2002
  if (unfixable.length === configs.length) {
1494
2003
  return {
1495
- text: `Nothing to fix \u2014 ${unfixable.join(", ")} ${unfixable.length === 1 ? "has" : "have"} no ContextPruner managed block (or, for .claude/settings.json, could not be merged safely).`,
2004
+ text: `${overridesWarning}Nothing to fix \u2014 ${unfixable.join(", ")} ${unfixable.length === 1 ? "has" : "have"} no ContextPruner managed block (or, for .claude/settings.json, could not be merged safely).`,
1496
2005
  exitCode: 2,
1497
2006
  channel: "stderr"
1498
2007
  };
1499
2008
  }
1500
2009
  const skipped = unfixable.length > 0 ? ` Skipped ${unfixable.join(", ")} (no ContextPruner managed block, or unmergeable).` : "";
1501
2010
  return {
1502
- text: `Nothing to fix \u2014 your configs already match the tree.${skipped}`,
2011
+ text: `${overridesWarning}Nothing to fix \u2014 your configs already match the tree.${skipped}`,
1503
2012
  exitCode: 0,
1504
2013
  channel: "stdout"
1505
2014
  };
1506
2015
  }
1507
- const report = reconcile(files, configs);
2016
+ const report = reconcile(files, configs, {
2017
+ keepDeclarations: parsedOverrides.keep
2018
+ });
1508
2019
  const kept = report.findings.filter((f) => !isFalseDead(f));
1509
2020
  const filtered = { ...report, findings: kept };
1510
- const text = deps.json ? JSON.stringify(filtered, null, 2) : formatReport(filtered);
2021
+ const text = deps.json ? JSON.stringify(filtered, null, 2) : `${overridesWarning}${formatReport(filtered)}`;
1511
2022
  return { text, exitCode: exitCodeFor(filtered), channel: "stdout" };
1512
2023
  }
1513
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
+
1514
2220
  // src/index.ts
1515
- var VERSION = "0.2.0";
1516
- 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
1517
2224
 
1518
2225
  Usage:
1519
- 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.
1520
2236
 
1521
2237
  Options:
1522
- --config <path> A config to lint (repeatable). Default: auto-detect
1523
- AGENTS.md, CLAUDE.md, GEMINI.md, Copilot, and Cursor rules,
1524
- plus the enforced .cursorignore, .geminiignore,
1525
- .codeiumignore, and .claude/settings.json.
1526
- --fix Rewrite each config's managed block in place to fix the
1527
- issues \u2014 enforced files too, via their managed block. Your
1528
- own content outside the block is left alone.
1529
- --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.
1530
2241
  -h, --help Show this help.
1531
2242
  -v, --version Show the version.
1532
2243
 
1533
- Auth:
2244
+ Auth (lint --fix and serve):
1534
2245
  export CONTEXTPRUNER_API_KEY=cp_live_... (create one at
1535
- https://contextpruner.app/account). Only your key is sent \u2014 the file tree
1536
- 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).
1537
2249
 
1538
- 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
+ }
1539
2461
  async function main() {
1540
2462
  const args = parseArgs(process.argv.slice(2));
1541
2463
  if (args.command === "help") {
@@ -1546,16 +2468,46 @@ async function main() {
1546
2468
  console.log(VERSION);
1547
2469
  return 0;
1548
2470
  }
1549
- if (args.command !== "lint" || args.problem) {
2471
+ if (args.problem || args.command === "unknown") {
1550
2472
  console.error(`contextpruner: ${args.problem ?? "invalid usage"}
1551
2473
 
1552
2474
  ${HELP}`);
1553
2475
  return 2;
1554
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
+ }
1555
2507
  const cwd = process.cwd();
1556
2508
  const { text, exitCode, channel } = await runLint({
1557
2509
  key: process.env.CONTEXTPRUNER_API_KEY,
1558
- baseUrl: process.env.CONTEXTPRUNER_API_URL ?? "https://contextpruner.app",
2510
+ baseUrl,
1559
2511
  configPaths: args.configs.length > 0 ? args.configs : DEFAULT_CONFIG_PATHS,
1560
2512
  json: args.json,
1561
2513
  fix: args.fix,
@@ -1563,9 +2515,9 @@ ${HELP}`);
1563
2515
  cwd,
1564
2516
  maxBuffer: 256 * 1024 * 1024
1565
2517
  }).toString("utf8").split("\0").filter(Boolean),
1566
- sizeOf: (path) => statSync(join(cwd, path)).size,
1567
- readConfig: (path) => existsSync(join(cwd, path)) ? readFileSync(join(cwd, path), "utf8") : null,
1568
- 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")
1569
2521
  });
1570
2522
  if (channel === "stderr") {
1571
2523
  console.error(text);