contextpruner 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +72 -0
  2. package/dist/index.js +1066 -0
  3. package/package.json +32 -0
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # contextpruner
2
+
3
+ Lint your AI-context config files against your repo. `contextpruner lint` checks
4
+ your `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` / Copilot / Cursor rules against the
5
+ files actually in your tree and reports:
6
+
7
+ - **Missing** — junk the config doesn't ignore yet (with what it's costing you),
8
+ - **Dead** — rules that match nothing,
9
+ - **Drift** — rules in one config but not another,
10
+ - **Conflict** — rules that contradict each other.
11
+
12
+ It exits non-zero when it finds issues, so it drops straight into CI or a
13
+ pre-commit hook.
14
+
15
+ ## Usage
16
+
17
+ ```sh
18
+ export CONTEXTPRUNER_API_KEY=cp_live_... # from https://contextpruner.app/account
19
+ npx contextpruner lint
20
+ ```
21
+
22
+ ```
23
+ contextpruner lint [--config <path>]... [--fix] [--json]
24
+ ```
25
+
26
+ - `--config <path>` — a config to lint (repeatable). Defaults to auto-detecting
27
+ the standard files at the repo root.
28
+ - `--fix` — rewrite each config's managed block in place to fix the issues. Your
29
+ own content outside the block (and the "Exceptions" section) is left alone.
30
+ - `--json` — emit the full report as JSON.
31
+
32
+ **Exit codes:** `0` clean · `1` issues found · `2` usage/auth error.
33
+
34
+ ## In CI
35
+
36
+ Fail the build when a config drifts. Save this as
37
+ `.github/workflows/contextpruner-lint.yml` and add your key as the repo secret
38
+ `CONTEXTPRUNER_API_KEY`:
39
+
40
+ ```yaml
41
+ name: ContextPruner Lint
42
+ on: [push, pull_request]
43
+ jobs:
44
+ lint:
45
+ runs-on: ubuntu-latest
46
+ steps:
47
+ - uses: actions/checkout@v4
48
+ - uses: actions/setup-node@v4
49
+ with:
50
+ node-version: 20
51
+ - run: npx --yes contextpruner lint
52
+ env:
53
+ CONTEXTPRUNER_API_KEY: ${{ secrets.CONTEXTPRUNER_API_KEY }}
54
+ ```
55
+
56
+ ## Privacy
57
+
58
+ The pruning engine runs **entirely on your machine** — your file tree never
59
+ leaves it. The only network call is to `/api/license/verify`, which sends just
60
+ your API key to confirm an active subscription.
61
+
62
+ ## Development
63
+
64
+ The CLI bundles the app's pure engine at build time:
65
+
66
+ ```sh
67
+ npm install # in packages/cli
68
+ npm run build # → dist/index.js
69
+ ```
70
+
71
+ The published package name is `contextpruner`; the repository's root
72
+ `package.json` is a separate private app that happens to share the name.
package/dist/index.js ADDED
@@ -0,0 +1,1066 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { execFileSync } from "node:child_process";
5
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ // src/args.ts
9
+ function parseArgs(argv) {
10
+ const [command, ...rest] = argv;
11
+ if (command === void 0 || command === "--help" || command === "-h") {
12
+ return { command: "help", configs: [], json: false, fix: false };
13
+ }
14
+ if (command === "--version" || command === "-v") {
15
+ return { command: "version", configs: [], json: false, fix: false };
16
+ }
17
+ if (command !== "lint") {
18
+ return {
19
+ command: "unknown",
20
+ configs: [],
21
+ json: false,
22
+ fix: false,
23
+ problem: `unknown command "${command}"`
24
+ };
25
+ }
26
+ const configs = [];
27
+ let json = false;
28
+ let fix = false;
29
+ for (let i = 0; i < rest.length; i++) {
30
+ const arg = rest[i];
31
+ if (arg === "--json") {
32
+ json = true;
33
+ } else if (arg === "--fix") {
34
+ fix = true;
35
+ } else if (arg === "--config") {
36
+ const value = rest[++i];
37
+ if (value === void 0) {
38
+ return { command: "lint", configs, json, fix, problem: "--config needs a path" };
39
+ }
40
+ configs.push(value);
41
+ } else if (arg === "--help" || arg === "-h") {
42
+ return { command: "help", configs: [], json: false, fix: false };
43
+ } else {
44
+ return {
45
+ command: "lint",
46
+ configs,
47
+ json,
48
+ fix,
49
+ problem: `unknown option "${arg}"`
50
+ };
51
+ }
52
+ }
53
+ return { command: "lint", configs, json, fix };
54
+ }
55
+
56
+ // ../../lib/shared/format.ts
57
+ function formatPctReduction(ratio) {
58
+ return ratio >= 1 ? 100 : Math.min(99, Math.floor(Number((ratio * 100).toPrecision(12))));
59
+ }
60
+
61
+ // ../../lib/engine/generators/shared.ts
62
+ function formatStatsLine(stats) {
63
+ const pct = stats.bytesTotal === 0 ? 0 : formatPctReduction(stats.bytesPruned / stats.bytesTotal);
64
+ return `${stats.filesPruned} of ${stats.filesTotal} files pruned (${pct}% smaller context)`;
65
+ }
66
+ var MANAGED_BLOCK_BEGIN = "<!-- contextpruner:begin -->";
67
+ var MANAGED_BLOCK_END = "<!-- contextpruner:end -->";
68
+ var MANAGED_BLOCK_NOTE = "<!-- generated block \u2014 edits inside are overwritten on regeneration -->";
69
+ function generateMarkdownRules(filename, input) {
70
+ const lines = [
71
+ MANAGED_BLOCK_BEGIN,
72
+ MANAGED_BLOCK_NOTE,
73
+ "",
74
+ `# ${filename} \u2014 AI context rules (generated by ContextPruner)`,
75
+ "",
76
+ `Generated ${input.generatedAt}. ${formatStatsLine(input.stats)}.`,
77
+ "",
78
+ "## Ignore",
79
+ "",
80
+ "Do not read, index, or load these paths into context:",
81
+ ""
82
+ ];
83
+ for (const glob of input.excludeGlobs) {
84
+ lines.push(`- \`${glob}\``);
85
+ }
86
+ if (input.summarizeGlobs.length > 0) {
87
+ lines.push(
88
+ "",
89
+ "## Skim",
90
+ "",
91
+ "Summarize these instead of reading them in full:",
92
+ ""
93
+ );
94
+ for (const glob of input.summarizeGlobs) {
95
+ lines.push(`- \`${glob}\``);
96
+ }
97
+ }
98
+ if (input.priorityGlobs.length > 0) {
99
+ lines.push("", "## Focus", "", "Prioritize these paths when exploring:", "");
100
+ for (const glob of input.priorityGlobs) {
101
+ lines.push(`- \`${glob}\``);
102
+ }
103
+ }
104
+ if (input.keepOverridePaths.length > 0 || input.skimOverridePaths.length > 0 || input.pruneOverridePaths.length > 0) {
105
+ lines.push(
106
+ "",
107
+ "## Overrides",
108
+ "",
109
+ "The repo owner pinned these verdicts \u2014 they take precedence over every rule above:",
110
+ ""
111
+ );
112
+ for (const path of input.keepOverridePaths) {
113
+ lines.push(`- Keep (read normally): \`${path}\``);
114
+ }
115
+ for (const path of input.skimOverridePaths) {
116
+ lines.push(`- Skim (summarize only): \`${path}\``);
117
+ }
118
+ for (const path of input.pruneOverridePaths) {
119
+ lines.push(`- Prune (do not read): \`${path}\``);
120
+ }
121
+ }
122
+ lines.push(
123
+ "",
124
+ MANAGED_BLOCK_END,
125
+ "",
126
+ "## Exceptions (yours \u2014 edit freely)",
127
+ "",
128
+ "ContextPruner only sees paths and byte sizes \u2014 you know which",
129
+ "junk-shaped files actually matter in this repo. Rules you add here",
130
+ "are yours: when regenerating, replace only the marked block above",
131
+ "and keep this section.",
132
+ "",
133
+ "- _none yet \u2014 add repo-specific overrides here_",
134
+ ""
135
+ );
136
+ return lines.join("\n");
137
+ }
138
+
139
+ // ../../lib/shared/constants.ts
140
+ var BYTES_PER_TOKEN_RATIO = 0.25;
141
+ var USD_PER_1M_INPUT_TOKENS = 3;
142
+ var PROMPTS_PER_DAY = 50;
143
+ var WORKING_DAYS_PER_MONTH = 20;
144
+ var PROMPTS_PER_MONTH = PROMPTS_PER_DAY * WORKING_DAYS_PER_MONTH;
145
+ var CONTEXT_SLICE_FACTOR = 0.05;
146
+ var DEFAULT_CONTEXT_WINDOW_TOKENS = 1e6;
147
+ var MONTHLY_SAVINGS_CAP_USD = 200;
148
+ var MAX_MANIFEST_FILES = 2e4;
149
+ var OVERSIZED_FILE_BYTES = 1048576;
150
+
151
+ // ../../lib/engine/rules.ts
152
+ var LOCKFILE_BASENAMES = /* @__PURE__ */ new Set([
153
+ "package-lock.json",
154
+ "npm-shrinkwrap.json",
155
+ "pnpm-lock.yaml",
156
+ "yarn.lock",
157
+ "bun.lockb",
158
+ "bun.lock",
159
+ "deno.lock",
160
+ "composer.lock",
161
+ "Gemfile.lock",
162
+ "poetry.lock",
163
+ "uv.lock",
164
+ "pdm.lock",
165
+ "Pipfile.lock",
166
+ "Cargo.lock",
167
+ "go.sum",
168
+ "flake.lock",
169
+ "packages.lock.json",
170
+ "gradle.lockfile"
171
+ ]);
172
+ var PRUNE_DIR_SEGMENT_REASONS = /* @__PURE__ */ new Map([
173
+ ["node_modules", "dependency-dir"],
174
+ ["vendor", "vendored-deps"],
175
+ [".venv", "dependency-dir"],
176
+ ["venv", "dependency-dir"],
177
+ ["dist", "build-output"],
178
+ ["build", "build-output"],
179
+ ["out", "build-output"],
180
+ [".next", "build-output"],
181
+ [".nuxt", "build-output"],
182
+ [".output", "build-output"],
183
+ ["target", "build-output"],
184
+ ["coverage", "test-coverage"],
185
+ [".nyc_output", "test-coverage"],
186
+ ["__snapshots__", "test-snapshot"],
187
+ [".git", "vcs-metadata"],
188
+ [".idea", "editor-metadata"],
189
+ [".vscode", "editor-metadata"],
190
+ ["__pycache__", "cache"],
191
+ [".pytest_cache", "cache"],
192
+ [".cache", "cache"],
193
+ [".turbo", "cache"],
194
+ ["fixtures", "test-fixture"],
195
+ ["__fixtures__", "test-fixture"],
196
+ ["testdata", "test-fixture"]
197
+ ]);
198
+ var PRUNE_BASENAME_REASONS = /* @__PURE__ */ new Map([
199
+ [".DS_Store", "os-metadata"],
200
+ ["Thumbs.db", "os-metadata"],
201
+ ["LICENSE", "license"],
202
+ ["LICENSE.txt", "license"],
203
+ ["LICENSE.md", "license"],
204
+ ["NOTICE", "license"]
205
+ ]);
206
+ var PRUNE_SUFFIX_REASONS = {
207
+ ".min.js": "minified",
208
+ ".min.css": "minified",
209
+ ".map": "source-map",
210
+ ".snap": "test-snapshot",
211
+ ".log": "log-file",
212
+ ".pyc": "compiled-artifact",
213
+ ".class": "compiled-artifact",
214
+ ".o": "compiled-artifact",
215
+ ".obj": "compiled-artifact"
216
+ };
217
+ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
218
+ // images
219
+ "png",
220
+ "jpg",
221
+ "jpeg",
222
+ "gif",
223
+ "webp",
224
+ "avif",
225
+ "ico",
226
+ "bmp",
227
+ "tiff",
228
+ "svg",
229
+ // fonts
230
+ "woff",
231
+ "woff2",
232
+ "ttf",
233
+ "otf",
234
+ "eot",
235
+ // audio / video
236
+ "mp3",
237
+ "wav",
238
+ "ogg",
239
+ "flac",
240
+ "mp4",
241
+ "mov",
242
+ "avi",
243
+ "mkv",
244
+ "webm",
245
+ // archives
246
+ "zip",
247
+ "gz",
248
+ "tgz",
249
+ "bz2",
250
+ "xz",
251
+ "tar",
252
+ "rar",
253
+ "7z",
254
+ "jar",
255
+ "war",
256
+ // binaries / data blobs
257
+ "exe",
258
+ "dll",
259
+ "so",
260
+ "dylib",
261
+ "a",
262
+ "wasm",
263
+ "bin",
264
+ "dat",
265
+ "pdf",
266
+ "psd",
267
+ "sketch",
268
+ "fig",
269
+ "sqlite",
270
+ "db"
271
+ ]);
272
+ var SUMMARIZE_EXTENSIONS = /* @__PURE__ */ new Set([
273
+ "md",
274
+ "mdx",
275
+ "markdown",
276
+ "rst",
277
+ "txt",
278
+ "adoc"
279
+ ]);
280
+
281
+ // ../../lib/engine/classify.ts
282
+ var ENV_EXEMPT_SUFFIXES = [".example", ".sample", ".template"];
283
+ function classify(file) {
284
+ const { path, sizeInBytes } = file;
285
+ const segments = path.split("/");
286
+ const basename = segments[segments.length - 1];
287
+ const lowerBasename = basename.toLowerCase();
288
+ const dotIndex = lowerBasename.lastIndexOf(".");
289
+ const ext = dotIndex > 0 ? lowerBasename.slice(dotIndex + 1) : "";
290
+ const entry = (verdict, reason, glob) => ({ path, verdict, reason, sizeInBytes, glob });
291
+ if (basename.startsWith(".env") && !ENV_EXEMPT_SUFFIXES.some((suffix) => lowerBasename.endsWith(suffix))) {
292
+ return entry("PRUNE", "env-file", "**/.env*");
293
+ }
294
+ for (let i = 0; i < segments.length - 1; i++) {
295
+ const reason = PRUNE_DIR_SEGMENT_REASONS.get(segments[i]);
296
+ if (reason) {
297
+ const glob = i === 0 ? `${segments[0]}/**` : `**/${segments[i]}/**`;
298
+ return entry("PRUNE", reason, glob);
299
+ }
300
+ }
301
+ if (LOCKFILE_BASENAMES.has(basename)) {
302
+ return entry("PRUNE", "lockfile", segments.length === 1 ? path : `**/${basename}`);
303
+ }
304
+ const basenameReason = PRUNE_BASENAME_REASONS.get(basename);
305
+ if (basenameReason) {
306
+ return entry("PRUNE", basenameReason, `**/${basename}`);
307
+ }
308
+ for (const [suffix, reason] of Object.entries(PRUNE_SUFFIX_REASONS)) {
309
+ if (lowerBasename.endsWith(suffix) && lowerBasename !== suffix) {
310
+ return entry("PRUNE", reason, `**/*${suffix}`);
311
+ }
312
+ }
313
+ if (BINARY_EXTENSIONS.has(ext)) {
314
+ return entry("PRUNE", "binary-asset", `**/*.${ext}`);
315
+ }
316
+ if (sizeInBytes > OVERSIZED_FILE_BYTES) {
317
+ return entry("PRUNE", "oversized", path);
318
+ }
319
+ if (SUMMARIZE_EXTENSIONS.has(ext)) {
320
+ return entry("SUMMARIZE", "docs", `**/*.${ext}`);
321
+ }
322
+ return entry("KEEP", "source", null);
323
+ }
324
+
325
+ // ../../lib/engine/generators/agentsMd.ts
326
+ function generateAgentsMd(input) {
327
+ return generateMarkdownRules("AGENTS.md", input);
328
+ }
329
+
330
+ // ../../lib/engine/generators/claudeMd.ts
331
+ function generateClaudeMd(input) {
332
+ return generateMarkdownRules("CLAUDE.md", input);
333
+ }
334
+
335
+ // ../../lib/engine/generators/copilotInstructions.ts
336
+ function generateCopilotInstructions(input) {
337
+ return generateMarkdownRules(".github/copilot-instructions.md", input);
338
+ }
339
+
340
+ // ../../lib/engine/generators/cursorMdc.ts
341
+ function generateCursorMdc(input) {
342
+ const frontmatter = [
343
+ "---",
344
+ "description: Repo context-pruning rules generated by ContextPruner",
345
+ "alwaysApply: true",
346
+ "---",
347
+ ""
348
+ ].join("\n");
349
+ return frontmatter + generateMarkdownRules("Cursor rules", input);
350
+ }
351
+
352
+ // ../../lib/engine/generators/geminiMd.ts
353
+ function generateGeminiMd(input) {
354
+ return generateMarkdownRules("GEMINI.md", input);
355
+ }
356
+
357
+ // ../../lib/engine/savings.ts
358
+ function computeSavings(bytesTotal, bytesKept, options = {}) {
359
+ const price = options.usdPer1MInputTokens ?? USD_PER_1M_INPUT_TOKENS;
360
+ const promptsPerMonth = options.promptsPerMonth ?? PROMPTS_PER_MONTH;
361
+ const sliceFactor = options.contextSliceFactor ?? CONTEXT_SLICE_FACTOR;
362
+ const cap = options.monthlySpendCapUsd === void 0 ? MONTHLY_SAVINGS_CAP_USD : options.monthlySpendCapUsd;
363
+ const windowTokens = options.contextWindowTokens ?? DEFAULT_CONTEXT_WINDOW_TOKENS;
364
+ const bytesPruned = Math.max(0, bytesTotal - bytesKept);
365
+ const tokensSaved = Math.round(bytesPruned * BYTES_PER_TOKEN_RATIO);
366
+ const windowClamped = tokensSaved > windowTokens;
367
+ const tokensWastedPerPrompt = Math.min(tokensSaved, windowTokens) * sliceFactor;
368
+ const usdPerPrompt = tokensWastedPerPrompt / 1e6 * price;
369
+ const usdSavedPerMonthUncapped = usdPerPrompt * promptsPerMonth;
370
+ const capApplied = cap !== null && usdSavedPerMonthUncapped > cap;
371
+ const usdSavedPerMonth = Math.min(usdSavedPerMonthUncapped, cap ?? Infinity);
372
+ const pctReduction = bytesTotal === 0 ? 0 : bytesPruned / bytesTotal;
373
+ return {
374
+ bytesPruned,
375
+ tokensSaved,
376
+ tokensWastedPerPrompt,
377
+ usdPerPrompt,
378
+ usdSavedPerMonth,
379
+ usdSavedPerMonthUncapped,
380
+ capApplied,
381
+ windowClamped,
382
+ pctReduction
383
+ };
384
+ }
385
+
386
+ // ../../lib/engine/prune.ts
387
+ var MAX_PRIORITY_GLOBS = 5;
388
+ function prune(files, options = {}) {
389
+ const overrides = options.verdictOverrides ?? {};
390
+ const classified = files.map((file) => {
391
+ const base = classify(file);
392
+ const pinned = Object.hasOwn(overrides, file.path) ? overrides[file.path] : void 0;
393
+ if (pinned === void 0 || pinned === base.verdict) {
394
+ return base;
395
+ }
396
+ return {
397
+ ...base,
398
+ verdict: pinned,
399
+ glob: null,
400
+ overridden: true
401
+ };
402
+ });
403
+ let bytesTotal = 0;
404
+ let bytesKept = 0;
405
+ let filesPruned = 0;
406
+ const excludeGlobs = /* @__PURE__ */ new Set();
407
+ const summarizeGlobs = /* @__PURE__ */ new Set();
408
+ const keptBytesByDir = /* @__PURE__ */ new Map();
409
+ for (const file of classified) {
410
+ bytesTotal += file.sizeInBytes;
411
+ if (file.verdict === "PRUNE") {
412
+ filesPruned++;
413
+ if (file.glob) excludeGlobs.add(file.glob);
414
+ } else {
415
+ bytesKept += file.sizeInBytes;
416
+ if (file.verdict === "SUMMARIZE") {
417
+ if (file.glob) summarizeGlobs.add(file.glob);
418
+ } else if (!file.overridden) {
419
+ const slash = file.path.indexOf("/");
420
+ if (slash > 0) {
421
+ const dir = file.path.slice(0, slash);
422
+ keptBytesByDir.set(
423
+ dir,
424
+ (keptBytesByDir.get(dir) ?? 0) + file.sizeInBytes
425
+ );
426
+ }
427
+ }
428
+ }
429
+ }
430
+ const savings = computeSavings(bytesTotal, bytesKept, options);
431
+ const pinnedSavings = computeSavings(bytesTotal, bytesKept, {
432
+ usdPer1MInputTokens: options.usdPer1MInputTokens,
433
+ monthlySpendCapUsd: options.monthlySpendCapUsd,
434
+ contextWindowTokens: options.contextWindowTokens
435
+ });
436
+ const stats = {
437
+ filesTotal: classified.length,
438
+ filesKept: classified.length - filesPruned,
439
+ filesPruned,
440
+ bytesTotal,
441
+ bytesKept,
442
+ bytesPruned: savings.bytesPruned,
443
+ tokensSaved: savings.tokensSaved,
444
+ junkTokensPerPrompt: Math.round(pinnedSavings.tokensWastedPerPrompt),
445
+ dollarsPerPrompt: Math.round(pinnedSavings.usdPerPrompt * 1e4) / 1e4,
446
+ dollarsSavedPerMonth: Math.round(pinnedSavings.usdSavedPerMonth * 100) / 100,
447
+ dollarsSavedPerMonthUncapped: Math.round(pinnedSavings.usdSavedPerMonthUncapped * 100) / 100,
448
+ savingsCapped: pinnedSavings.capApplied,
449
+ windowClamped: pinnedSavings.windowClamped
450
+ };
451
+ for (const glob of options.extraExcludeGlobs ?? []) excludeGlobs.add(glob);
452
+ const priorityGlobs = [...keptBytesByDir.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_PRIORITY_GLOBS).map(([dir]) => `${dir}/**`);
453
+ const generatorInput = {
454
+ generatedAt: (options.now ?? /* @__PURE__ */ new Date()).toISOString(),
455
+ stats,
456
+ excludeGlobs: [...excludeGlobs].sort(),
457
+ summarizeGlobs: [...summarizeGlobs].sort(),
458
+ priorityGlobs,
459
+ keepOverridePaths: classified.filter((f) => f.overridden && f.verdict === "KEEP").map((f) => f.path).sort(),
460
+ skimOverridePaths: classified.filter((f) => f.overridden && f.verdict === "SUMMARIZE").map((f) => f.path).sort(),
461
+ pruneOverridePaths: classified.filter((f) => f.overridden && f.verdict === "PRUNE").map((f) => f.path).sort()
462
+ };
463
+ const verdicts = classified.map(
464
+ ({ path, verdict, reason, sizeInBytes, overridden }) => ({
465
+ path,
466
+ verdict,
467
+ reason,
468
+ sizeInBytes,
469
+ ...overridden ? { overridden: true } : {}
470
+ })
471
+ );
472
+ return {
473
+ version: 2,
474
+ generatedAt: generatorInput.generatedAt,
475
+ stats,
476
+ verdicts,
477
+ artifacts: {
478
+ agentsMd: generateAgentsMd(generatorInput),
479
+ claudeMd: generateClaudeMd(generatorInput),
480
+ cursorMdc: generateCursorMdc(generatorInput),
481
+ copilotInstructionsMd: generateCopilotInstructions(generatorInput),
482
+ geminiMd: generateGeminiMd(generatorInput)
483
+ }
484
+ };
485
+ }
486
+
487
+ // ../../lib/engine/lintFix.ts
488
+ var ARTIFACT_BY_TITLE = {
489
+ "AGENTS.md": "agentsMd",
490
+ "CLAUDE.md": "claudeMd",
491
+ "GEMINI.md": "geminiMd",
492
+ ".github/copilot-instructions.md": "copilotInstructionsMd",
493
+ "Cursor rules": "cursorMdc"
494
+ };
495
+ var TITLE_RE = /^# (.+?) — AI context rules/m;
496
+ function detectArtifact(block) {
497
+ const match = TITLE_RE.exec(block);
498
+ return match && ARTIFACT_BY_TITLE[match[1]] || "agentsMd";
499
+ }
500
+ function managedBlock(source) {
501
+ const begin = source.indexOf(MANAGED_BLOCK_BEGIN);
502
+ if (begin === -1) return null;
503
+ const endMarker = source.indexOf(
504
+ MANAGED_BLOCK_END,
505
+ begin + MANAGED_BLOCK_BEGIN.length
506
+ );
507
+ if (endMarker === -1) return null;
508
+ return source.slice(begin, endMarker + MANAGED_BLOCK_END.length);
509
+ }
510
+ function buildFixedConfig(files, userConfig, now, preserveExcludeGlobs) {
511
+ const userBlock = managedBlock(userConfig);
512
+ if (userBlock === null) {
513
+ return { fixable: false, changed: false, fixed: userConfig };
514
+ }
515
+ const artifact = detectArtifact(userBlock);
516
+ const corrected = prune(files, {
517
+ ...now ? { now } : {},
518
+ extraExcludeGlobs: preserveExcludeGlobs
519
+ }).artifacts[artifact];
520
+ const canonicalBlock = managedBlock(corrected);
521
+ if (canonicalBlock === null) {
522
+ return { fixable: false, changed: false, fixed: userConfig };
523
+ }
524
+ const begin = userConfig.indexOf(MANAGED_BLOCK_BEGIN);
525
+ const end = userConfig.indexOf(MANAGED_BLOCK_END, begin + MANAGED_BLOCK_BEGIN.length) + MANAGED_BLOCK_END.length;
526
+ const fixed = userConfig.slice(0, begin) + canonicalBlock + userConfig.slice(end);
527
+ return { fixable: true, changed: fixed !== userConfig, fixed };
528
+ }
529
+
530
+ // ../../lib/engine/parseConfig.ts
531
+ var GLOB_BULLET = /^- `(.+)`$/;
532
+ var OVERRIDE_BULLET = /^- (Keep|Skim|Prune) \([^)]*\): `(.+)`$/;
533
+ var SECTION_HEADINGS = {
534
+ "## Ignore": "ignore",
535
+ "## Skim": "skim",
536
+ "## Focus": "focus",
537
+ "## Overrides": "overrides"
538
+ };
539
+ function empty(hasManagedBlock) {
540
+ return {
541
+ hasManagedBlock,
542
+ excludeGlobs: [],
543
+ summarizeGlobs: [],
544
+ priorityGlobs: [],
545
+ overrides: { keep: [], skim: [], prune: [] }
546
+ };
547
+ }
548
+ function parseConfig(source) {
549
+ const begin = source.indexOf(MANAGED_BLOCK_BEGIN);
550
+ if (begin === -1) return empty(false);
551
+ const end = source.indexOf(MANAGED_BLOCK_END, begin + MANAGED_BLOCK_BEGIN.length);
552
+ if (end === -1) return empty(false);
553
+ const config = empty(true);
554
+ const block = source.slice(begin + MANAGED_BLOCK_BEGIN.length, end);
555
+ let section = null;
556
+ for (const raw of block.split("\n")) {
557
+ const line = raw.trimEnd();
558
+ const heading = SECTION_HEADINGS[line];
559
+ if (heading) {
560
+ section = heading;
561
+ continue;
562
+ }
563
+ if (line.startsWith("## ")) {
564
+ section = null;
565
+ continue;
566
+ }
567
+ if (section === null) continue;
568
+ if (section === "overrides") {
569
+ const match2 = OVERRIDE_BULLET.exec(line);
570
+ if (!match2) continue;
571
+ const bucket = match2[1] === "Keep" ? "keep" : match2[1] === "Skim" ? "skim" : "prune";
572
+ config.overrides[bucket].push(match2[2]);
573
+ continue;
574
+ }
575
+ const match = GLOB_BULLET.exec(line);
576
+ if (!match) continue;
577
+ const glob = match[1];
578
+ if (section === "ignore") config.excludeGlobs.push(glob);
579
+ else if (section === "skim") config.summarizeGlobs.push(glob);
580
+ else config.priorityGlobs.push(glob);
581
+ }
582
+ return config;
583
+ }
584
+
585
+ // ../../lib/engine/globMatch.ts
586
+ var ESCAPE = /[.+^${}()|[\]\\]/g;
587
+ function globToRegExp(glob) {
588
+ let re = "";
589
+ for (let i = 0; i < glob.length; i++) {
590
+ const c = glob[i];
591
+ if (c === "*") {
592
+ if (glob[i + 1] === "*") {
593
+ const atSegmentStart = i === 0 || glob[i - 1] === "/";
594
+ if (glob[i + 2] === "/" && atSegmentStart) {
595
+ re += "(?:[^/]+/)*";
596
+ i += 2;
597
+ } else {
598
+ re += ".*";
599
+ i += 1;
600
+ }
601
+ } else {
602
+ re += "[^/]*";
603
+ }
604
+ } else if (c === "?") {
605
+ re += "[^/]";
606
+ } else {
607
+ re += c.replace(ESCAPE, "\\$&");
608
+ }
609
+ }
610
+ return new RegExp(`^${re}$`);
611
+ }
612
+ function compileGlob(glob) {
613
+ const re = globToRegExp(glob);
614
+ return (path) => re.test(path);
615
+ }
616
+
617
+ // ../../lib/engine/reconcile.ts
618
+ var SEVERITY_RANK = { high: 0, medium: 1, low: 2 };
619
+ function gradeFor(coveragePct) {
620
+ if (coveragePct >= 95) return "A";
621
+ if (coveragePct >= 85) return "B";
622
+ if (coveragePct >= 70) return "C";
623
+ if (coveragePct >= 50) return "D";
624
+ return "F";
625
+ }
626
+ function tokensFor(bytes) {
627
+ return Math.round(bytes * BYTES_PER_TOKEN_RATIO);
628
+ }
629
+ function compileConfig({ name, source }) {
630
+ const parsed = parseConfig(source);
631
+ const excludeMatchers = parsed.excludeGlobs.map(compileGlob);
632
+ const summarizeMatchers = parsed.summarizeGlobs.map(compileGlob);
633
+ return {
634
+ ...parsed,
635
+ name,
636
+ excludeMatch: (path) => excludeMatchers.some((m) => m(path)),
637
+ summarizeMatch: (path) => summarizeMatchers.some((m) => m(path)),
638
+ keepSet: new Set(parsed.overrides.keep),
639
+ pruneSet: new Set(parsed.overrides.prune),
640
+ skimSet: new Set(parsed.overrides.skim)
641
+ };
642
+ }
643
+ function isAddressed(config, path) {
644
+ return config.keepSet.has(path) || config.pruneSet.has(path) || config.skimSet.has(path) || config.excludeMatch(path) || config.summarizeMatch(path);
645
+ }
646
+ function reconcile(files, configs, options = {}) {
647
+ const primary = configs[0];
648
+ const empty2 = {
649
+ configName: primary?.name ?? "",
650
+ configRecognized: false,
651
+ filesTotal: files.length,
652
+ grade: "\u2014",
653
+ coveragePct: 0,
654
+ dollarsLeaking: 0,
655
+ findings: []
656
+ };
657
+ if (!primary) return empty2;
658
+ const config = compileConfig(primary);
659
+ if (!config.hasManagedBlock) return empty2;
660
+ const classified = files.map((file) => ({ ...classify(file) }));
661
+ const paths = files.map((f) => f.path);
662
+ const bytesTotal = files.reduce((sum, f) => sum + f.sizeInBytes, 0);
663
+ const findings = [];
664
+ const missingBytesByGlob = /* @__PURE__ */ new Map();
665
+ for (const file of classified) {
666
+ if (file.verdict === "KEEP") continue;
667
+ if (isAddressed(config, file.path)) continue;
668
+ const glob = file.glob ?? file.path;
669
+ const entry = missingBytesByGlob.get(glob) ?? {
670
+ verdict: file.verdict,
671
+ bytes: 0
672
+ };
673
+ entry.bytes += file.sizeInBytes;
674
+ missingBytesByGlob.set(glob, entry);
675
+ }
676
+ for (const [glob, { verdict, bytes }] of missingBytesByGlob) {
677
+ const tokens2 = tokensFor(bytes);
678
+ const severity = verdict === "SUMMARIZE" ? "low" : tokens2 >= 1e4 ? "high" : "medium";
679
+ findings.push({
680
+ kind: "missing",
681
+ severity,
682
+ glob,
683
+ tokens: tokens2,
684
+ message: verdict === "SUMMARIZE" ? `Docs matching \`${glob}\` aren't marked to skim` : `Junk matching \`${glob}\` isn't ignored`
685
+ });
686
+ }
687
+ const allConfigGlobs = [
688
+ ...config.excludeGlobs,
689
+ ...config.summarizeGlobs,
690
+ ...config.priorityGlobs
691
+ ];
692
+ for (const glob of allConfigGlobs) {
693
+ if (!paths.some(compileGlob(glob))) {
694
+ findings.push({
695
+ kind: "dead",
696
+ severity: "low",
697
+ glob,
698
+ message: `Rule \`${glob}\` matches no files`
699
+ });
700
+ }
701
+ }
702
+ const bucketsByPath = /* @__PURE__ */ new Map();
703
+ for (const [bucket, list] of [
704
+ ["Keep", config.overrides.keep],
705
+ ["Skim", config.overrides.skim],
706
+ ["Prune", config.overrides.prune]
707
+ ]) {
708
+ for (const path of list) {
709
+ const seen = bucketsByPath.get(path) ?? [];
710
+ seen.push(bucket);
711
+ bucketsByPath.set(path, seen);
712
+ }
713
+ }
714
+ for (const [path, buckets] of bucketsByPath) {
715
+ if (buckets.length > 1) {
716
+ findings.push({
717
+ kind: "conflict",
718
+ severity: "medium",
719
+ path,
720
+ message: `\`${path}\` is pinned to conflicting verdicts (${buckets.join(" + ")})`
721
+ });
722
+ }
723
+ }
724
+ const excludeSet = new Set(config.excludeGlobs);
725
+ for (const glob of config.priorityGlobs) {
726
+ if (excludeSet.has(glob)) {
727
+ findings.push({
728
+ kind: "conflict",
729
+ severity: "medium",
730
+ glob,
731
+ message: `\`${glob}\` is both a Focus and an Ignore rule`
732
+ });
733
+ }
734
+ }
735
+ for (let i = 1; i < configs.length; i++) {
736
+ const other = compileConfig(configs[i]);
737
+ if (!other.hasManagedBlock) continue;
738
+ for (const [label, a, b] of [
739
+ ["Ignore", config.excludeGlobs, other.excludeGlobs],
740
+ ["Skim", config.summarizeGlobs, other.summarizeGlobs],
741
+ ["Focus", config.priorityGlobs, other.priorityGlobs]
742
+ ]) {
743
+ const bSet = new Set(b);
744
+ const aSet = new Set(a);
745
+ for (const glob of a) {
746
+ if (!bSet.has(glob)) {
747
+ findings.push({
748
+ kind: "drift",
749
+ severity: "medium",
750
+ glob,
751
+ message: `${label} \`${glob}\` is in ${config.name} but not ${other.name}`
752
+ });
753
+ }
754
+ }
755
+ for (const glob of b) {
756
+ if (!aSet.has(glob)) {
757
+ findings.push({
758
+ kind: "drift",
759
+ severity: "medium",
760
+ glob,
761
+ message: `${label} \`${glob}\` is in ${other.name} but not ${config.name}`
762
+ });
763
+ }
764
+ }
765
+ }
766
+ }
767
+ let junkBytes = 0;
768
+ let prunedBytes = 0;
769
+ for (const file of classified) {
770
+ if (file.verdict !== "PRUNE") continue;
771
+ if (config.keepSet.has(file.path)) continue;
772
+ junkBytes += file.sizeInBytes;
773
+ if (config.pruneSet.has(file.path) || config.excludeMatch(file.path)) {
774
+ prunedBytes += file.sizeInBytes;
775
+ }
776
+ }
777
+ const coveragePct = junkBytes === 0 ? 100 : Math.floor(prunedBytes / junkBytes * 100);
778
+ const full = computeSavings(bytesTotal, bytesTotal - junkBytes, options).usdSavedPerMonth;
779
+ const achieved = computeSavings(bytesTotal, bytesTotal - prunedBytes, options).usdSavedPerMonth;
780
+ const dollarsLeaking = Math.max(0, Math.floor((full - achieved) * 100) / 100);
781
+ findings.sort(
782
+ (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.kind.localeCompare(b.kind) || (a.glob ?? "").localeCompare(b.glob ?? "")
783
+ );
784
+ return {
785
+ configName: config.name,
786
+ configRecognized: true,
787
+ filesTotal: files.length,
788
+ grade: gradeFor(coveragePct),
789
+ coveragePct,
790
+ dollarsLeaking,
791
+ findings
792
+ };
793
+ }
794
+
795
+ // src/format.ts
796
+ var KIND = {
797
+ missing: { symbol: "\u2717", label: "MISSING" },
798
+ conflict: { symbol: "\u2717", label: "CONFLICT" },
799
+ dead: { symbol: "\u26A0", label: "DEAD" },
800
+ drift: { symbol: "~", label: "DRIFT" }
801
+ };
802
+ function tokens(n) {
803
+ return n >= 1e3 ? `~${Math.round(n / 1e3)}k tokens` : `~${n} tokens`;
804
+ }
805
+ function exitCodeFor(report) {
806
+ if (!report.configRecognized) return 2;
807
+ return report.findings.length > 0 ? 1 : 0;
808
+ }
809
+ function formatReport(report) {
810
+ const lines = ["", `ContextPruner \u2014 ${report.configName}`, ""];
811
+ if (!report.configRecognized) {
812
+ lines.push(
813
+ " This isn't a ContextPruner-generated config (no managed block).",
814
+ " Generate one at https://contextpruner.app.",
815
+ ""
816
+ );
817
+ return lines.join("\n");
818
+ }
819
+ if (report.findings.length === 0) {
820
+ lines.push(" \u2713 No issues \u2014 this config matches the tree.");
821
+ } else {
822
+ for (const f of report.findings) {
823
+ const { symbol, label } = KIND[f.kind];
824
+ const suffix = f.tokens ? ` (${tokens(f.tokens)})` : "";
825
+ lines.push(` ${symbol} ${label.padEnd(9)}${f.message}${suffix}`);
826
+ }
827
+ }
828
+ const dollars = report.dollarsLeaking > 0 ? ` \xB7 ~$${report.dollarsLeaking.toFixed(2)}/mo leaking` : "";
829
+ lines.push(
830
+ "",
831
+ ` Grade ${report.grade} \xB7 ${report.coveragePct}% of junk covered${dollars} \xB7 ${report.filesTotal.toLocaleString("en-US")} files`,
832
+ ""
833
+ );
834
+ return lines.join("\n");
835
+ }
836
+
837
+ // src/manifest.ts
838
+ function buildManifest(deps) {
839
+ const paths = deps.listTrackedFiles();
840
+ const truncated = paths.length > MAX_MANIFEST_FILES;
841
+ const files = [];
842
+ for (const path of paths.slice(0, MAX_MANIFEST_FILES)) {
843
+ let size;
844
+ try {
845
+ size = deps.sizeOf(path);
846
+ } catch {
847
+ continue;
848
+ }
849
+ if (Number.isFinite(size)) files.push({ path, sizeInBytes: size });
850
+ }
851
+ return { files, truncated };
852
+ }
853
+
854
+ // src/verify.ts
855
+ var ACCOUNT = "https://contextpruner.app/account";
856
+ async function verifyKey(opts) {
857
+ const doFetch = opts.fetchImpl ?? fetch;
858
+ let res;
859
+ try {
860
+ res = await doFetch(`${opts.baseUrl}/api/license/verify`, {
861
+ method: "POST",
862
+ headers: { authorization: `Bearer ${opts.key}` }
863
+ });
864
+ } catch {
865
+ return {
866
+ ok: false,
867
+ kind: "error",
868
+ message: "Couldn't reach ContextPruner to verify your key (network error)."
869
+ };
870
+ }
871
+ if (res.status === 200) return { ok: true };
872
+ if (res.status === 401) {
873
+ return {
874
+ ok: false,
875
+ kind: "invalid_key",
876
+ message: `Invalid API key. Create or rotate one at ${ACCOUNT}.`
877
+ };
878
+ }
879
+ if (res.status === 402) {
880
+ return {
881
+ ok: false,
882
+ kind: "inactive",
883
+ message: `Subscription inactive. Manage billing at ${ACCOUNT}.`
884
+ };
885
+ }
886
+ if (res.status === 429) {
887
+ const retryAfter = res.headers.get("retry-after") ?? await res.json().then((b) => b.retryAfter).catch(() => void 0) ?? 60;
888
+ return {
889
+ ok: false,
890
+ kind: "rate_limited",
891
+ message: `Rate limited \u2014 retry in ${retryAfter}s.`
892
+ };
893
+ }
894
+ return {
895
+ ok: false,
896
+ kind: "error",
897
+ message: `Key verification failed (HTTP ${res.status}).`
898
+ };
899
+ }
900
+
901
+ // src/run.ts
902
+ var DEFAULT_CONFIG_PATHS = [
903
+ "AGENTS.md",
904
+ "CLAUDE.md",
905
+ "GEMINI.md",
906
+ ".github/copilot-instructions.md",
907
+ ".cursor/rules/contextpruner.mdc"
908
+ ];
909
+ var CREATE_KEY = "Set CONTEXTPRUNER_API_KEY (create one at https://contextpruner.app/account).";
910
+ function dirNameOf(glob) {
911
+ return glob.match(/^([^*/]+)\/\*\*$/)?.[1] ?? glob.match(/^\*\*\/([^*/]+)\/\*\*$/)?.[1] ?? null;
912
+ }
913
+ function isFalseDead(finding) {
914
+ if (finding.kind !== "dead" || !finding.glob) return false;
915
+ const dir = dirNameOf(finding.glob);
916
+ return dir !== null && PRUNE_DIR_SEGMENT_REASONS.has(dir);
917
+ }
918
+ function hasRuleChange(before, after) {
919
+ const normalize = (s) => s.replace(/Generated \S+Z\./, "Generated <ts>.");
920
+ return normalize(before) !== normalize(after);
921
+ }
922
+ async function runLint(deps) {
923
+ if (!deps.key) {
924
+ return { text: `No API key. ${CREATE_KEY}`, exitCode: 2, channel: "stderr" };
925
+ }
926
+ const verified = await verifyKey({
927
+ key: deps.key,
928
+ baseUrl: deps.baseUrl,
929
+ fetchImpl: deps.fetchImpl
930
+ });
931
+ if (!verified.ok)
932
+ return { text: verified.message, exitCode: 2, channel: "stderr" };
933
+ const configs = [];
934
+ for (const path of deps.configPaths) {
935
+ const source = deps.readConfig(path);
936
+ if (source !== null) configs.push({ name: path, source });
937
+ }
938
+ if (configs.length === 0) {
939
+ return {
940
+ text: `No config files found (${deps.configPaths.join(", ")}). Generate one at https://contextpruner.app.`,
941
+ exitCode: 2,
942
+ channel: "stderr"
943
+ };
944
+ }
945
+ const { files, truncated } = buildManifest(deps);
946
+ if (truncated) {
947
+ return {
948
+ text: `This repo has more than ${MAX_MANIFEST_FILES.toLocaleString("en-US")} tracked files. Lint needs the whole tree to be accurate \u2014 check out a narrower path.`,
949
+ exitCode: 2,
950
+ channel: "stderr"
951
+ };
952
+ }
953
+ if (files.length === 0) {
954
+ return { text: "No tracked files found.", exitCode: 2, channel: "stderr" };
955
+ }
956
+ if (deps.fix) {
957
+ const fixed = [];
958
+ const unfixable = [];
959
+ for (const { name, source } of configs) {
960
+ const spared = parseConfig(source).excludeGlobs.filter((glob) => {
961
+ const dir = dirNameOf(glob);
962
+ return dir !== null && PRUNE_DIR_SEGMENT_REASONS.has(dir);
963
+ });
964
+ const result = buildFixedConfig(files, source, void 0, spared);
965
+ if (!result.fixable) {
966
+ unfixable.push(name);
967
+ } else if (hasRuleChange(source, result.fixed)) {
968
+ deps.writeConfig(name, result.fixed);
969
+ fixed.push(name);
970
+ }
971
+ }
972
+ if (fixed.length > 0) {
973
+ return {
974
+ text: `Fixed ${fixed.join(", ")}. Re-run without --fix to confirm.`,
975
+ exitCode: 0,
976
+ channel: "stdout"
977
+ };
978
+ }
979
+ if (unfixable.length === configs.length) {
980
+ return {
981
+ text: `Nothing to fix \u2014 ${unfixable.join(", ")} ${unfixable.length === 1 ? "has" : "have"} no ContextPruner managed block.`,
982
+ exitCode: 2,
983
+ channel: "stderr"
984
+ };
985
+ }
986
+ const skipped = unfixable.length > 0 ? ` Skipped ${unfixable.join(", ")} (no ContextPruner managed block).` : "";
987
+ return {
988
+ text: `Nothing to fix \u2014 your configs already match the tree.${skipped}`,
989
+ exitCode: 0,
990
+ channel: "stdout"
991
+ };
992
+ }
993
+ const report = reconcile(files, configs);
994
+ const kept = report.findings.filter((f) => !isFalseDead(f));
995
+ const filtered = { ...report, findings: kept };
996
+ const text = deps.json ? JSON.stringify(filtered, null, 2) : formatReport(filtered);
997
+ return { text, exitCode: exitCodeFor(filtered), channel: "stdout" };
998
+ }
999
+
1000
+ // src/index.ts
1001
+ var VERSION = "0.1.0";
1002
+ var HELP = `contextpruner lint \u2014 check your AI-context config against your repo
1003
+
1004
+ Usage:
1005
+ contextpruner lint [--config <path>]... [--json]
1006
+
1007
+ Options:
1008
+ --config <path> A config to lint (repeatable). Default: auto-detect
1009
+ AGENTS.md, CLAUDE.md, GEMINI.md, Copilot, and Cursor rules.
1010
+ --fix Rewrite each config's managed block in place to fix the
1011
+ issues. Your own content outside the block is left alone.
1012
+ --json Emit the full report as JSON.
1013
+ -h, --help Show this help.
1014
+ -v, --version Show the version.
1015
+
1016
+ Auth:
1017
+ export CONTEXTPRUNER_API_KEY=cp_live_... (create one at
1018
+ https://contextpruner.app/account). Only your key is sent \u2014 the file tree
1019
+ never leaves your machine.
1020
+
1021
+ Exit codes: 0 clean \xB7 1 issues found \xB7 2 usage/auth error.`;
1022
+ async function main() {
1023
+ const args = parseArgs(process.argv.slice(2));
1024
+ if (args.command === "help") {
1025
+ console.log(HELP);
1026
+ return 0;
1027
+ }
1028
+ if (args.command === "version") {
1029
+ console.log(VERSION);
1030
+ return 0;
1031
+ }
1032
+ if (args.command !== "lint" || args.problem) {
1033
+ console.error(`contextpruner: ${args.problem ?? "invalid usage"}
1034
+
1035
+ ${HELP}`);
1036
+ return 2;
1037
+ }
1038
+ const cwd = process.cwd();
1039
+ const { text, exitCode, channel } = await runLint({
1040
+ key: process.env.CONTEXTPRUNER_API_KEY,
1041
+ baseUrl: process.env.CONTEXTPRUNER_API_URL ?? "https://contextpruner.app",
1042
+ configPaths: args.configs.length > 0 ? args.configs : DEFAULT_CONFIG_PATHS,
1043
+ json: args.json,
1044
+ fix: args.fix,
1045
+ listTrackedFiles: () => execFileSync("git", ["ls-files", "-z"], {
1046
+ cwd,
1047
+ maxBuffer: 256 * 1024 * 1024
1048
+ }).toString("utf8").split("\0").filter(Boolean),
1049
+ sizeOf: (path) => statSync(join(cwd, path)).size,
1050
+ readConfig: (path) => existsSync(join(cwd, path)) ? readFileSync(join(cwd, path), "utf8") : null,
1051
+ writeConfig: (path, content) => writeFileSync(join(cwd, path), content, "utf8")
1052
+ });
1053
+ if (channel === "stderr") {
1054
+ console.error(text);
1055
+ } else {
1056
+ console.log(text);
1057
+ }
1058
+ return exitCode;
1059
+ }
1060
+ main().then((code) => {
1061
+ process.exitCode = code;
1062
+ }).catch((error) => {
1063
+ const message = error instanceof Error ? error.message : String(error);
1064
+ console.error(`contextpruner: ${message}`);
1065
+ process.exitCode = 2;
1066
+ });
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "contextpruner",
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "AGENTS.md",
7
+ "CLAUDE.md",
8
+ "cursor rules",
9
+ "ai",
10
+ "context",
11
+ "lint"
12
+ ],
13
+ "homepage": "https://contextpruner.app",
14
+ "type": "module",
15
+ "bin": {
16
+ "contextpruner": "dist/index.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md"
21
+ ],
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "scripts": {
26
+ "build": "node build.mjs",
27
+ "prepublishOnly": "node build.mjs"
28
+ },
29
+ "devDependencies": {
30
+ "esbuild": "^0.24.0"
31
+ }
32
+ }