shrinker-ai 0.3.3 → 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/dist/src/cli.js CHANGED
@@ -1,32 +1,37 @@
1
1
  #!/usr/bin/env node
2
- import process from "node:process";
3
2
  import path from "node:path";
3
+ import process from "node:process";
4
4
  import { applyFilter } from "./filters/select-filter.js";
5
5
  import { runCommand } from "./execution/run-command.js";
6
6
  import { getLatestRawOutput, getRawOutput, saveRawOutput } from "./execution/raw-output-store.js";
7
7
  import { cleanText } from "./formatting/ansi.js";
8
8
  import { formatMeasurements, measure } from "./metrics/measure.js";
9
- import { openStatsDashboard, writeStatsDashboard } from "./metrics/dashboard.js";
10
- import { defaultStatsPath, formatStats, formatStatsChart, getStats, recordRun } from "./metrics/stats-store.js";
9
+ import { serveStatsDashboard, startStatsDashboard, writeStatsDashboard } from "./metrics/dashboard.js";
10
+ import { classifyWrappedRun, commandSignature, isCoverageTrackingEnabled } from "./metrics/coverage.js";
11
+ import { defaultStatsPath, formatCoverage, formatStats, formatStatsChart, getStats, recordRun, recordUncovered, } from "./metrics/stats-store.js";
11
12
  function usage() {
12
- return `Usage:
13
- shrinker <command> [args...]
14
- shrinker exec [options] [--] <command> [args...]
15
- shrinker pipe [options]
16
- shrinker stats [--json] [--chart] [--dashboard]
17
- shrinker last [--path]
18
- shrinker raw <capture-id> [--path]
19
- shrinker help
20
-
21
- Options:
22
- --kind <auto|git-status|git-diff|git-log|git-list|npm|tail|find|rg|docker|kubectl|cat|gh|test|log>
23
- --max-lines <number> default: 120
24
- --per-file-lines <number> default: 40
25
- --raw bypass filtering
26
- --metrics print per-run savings and duration
27
- --no-save do not save omitted raw output
28
- --no-stats do not record this run
29
- --dashboard write a browser dashboard to ~/.shrinker/dashboard.html
13
+ return `Usage:
14
+ shrinker <command> [args...]
15
+ shrinker exec [options] [--] <command> [args...]
16
+ shrinker pipe [options]
17
+ shrinker stats [--json] [--chart] [--coverage] [--dashboard] [--restart] [--port <number>]
18
+ shrinker last [--path]
19
+ shrinker raw <capture-id> [--path]
20
+ shrinker track --executable <name> [--subcommand <name>] [--bytes <number>] [--exit-code <number>]
21
+ shrinker help
22
+
23
+ Options:
24
+ --kind <auto|git-status|git-diff|git-log|git-list|npm|tail|find|rg|docker|kubectl|cat|gh|test|log>
25
+ --max-lines <number> default: 120
26
+ --per-file-lines <number> default: 40
27
+ --raw bypass filtering
28
+ --metrics print per-run savings and duration
29
+ --no-save do not save omitted raw output
30
+ --no-stats do not record this run
31
+ --coverage list commands shrinker does not cover yet
32
+ --dashboard serve and open the local dashboard at http://127.0.0.1:4317
33
+ --restart restart the local dashboard server
34
+ --port <number> dashboard server port (default: 4317)
30
35
  --help`;
31
36
  }
32
37
  function parsePositiveInteger(value, option) {
@@ -36,6 +41,20 @@ function parsePositiveInteger(value, option) {
36
41
  }
37
42
  return parsed;
38
43
  }
44
+ function parseNonNegativeInteger(value, option) {
45
+ const parsed = Number(value);
46
+ if (!Number.isInteger(parsed) || parsed < 0) {
47
+ throw new Error(`${option} requires a non-negative integer`);
48
+ }
49
+ return parsed;
50
+ }
51
+ function parseInteger(value, option) {
52
+ const parsed = Number(value);
53
+ if (!Number.isInteger(parsed)) {
54
+ throw new Error(`${option} requires an integer`);
55
+ }
56
+ return parsed;
57
+ }
39
58
  function parseArgs(args) {
40
59
  const first = args[0];
41
60
  let mode;
@@ -48,6 +67,7 @@ function parseArgs(args) {
48
67
  first === "pipe" ||
49
68
  first === "stats" ||
50
69
  first === "last" ||
70
+ first === "track" ||
51
71
  first === "raw") {
52
72
  const reserved = args.shift();
53
73
  mode = reserved === "raw" ? "raw-output" : reserved;
@@ -62,9 +82,17 @@ function parseArgs(args) {
62
82
  let showMetrics = false;
63
83
  let json = false;
64
84
  let chart = false;
85
+ let coverage = false;
65
86
  let dashboard = false;
87
+ let dashboardServer = false;
88
+ let dashboardRestart = false;
89
+ let dashboardPort = 4317;
66
90
  let showPath = false;
67
91
  let captureId;
92
+ let trackExecutable;
93
+ let trackSubcommand;
94
+ let trackBytes;
95
+ let trackExitCode;
68
96
  let maxLines = 120;
69
97
  let perFileLines = 40;
70
98
  while (args.length > 0 && args[0] !== "--") {
@@ -85,8 +113,24 @@ function parseArgs(args) {
85
113
  json = true;
86
114
  else if (option === "--chart" && mode === "stats")
87
115
  chart = true;
116
+ else if (option === "--coverage" && mode === "stats")
117
+ coverage = true;
118
+ else if (option === "--executable" && mode === "track")
119
+ trackExecutable = args.shift();
120
+ else if (option === "--subcommand" && mode === "track")
121
+ trackSubcommand = args.shift();
122
+ else if (option === "--bytes" && mode === "track")
123
+ trackBytes = parseNonNegativeInteger(args.shift(), "--bytes");
124
+ else if (option === "--exit-code" && mode === "track")
125
+ trackExitCode = parseInteger(args.shift(), "--exit-code");
88
126
  else if (option === "--dashboard" && mode === "stats")
89
127
  dashboard = true;
128
+ else if (option === "--dashboard-server" && mode === "stats")
129
+ dashboardServer = true;
130
+ else if (option === "--restart" && mode === "stats")
131
+ dashboardRestart = true;
132
+ else if (option === "--port" && mode === "stats")
133
+ dashboardPort = parsePositiveInteger(args.shift(), "--port");
90
134
  else if (option === "--path" && mode === "last")
91
135
  showPath = true;
92
136
  else if (option === "--path" && mode === "raw-output")
@@ -138,10 +182,16 @@ function parseArgs(args) {
138
182
  throw new Error("exec requires a command");
139
183
  if (mode === "stats" && args.length > 0)
140
184
  throw new Error("stats does not accept command arguments");
185
+ if (dashboardRestart && !dashboard)
186
+ throw new Error("--restart requires stats --dashboard");
141
187
  if (mode === "last" && args.length > 0)
142
188
  throw new Error("last does not accept command arguments");
143
189
  if (mode === "raw-output" && !captureId)
144
190
  throw new Error("raw requires a capture ID");
191
+ if (mode === "track" && !trackExecutable)
192
+ throw new Error("track requires --executable");
193
+ if (mode === "track" && args.length > 0)
194
+ throw new Error("track does not accept command arguments");
145
195
  return {
146
196
  mode,
147
197
  kind,
@@ -151,9 +201,17 @@ function parseArgs(args) {
151
201
  showMetrics,
152
202
  json,
153
203
  chart,
204
+ coverage,
154
205
  dashboard,
206
+ dashboardServer,
207
+ dashboardRestart,
208
+ dashboardPort,
155
209
  showPath,
156
210
  ...(captureId ? { captureId } : {}),
211
+ ...(trackExecutable ? { trackExecutable } : {}),
212
+ ...(trackSubcommand ? { trackSubcommand } : {}),
213
+ ...(trackBytes === undefined ? {} : { trackBytes }),
214
+ ...(trackExitCode === undefined ? {} : { trackExitCode }),
157
215
  maxLines,
158
216
  perFileLines,
159
217
  command: args,
@@ -187,11 +245,13 @@ async function render(rawOutput, options, durationMs, exitCode) {
187
245
  process.stderr.write(`${formatMeasurements(measurements, durationMs)}\n`);
188
246
  }
189
247
  if (options.trackStats) {
248
+ const signature = options.mode === "pipe" ? undefined : commandSignature(options.command);
190
249
  try {
191
250
  recordRun({
192
251
  mode: options.mode === "pipe" ? "pipe" : "exec",
193
252
  filterKind: result.kind,
194
253
  commandName: options.mode === "pipe" ? "stdin" : path.basename(options.command[0] ?? "unknown"),
254
+ ...(signature?.subcommand ? { commandSubcommand: signature.subcommand } : {}),
195
255
  measurements,
196
256
  ...(durationMs === undefined ? {} : { durationMs }),
197
257
  omitted: result.omitted,
@@ -201,6 +261,29 @@ async function render(rawOutput, options, durationMs, exitCode) {
201
261
  catch (error) {
202
262
  process.stderr.write(`[shrinker] could not record stats: ${String(error)}\n`);
203
263
  }
264
+ if (options.mode !== "pipe" && isCoverageTrackingEnabled()) {
265
+ try {
266
+ const reason = classifyWrappedRun({
267
+ matched: result.matched,
268
+ kind: result.kind,
269
+ measurements,
270
+ });
271
+ if (reason && signature) {
272
+ recordUncovered({
273
+ source: "wrapped",
274
+ reason,
275
+ executable: signature.executable,
276
+ ...(signature.subcommand ? { subcommand: signature.subcommand } : {}),
277
+ rawBytes: measurements.rawBytes,
278
+ rawEstimatedTokens: measurements.rawEstimatedTokens,
279
+ ...(exitCode === undefined ? {} : { exitCode }),
280
+ });
281
+ }
282
+ }
283
+ catch (error) {
284
+ process.stderr.write(`[shrinker] could not record coverage: ${String(error)}\n`);
285
+ }
286
+ }
204
287
  }
205
288
  const isWrappedGitLog = options.mode === "exec" && result.kind === "git-log";
206
289
  const shouldSave = result.recovery !== "threshold" && !isWrappedGitLog;
@@ -223,15 +306,51 @@ async function main() {
223
306
  if (options.mode === "stats") {
224
307
  const summary = getStats(defaultStatsPath());
225
308
  if (options.dashboard) {
226
- const dashboardPath = writeStatsDashboard(summary);
227
- process.stdout.write(`Dashboard written to: ${dashboardPath}\n`);
228
- openStatsDashboard(dashboardPath);
309
+ if (options.dashboardServer) {
310
+ await serveStatsDashboard(() => getStats(defaultStatsPath()), options.dashboardPort);
311
+ }
312
+ else {
313
+ writeStatsDashboard(summary);
314
+ const dashboard = await startStatsDashboard(options.dashboardPort, options.dashboardRestart);
315
+ if (dashboard.reused) {
316
+ process.stdout.write(`Dashboard server already running at http://127.0.0.1:${options.dashboardPort}\n`);
317
+ }
318
+ else if (dashboard.restarted) {
319
+ process.stdout.write(`Dashboard server restarted at http://127.0.0.1:${options.dashboardPort} (PID ${dashboard.pid})\n`);
320
+ }
321
+ else {
322
+ process.stdout.write(`Dashboard server started at http://127.0.0.1:${options.dashboardPort} (PID ${dashboard.pid})\n`);
323
+ }
324
+ }
229
325
  return;
