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.
- package/README.md +98 -3
- package/dist/index.js +1055 -103
- 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 {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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 (!
|
|
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 (!
|
|
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
|
|
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
|
|
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 ===
|
|
761
|
-
return
|
|
1216
|
+
if (pinned === void 0 || pinned === base2.verdict) {
|
|
1217
|
+
return base2;
|
|
762
1218
|
}
|
|
763
1219
|
return {
|
|
764
|
-
...
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
1036
|
-
if (
|
|
1037
|
-
const baseSet = new Set(
|
|
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
|
|
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 ${
|
|
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 ${
|
|
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(
|
|
1111
|
-
|
|
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(
|
|
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
|
|
1395
|
-
if (
|
|
1892
|
+
const base2 = path.split("/").pop() ?? path;
|
|
1893
|
+
if (base2 === ".cursorignore" || base2 === ".geminiignore" || base2 === ".codeiumignore") {
|
|
1396
1894
|
return "gitignore";
|
|
1397
1895
|
}
|
|
1398
|
-
if (
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
1516
|
-
var
|
|
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
|
-
|
|
1524
|
-
|
|
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).
|
|
1536
|
-
|
|
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
|
|
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
|
|
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(
|
|
1567
|
-
readConfig: (path) =>
|
|
1568
|
-
writeConfig: (path, content) =>
|
|
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);
|