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