shrinker-ai 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 (35) hide show
  1. package/.copilot-instructions.md +2 -0
  2. package/CLAUDE.md +2 -0
  3. package/README.md +295 -0
  4. package/dist/src/cli.js +265 -0
  5. package/dist/src/execution/raw-output-store.js +132 -0
  6. package/dist/src/execution/run-command.js +172 -0
  7. package/dist/src/filters/cat.js +13 -0
  8. package/dist/src/filters/docker.js +42 -0
  9. package/dist/src/filters/find.js +63 -0
  10. package/dist/src/filters/generic-log.js +38 -0
  11. package/dist/src/filters/gh.js +38 -0
  12. package/dist/src/filters/git-diff.js +56 -0
  13. package/dist/src/filters/git-list.js +25 -0
  14. package/dist/src/filters/git-log.js +256 -0
  15. package/dist/src/filters/git-status.js +67 -0
  16. package/dist/src/filters/kubectl.js +76 -0
  17. package/dist/src/filters/npm.js +44 -0
  18. package/dist/src/filters/rg.js +64 -0
  19. package/dist/src/filters/select-filter.js +119 -0
  20. package/dist/src/filters/table.js +27 -0
  21. package/dist/src/filters/tail.js +6 -0
  22. package/dist/src/filters/test-output.js +54 -0
  23. package/dist/src/filters/types.js +2 -0
  24. package/dist/src/formatting/ansi.js +13 -0
  25. package/dist/src/formatting/limits.js +17 -0
  26. package/dist/src/metrics/dashboard.js +138 -0
  27. package/dist/src/metrics/measure.js +29 -0
  28. package/dist/src/metrics/stats-store.js +166 -0
  29. package/integrations/install-shrinker.ps1 +166 -0
  30. package/integrations/install.ps1 +46 -0
  31. package/integrations/shrinker-profile.ps1 +136 -0
  32. package/integrations/uninstall-shrinker.ps1 +79 -0
  33. package/integrations/uninstall.ps1 +35 -0
  34. package/package.json +31 -0
  35. package/templates/agent-rules.md +19 -0