230
326
  }
231
- const output = options.json ? JSON.stringify(summary, null, 2) : options.chart ? formatStatsChart(summary) : formatStats(summary);
327
+ const output = options.json
328
+ ? JSON.stringify(summary, null, 2)
329
+ : options.coverage
330
+ ? formatCoverage(summary)
331
+ : options.chart
332
+ ? formatStatsChart(summary)
333
+ : formatStats(summary);
232
334
  process.stdout.write(`${output}\n`);
233
335
  return;
234
336
  }
337
+ if (options.mode === "track") {
338
+ try {
339
+ recordUncovered({
340
+ source: "shell",
341
+ reason: "unlisted-subcommand",
342
+ executable: options.trackExecutable ?? "",
343
+ ...(options.trackSubcommand ? { subcommand: options.trackSubcommand } : {}),
344
+ ...(options.trackBytes === undefined ? {} : { rawBytes: options.trackBytes }),
345
+ ...(options.trackBytes === undefined
346
+ ? {}
347
+ : { rawEstimatedTokens: Math.ceil(options.trackBytes / 4) }),
348
+ ...(options.trackExitCode === undefined ? {} : { exitCode: options.trackExitCode }),
349
+ });
350
+ }
351
+ catch { }
352
+ return;
353
+ }
235
354
  if (options.mode === "last") {
236
355
  const latest = await getLatestRawOutput();
237
356
  if (!latest)
@@ -0,0 +1,42 @@
1
+ import { readFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ export function defaultConfigPath() {
5
+ return process.env['SHRINKER_CONFIG_PATH'] ?? path.join(os.homedir(), ".shrinker", "config");
6
+ }
7
+ // `KEY=value` lines; `#` starts a comment. Unknown keys are ignored so older CLIs tolerate newer files.
8
+ export function readConfig(configPath = defaultConfigPath()) {
9
+ const settings = new Map();
10
+ let contents;
11
+ try {
12
+ contents = readFileSync(configPath, "utf8");
13
+ }
14
+ catch {
15
+ return settings;
16
+ }
17
+ for (const line of contents.split(/\r?\n/)) {
18
+ const withoutComment = line.split("#")[0]?.trim();
19
+ if (!withoutComment)
20
+ continue;
21
+ const separator = withoutComment.indexOf("=");
22
+ if (separator <= 0)
23
+ continue;
24
+ const key = withoutComment.slice(0, separator).trim();
25
+ const value = withoutComment.slice(separator + 1).trim();
26
+ if (key)
27
+ settings.set(key, value);
28
+ }
29
+ return settings;
30
+ }
31
+ // Environment wins so a single command can override the persisted choice.
32
+ export function resolveSetting(key, configPath) {
33
+ const fromEnvironment = process.env[key];
34
+ if (fromEnvironment !== undefined && fromEnvironment.trim() !== "")
35
+ return fromEnvironment;
36
+ return readConfig(configPath).get(key);
37
+ }
38
+ export function isTruthy(value) {
39
+ const normalized = value?.trim().toLowerCase();
40
+ return normalized === "1" || normalized === "true" || normalized === "yes";
41
+ }
42
+ //# sourceMappingURL=config.js.map
@@ -12,9 +12,9 @@ function quoteForCmd(argument) {
12
12
  async function resolveWindowsCommand(command) {
13
13
  if (process.platform !== "win32" || path.extname(command))
14
14
  return command;
15
- const pathEntries = (process.env.PATH ?? "").split(path.delimiter);
15
+ const pathEntries = (process.env['PATH'] ?? "").split(path.delimiter);
16
16
  const executableExtensions = new Set([".com", ".exe", ".bat", ".cmd"]);
17
- const extensions = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
17
+ const extensions = (process.env['PATHEXT'] ?? ".COM;.EXE;.BAT;.CMD")
18
18
  .split(";")
19
19
  .map((extension) => extension.trim().toLowerCase())
20
20
  .filter((extension) => executableExtensions.has(extension));
@@ -31,41 +31,45 @@ const FILTERS = {
31
31
  log: filterGenericLog,
32
32
  };
33
33
  export function detectFilter(command) {
34
+ return detectFilterMatch(command).kind;
35
+ }
36
+ export function detectFilterMatch(command) {
34
37
  const executable = detectExecutable(command);
38
+ const matched = (kind) => ({ kind, matched: true });
35
39
  const gitSubcommand = detectGitSubcommand(command);
36
40
  if (gitSubcommand === "status")
37
- return "git-status";
41
+ return matched("git-status");
38
42
  if (gitSubcommand === "log")
39
- return "git-log";
43
+ return matched("git-log");
40
44
  if (gitSubcommand === "reflog")
41
- return "git-log";
45
+ return matched("git-log");
42
46
  if (gitSubcommand === "diff" || gitSubcommand === "show")
43
- return "git-diff";
47
+ return matched("git-diff");
44
48
  if (gitSubcommand)
45
- return "git-list";
49
+ return matched("git-list");
46
50
  if (executable === "npm" || executable === "pnpm" || executable === "yarn") {
47
51
  if (isTestRunnerCommand(command))
48
- return "test";
49
- return "npm";
52
+ return matched("test");
53
+ return matched("npm");
50
54
  }
51
55
  if (executable === "tail")
52
- return "tail";
56
+ return matched("tail");
53
57
  if (executable === "find")
54
- return "find";
58
+ return matched("find");
55
59
  if (executable === "rg" || executable === "ripgrep")
56
- return "rg";
60
+ return matched("rg");
57
61
  if (executable === "docker")
58
- return "docker";
62
+ return matched("docker");
59
63
  if (executable === "kubectl")
60
- return "kubectl";
64
+ return matched("kubectl");
61
65
  if (executable === "cat")
62
- return "cat";
66
+ return matched("cat");
63
67
  if (executable === "gh")
64
- return "gh";
68
+ return matched("gh");
65
69
  if (isTestRunnerCommand(command)) {
66
- return "test";
70
+ return matched("test");
67
71
  }
68
- return "log";
72
+ return { kind: "log", matched: false };
69
73
  }
70
74
  function detectExecutable(command) {
71
75
  const first = command[0];
@@ -101,7 +105,10 @@ function detectGitSubcommand(command) {
101
105
  return undefined;
102
106
  }
103
107
  export function applyFilter(input, requestedKind, command, options) {
104
- const kind = requestedKind === "auto" ? detectFilter(command) : requestedKind;
108
+ const detection = requestedKind === "auto"
109
+ ? detectFilterMatch(command)
110
+ : { kind: requestedKind, matched: true };
111
+ const kind = detection.kind;
105
112
  const result = FILTERS[kind](input, { ...options, command });
106
113
  const cleanedRaw = cleanText(input);
107
114
  const comparison = measure(cleanedRaw, result.output);
@@ -110,10 +117,11 @@ export function applyFilter(input, requestedKind, command, options) {
110
117
  return {
111
118
  output: cleanedRaw,
112
119
  kind,
120
+ matched: detection.matched,
113
121
  omitted: false,
114
122
  notes: [...result.notes, "compact output was not smaller; returned cleaned raw output"],
115
123
  };
116
124
  }
117
- return result;
125
+ return { ...result, matched: detection.matched };
118
126
  }
119
127
  //# sourceMappingURL=select-filter.js.map
@@ -0,0 +1,67 @@
1
+ import { isTruthy, resolveSetting } from "../config.js";
2
+ const DEFAULT_LOW_REDUCTION_PERCENT = 10;
3
+ const MINIMUM_TRACKED_RAW_TOKENS = 200;
4
+ const TOKEN_PATTERN = /^[a-z0-9][a-z0-9._+-]{0,63}$/;
5
+ const OPTION_VALUE_FLAGS = {
6
+ git: ["-C", "-c", "--git-dir", "--work-tree", "--namespace"],
7
+ npm: ["--prefix", "--cache", "--registry", "--workspace", "--userconfig", "-w", "-C"],
8
+ pnpm: ["--prefix", "--registry", "--workspace", "-w", "-C", "--dir"],
9
+ yarn: ["--cwd", "--registry"],
10
+ docker: ["-H", "--host", "--context", "--config"],
11
+ kubectl: ["-n", "--namespace", "-o", "--output", "--context", "--kubeconfig", "--cluster", "--user"],
12
+ gh: ["-R", "--repo"],
13
+ };
14
+ export function isCoverageTrackingEnabled() {
15
+ return isTruthy(resolveSetting("SHRINKER_TRACK_UNCOVERED"));
16
+ }
17
+ function lowReductionPercent() {
18
+ const configured = Number(process.env['SHRINKER_LOW_REDUCTION_PERCENT']);
19
+ return Number.isFinite(configured) && configured >= 0 && configured <= 100
20
+ ? configured
21
+ : DEFAULT_LOW_REDUCTION_PERCENT;
22
+ }
23
+ export function sanitizeToken(value) {
24
+ if (!value)
25
+ return undefined;
26
+ const normalized = value.trim().toLowerCase();
27
+ return TOKEN_PATTERN.test(normalized) ? normalized : undefined;
28
+ }
29
+ function normalizeExecutable(value) {
30
+ if (!value)
31
+ return undefined;
32
+ const base = value
33
+ .split(/[\\/]/)
34
+ .pop()
35
+ ?.replace(/\.(exe|cmd|bat|ps1)$/i, "");
36
+ return sanitizeToken(base);
37
+ }
38
+ export function commandSignature(command) {
39
+ const executable = normalizeExecutable(command[0]);
40
+ if (!executable)
41
+ return undefined;
42
+ const valueFlags = new Set(OPTION_VALUE_FLAGS[executable] ?? []);
43
+ for (let index = 1; index < command.length; index += 1) {
44
+ const part = command[index];
45
+ if (!part)
46
+ continue;
47
+ if (valueFlags.has(part)) {
48
+ index += 1;
49
+ continue;
50
+ }
51
+ if (part.startsWith("-"))
52
+ continue;
53
+ const subcommand = sanitizeToken(part);
54
+ return subcommand ? { executable, subcommand } : { executable };
55
+ }
56
+ return { executable };
57
+ }
58
+ export function classifyWrappedRun(run) {
59
+ if (run.measurements.rawEstimatedTokens < MINIMUM_TRACKED_RAW_TOKENS)
60
+ return undefined;
61
+ if (!run.matched)
62
+ return "no-filter";
63
+ if (run.measurements.reductionPercent < lowReductionPercent())
64
+ return "low-reduction";
65
+ return undefined;
66
+ }
67
+ //# sourceMappingURL=coverage.js.map