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.
- package/.copilot-instructions.md +2 -0
- package/CLAUDE.md +2 -0
- package/README.md +295 -0
- package/dist/src/cli.js +265 -0
- package/dist/src/execution/raw-output-store.js +132 -0
- package/dist/src/execution/run-command.js +172 -0
- package/dist/src/filters/cat.js +13 -0
- package/dist/src/filters/docker.js +42 -0
- package/dist/src/filters/find.js +63 -0
- package/dist/src/filters/generic-log.js +38 -0
- package/dist/src/filters/gh.js +38 -0
- package/dist/src/filters/git-diff.js +56 -0
- package/dist/src/filters/git-list.js +25 -0
- package/dist/src/filters/git-log.js +256 -0
- package/dist/src/filters/git-status.js +67 -0
- package/dist/src/filters/kubectl.js +76 -0
- package/dist/src/filters/npm.js +44 -0
- package/dist/src/filters/rg.js +64 -0
- package/dist/src/filters/select-filter.js +119 -0
- package/dist/src/filters/table.js +27 -0
- package/dist/src/filters/tail.js +6 -0
- package/dist/src/filters/test-output.js +54 -0
- package/dist/src/filters/types.js +2 -0
- package/dist/src/formatting/ansi.js +13 -0
- package/dist/src/formatting/limits.js +17 -0
- package/dist/src/metrics/dashboard.js +138 -0
- package/dist/src/metrics/measure.js +29 -0
- package/dist/src/metrics/stats-store.js +166 -0
- package/integrations/install-shrinker.ps1 +166 -0
- package/integrations/install.ps1 +46 -0
- package/integrations/shrinker-profile.ps1 +136 -0
- package/integrations/uninstall-shrinker.ps1 +79 -0
- package/integrations/uninstall.ps1 +35 -0
- package/package.json +31 -0
- package/templates/agent-rules.md +19 -0
package/CLAUDE.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# shrinker
|
|
2
|
+
|
|
3
|
+
A small local CLI proof of concept that removes noise from command output before a coding agent or LLM reads it.
|
|
4
|
+
|
|
5
|
+
this POC proves that a few conservative, deterministic filters can save useful context without requiring an API key, service, UI, database, or agent-specific integration.
|
|
6
|
+
|
|
7
|
+
## What the POC demonstrates
|
|
8
|
+
|
|
9
|
+
- Explicit cross-agent command wrapping: `shrinker exec -- <command>`.
|
|
10
|
+
- Filters for Git status, Git diff, test output, and generic logs.
|
|
11
|
+
- Failures, warnings, changed paths, and command exit codes are preserved.
|
|
12
|
+
- Omitted raw output is saved locally for recovery.
|
|
13
|
+
- Optional per-run metrics report bytes and approximate before/after tokens.
|
|
14
|
+
- Local SQLite statistics accumulate savings across runs.
|
|
15
|
+
- No command output leaves the machine.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
Requires Node.js 22.13 or newer. This is the first Node 22 release where the built-in SQLite module no longer requires an experimental flag.
|
|
20
|
+
|
|
21
|
+
### One-command install (Windows PowerShell)
|
|
22
|
+
|
|
23
|
+
Quick one-liner:
|
|
24
|
+
|
|
25
|
+
```powershell
|
|
26
|
+
irm https://raw.githubusercontent.com/ivanduplenskikh/shrinker/main/integrations/install.ps1 | iex
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Package install from npm
|
|
30
|
+
|
|
31
|
+
The installer downloads the published package from the public npm registry, then installs its local integration files.
|
|
32
|
+
|
|
33
|
+
```powershell
|
|
34
|
+
irm https://raw.githubusercontent.com/ivanduplenskikh/shrinker/main/integrations/install.ps1 | iex
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This script installs `shrinker-ai` from `https://registry.npmjs.org` and installs Copilot/Claude rules by default.
|
|
38
|
+
|
|
39
|
+
To enable automatic PowerShell routing (so `git log` routes through `shrinker`), download the script and pass the option:
|
|
40
|
+
|
|
41
|
+
```powershell
|
|
42
|
+
$tmp = Join-Path $env:TEMP "install-shrinker.ps1"
|
|
43
|
+
Invoke-WebRequest "https://raw.githubusercontent.com/ivanduplenskikh/shrinker/main/integrations/install.ps1" -OutFile $tmp
|
|
44
|
+
pwsh -ExecutionPolicy Bypass -File $tmp -EnableProfileRouting
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For a local checkout, use the complete installer without profile routing:
|
|
48
|
+
|
|
49
|
+
```powershell
|
|
50
|
+
pwsh -ExecutionPolicy Bypass -File .\integrations\install-shrinker.ps1
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This writes managed guidance blocks to:
|
|
54
|
+
|
|
55
|
+
- `.copilot-instructions.md`
|
|
56
|
+
- `CLAUDE.md`
|
|
57
|
+
|
|
58
|
+
The shared guidance source is `templates/agent-rules.md`; the two files above are generated in the target project.
|
|
59
|
+
|
|
60
|
+
The rules tell agents to prefer `shrinker <command>` for high-volume commands while leaving native commands untouched.
|
|
61
|
+
|
|
62
|
+
### Remote package uninstall
|
|
63
|
+
|
|
64
|
+
Quick one-liner:
|
|
65
|
+
|
|
66
|
+
```powershell
|
|
67
|
+
irm https://raw.githubusercontent.com/ivanduplenskikh/shrinker/main/integrations/uninstall.ps1 | iex
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Local repo install/uninstall (contributors)
|
|
71
|
+
|
|
72
|
+
If you cloned this repository and want to run scripts directly from the local path:
|
|
73
|
+
|
|
74
|
+
```powershell
|
|
75
|
+
pwsh -ExecutionPolicy Bypass -File .\integrations\install-shrinker.ps1
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
To uninstall:
|
|
79
|
+
|
|
80
|
+
```powershell
|
|
81
|
+
pwsh -ExecutionPolicy Bypass -File .\integrations\uninstall-shrinker.ps1
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Uninstall options:
|
|
85
|
+
|
|
86
|
+
```powershell
|
|
87
|
+
# Remove only profile routing, keep shrinker command installed
|
|
88
|
+
pwsh -ExecutionPolicy Bypass -File .\integrations\uninstall-shrinker.ps1 -SkipUnlink
|
|
89
|
+
|
|
90
|
+
# Remove managed Copilot/Claude rules block only
|
|
91
|
+
pwsh -ExecutionPolicy Bypass -File .\integrations\uninstall-shrinker.ps1 -SkipUnlink
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
If your current terminal had already loaded `shrinker-profile.ps1`, restart terminal (or remove loaded wrapper functions) to fully return to native command behavior.
|
|
95
|
+
|
|
96
|
+
```powershell
|
|
97
|
+
npm install
|
|
98
|
+
npm run build
|
|
99
|
+
node dist\src\cli.js exec -- git status
|
|
100
|
+
node dist\src\cli.js exec -- git diff
|
|
101
|
+
node dist\src\cli.js exec -- git log -n 10
|
|
102
|
+
node dist\src\cli.js exec -- npm test
|
|
103
|
+
Get-Content .\tests\fixtures\generic-log.txt -Raw |
|
|
104
|
+
node dist\src\cli.js pipe --kind log
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
To install the `shrinker` command locally:
|
|
108
|
+
|
|
109
|
+
```powershell
|
|
110
|
+
npm link
|
|
111
|
+
shrinker git status
|
|
112
|
+
shrinker git log -n 10
|
|
113
|
+
shrinker npm test
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## CLI
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
shrinker <command> [args...]
|
|
120
|
+
shrinker exec [options] [--] <command> [args...]
|
|
121
|
+
shrinker pipe [options]
|
|
122
|
+
shrinker stats [--json]
|
|
123
|
+
shrinker last [--path]
|
|
124
|
+
shrinker raw <capture-id> [--path]
|
|
125
|
+
shrinker help
|
|
126
|
+
|
|
127
|
+
--kind <auto|git-status|git-diff|git-log|test|log>
|
|
128
|
+
--max-lines <number> default: 120
|
|
129
|
+
--per-file-lines <number> default: 40
|
|
130
|
+
--raw bypass filtering
|
|
131
|
+
--metrics print per-run savings and duration
|
|
132
|
+
--no-save do not save omitted raw output
|
|
133
|
+
--no-stats do not record this run
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`help`, `stats`, `last`, `raw`, `pipe`, and `exec` are reserved shrinker commands. Every other top-level token starts a wrapped command, so `shrinker git log` is equivalent to `shrinker exec git log`. The `--` separator remains optional because npm's PowerShell shim may consume it. `pipe` reads existing text from stdin and defaults to the generic log filter unless `--kind` is specified.
|
|
137
|
+
|
|
138
|
+
## Automatic PowerShell routing
|
|
139
|
+
|
|
140
|
+
The optional profile integration routes allowlisted commands through `shrinker` and invokes the native executable for everything else. Install it after `npm link`:
|
|
141
|
+
|
|
142
|
+
```powershell
|
|
143
|
+
if (!(Test-Path $PROFILE)) {
|
|
144
|
+
New-Item -ItemType File -Path $PROFILE -Force | Out-Null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
$integration = (Resolve-Path .\integrations\shrinker-profile.ps1).Path
|
|
148
|
+
Add-Content $PROFILE "`n. `"$integration`""
|
|
149
|
+
. $PROFILE
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The default rules are:
|
|
153
|
+
|
|
154
|
+
```text
|
|
155
|
+
git status -> shrinker git status
|
|
156
|
+
git diff -> shrinker git diff
|
|
157
|
+
git log -> shrinker git log
|
|
158
|
+
npm test -> shrinker npm test
|
|
159
|
+
docker ps -> shrinker docker ps
|
|
160
|
+
kubectl get -> shrinker kubectl get
|
|
161
|
+
gh pr list -> shrinker gh pr list
|
|
162
|
+
rg/find/tail/cat/ls/dir -> shrinker <command>
|
|
163
|
+
|
|
164
|
+
git push, git fetch, and all other commands -> native executable
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Edit `$global:ShrinkPowerShellRules` in `integrations\shrinker-profile.ps1` to change the allowlist. The router is now option-aware for common global flags, so forms like `git -C <path> log` and `kubectl --context prod get pods` are routed correctly.
|
|
168
|
+
|
|
169
|
+
## Savings statistics
|
|
170
|
+
|
|
171
|
+
Filtered runs are recorded locally in `~/.shrinker/stats.db`. The database stores only measurements, filter kind, executable basename, duration, omission state, and exit code. It does **not** store command arguments or command output.
|
|
172
|
+
|
|
173
|
+
```powershell
|
|
174
|
+
node dist\src\cli.js stats
|
|
175
|
+
node dist\src\cli.js stats --json
|
|
176
|
+
node dist\src\cli.js stats --chart
|
|
177
|
+
node dist\src\cli.js stats --dashboard
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The summary shows all-time and last-seven-day savings plus a breakdown by filter. Use `--no-stats` before `--` to opt out for an individual run:
|
|
181
|
+
|
|
182
|
+
`stats --chart` shows daily runs, estimated tokens saved, reduction percentage, and an activity bar for the last 30 days.
|
|
183
|
+
`stats --dashboard` writes a self-contained browser dashboard to `~/.shrinker/dashboard.html` with a line chart and filter breakdown.
|
|
184
|
+
|
|
185
|
+
```powershell
|
|
186
|
+
node dist\src\cli.js exec --no-stats -- git log -n 10
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Detailed per-run measurements are hidden by default so agents do not spend tokens reading wrapper telemetry. Enable them for benchmarking or demos:
|
|
190
|
+
|
|
191
|
+
```powershell
|
|
192
|
+
shrinker --metrics git log -n 10
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
When meaningful content is omitted, the full capture is saved under `~/.shrinker/raw` and a compact exact-recovery hint such as `[full: shrinker raw ab12cd34]` is printed instead of an absolute path. Retrieve it only when needed:
|
|
196
|
+
|
|
197
|
+
```powershell
|
|
198
|
+
shrinker raw ab12cd34
|
|
199
|
+
shrinker raw ab12cd34 --path
|
|
200
|
+
shrinker last
|
|
201
|
+
shrinker last --path
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`raw` retrieves the exact capture referenced by a hint; `last` is a convenience for human use. The cache uses atomic publication and best-effort rotation to retain up to 20 recent files. File names contain only the executable name, not command arguments. Wrapped `git log` output never creates a recovery file or hint because the full history can be reproduced by rerunning Git; piped Git-log text still gets a recovery hint when meaningful content is omitted. Use `--no-save` for other output that should not be persisted.
|
|
205
|
+
|
|
206
|
+
## Demo
|
|
207
|
+
|
|
208
|
+
```powershell
|
|
209
|
+
npm run demo
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Current representative fixtures:
|
|
213
|
+
|
|
214
|
+
| Output | Estimated token reduction |
|
|
215
|
+
|---|---:|
|
|
216
|
+
| Git status | 62% |
|
|
217
|
+
| Git diff | 26% |
|
|
218
|
+
| Git log with commit bodies | 39% |
|
|
219
|
+
| Git log with one short commit | 69%, but only 27 estimated tokens |
|
|
220
|
+
| Test failure | 51% |
|
|
221
|
+
| Noisy log | 39% |
|
|
222
|
+
| **Average** | **48%** |
|
|
223
|
+
|
|
224
|
+
The token estimate uses `ceil(characters / 4)`. It is suitable for relative before/after comparisons, not billing claims. Byte counts and absolute estimated tokens saved are also reported; gains below 50 tokens are labeled as small.
|
|
225
|
+
|
|
226
|
+
## Architecture
|
|
227
|
+
|
|
228
|
+
```text
|
|
229
|
+
command/stdin
|
|
230
|
+
|
|
|
231
|
+
v
|
|
232
|
+
capture output + exit code
|
|
233
|
+
|
|
|
234
|
+
v
|
|
235
|
+
select deterministic filter
|
|
236
|
+
|
|
|
237
|
+
+--> git status: group files by state
|
|
238
|
+
+--> git diff: retain changed lines, drop metadata/context
|
|
239
|
+
+--> git log: retain short hash, refs, subject, author, date,
|
|
240
|
+
| and up to three useful body lines
|
|
241
|
+
+--> tests: collapse passes, retain failures and summaries
|
|
242
|
+
+--> logs: collapse progress and repeated lines
|
|
243
|
+
|
|
|
244
|
+
v
|
|
245
|
+
compact output + optional metrics + meaningful-omission recovery hint
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Filters are pure functions, so the same pipeline can later sit behind a GitHub Copilot hook or MCP server without rewriting the compression logic.
|
|
249
|
+
|
|
250
|
+
## Safety and limitations
|
|
251
|
+
|
|
252
|
+
- The tool does not execute through a shell. Compound shell expressions and interactive commands are out of scope.
|
|
253
|
+
- Stdout and stderr are captured separately and presented as stdout followed by stderr; exact interleaving is not preserved.
|
|
254
|
+
- Filtering is conservative, but any lossy transform can hide useful context. The recovery file and `--raw` are escape hatches.
|
|
255
|
+
- Git log patch/stat/name-list flags and explicit custom formats are preserved rather than destructively reinterpreted.
|
|
256
|
+
- Git log does not impose hidden commit limits or suppress merge commits.
|
|
257
|
+
- This measures command-output reduction, not total Copilot usage, total conversation context, or billing.
|
|
258
|
+
- Streaming, agent hooks, MCP, telemetry, dashboards, custom configuration, and a broad command registry are deliberately deferred.
|
|
259
|
+
|
|
260
|
+
## Validation
|
|
261
|
+
|
|
262
|
+
```powershell
|
|
263
|
+
npm test
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Tests cover information retention, reduction targets, ANSI cleanup, filter selection, command capture, and non-zero exit-code propagation.
|
|
267
|
+
|
|
268
|
+
## Release to npm
|
|
269
|
+
|
|
270
|
+
A GitHub Actions workflow publishes this CLI to npm:
|
|
271
|
+
|
|
272
|
+
- Workflow file: `.github/workflows/publish-npm.yml`
|
|
273
|
+
- Triggers:
|
|
274
|
+
- Tag push matching `v*` (for example `v0.2.0`)
|
|
275
|
+
- Manual run via workflow_dispatch
|
|
276
|
+
- Pipeline steps:
|
|
277
|
+
- `npm ci`
|
|
278
|
+
- `npm test`
|
|
279
|
+
- `npm publish` to `https://registry.npmjs.org`
|
|
280
|
+
|
|
281
|
+
How to publish:
|
|
282
|
+
|
|
283
|
+
1. Push your changes to GitHub.
|
|
284
|
+
2. Push a version tag (or run the workflow manually):
|
|
285
|
+
- `git tag v0.2.0`
|
|
286
|
+
- `git push origin v0.2.0`
|
|
287
|
+
3. After the workflow succeeds, install from npm:
|
|
288
|
+
- `npm install -g shrinker-ai`
|
|
289
|
+
|
|
290
|
+
## Suggested roadmap
|
|
291
|
+
|
|
292
|
+
1. Validate the POC with real Copilot/Agency workflows and identify the highest-volume commands.
|
|
293
|
+
2. Add a GitHub Copilot pre-tool hook for transparent rewriting.
|
|
294
|
+
3. Expose the executor and filters through MCP for other agents.
|
|
295
|
+
4. Add filters only when measured usage justifies them.
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { applyFilter } from "./filters/select-filter.js";
|
|
5
|
+
import { runCommand } from "./execution/run-command.js";
|
|
6
|
+
import { getLatestRawOutput, getRawOutput, saveRawOutput } from "./execution/raw-output-store.js";
|
|
7
|
+
import { cleanText } from "./formatting/ansi.js";
|
|
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";
|
|
11
|
+
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
|
|
30
|
+
--help`;
|
|
31
|
+
}
|
|
32
|
+
function parsePositiveInteger(value, option) {
|
|
33
|
+
const parsed = Number(value);
|
|
34
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
35
|
+
throw new Error(`${option} requires a positive integer`);
|
|
36
|
+
}
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
function parseArgs(args) {
|
|
40
|
+
const first = args[0];
|
|
41
|
+
let mode;
|
|
42
|
+
if (!first || first === "help" || first === "--help" || first === "-h") {
|
|
43
|
+
if (first)
|
|
44
|
+
args.shift();
|
|
45
|
+
mode = "help";
|
|
46
|
+
}
|
|
47
|
+
else if (first === "exec" ||
|
|
48
|
+
first === "pipe" ||
|
|
49
|
+
first === "stats" ||
|
|
50
|
+
first === "last" ||
|
|
51
|
+
first === "raw") {
|
|
52
|
+
const reserved = args.shift();
|
|
53
|
+
mode = reserved === "raw" ? "raw-output" : reserved;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
mode = "exec";
|
|
57
|
+
}
|
|
58
|
+
let kind = "auto";
|
|
59
|
+
let raw = false;
|
|
60
|
+
let save = true;
|
|
61
|
+
let trackStats = true;
|
|
62
|
+
let showMetrics = false;
|
|
63
|
+
let json = false;
|
|
64
|
+
let chart = false;
|
|
65
|
+
let dashboard = false;
|
|
66
|
+
let showPath = false;
|
|
67
|
+
let captureId;
|
|
68
|
+
let maxLines = 120;
|
|
69
|
+
let perFileLines = 40;
|
|
70
|
+
while (args.length > 0 && args[0] !== "--") {
|
|
71
|
+
const option = args.shift();
|
|
72
|
+
if (option === "--help" || option === "-h") {
|
|
73
|
+
mode = "help";
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
if (option === "--raw")
|
|
77
|
+
raw = true;
|
|
78
|
+
else if (option === "--metrics")
|
|
79
|
+
showMetrics = true;
|
|
80
|
+
else if (option === "--no-save")
|
|
81
|
+
save = false;
|
|
82
|
+
else if (option === "--no-stats")
|
|
83
|
+
trackStats = false;
|
|
84
|
+
else if (option === "--json" && mode === "stats")
|
|
85
|
+
json = true;
|
|
86
|
+
else if (option === "--chart" && mode === "stats")
|
|
87
|
+
chart = true;
|
|
88
|
+
else if (option === "--dashboard" && mode === "stats")
|
|
89
|
+
dashboard = true;
|
|
90
|
+
else if (option === "--path" && mode === "last")
|
|
91
|
+
showPath = true;
|
|
92
|
+
else if (option === "--path" && mode === "raw-output")
|
|
93
|
+
showPath = true;
|
|
94
|
+
else if (mode === "raw-output" && option && !option.startsWith("-") && !captureId) {
|
|
95
|
+
captureId = option;
|
|
96
|
+
}
|
|
97
|
+
else if (option === "--kind") {
|
|
98
|
+
const value = args.shift();
|
|
99
|
+
if (!value ||
|
|
100
|
+
![
|
|
101
|
+
"auto",
|
|
102
|
+
"git-status",
|
|
103
|
+
"git-diff",
|
|
104
|
+
"git-log",
|
|
105
|
+
"git-list",
|
|
106
|
+
"npm",
|
|
107
|
+
"tail",
|
|
108
|
+
"find",
|
|
109
|
+
"rg",
|
|
110
|
+
"docker",
|
|
111
|
+
"kubectl",
|
|
112
|
+
"cat",
|
|
113
|
+
"gh",
|
|
114
|
+
"test",
|
|
115
|
+
"log",
|
|
116
|
+
].includes(value)) {
|
|
117
|
+
throw new Error("--kind must be auto, git-status, git-diff, git-log, git-list, npm, tail, find, rg, docker, kubectl, cat, gh, test, or log");
|
|
118
|
+
}
|
|
119
|
+
kind = value;
|
|
120
|
+
}
|
|
121
|
+
else if (option === "--max-lines") {
|
|
122
|
+
maxLines = parsePositiveInteger(args.shift(), "--max-lines");
|
|
123
|
+
}
|
|
124
|
+
else if (option === "--per-file-lines") {
|
|
125
|
+
perFileLines = parsePositiveInteger(args.shift(), "--per-file-lines");
|
|
126
|
+
}
|
|
127
|
+
else if (mode === "exec" && option && !option.startsWith("-")) {
|
|
128
|
+
args.unshift(option);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
throw new Error(`Unknown option: ${option}\n\n${usage()}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (args[0] === "--")
|
|
136
|
+
args.shift();
|
|
137
|
+
if (mode === "exec" && args.length === 0)
|
|
138
|
+
throw new Error("exec requires a command");
|
|
139
|
+
if (mode === "stats" && args.length > 0)
|
|
140
|
+
throw new Error("stats does not accept command arguments");
|
|
141
|
+
if (mode === "last" && args.length > 0)
|
|
142
|
+
throw new Error("last does not accept command arguments");
|
|
143
|
+
if (mode === "raw-output" && !captureId)
|
|
144
|
+
throw new Error("raw requires a capture ID");
|
|
145
|
+
return {
|
|
146
|
+
mode,
|
|
147
|
+
kind,
|
|
148
|
+
raw,
|
|
149
|
+
save,
|
|
150
|
+
trackStats,
|
|
151
|
+
showMetrics,
|
|
152
|
+
json,
|
|
153
|
+
chart,
|
|
154
|
+
dashboard,
|
|
155
|
+
showPath,
|
|
156
|
+
...(captureId ? { captureId } : {}),
|
|
157
|
+
maxLines,
|
|
158
|
+
perFileLines,
|
|
159
|
+
command: args,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
async function readStdin() {
|
|
163
|
+
const chunks = [];
|
|
164
|
+
for await (const chunk of process.stdin) {
|
|
165
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
166
|
+
}
|
|
167
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
168
|
+
}
|
|
169
|
+
async function render(rawOutput, options, durationMs, exitCode) {
|
|
170
|
+
if (options.raw) {
|
|
171
|
+
process.stdout.write(rawOutput);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
let result;
|
|
175
|
+
try {
|
|
176
|
+
result = applyFilter(rawOutput, options.kind, options.command, { maxLines: options.maxLines, perFileLines: options.perFileLines });
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
process.stderr.write(`[shrinker] filter failed; returning raw output: ${String(error)}\n`);
|
|
180
|
+
process.stdout.write(rawOutput);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const output = cleanText(result.output);
|
|
184
|
+
const measurements = measure(rawOutput, output);
|
|
185
|
+
process.stdout.write(`${output}\n`);
|
|
186
|
+
if (options.showMetrics) {
|
|
187
|
+
process.stderr.write(`${formatMeasurements(measurements, durationMs)}\n`);
|
|
188
|
+
}
|
|
189
|
+
if (options.trackStats) {
|
|
190
|
+
try {
|
|
191
|
+
recordRun({
|
|
192
|
+
mode: options.mode === "pipe" ? "pipe" : "exec",
|
|
193
|
+
filterKind: result.kind,
|
|
194
|
+
commandName: options.mode === "pipe" ? "stdin" : path.basename(options.command[0] ?? "unknown"),
|
|
195
|
+
measurements,
|
|
196
|
+
...(durationMs === undefined ? {} : { durationMs }),
|
|
197
|
+
omitted: result.omitted,
|
|
198
|
+
...(exitCode === undefined ? {} : { exitCode }),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
process.stderr.write(`[shrinker] could not record stats: ${String(error)}\n`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const isWrappedGitLog = options.mode === "exec" && result.kind === "git-log";
|
|
206
|
+
const shouldSave = result.recovery !== "threshold" && !isWrappedGitLog;
|
|
207
|
+
if (result.omitted && options.save && shouldSave) {
|
|
208
|
+
try {
|
|
209
|
+
const capture = await saveRawOutput(rawOutput, options.command);
|
|
210
|
+
process.stderr.write(`[full: shrinker raw ${capture.id}]\n`);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
process.stderr.write(`[shrinker] could not save full output: ${String(error)}\n`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async function main() {
|
|
218
|
+
const options = parseArgs(process.argv.slice(2));
|
|
219
|
+
if (options.mode === "help") {
|
|
220
|
+
process.stdout.write(`${usage()}\n`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (options.mode === "stats") {
|
|
224
|
+
const summary = getStats(defaultStatsPath());
|
|
225
|
+
if (options.dashboard) {
|
|
226
|
+
const dashboardPath = writeStatsDashboard(summary);
|
|
227
|
+
process.stdout.write(`Dashboard written to: ${dashboardPath}\n`);
|
|
228
|
+
openStatsDashboard(dashboardPath);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const output = options.json ? JSON.stringify(summary, null, 2) : options.chart ? formatStatsChart(summary) : formatStats(summary);
|
|
232
|
+
process.stdout.write(`${output}\n`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (options.mode === "last") {
|
|
236
|
+
const latest = await getLatestRawOutput();
|
|
237
|
+
if (!latest)
|
|
238
|
+
throw new Error("No saved raw output");
|
|
239
|
+
process.stdout.write(options.showPath ? `${latest.path}\n` : latest.output);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (options.mode === "raw-output") {
|
|
243
|
+
const capture = await getRawOutput(options.captureId ?? "");
|
|
244
|
+
if (!capture)
|
|
245
|
+
throw new Error(`Raw capture not found: ${options.captureId}`);
|
|
246
|
+
process.stdout.write(options.showPath ? `${capture.path}\n` : capture.output);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (options.mode === "pipe") {
|
|
250
|
+
const input = await readStdin();
|
|
251
|
+
await render(input, options);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const [command, ...args] = options.command;
|
|
255
|
+
if (!command)
|
|
256
|
+
throw new Error("Missing command");
|
|
257
|
+
const result = await runCommand(command, args);
|
|
258
|
+
await render(result.combined, options, result.durationMs, result.exitCode);
|
|
259
|
+
process.exitCode = result.exitCode;
|
|
260
|
+
}
|
|
261
|
+
main().catch((error) => {
|
|
262
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
263
|
+
process.exitCode = 2;
|
|
264
|
+
});
|
|
265
|
+
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, stat, utimes, writeFile, } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
const MAX_FILES = 20;
|
|
6
|
+
const LOCK_STALE_MS = 30_000;
|
|
7
|
+
export function defaultRawDirectory() {
|
|
8
|
+
return path.join(os.homedir(), ".shrinker", "raw");
|
|
9
|
+
}
|
|
10
|
+
function safeSlug(command) {
|
|
11
|
+
return path
|
|
12
|
+
.basename(command[0] ?? "output")
|
|
13
|
+
.replace(/[^a-zA-Z0-9_-]+/g, "_")
|
|
14
|
+
.replace(/^_+|_+$/g, "")
|
|
15
|
+
.slice(0, 60) || "output";
|
|
16
|
+
}
|
|
17
|
+
export async function saveRawOutput(output, command, directory = defaultRawDirectory()) {
|
|
18
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
19
|
+
const id = randomUUID().slice(0, 8);
|
|
20
|
+
const fileName = `${Date.now()}_${id}_${safeSlug(command)}.log`;
|
|
21
|
+
const filePath = path.join(directory, fileName);
|
|
22
|
+
const temporaryPath = `${filePath}.${process.pid}.tmp`;
|
|
23
|
+
let published = false;
|
|
24
|
+
try {
|
|
25
|
+
await writeFile(temporaryPath, output, { encoding: "utf8", mode: 0o600 });
|
|
26
|
+
await rename(temporaryPath, filePath);
|
|
27
|
+
published = true;
|
|
28
|
+
const publishedAt = new Date();
|
|
29
|
+
await utimes(filePath, publishedAt, publishedAt);
|
|
30
|
+
await cleanStaleTemporaryFiles(directory);
|
|
31
|
+
const captures = await listCaptures(directory);
|
|
32
|
+
const excess = Math.max(0, captures.length - MAX_FILES);
|
|
33
|
+
const stale = captures.filter((capture) => capture.filePath !== filePath).slice(0, excess);
|
|
34
|
+
for (const capture of stale) {
|
|
35
|
+
await rm(capture.filePath, { force: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (!published)
|
|
40
|
+
await rm(temporaryPath, { force: true });
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
return { id, path: filePath, output };
|
|
44
|
+
}
|
|
45
|
+
export async function getLatestRawOutput(directory = defaultRawDirectory()) {
|
|
46
|
+
const captures = await listCaptures(directory);
|
|
47
|
+
for (const capture of captures.reverse()) {
|
|
48
|
+
const result = await readCaptureIfPresent(capture.filePath);
|
|
49
|
+
if (result)
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
export async function getRawOutput(id, directory = defaultRawDirectory()) {
|
|
55
|
+
if (!/^[a-f0-9]{8}$/i.test(id))
|
|
56
|
+
return undefined;
|
|
57
|
+
const captures = await listCaptures(directory);
|
|
58
|
+
const normalizedId = id.toLowerCase();
|
|
59
|
+
const match = captures.find(({ filePath }) => path.basename(filePath).split("_")[1]?.toLowerCase() === normalizedId);
|
|
60
|
+
return match ? readCaptureIfPresent(match.filePath) : undefined;
|
|
61
|
+
}
|
|
62
|
+
async function readCapture(filePath) {
|
|
63
|
+
const id = path.basename(filePath).split("_")[1] ?? "";
|
|
64
|
+
return { id, path: filePath, output: await readFile(filePath, "utf8") };
|
|
65
|
+
}
|
|
66
|
+
async function readCaptureIfPresent(filePath) {
|
|
67
|
+
try {
|
|
68
|
+
return await readCapture(filePath);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code === "ENOENT")
|
|
72
|
+
return undefined;
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function listCaptures(directory) {
|
|
77
|
+
let files;
|
|
78
|
+
try {
|
|
79
|
+
files = (await readdir(directory, { withFileTypes: true }))
|
|
80
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".log"))
|
|
81
|
+
.map((entry) => entry.name);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code === "ENOENT")
|
|
85
|
+
return [];
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
const captures = await Promise.all(files.map(async (file) => {
|
|
89
|
+
const filePath = path.join(directory, file);
|
|
90
|
+
try {
|
|
91
|
+
return { filePath, modifiedAt: (await stat(filePath)).mtimeMs };
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error.code === "ENOENT")
|
|
95
|
+
return undefined;
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}));
|
|
99
|
+
return captures.filter((capture) => capture !== undefined).sort((left, right) => left.modifiedAt - right.modifiedAt || left.filePath.localeCompare(right.filePath));
|
|
100
|
+
}
|
|
101
|
+
function isProcessAlive(pid) {
|
|
102
|
+
try {
|
|
103
|
+
process.kill(pid, 0);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
const code = error.code;
|
|
108
|
+
return code !== "ESRCH" && code !== "EINVAL";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function cleanStaleTemporaryFiles(directory) {
|
|
112
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
113
|
+
await Promise.all(entries
|
|
114
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".tmp"))
|
|
115
|
+
.map(async (entry) => {
|
|
116
|
+
const temporaryPath = path.join(directory, entry.name);
|
|
117
|
+
try {
|
|
118
|
+
const metadata = await stat(temporaryPath);
|
|
119
|
+
const pidMatch = entry.name.match(/\.(\d+)\.tmp$/);
|
|
120
|
+
const ownerPid = pidMatch ? Number.parseInt(pidMatch[1] ?? "", 10) : undefined;
|
|
121
|
+
if (Date.now() - metadata.mtimeMs > LOCK_STALE_MS &&
|
|
122
|
+
(!ownerPid || !isProcessAlive(ownerPid))) {
|
|
123
|
+
await rm(temporaryPath, { force: true });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error.code !== "ENOENT")
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=raw-output-store.js.map
|