@@ -0,0 +1,54 @@
1
+ import { cleanText } from "../formatting/ansi.js";
2
+ import { limitLines } from "../formatting/limits.js";
3
+ const IMPORTANT = /\b(fail(?:ed|ure|ing)?|error|exception|panic|expected|received|assert(?:ion)?|×|✕|not ok)\b/i;
4
+ const SUMMARY = /\b(tests?|suites?|passed|failed|skipped|duration|time|snapshots?|collected)\b/i;
5
+ const PASS_LINE = /^\s*(?:✓|✔|ok\b|pass(?:ed)?\b|\.{2,})/i;
6
+ export function filterTestOutput(input, options) {
7
+ const lines = cleanText(input).split("\n");
8
+ const kept = [];
9
+ let passingLines = 0;
10
+ let inFailure = false;
11
+ let failureContext = 0;
12
+ for (const line of lines) {
13
+ if (IMPORTANT.test(line)) {
14
+ inFailure = true;
15
+ failureContext = 8;
16
+ kept.push(line);
17
+ continue;
18
+ }
19
+ if (inFailure && failureContext > 0) {
20
+ if (line.trim() || failureContext >= 6)
21
+ kept.push(line);
22
+ failureContext -= 1;
23
+ if (failureContext === 0)
24
+ inFailure = false;
25
+ continue;
26
+ }
27
+ if (PASS_LINE.test(line)) {
28
+ passingLines += 1;
29
+ }
30
+ else if (SUMMARY.test(line)) {
31
+ kept.push(line);
32
+ }
33
+ }
34
+ if (passingLines > 0) {
35
+ kept.unshift(`[${passingLines} passing-detail lines collapsed]`);
36
+ }
37
+ if (kept.length === 0) {
38
+ return {
39
+ output: lines.join("\n"),
40
+ kind: "test",
41
+ omitted: false,
42
+ notes: ["unrecognized test format; returned cleaned output"],
43
+ };
44
+ }
45
+ const deduplicated = kept.filter((line, index) => index === 0 || line !== kept[index - 1]);
46
+ const limited = limitLines(deduplicated, options.maxLines);
47
+ return {
48
+ output: limited.lines.join("\n"),
49
+ kind: "test",
50
+ omitted: passingLines > 0 || limited.omitted > 0 || kept.length < lines.length,
51
+ notes: passingLines > 0 ? [`collapsed ${passingLines} passing lines`] : [],
52
+ };
53
+ }
54
+ //# sourceMappingURL=test-output.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,13 @@
1
+ const ANSI_PATTERN =
2
+ // eslint-disable-next-line no-control-regex
3
+ /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*)?\u0007)|(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
4
+ export function cleanText(input) {
5
+ return input
6
+ .replace(ANSI_PATTERN, "")
7
+ .replace(/\r(?!\n)/g, "\n")
8
+ .replace(/\r\n/g, "\n")
9
+ .replace(/[ \t]+$/gm, "")
10
+ .replace(/\n{3,}/g, "\n\n")
11
+ .trim();
12
+ }
13
+ //# sourceMappingURL=ansi.js.map
@@ -0,0 +1,17 @@
1
+ export function limitLines(lines, maxLines) {
2
+ if (lines.length <= maxLines) {
3
+ return { lines, omitted: 0 };
4
+ }
5
+ const headCount = Math.ceil(maxLines * 0.65);
6
+ const tailCount = Math.max(1, maxLines - headCount);
7
+ const omitted = lines.length - headCount - tailCount;
8
+ return {
9
+ lines: [
10
+ ...lines.slice(0, headCount),
11
+ `... ${omitted} lines omitted ...`,
12
+ ...lines.slice(-tailCount),
13
+ ],
14
+ omitted,
15
+ };
16
+ }
17
+ //# sourceMappingURL=limits.js.map
@@ -0,0 +1,138 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { spawn } from "node:child_process";
3
+ import path from "node:path";
4
+ function escapeHtml(value) {
5
+ return value
6
+ .replaceAll("&", "&amp;")
7
+ .replaceAll("<", "&lt;")
8
+ .replaceAll(">", "&gt;")
9
+ .replaceAll('"', "&quot;")
10
+ .replaceAll("'", "&#39;");
11
+ }
12
+ function formatInteger(value) {
13
+ return new Intl.NumberFormat("en-US").format(value);
14
+ }
15
+ function makeChart(summary) {
16
+ const width = 960;
17
+ const height = 420;
18
+ const left = 72;
19
+ const right = 28;
20
+ const top = 34;
21
+ const bottom = 62;
22
+ const plotWidth = width - left - right;
23
+ const plotHeight = height - top - bottom;
24
+ const maxSaved = Math.max(1, ...summary.daily.map((row) => row.estimatedTokensSaved));
25
+ const points = summary.daily.map((row, index) => {
26
+ const x = summary.daily.length === 1
27
+ ? left + plotWidth / 2
28
+ : left + (index / (summary.daily.length - 1)) * plotWidth;
29
+ const y = top + plotHeight - (row.estimatedTokensSaved / maxSaved) * plotHeight;
30
+ return { ...row, x, y };
31
+ });
32
+ const grid = Array.from({ length: 5 }, (_, index) => {
33
+ const value = Math.round((maxSaved * (4 - index)) / 4);
34
+ const y = top + (index / 4) * plotHeight;
35
+ return `<line class="grid" x1="${left}" y1="${y}" x2="${width - right}" y2="${y}" />` +
36
+ `<text class="axis-label" x="${left - 12}" y="${y + 4}" text-anchor="end">${formatInteger(value)}</text>`;
37
+ }).join("");
38
+ const labels = points.map((point, index) => {
39
+ if (summary.daily.length > 10 && index % Math.ceil(summary.daily.length / 8) !== 0 && index !== points.length - 1)
40
+ return "";
41
+ return `<text class="axis-label" x="${point.x}" y="${height - 28}" text-anchor="middle">${escapeHtml(point.date.slice(5))}</text>`;
42
+ }).join("");
43
+ const line = points.map((point) => `${point.x},${point.y}`).join(" ");
44
+ const dots = points.map((point) => `<circle class="point" cx="${point.x}" cy="${point.y}" r="4"><title>${escapeHtml(point.date)}: ${formatInteger(point.estimatedTokensSaved)} tokens saved, ${point.runs} runs</title></circle>`).join("");
45
+ if (points.length === 0) {
46
+ return `<div class="empty-chart">No recorded runs in the last 30 days.</div>`;
47
+ }
48
+ return `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Daily estimated tokens saved over the last 30 days">
49
+ <text class="axis-title" x="18" y="${top + plotHeight / 2}" transform="rotate(-90 18 ${top + plotHeight / 2})">Tokens saved</text>
50
+ ${grid}
51
+ <line class="axis" x1="${left}" y1="${top + plotHeight}" x2="${width - right}" y2="${top + plotHeight}" />
52
+ <polyline class="trend" points="${line}" />
53
+ ${dots}
54
+ ${labels}
55
+ <text class="axis-title" x="${left + plotWidth / 2}" y="${height - 6}" text-anchor="middle">Date</text>
56
+ </svg>`;
57
+ }
58
+ export function defaultDashboardPath(databasePath) {
59
+ return path.join(path.dirname(databasePath), "dashboard.html");
60
+ }
61
+ export function writeStatsDashboard(summary, outputPath = defaultDashboardPath(summary.databasePath)) {
62
+ const filters = summary.byFilter.length === 0
63
+ ? `<p class="muted">No filter data yet.</p>`
64
+ : summary.byFilter.map((row) => `<div class="filter-row"><span>${escapeHtml(row.filterKind)}</span><strong>${formatInteger(row.estimatedTokensSaved)}</strong><small>${row.reductionPercent}% reduction</small></div>`).join("");
65
+ const html = `<!doctype html>
66
+ <html lang="en">
67
+ <head>
68
+ <meta charset="utf-8">
69
+ <meta name="viewport" content="width=device-width, initial-scale=1">
70
+ <title>Shrinker stats dashboard</title>
71
+ <style>
72
+ :root { color-scheme: light; --ink: #17202a; --muted: #66727f; --line: #dce3e8; --blue: #2774d9; --blue-soft: #eaf2ff; --green: #16856b; --surface: #ffffff; --background: #f4f7fa; }
73
+ * { box-sizing: border-box; }
74
+ body { margin: 0; color: var(--ink); background: var(--background); font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, sans-serif; }
75
+ main { max-width: 1180px; margin: 0 auto; padding: 42px 28px 56px; }
76
+ .eyebrow { margin: 0 0 8px; color: var(--blue); font-size: 12px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
77
+ h1 { margin: 0; font-size: clamp(28px, 4vw, 44px); letter-spacing: -.03em; }
78
+ .subtitle { margin: 8px 0 30px; color: var(--muted); }
79
+ .cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-bottom: 20px; }
80
+ .card, .panel { border: 1px solid var(--line); border-radius: 8px; background: var(--surface); box-shadow: 0 8px 24px #26394d0d; }
81
+ .card { padding: 18px 20px; }
82
+ .card label { display: block; color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
83
+ .card strong { display: block; margin-top: 6px; font-size: 27px; }
84
+ .panel { padding: 22px; }
85
+ .panel h2 { margin: 0 0 4px; font-size: 18px; }
86
+ .panel p { margin: 0 0 14px; color: var(--muted); }
87
+ .chart { overflow: hidden; }
88
+ svg { display: block; width: 100%; min-width: 620px; height: auto; }
89
+ .chart { overflow-x: auto; }
90
+ .grid { stroke: var(--line); stroke-width: 1; }
91
+ .axis { stroke: #9aa9b5; stroke-width: 1; }
92
+ .axis-label, .axis-title { fill: var(--muted); font-size: 12px; }
93
+ .trend { fill: none; stroke: var(--blue); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
94
+ .point { fill: var(--surface); stroke: var(--blue); stroke-width: 3; }
95
+ .point:hover { fill: var(--blue); r: 6; }
96
+ .lower { display: grid; grid-template-columns: 1.2fr .8fr; gap: 20px; margin-top: 20px; }
97
+ .filter-row { display: grid; grid-template-columns: 1fr auto auto; gap: 18px; align-items: baseline; padding: 11px 0; border-bottom: 1px solid var(--line); }
98
+ .filter-row:last-child { border-bottom: 0; }
99
+ .filter-row small { color: var(--green); }
100
+ .muted { color: var(--muted); }
101
+ @media (max-width: 720px) { main { padding: 28px 16px 40px; } .cards, .lower { grid-template-columns: 1fr; } .card strong { font-size: 24px; } }
102
+ </style>
103
+ </head>
104
+ <body>
105
+ <main>
106
+ <p class="eyebrow">Local activity</p>
107
+ <h1>Shrinker stats</h1>
108
+ <p class="subtitle">Token reduction over the last 30 days</p>
109
+ <section class="cards">
110
+ <div class="card"><label>All-time saved</label><strong>${formatInteger(summary.total.estimatedTokensSaved)}</strong></div>
111
+ <div class="card"><label>Runs this week</label><strong>${formatInteger(summary.last7Days.runs)}</strong></div>
112
+ <div class="card"><label>Average reduction</label><strong>${summary.total.reductionPercent}%</strong></div>
113
+ </section>
114
+ <section class="panel chart"><h2>Tokens saved over time</h2><p>Estimated savings from recorded command runs.</p>${makeChart(summary)}</section>
115
+ <section class="lower">
116
+ <section class="panel"><h2>By filter</h2><p>Where the savings come from.</p>${filters}</section>
117
+ <section class="panel"><h2>Storage</h2><p>Stats stay on this machine.</p><p class="muted">${escapeHtml(summary.databasePath)}</p></section>
118
+ </section>
119
+ </main>
120
+ </body>
121
+ </html>
122
+ `;
123
+ mkdirSync(path.dirname(outputPath), { recursive: true, mode: 0o700 });
124
+ writeFileSync(outputPath, html, "utf8");
125
+ return outputPath;
126
+ }
127
+ export function openStatsDashboard(outputPath) {
128
+ const platform = process.platform;
129
+ if (platform === "win32") {
130
+ const child = spawn("cmd.exe", ["/c", "start", "", outputPath], { detached: true, stdio: "ignore" });
131
+ child.unref();
132
+ return;
133
+ }
134
+ const command = platform === "darwin" ? "open" : "xdg-open";
135
+ const child = spawn(command, [outputPath], { detached: true, stdio: "ignore" });
136
+ child.unref();
137
+ }
138
+ //# sourceMappingURL=dashboard.js.map
@@ -0,0 +1,29 @@
1
+ function estimatedTokens(text) {
2
+ return Math.ceil(text.length / 4);
3
+ }
4
+ export function measure(raw, output) {
5
+ const rawBytes = Buffer.byteLength(raw);
6
+ const outputBytes = Buffer.byteLength(output);
7
+ const rawEstimatedTokens = estimatedTokens(raw);
8
+ const outputEstimatedTokens = estimatedTokens(output);
9
+ const estimatedTokensSaved = Math.max(0, rawEstimatedTokens - outputEstimatedTokens);
10
+ const reductionPercent = rawEstimatedTokens === 0
11
+ ? 0
12
+ : Math.max(0, Math.round((1 - outputEstimatedTokens / rawEstimatedTokens) * 100));
13
+ return {
14
+ rawBytes,
15
+ outputBytes,
16
+ rawEstimatedTokens,
17
+ outputEstimatedTokens,
18
+ estimatedTokensSaved,
19
+ reductionPercent,
20
+ };
21
+ }
22
+ export function formatMeasurements(measurements, durationMs) {
23
+ const duration = durationMs === undefined ? "" : ` | ${durationMs}ms`;
24
+ const gain = measurements.estimatedTokensSaved > 0 && measurements.estimatedTokensSaved < 50
25
+ ? `${measurements.estimatedTokensSaved} saved, small absolute gain`
26
+ : `${measurements.estimatedTokensSaved} saved`;
27
+ return `[shrinker] ${measurements.rawBytes}B -> ${measurements.outputBytes}B | est. tokens ${measurements.rawEstimatedTokens} -> ${measurements.outputEstimatedTokens} (${gain}) | -${measurements.reductionPercent}%${duration}`;
28
+ }
29
+ //# sourceMappingURL=measure.js.map
@@ -0,0 +1,166 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ export function defaultStatsPath() {
6
+ return path.join(os.homedir(), ".shrinker", "stats.db");
7
+ }
8
+ function openDatabase(databasePath) {
9
+ mkdirSync(path.dirname(databasePath), { recursive: true, mode: 0o700 });
10
+ const database = new DatabaseSync(databasePath);
11
+ database.exec(`
12
+ PRAGMA journal_mode = WAL;
13
+ PRAGMA busy_timeout = 2000;
14
+ CREATE TABLE IF NOT EXISTS runs (
15
+ id INTEGER PRIMARY KEY,
16
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
17
+ mode TEXT NOT NULL,
18
+ filter_kind TEXT NOT NULL,
19
+ command_name TEXT NOT NULL,
20
+ raw_bytes INTEGER NOT NULL,
21
+ output_bytes INTEGER NOT NULL,
22
+ raw_estimated_tokens INTEGER NOT NULL,
23
+ output_estimated_tokens INTEGER NOT NULL,
24
+ estimated_tokens_saved INTEGER NOT NULL,
25
+ reduction_percent INTEGER NOT NULL,
26
+ duration_ms INTEGER,
27
+ omitted INTEGER NOT NULL,
28
+ exit_code INTEGER
29
+ );
30
+ CREATE INDEX IF NOT EXISTS runs_created_at_idx ON runs(created_at);
31
+ CREATE INDEX IF NOT EXISTS runs_filter_kind_idx ON runs(filter_kind);
32
+ `);
33
+ return database;
34
+ }
35
+ export function recordRun(statistic, databasePath = defaultStatsPath()) {
36
+ const database = openDatabase(databasePath);
37
+ try {
38
+ database
39
+ .prepare(`
40
+ INSERT INTO runs (
41
+ mode, filter_kind, command_name, raw_bytes, output_bytes,
42
+ raw_estimated_tokens, output_estimated_tokens, estimated_tokens_saved,
43
+ reduction_percent, duration_ms, omitted, exit_code
44
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
45
+ `)
46
+ .run(statistic.mode, statistic.filterKind, statistic.commandName, statistic.measurements.rawBytes, statistic.measurements.outputBytes, statistic.measurements.rawEstimatedTokens, statistic.measurements.outputEstimatedTokens, statistic.measurements.estimatedTokensSaved, statistic.measurements.reductionPercent, statistic.durationMs ?? null, statistic.omitted ? 1 : 0, statistic.exitCode ?? null);
47
+ }
48
+ finally {
49
+ database.close();
50
+ }
51
+ }
52
+ function toStatsRow(row, filterKind = "all") {
53
+ const raw = Number(row.raw_tokens ?? 0);
54
+ const output = Number(row.output_tokens ?? 0);
55
+ return {
56
+ filterKind,
57
+ runs: Number(row.runs ?? 0),
58
+ rawEstimatedTokens: raw,
59
+ outputEstimatedTokens: output,
60
+ estimatedTokensSaved: Number(row.tokens_saved ?? 0),
61
+ reductionPercent: raw === 0 ? 0 : Math.max(0, Math.round((1 - output / raw) * 100)),
62
+ };
63
+ }
64
+ const AGGREGATE = `
65
+ COUNT(*) AS runs,
66
+ COALESCE(SUM(raw_estimated_tokens), 0) AS raw_tokens,
67
+ COALESCE(SUM(output_estimated_tokens), 0) AS output_tokens,
68
+ COALESCE(SUM(estimated_tokens_saved), 0) AS tokens_saved
69
+ `;
70
+ export function getStats(databasePath = defaultStatsPath()) {
71
+ const database = openDatabase(databasePath);
72
+ try {
73
+ const total = database
74
+ .prepare(`SELECT ${AGGREGATE} FROM runs`)
75
+ .get();
76
+ const last7Days = database
77
+ .prepare(`SELECT ${AGGREGATE} FROM runs WHERE created_at >= datetime('now', '-7 days')`)
78
+ .get();
79
+ const byFilter = database
80
+ .prepare(`
81
+ SELECT filter_kind, ${AGGREGATE}
82
+ FROM runs
83
+ GROUP BY filter_kind
84
+ ORDER BY tokens_saved DESC, filter_kind ASC
85
+ `)
86
+ .all();
87
+ const daily = database
88
+ .prepare(`
89
+ SELECT substr(created_at, 1, 10) AS date, ${AGGREGATE}
90
+ FROM runs
91
+ WHERE created_at >= datetime('now', '-30 days')
92
+ GROUP BY substr(created_at, 1, 10)
93
+ ORDER BY date ASC
94
+ `)
95
+ .all();
96
+ return {
97
+ databasePath,
98
+ total: toStatsRow(total),
99
+ last7Days: toStatsRow(last7Days),
100
+ byFilter: byFilter.map((row) => toStatsRow(row, row.filter_kind ?? "unknown")),
101
+ daily: daily.map((row) => ({
102
+ date: row.date ?? "unknown",
103
+ runs: Number(row.runs ?? 0),
104
+ estimatedTokensSaved: Number(row.tokens_saved ?? 0),
105
+ reductionPercent: toStatsRow(row).reductionPercent,
106
+ })),
107
+ };
108
+ }
109
+ finally {
110
+ database.close();
111
+ }
112
+ }
113
+ function formatInteger(value) {
114
+ return new Intl.NumberFormat("en-US").format(value);
115
+ }
116
+ function formatRuns(value) {
117
+ return `${formatInteger(value)} ${value === 1 ? "run" : "runs"}`;
118
+ }
119
+ function formatPercent(value) {
120
+ return `${value}%`;
121
+ }
122
+ function makeBar(value, maxValue, width = 18) {
123
+ if (maxValue <= 0 || value <= 0)
124
+ return "-".repeat(width);
125
+ const filled = Math.max(1, Math.round((value / maxValue) * width));
126
+ return `${"#".repeat(Math.min(width, filled))}${"-".repeat(Math.max(0, width - filled))}`;
127
+ }
128
+ export function formatStats(summary) {
129
+ const allTime = `All time: ${formatRuns(summary.total.runs)} | est. ${formatInteger(summary.total.estimatedTokensSaved)} tokens saved | -${summary.total.reductionPercent}%`;
130
+ const last7 = `Last 7 days: ${formatRuns(summary.last7Days.runs)} | est. ${formatInteger(summary.last7Days.estimatedTokensSaved)} tokens saved | -${summary.last7Days.reductionPercent}%`;
131
+ const lines = [
132
+ "Shrinker Token Savings Dashboard",
133
+ "================================",
134
+ "Overview",
135
+ ` ${allTime}`,
136
+ ` ${last7}`,
137
+ ];
138
+ if (summary.byFilter.length > 0) {
139
+ lines.push("", "By Filter", " Filter Runs Raw Output Saved Reduce Share Savings Bar");
140
+ lines.push(" --------------- ---------- ---------- ---------- ---------- ------- ------ ------------------");
141
+ const maxSaved = Math.max(...summary.byFilter.map((row) => row.estimatedTokensSaved));
142
+ const totalSaved = summary.total.estimatedTokensSaved;
143
+ for (const row of summary.byFilter) {
144
+ const share = totalSaved > 0 ? Math.round((row.estimatedTokensSaved / totalSaved) * 100) : 0;
145
+ lines.push(` ${row.filterKind.padEnd(15)} ${formatRuns(row.runs).padStart(10)} ${formatInteger(row.rawEstimatedTokens).padStart(10)} ${formatInteger(row.outputEstimatedTokens).padStart(10)} ${formatInteger(row.estimatedTokensSaved).padStart(10)} ${`-${formatPercent(row.reductionPercent)}`.padStart(7)} ${formatPercent(share).padStart(6)} ${makeBar(row.estimatedTokensSaved, maxSaved)}`);
146
+ }
147
+ }
148
+ lines.push("", "Storage", ` Database: ${summary.databasePath}`);
149
+ return lines.join("\n");
150
+ }
151
+ export function formatStatsChart(summary) {
152
+ const lines = ["Shrinker Token Savings - Last 30 Days", "====================================="];
153
+ if (summary.daily.length === 0) {
154
+ lines.push("No recorded runs in the last 30 days.");
155
+ return lines.join("\n");
156
+ }
157
+ const maxSaved = Math.max(...summary.daily.map((row) => row.estimatedTokensSaved));
158
+ lines.push("Date Runs Saved Reduction Activity");
159
+ lines.push("---------- ----- ------ --------- ------------------------------");
160
+ for (const row of summary.daily) {
161
+ const bar = makeBar(row.estimatedTokensSaved, maxSaved, 30);
162
+ lines.push(`${row.date.padEnd(10)} ${formatInteger(row.runs).padStart(5)} ${formatInteger(row.estimatedTokensSaved).padStart(6)} ${formatPercent(row.reductionPercent).padStart(9)} ${bar}`);
163
+ }
164
+ return lines.join("\n");
165
+ }
166
+ //# sourceMappingURL=stats-store.js.map
@@ -0,0 +1,166 @@
1
+ param(
2
+ [switch]$SkipNpmInstall,
3
+ [switch]$SkipBuild,
4
+ [switch]$SkipLink,
5
+ [switch]$EnableProfileRouting,
6
+ [switch]$SkipProfile,
7
+ [switch]$SkipAgentRules,
8
+ [switch]$CopilotOnly,
9
+ [switch]$ClaudeOnly,
10
+ [string]$ProfilePath = $PROFILE
11
+ )
12
+
13
+ $ErrorActionPreference = "Stop"
14
+
15
+ if ($EnableProfileRouting -and $SkipProfile) {
16
+ throw "Use either -EnableProfileRouting or -SkipProfile, not both."
17
+ }
18
+ if ($CopilotOnly -and $ClaudeOnly) {
19
+ throw "Use either -CopilotOnly or -ClaudeOnly, not both."
20
+ }
21
+
22
+ $blockStart = "<!-- shrinker agent rules start -->"
23
+ $blockEnd = "<!-- shrinker agent rules end -->"
24
+
25
+ function Set-AgentRules {
26
+ param(
27
+ [string]$RepoRoot,
28
+ [string]$Body
29
+ )
30
+
31
+ $targets = @()
32
+ if (-not $ClaudeOnly) { $targets += (Join-Path $RepoRoot ".copilot-instructions.md") }
33
+ if (-not $CopilotOnly) { $targets += (Join-Path $RepoRoot "CLAUDE.md") }
34
+
35
+ foreach ($target in $targets) {
36
+ $content = if (Test-Path $target) { Get-Content -Path $target -Raw } else { "" }
37
+ $newBlock = "$blockStart`n$Body`n$blockEnd"
38
+ if ($content -and $content.Contains($blockStart) -and $content.Contains($blockEnd)) {
39
+ $pattern = [regex]::Escape($blockStart) + ".*?" + [regex]::Escape($blockEnd)
40
+ $content = [regex]::Replace($content, $pattern, $newBlock, [System.Text.RegularExpressions.RegexOptions]::Singleline)
41
+ Set-Content -Path $target -Value $content
42
+ } elseif ($content) {
43
+ Add-Content -Path $target -Value "`n$newBlock`n"
44
+ } else {
45
+ Set-Content -Path $target -Value "$newBlock`n"
46
+ }
47
+ Write-Host "Installed managed rules in: $target"
48
+ }
49
+ }
50
+
51
+ function Test-NodeVersion {
52
+ $versionText = & node -v 2>$null
53
+ if (-not $versionText) {
54
+ throw "Node.js was not found on PATH. Install Node.js 22.13+ first."
55
+ }
56
+
57
+ $match = [regex]::Match($versionText, "^v(\d+)\.(\d+)\.(\d+)$")
58
+ if (-not $match.Success) {
59
+ throw "Could not parse Node.js version: $versionText"
60
+ }
61
+
62
+ $major = [int]$match.Groups[1].Value
63
+ $minor = [int]$match.Groups[2].Value
64
+ $patch = [int]$match.Groups[3].Value
65
+
66
+ if ($major -lt 22 -or ($major -eq 22 -and $minor -lt 13)) {
67
+ throw "Node.js 22.13+ is required. Found $versionText"
68
+ }
69
+
70
+ return $versionText
71
+ }
72
+
73
+ function Add-ProfileIntegration {
74
+ param(
75
+ [string]$ProfileFile,
76
+ [string]$IntegrationFile
77
+ )
78
+
79
+ if (-not (Test-Path $ProfileFile)) {
80
+ New-Item -ItemType File -Path $ProfileFile -Force | Out-Null
81
+ }
82
+
83
+ $startMarker = "# >>> shrinker integration >>>"
84
+ $endMarker = "# <<< shrinker integration <<<"
85
+
86
+ $existing = Get-Content -Path $ProfileFile -Raw -ErrorAction SilentlyContinue
87
+ if ($existing -and $existing.Contains($startMarker)) {
88
+ Write-Host "Profile already contains shrinker integration block: $ProfileFile"
89
+ return
90
+ }
91
+
92
+ $block = @"
93
+ $startMarker
94
+ . "$IntegrationFile"
95
+ $endMarker
96
+ "@
97
+
98
+ Add-Content -Path $ProfileFile -Value "`n$block`n"
99
+ Write-Host "Added shrinker integration block to profile: $ProfileFile"
100
+ }
101
+
102
+ $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
103
+ $templatePath = Join-Path $scriptDir "..\templates\agent-rules.md"
104
+ $repoRoot = Resolve-Path (Join-Path $scriptDir "..")
105
+ $integrationPath = (Resolve-Path (Join-Path $scriptDir "shrinker-profile.ps1")).Path
106
+
107
+ if (-not $SkipAgentRules) {
108
+ if (-not (Test-Path $templatePath)) {
109
+ throw "Agent rules template not found: $templatePath"
110
+ }
111
+ $rulesBody = Get-Content -Path $templatePath -Raw
112
+ }
113
+
114
+ Write-Host "Installing shrinker from: $repoRoot"
115
+ $nodeVersion = Test-NodeVersion
116
+ Write-Host "Detected Node.js: $nodeVersion"
117
+
118
+ Push-Location $repoRoot
119
+ try {
120
+ if (-not $SkipNpmInstall) {
121
+ Write-Host "Running npm install..."
122
+ & npm install
123
+ if ($LASTEXITCODE -ne 0) {
124
+ throw "npm install failed with exit code $LASTEXITCODE"
125
+ }
126
+ }
127
+
128
+ if (-not $SkipBuild) {
129
+ Write-Host "Running npm run build..."
130
+ & npm run build --silent
131
+ if ($LASTEXITCODE -ne 0) {
132
+ throw "npm run build failed with exit code $LASTEXITCODE"
133
+ }
134
+ }
135
+
136
+ if (-not $SkipLink) {
137
+ Write-Host "Running npm link..."
138
+ & npm link
139
+ if ($LASTEXITCODE -ne 0) {
140
+ throw "npm link failed with exit code $LASTEXITCODE"
141
+ }
142
+ }
143
+ }
144
+ finally {
145
+ Pop-Location
146
+ }
147
+
148
+ if (-not $SkipProfile) {
149
+ if ($EnableProfileRouting) {
150
+ Add-ProfileIntegration -ProfileFile $ProfilePath -IntegrationFile $integrationPath
151
+ Write-Host "Reload your profile with: . `$PROFILE"
152
+ }
153
+ else {
154
+ Write-Host "Profile routing not enabled (default). Native commands remain unchanged."
155
+ Write-Host "To enable routing later: pwsh -ExecutionPolicy Bypass -File .\integrations\install-shrinker.ps1 -SkipNpmInstall -SkipBuild -SkipLink -EnableProfileRouting"
156
+ }
157
+ }
158
+ else {
159
+ Write-Host "Profile routing skipped via -SkipProfile. Native commands remain unchanged."
160
+ }
161
+
162
+ if (-not $SkipAgentRules) {
163
+ Set-AgentRules -RepoRoot (Get-Location).Path -Body $rulesBody
164
+ }
165
+
166
+ Write-Host "Install complete. Try: shrinker help"
@@ -0,0 +1,46 @@
1
+ param(
2
+ [string]$PackageName = "shrinker-ai",
3
+ [string]$Registry = "https://registry.npmjs.org",
4
+ [string]$Version,
5
+ [switch]$EnableProfileRouting,
6
+ [switch]$SkipAgentRules,
7
+ [switch]$CopilotOnly,
8
+ [switch]$ClaudeOnly
9
+ )
10
+
11
+ $ErrorActionPreference = "Stop"
12
+
13
+ if ($CopilotOnly -and $ClaudeOnly) {
14
+ throw "Use either -CopilotOnly or -ClaudeOnly, not both."
15
+ }
16
+
17
+ $packageSpec = if ($Version) { "$PackageName@$Version" } else { $PackageName }
18
+ Write-Host "Installing $packageSpec from $Registry..."
19
+ & npm install --global $packageSpec "--registry=$Registry"
20
+ if ($LASTEXITCODE -ne 0) {
21
+ throw "npm package installation failed with exit code $LASTEXITCODE"
22
+ }
23
+
24
+ $globalRoot = (& npm root --global).Trim()
25
+ if (-not $globalRoot) {
26
+ throw "Could not determine the global npm package directory."
27
+ }
28
+
29
+ $packageRoot = Join-Path $globalRoot ($PackageName -replace '/', '\')
30
+ $localInstaller = Join-Path $packageRoot "integrations\install-shrinker.ps1"
31
+ if (-not (Test-Path $localInstaller)) {
32
+ throw "Installed package integration not found: $localInstaller"
33
+ }
34
+
35
+ $localArgs = @("-ExecutionPolicy", "Bypass", "-File", $localInstaller, "-SkipNpmInstall", "-SkipBuild", "-SkipLink")
36
+ if ($EnableProfileRouting) { $localArgs += "-EnableProfileRouting" }
37
+ if ($SkipAgentRules) { $localArgs += "-SkipAgentRules" }
38
+ if ($CopilotOnly) { $localArgs += "-CopilotOnly" }
39
+ if ($ClaudeOnly) { $localArgs += "-ClaudeOnly" }
40
+
41
+ & pwsh @localArgs
42
+ if ($LASTEXITCODE -ne 0) {
43
+ throw "Installed package integration failed with exit code $LASTEXITCODE"
44
+ }
45
+
46
+ Write-Host "Package installation complete. Try: shrinker help"