pi-squeeze 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +96 -2
- package/extensions/command/parse.ts +82 -0
- package/extensions/core/audit.ts +53 -0
- package/extensions/core/blame.ts +32 -0
- package/extensions/core/command.ts +86 -0
- package/extensions/core/compact.ts +207 -0
- package/extensions/core/config.ts +109 -0
- package/extensions/core/diff.ts +93 -0
- package/extensions/core/dust.ts +24 -0
- package/extensions/core/elide.ts +31 -0
- package/extensions/core/files.ts +15 -0
- package/extensions/core/generic.ts +37 -0
- package/extensions/core/gitlog.ts +75 -0
- package/extensions/core/gitstatus.ts +52 -0
- package/extensions/core/gradle.ts +73 -0
- package/extensions/core/lsd.ts +64 -0
- package/extensions/core/paths.ts +50 -0
- package/extensions/core/rg.ts +126 -0
- package/extensions/core/rtk.ts +50 -0
- package/extensions/core/runners.ts +50 -0
- package/extensions/core/rustc.ts +99 -0
- package/extensions/core/save.ts +11 -0
- package/extensions/core/stats.ts +31 -0
- package/extensions/core/tests.ts +46 -0
- package/extensions/core/tree.ts +44 -0
- package/extensions/core/tsc.ts +49 -0
- package/extensions/squeeze.ts +111 -0
- package/package.json +32 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sorin Albu-Irimies
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,97 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-squeeze
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/pi-squeeze)
|
|
4
|
+
[](https://github.com/sorinirimies/pi-squeeze/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://github.com/sorinirimies/pi-squeeze/actions/workflows/security.yml)
|
|
7
|
+
[](https://github.com/sorinirimies/pi-squeeze/actions/workflows/fuzz.yml)
|
|
8
|
+
|
|
9
|
+
Shrinks bash output before it reaches the agent's context: native compactors for git, cargo, rg, ls, test and build output, plus rtk when installed. Full output stays one temp file away.
|
|
10
|
+
|
|
11
|
+
A [pi](https://github.com/earendil-works/pi-coding-agent) extension.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi install npm:pi-squeeze
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## What it does
|
|
20
|
+
|
|
21
|
+
After a `bash` command, pi-squeeze replaces its output with a shorter form with the same facts.
|
|
22
|
+
It only touches output it fully understands, and otherwise passes it through unchanged:
|
|
23
|
+
|
|
24
|
+
| Command | Becomes |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `rg -n` / `grep -rn` | totals line, then matches grouped by file (path written once, indent trimmed, long lines and busy files cut) |
|
|
27
|
+
| `rg pattern dir` / `grep -r` (no `-n`) | the same, but only when every `path:` prefix is a file that exists in the session's directory (a colon in the text is never taken for a path) |
|
|
28
|
+
| `fd`, `find` | paths grouped by directory |
|
|
29
|
+
| `lsd -l`, `eza -l`, `ls -l` | type, size, short date and name (owner, group, permissions and the `total` line dropped) |
|
|
30
|
+
| `git diff` | one header line per file (`path (new, +12 −0)`), `index`/mode/`---`/`+++` lines dropped, every changed line kept, context trimmed to 1 line, long files cut |
|
|
31
|
+
| `cargo test` | totals line, then only warnings, failures, panics and result lines (build progress and passing tests dropped); also when the run failed |
|
|
32
|
+
| `rg -l` / `rg --files` / `grep -l` / `git ls-files` / `git diff --name-only` | paths grouped by directory |
|
|
33
|
+
| `git status` | `## main...origin/main [ahead 2]`, then `staged:` / `unstaged:` / `untracked:` with `M`/`A`/`D`/`R` codes; the hints go |
|
|
34
|
+
| `git log` | one line per commit (short hash, date, author, subject); the body stays, capped at 8 lines; one author is said once |
|
|
35
|
+
| `git show` | that commit line, then the compacted diff |
|
|
36
|
+
| `git blame` | one header per run of lines from the same commit |
|
|
37
|
+
| `dust` | size, tree and share; the bar chart goes |
|
|
38
|
+
| `cargo build` / `check` / `clippy` / `doc` | errors keep their whole block; warnings become one line each, grouped by lint (`clippy::needless_return (2)`); `Compiling`/`Finished` lines go |
|
|
39
|
+
| `cargo audit` | one line per advisory (id, crate, version, kind, title, fix) |
|
|
40
|
+
| `cargo tree`, `tree`, `eza --tree` | cut below 3 levels, `… [+15 lines deeper]` under each cut |
|
|
41
|
+
| `tsc` | errors grouped by file (`a.ts (8)`, `1:7 TS2322 …`) |
|
|
42
|
+
| `gradle`, `./gradlew` | task lines counted (`11 tasks (5 executed, 4 no-source, 2 up-to-date)`); failures, compiler errors, test failures and the `BUILD` line stay; daemon and `--scan` hints go; Kotlin `w:`/`e:` paths lose their shared directory |
|
|
43
|
+
| `bun test`, `go test` | passing tests counted, not listed; failures and totals stay |
|
|
44
|
+
| anything else | colour codes dropped, progress bars (`\r`) keep their last frame, 4+ identical lines become `line [×N]`, 3+ blank lines become one. Never for `cat`, `head`, `jq`, `curl`… (content commands) |
|
|
45
|
+
| `git`, `cargo`, `docker`… | rewritten through [rtk](https://github.com/rtk-ai/rtk) when it is installed (switch: `/squeeze rtk off`) |
|
|
46
|
+
|
|
47
|
+
Output with a marker of its own (a `tsc` error list, a gradle build, a `bun test` run, rustc diagnostics) is recognised whatever ran it: `just check`, `npm run build`, `make test`, `… 2>&1`.
|
|
48
|
+
A **failed** command is compacted too, but only for test and build output, where the errors are the point.
|
|
49
|
+
`git -C dir …`, `git --no-pager …`, `--color=never` and `cargo +nightly …` are read as the plain command.
|
|
50
|
+
|
|
51
|
+
Safety rules:
|
|
52
|
+
|
|
53
|
+
* Pipes, redirects, chains and `--json`/`-c`/`-l`/`-x` style flags are never touched: the output is no longer the tool's own format.
|
|
54
|
+
* A line that does not parse makes the whole output pass through.
|
|
55
|
+
* The result must be clearly smaller (under 90%) and the output at least 400 characters.
|
|
56
|
+
* Every cut leaves a marker (`… [+12 more in this file]`), and the full original goes to a temp file whose path is in the output.
|
|
57
|
+
* `read`, `edit` and `write` are never changed.
|
|
58
|
+
|
|
59
|
+
## Commands
|
|
60
|
+
|
|
61
|
+
| Command | |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `/squeeze` or `/squeeze status` | the settings and the tokens saved so far |
|
|
64
|
+
| `/squeeze [on\|off]` | switch everything |
|
|
65
|
+
| `/squeeze [search\|paths\|listing\|diff\|tests\|git\|build\|tree\|generic] [on\|off]` | switch one compactor |
|
|
66
|
+
| `/squeeze rtk [on\|off]` | rewrite git, cargo, docker… through [rtk](https://github.com/rtk-ai/rtk), when installed |
|
|
67
|
+
| `/squeeze min <chars>` | smallest output worth compacting (default 400) |
|
|
68
|
+
|
|
69
|
+
Settings are remembered in `<pi agent dir>/squeeze.json` (`PI_CODING_AGENT_DIR`, else `~/.pi/agent`); it also holds
|
|
70
|
+
`lineChars`, `perGroup` and `total`, the limits of a compacted result. `PI_SQUEEZE=off` disables it at start.
|
|
71
|
+
The status line shows the tokens saved.
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
bun install
|
|
77
|
+
just check # typecheck + tests (95% coverage gate) + pack check + nu script tests
|
|
78
|
+
just --list # everything else: fuzz, audit, release, remotes
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Layout:
|
|
82
|
+
|
|
83
|
+
| Path | What |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `extensions/squeeze.ts` | the extension: pure functions plus a thin default export |
|
|
86
|
+
| `tests/` | `bun test`: `core` (pure logic), `extension` (hooks), `fuzz` (randomised properties) |
|
|
87
|
+
| `scripts/` | Nushell only, each with a test in `scripts/tests/` |
|
|
88
|
+
| `.github/`, `.gitea/` | CI, release, nightlies on both hosts |
|
|
89
|
+
|
|
90
|
+
Releases run in CI: **Actions → Release → Run workflow** with a bump (`patch`, `minor`, `major`, or
|
|
91
|
+
`current` for the version already in `package.json`), or `just release-ci patch`. The workflow runs the
|
|
92
|
+
quality gate, bumps `package.json`, regenerates `CHANGELOG.md` with git-cliff, commits, tags, pushes and
|
|
93
|
+
publishes to npm (secret `NPM_TOKEN`). Locally, `just release patch` does the same up to the tag push.
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** `/squeeze ...`: parsing and argument completion. Pure: the handler in squeeze.ts acts on the result. */
|
|
2
|
+
import { KINDS, type Kind } from "../core/compact.ts";
|
|
3
|
+
|
|
4
|
+
export type Command =
|
|
5
|
+
| { kind: "status" }
|
|
6
|
+
| { kind: "on" }
|
|
7
|
+
| { kind: "off" }
|
|
8
|
+
| { kind: "compactor"; compactor: Kind; on: boolean }
|
|
9
|
+
| { kind: "rtk"; on: boolean }
|
|
10
|
+
| { kind: "min"; chars: number }
|
|
11
|
+
| { kind: "invalid"; message: string };
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
export const USAGE = `Usage: /squeeze [status] · on · off · ${KINDS.join("|")}|rtk <on|off> · min <chars>`;
|
|
15
|
+
|
|
16
|
+
export function parseCommand(args: string | undefined): Command {
|
|
17
|
+
const [cmd, p1] = (args ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
18
|
+
switch (cmd) {
|
|
19
|
+
case undefined:
|
|
20
|
+
case "status":
|
|
21
|
+
return { kind: "status" };
|
|
22
|
+
case "on":
|
|
23
|
+
case "enable":
|
|
24
|
+
return { kind: "on" };
|
|
25
|
+
case "off":
|
|
26
|
+
case "disable":
|
|
27
|
+
return { kind: "off" };
|
|
28
|
+
case "min": {
|
|
29
|
+
const n = Number(p1);
|
|
30
|
+
return Number.isInteger(n) && n >= 100 && n <= 1_000_000
|
|
31
|
+
? { kind: "min", chars: n }
|
|
32
|
+
: { kind: "invalid", message: "Usage: /squeeze min <chars> (100 to 1000000)" };
|
|
33
|
+
}
|
|
34
|
+
case "rtk":
|
|
35
|
+
return p1 === "on" || p1 === "off" ? { kind: "rtk", on: p1 === "on" } : { kind: "invalid", message: "Usage: /squeeze rtk <on|off>" };
|
|
36
|
+
default:
|
|
37
|
+
if ((KINDS as readonly string[]).includes(cmd)) {
|
|
38
|
+
return p1 === "on" || p1 === "off"
|
|
39
|
+
? { kind: "compactor", compactor: cmd as Kind, on: p1 === "on" }
|
|
40
|
+
: { kind: "invalid", message: `Usage: /squeeze ${cmd} <on|off>` };
|
|
41
|
+
}
|
|
42
|
+
return { kind: "invalid", message: `Unknown option "${cmd}". ${USAGE}` };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface CompletionItem {
|
|
47
|
+
value: string;
|
|
48
|
+
/** Required by pi: it calls `label.endsWith(...)` when applying a completion. */
|
|
49
|
+
label: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const TOP_LEVEL: CompletionItem[] = [
|
|
54
|
+
{ value: "status", label: "status", description: "Show the settings and the savings" },
|
|
55
|
+
{ value: "on", label: "on", description: "Turn pi-squeeze on" },
|
|
56
|
+
{ value: "off", label: "off", description: "Turn it off (nothing is compacted)" },
|
|
57
|
+
{ value: "search ", label: "search", description: "rg / grep results grouped by file (on or off)" },
|
|
58
|
+
{ value: "paths ", label: "paths", description: "fd / find results grouped by directory (on or off)" },
|
|
59
|
+
{ value: "listing ", label: "listing", description: "lsd / eza -l listings (on or off)" },
|
|
60
|
+
{ value: "diff ", label: "diff", description: "git diff with trimmed context (on or off)" },
|
|
61
|
+
{ value: "tests ", label: "tests", description: "cargo test without build noise and passing tests (on or off)" },
|
|
62
|
+
{ value: "git ", label: "git", description: "git status, log, show and blame, shorter (on or off)" },
|
|
63
|
+
{ value: "build ", label: "build", description: "cargo build/clippy/audit, tsc and gradle, errors kept and warnings grouped (on or off)" },
|
|
64
|
+
{ value: "tree ", label: "tree", description: "cargo tree, tree and eza --tree cut below a depth (on or off)" },
|
|
65
|
+
{ value: "generic ", label: "generic", description: "ANSI codes, progress frames and repeated lines in any output (on or off)" },
|
|
66
|
+
{ value: "rtk ", label: "rtk", description: "Rewrite git, cargo, docker… through rtk, when installed (on or off)" },
|
|
67
|
+
{ value: "min ", label: "min", description: "Smallest output to compact, in characters" },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/** `prefix` is the whole argument text typed so far (pi replaces it with `value`). */
|
|
71
|
+
export function completions(prefix: string): CompletionItem[] | null {
|
|
72
|
+
const text = (prefix ?? "").toLowerCase().replace(/^\s+/, "");
|
|
73
|
+
const kind = [...KINDS, "rtk"].find((k) => text.startsWith(`${k} `));
|
|
74
|
+
const pool: CompletionItem[] = kind
|
|
75
|
+
? [
|
|
76
|
+
{ value: `${kind} on`, label: "on", description: kind === "rtk" ? "Rewrite commands through rtk" : `Compact ${kind} output` },
|
|
77
|
+
{ value: `${kind} off`, label: "off", description: kind === "rtk" ? "Run commands as written" : `Leave ${kind} output alone` },
|
|
78
|
+
]
|
|
79
|
+
: TOP_LEVEL;
|
|
80
|
+
const out = pool.filter((i) => i.value.startsWith(text));
|
|
81
|
+
return out.length > 0 ? out : null;
|
|
82
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cargo audit` advisories, one line each:
|
|
3
|
+
*
|
|
4
|
+
* RUSTSEC-2025-0141 bincode 1.3.3 unmaintained: Bincode is unmaintained (fix: upgrade to >=1.3.4)
|
|
5
|
+
*
|
|
6
|
+
* The dependency-tree dump under a vulnerability, the date and the URL go. Strict: a block
|
|
7
|
+
* without an `ID:` line makes it return undefined.
|
|
8
|
+
*/
|
|
9
|
+
import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
10
|
+
|
|
11
|
+
const FIELD = /^([A-Z][A-Za-z ]*?):\s+(.*)$/;
|
|
12
|
+
|
|
13
|
+
export function compactAudit(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
14
|
+
const lines = output.split("\n");
|
|
15
|
+
const head: string[] = [];
|
|
16
|
+
const rows: string[] = [];
|
|
17
|
+
const tail: string[] = [];
|
|
18
|
+
let fields: Record<string, string> | undefined;
|
|
19
|
+
let inTree = false;
|
|
20
|
+
const finish = () => {
|
|
21
|
+
if (!fields) return true;
|
|
22
|
+
const f = fields;
|
|
23
|
+
fields = undefined;
|
|
24
|
+
if (!f.ID || !f.Crate) return false;
|
|
25
|
+
const kind = f.Warning ?? (f.Severity ? `vulnerability (${f.Severity})` : "vulnerability");
|
|
26
|
+
rows.push(cutLine(`${f.ID} ${f.Crate} ${f.Version ?? ""} ${kind}: ${f.Title ?? ""}${f.Solution ? ` (fix: ${f.Solution})` : ""}`, limits.lineChars));
|
|
27
|
+
return true;
|
|
28
|
+
};
|
|
29
|
+
for (const l of lines) {
|
|
30
|
+
if (l === "") {
|
|
31
|
+
inTree = false;
|
|
32
|
+
if (!finish()) return undefined;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (fields && l.startsWith("Dependency tree:")) {
|
|
36
|
+
inTree = true;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const m = FIELD.exec(l);
|
|
40
|
+
if (m && m[1] === "Crate") {
|
|
41
|
+
if (!finish()) return undefined;
|
|
42
|
+
fields = { Crate: m[2]! };
|
|
43
|
+
} else if (fields && m && !inTree) {
|
|
44
|
+
fields[m[1]!] = m[2]!;
|
|
45
|
+
} else if (fields) {
|
|
46
|
+
if (!inTree) return undefined;
|
|
47
|
+
} else if (rows.length || /^(warning|error): \d+ /.test(l)) tail.push(l);
|
|
48
|
+
else head.push(l);
|
|
49
|
+
}
|
|
50
|
+
if (!finish() || !rows.length) return undefined;
|
|
51
|
+
const scanned = head.find((l) => /^\s+Scanning /.test(l));
|
|
52
|
+
return capLines([...(scanned ? [scanned.trim()] : []), `${rows.length} advisories`, ...rows, ...tail], limits.total);
|
|
53
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `git blame` with one header per run of lines from the same commit:
|
|
3
|
+
*
|
|
4
|
+
* bd6a3dd4 Sorin Albu-Irimies 2026-10-16
|
|
5
|
+
* 1: # Dodai UI
|
|
6
|
+
* 2:
|
|
7
|
+
*
|
|
8
|
+
* Strict: a line that is not in the default blame format returns undefined.
|
|
9
|
+
*/
|
|
10
|
+
import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
11
|
+
|
|
12
|
+
const ROW = /^(\^?[0-9a-f]{6,40}) (?:\S+ +)?\((.+?) +(\d{4}-\d\d-\d\d) \d\d:\d\d:\d\d [+-]\d{4} +(\d+)\) ?(.*)$/;
|
|
13
|
+
|
|
14
|
+
export function compactBlame(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
15
|
+
const lines = output.split("\n");
|
|
16
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
17
|
+
if (!lines.length) return undefined;
|
|
18
|
+
const out: string[] = [];
|
|
19
|
+
let last = "";
|
|
20
|
+
for (const l of lines) {
|
|
21
|
+
const m = ROW.exec(l);
|
|
22
|
+
if (!m) return undefined;
|
|
23
|
+
const [, hash, author, date, no, text] = m;
|
|
24
|
+
const key = `${hash} ${author} ${date}`;
|
|
25
|
+
if (key !== last) {
|
|
26
|
+
out.push(key);
|
|
27
|
+
last = key;
|
|
28
|
+
}
|
|
29
|
+
out.push(cutLine(` ${no}: ${text}`, limits.lineChars));
|
|
30
|
+
}
|
|
31
|
+
return capLines(out, limits.total);
|
|
32
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a bash command line just far enough to know which program ran and with which
|
|
3
|
+
* arguments. Anything that reshapes the output (pipes, redirects, chains, substitution)
|
|
4
|
+
* makes it return undefined: the output is then not in the program's own format and
|
|
5
|
+
* must pass through untouched.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type Invocation = { program: string; args: string[] };
|
|
9
|
+
|
|
10
|
+
const OPERATOR = /[|;&<>`]|\$\(/;
|
|
11
|
+
|
|
12
|
+
/** Split on whitespace, honouring single/double quotes and backslash escapes. */
|
|
13
|
+
export function tokenize(line: string): string[] | undefined {
|
|
14
|
+
const out: string[] = [];
|
|
15
|
+
let cur = "";
|
|
16
|
+
let has = false;
|
|
17
|
+
let quote: "'" | '"' | undefined;
|
|
18
|
+
for (let i = 0; i < line.length; i++) {
|
|
19
|
+
const c = line[i]!;
|
|
20
|
+
if (quote) {
|
|
21
|
+
if (c === quote) quote = undefined;
|
|
22
|
+
else if (c === "\\" && quote === '"' && i + 1 < line.length) cur += line[++i];
|
|
23
|
+
else cur += c;
|
|
24
|
+
} else if (c === "'" || c === '"') {
|
|
25
|
+
quote = c;
|
|
26
|
+
has = true;
|
|
27
|
+
} else if (c === "\\" && i + 1 < line.length) {
|
|
28
|
+
cur += line[++i];
|
|
29
|
+
has = true;
|
|
30
|
+
} else if (/\s/.test(c)) {
|
|
31
|
+
if (has || cur) out.push(cur);
|
|
32
|
+
cur = "";
|
|
33
|
+
has = false;
|
|
34
|
+
} else {
|
|
35
|
+
cur += c;
|
|
36
|
+
has = true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (quote) return undefined;
|
|
40
|
+
if (has || cur) out.push(cur);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True when `text` contains a shell operator outside quotes. */
|
|
45
|
+
export function hasOperator(text: string): boolean {
|
|
46
|
+
let quote: "'" | '"' | undefined;
|
|
47
|
+
for (let i = 0; i < text.length; i++) {
|
|
48
|
+
const c = text[i]!;
|
|
49
|
+
if (quote) {
|
|
50
|
+
if (c === quote) quote = undefined;
|
|
51
|
+
else if (c === "\\" && quote === '"') i++;
|
|
52
|
+
else if (quote === '"' && (c === "`" || (c === "$" && text[i + 1] === "("))) return true;
|
|
53
|
+
} else if (c === "'" || c === '"') quote = c;
|
|
54
|
+
else if (c === "\\") i++;
|
|
55
|
+
else if (OPERATOR.test(c === "$" ? text.slice(i, i + 2) : c)) return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Drop a leading `cd <dir> &&`, which only sets the working directory. */
|
|
61
|
+
export function stripCd(command: string): string {
|
|
62
|
+
return command.replace(/^\s*cd\s+(?:"[^"]*"|'[^']*'|[^\s;&|]+)\s*&&\s*/, "");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The single simple command a line runs, or undefined when it is anything more. */
|
|
66
|
+
export function parse(command: string): Invocation | undefined {
|
|
67
|
+
// `2>&1` only merges the streams, which is how pi shows them anyway.
|
|
68
|
+
const line = stripCd(command).replace(/\s+2>&1\s*$/, "").trim();
|
|
69
|
+
if (!line || hasOperator(line)) return undefined;
|
|
70
|
+
const words = tokenize(line);
|
|
71
|
+
if (!words) return undefined;
|
|
72
|
+
while (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]!)) words.shift();
|
|
73
|
+
const first = words.shift();
|
|
74
|
+
if (!first) return undefined;
|
|
75
|
+
return { program: first.split("/").pop()!, args: words };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Does a short or long flag appear in `args` (also inside combined short flags like -rn)? */
|
|
79
|
+
export function hasFlag(args: string[], long: string[], short: string[] = []): boolean {
|
|
80
|
+
for (const a of args) {
|
|
81
|
+
if (a === "--") return false;
|
|
82
|
+
if (long.includes(a) || long.some((l) => a.startsWith(`${l}=`))) return true;
|
|
83
|
+
if (/^-[A-Za-z]+$/.test(a) && short.some((s) => a.includes(s))) return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/** Chooses the compactor for a bash command and applies the safety rules. */
|
|
2
|
+
import { hasFlag, parse } from "./command.ts";
|
|
3
|
+
import { DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
4
|
+
import { compactAudit } from "./audit.ts";
|
|
5
|
+
import { compactBlame } from "./blame.ts";
|
|
6
|
+
import { compactDiff } from "./diff.ts";
|
|
7
|
+
import { compactDust } from "./dust.ts";
|
|
8
|
+
import { compactGradle } from "./gradle.ts";
|
|
9
|
+
import { compactGeneric, isContent } from "./generic.ts";
|
|
10
|
+
import { compactLog, compactShow } from "./gitlog.ts";
|
|
11
|
+
import { compactStatus } from "./gitstatus.ts";
|
|
12
|
+
import { compactLsd } from "./lsd.ts";
|
|
13
|
+
import { compactPaths } from "./paths.ts";
|
|
14
|
+
import { compactRg, compactRgPlain } from "./rg.ts";
|
|
15
|
+
import { compactRustc } from "./rustc.ts";
|
|
16
|
+
import { compactBunTest, compactGoTest } from "./runners.ts";
|
|
17
|
+
import { compactTests } from "./tests.ts";
|
|
18
|
+
import { compactTree } from "./tree.ts";
|
|
19
|
+
import { compactTsc } from "./tsc.ts";
|
|
20
|
+
|
|
21
|
+
export const KINDS = ["search", "paths", "listing", "diff", "tests", "git", "build", "tree", "generic"] as const;
|
|
22
|
+
export type Kind = (typeof KINDS)[number];
|
|
23
|
+
|
|
24
|
+
export type Result = { text: string; kind: Kind; truncated: boolean; before: number; after: number };
|
|
25
|
+
|
|
26
|
+
export type Options = {
|
|
27
|
+
limits?: Limits;
|
|
28
|
+
/** Output shorter than this is never touched. */
|
|
29
|
+
minChars?: number;
|
|
30
|
+
/** The result must be at most this share of the original, or the original is kept. */
|
|
31
|
+
maxRatio?: number;
|
|
32
|
+
/** Compactors that may run; a missing entry means on. */
|
|
33
|
+
kinds?: Partial<Record<Kind, boolean>>;
|
|
34
|
+
/** The command failed: only test and build output is worth compacting then (the errors are the point). */
|
|
35
|
+
failed?: boolean;
|
|
36
|
+
/** Is `path` (relative to the command's directory) an existing file? Lets search output without line numbers be grouped. */
|
|
37
|
+
isFile?: (path: string) => boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
const SEARCH = new Set(["rg", "grep", "egrep", "ugrep"]);
|
|
42
|
+
const PATHS = new Set(["fd", "fdfind", "find"]);
|
|
43
|
+
const LISTING = new Set(["lsd", "eza", "exa", "ls"]);
|
|
44
|
+
const LIST_ONLY = { long: ["--files", "--files-with-matches", "--files-without-match"], short: ["l"] };
|
|
45
|
+
const NOT_A_LIST = { long: ["--json", "--count", "--null", "--print0", "--stats", "--vimgrep"], short: ["c", "0", "z"] };
|
|
46
|
+
|
|
47
|
+
/** Flags after which the output is no longer plain `path:line:text` / one path per line. */
|
|
48
|
+
const NOT_PLAIN_SEARCH = { long: ["--json", "--count", "--files-with-matches", "--files-without-match", "--files", "--heading", "--stats", "--vimgrep", "--only-matching", "--null", "--passthru"], short: ["c", "l", "L", "o", "z"] };
|
|
49
|
+
const NOT_PLAIN_PATHS = { long: ["--exec", "--exec-batch", "--list-details", "--print0", "--format"], short: ["x", "X", "l", "0"] };
|
|
50
|
+
|
|
51
|
+
/** Flags of `git log`, `show`, `blame` and `status` that leave the default format. */
|
|
52
|
+
const GIT_FORMAT = ["--oneline", "--pretty", "--stat", "--shortstat", "--numstat", "--name-only", "--name-status", "--raw", "--graph", "--patch", "--color-words", "--word-diff", "--porcelain", "--line-porcelain", "--incremental", "--short", "--branch", "--stage", "--eol", "--debug", "--format", "--null", "--color"];
|
|
53
|
+
const GIT_FORMAT_SHORT = ["p", "s", "z", "b", "e", "t", "v"];
|
|
54
|
+
|
|
55
|
+
/** `git diff` flags that change the output format away from a unified diff. */
|
|
56
|
+
const NOT_PLAIN_DIFF = {
|
|
57
|
+
long: ["--stat", "--numstat", "--shortstat", "--dirstat", "--summary", "--name-only", "--name-status", "--raw", "--check", "--patch-with-stat", "--patch-with-raw", "--word-diff", "--color-words", "--color", "--compact-summary", "--submodule", "--no-index", "--output"],
|
|
58
|
+
short: ["s", "z"],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function plainFind(args: string[]): boolean {
|
|
62
|
+
return !args.some((a) => ["-exec", "-execdir", "-ls", "-print0", "-printf", "-fprint", "-delete", "-ok"].includes(a));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function pickCargo(all: string[]): ReturnType<typeof pick> {
|
|
66
|
+
// `cargo +nightly build`: the toolchain does not change the output.
|
|
67
|
+
const [sub, ...rest] = all[0]?.startsWith("+") ? all.slice(1) : all;
|
|
68
|
+
if (hasFlag(rest, ["--format", "--message-format", "--prefix", "--charset", "--timings"])) return undefined;
|
|
69
|
+
switch (sub) {
|
|
70
|
+
case "test":
|
|
71
|
+
return { kind: "tests", run: compactTests };
|
|
72
|
+
case "build":
|
|
73
|
+
case "check":
|
|
74
|
+
case "clippy":
|
|
75
|
+
case "doc":
|
|
76
|
+
case "bench":
|
|
77
|
+
return { kind: "build", run: compactRustc };
|
|
78
|
+
case "audit":
|
|
79
|
+
return { kind: "build", run: compactAudit };
|
|
80
|
+
case "tree":
|
|
81
|
+
return { kind: "tree", run: compactTree };
|
|
82
|
+
default:
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Output with a strong marker of its own is recognised whatever command produced it (`just check`, `npm run build`, pipes). */
|
|
88
|
+
const SNIFFERS: { kind: Kind; run: (out: string, l: Limits) => Squeezed | undefined }[] = [
|
|
89
|
+
{ kind: "tests", run: compactBunTest },
|
|
90
|
+
{ kind: "tests", run: compactGoTest },
|
|
91
|
+
{ kind: "build", run: compactGradle },
|
|
92
|
+
{ kind: "build", run: compactTsc },
|
|
93
|
+
{ kind: "build", run: compactRustc },
|
|
94
|
+
{ kind: "build", run: compactAudit },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/** `git` options before the subcommand that do not change its output: `-C dir`, `-c k=v`, `--no-pager`, ... */
|
|
98
|
+
function gitSubcommand(args: string[]): string[] {
|
|
99
|
+
let i = 0;
|
|
100
|
+
while (i < args.length) {
|
|
101
|
+
const a = args[i]!;
|
|
102
|
+
if (a === "-C" || a === "-c") i += 2;
|
|
103
|
+
else if (["--no-pager", "-P", "--no-optional-locks", "--literal-pathspecs"].includes(a) || /^--(git-dir|work-tree)=/.test(a)) i++;
|
|
104
|
+
else break;
|
|
105
|
+
}
|
|
106
|
+
return args.slice(i);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `--color=never` and `--no-color` leave the output as it is without them. */
|
|
110
|
+
const noColor = (args: string[]) => args.filter((a) => a !== "--color=never" && a !== "--no-color");
|
|
111
|
+
|
|
112
|
+
function pickGit(all: string[]): ReturnType<typeof pick> {
|
|
113
|
+
const [sub, ...rest] = noColor(gitSubcommand(all));
|
|
114
|
+
switch (sub) {
|
|
115
|
+
case "diff":
|
|
116
|
+
if (hasFlag(rest, ["--name-only"]) && !hasFlag(rest, ["--null"], ["z"])) return { kind: "paths", run: compactPaths };
|
|
117
|
+
return hasFlag(rest, NOT_PLAIN_DIFF.long, NOT_PLAIN_DIFF.short) ? undefined : { kind: "diff", run: compactDiff };
|
|
118
|
+
case "ls-files":
|
|
119
|
+
return hasFlag(rest, ["--stage", "--eol", "--debug", "--format", "--deleted", "--others"], ["s", "z", "t", "v"]) ? undefined : { kind: "paths", run: compactPaths };
|
|
120
|
+
case "status":
|
|
121
|
+
return hasFlag(rest, GIT_FORMAT, GIT_FORMAT_SHORT) ? undefined : { kind: "git", run: compactStatus };
|
|
122
|
+
case "log":
|
|
123
|
+
return hasFlag(rest, GIT_FORMAT, GIT_FORMAT_SHORT) ? undefined : { kind: "git", run: compactLog };
|
|
124
|
+
case "show":
|
|
125
|
+
return hasFlag(rest, GIT_FORMAT, GIT_FORMAT_SHORT) ? undefined : { kind: "git", run: compactShow };
|
|
126
|
+
case "blame":
|
|
127
|
+
return hasFlag(rest, GIT_FORMAT, GIT_FORMAT_SHORT) ? undefined : { kind: "git", run: compactBlame };
|
|
128
|
+
default:
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Flags that print context lines (`path-text`, which cannot be told apart from a path with a dash). */
|
|
134
|
+
const CONTEXT = { long: ["--context", "--after-context", "--before-context"], short: /^-[A-Za-z]*[ABC]/ };
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `rg pattern dir` without -n: grouped only when the paths can be checked on disk, and not when the
|
|
138
|
+
* only path searched is itself a file (then there is no path prefix, and a colon in the text would pass for one).
|
|
139
|
+
*/
|
|
140
|
+
function plainSearch(args: string[], isFile: ((path: string) => boolean) | undefined): ReturnType<typeof pick> {
|
|
141
|
+
if (hasFlag(args, ["--line-number"], ["n"]) || hasFlag(args, CONTEXT.long) || args.some((a) => CONTEXT.short.test(a)) || !isFile) return undefined;
|
|
142
|
+
const operands = args.filter((a) => !a.startsWith("-")).slice(1);
|
|
143
|
+
if (operands.length === 1 && isFile(operands[0]!)) return undefined;
|
|
144
|
+
return { kind: "search", run: (out, l) => compactRgPlain(out, l, isFile) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function pick(command: string, isFile?: (path: string) => boolean): { kind: Kind; run: (out: string, l: Limits) => Squeezed | undefined } | undefined {
|
|
148
|
+
const inv = parse(command);
|
|
149
|
+
if (!inv) return undefined;
|
|
150
|
+
// Relative paths in the output are relative to the `cd`, which the file check does not know.
|
|
151
|
+
const local = /^\s*cd\s/.test(command) ? undefined : isFile;
|
|
152
|
+
const { program, args } = inv;
|
|
153
|
+
if (SEARCH.has(program)) {
|
|
154
|
+
const files = hasFlag(args, LIST_ONLY.long, program === "rg" ? LIST_ONLY.short : [...LIST_ONLY.short, "L"]);
|
|
155
|
+
if (files) return hasFlag(args, NOT_A_LIST.long, NOT_A_LIST.short) ? undefined : { kind: "paths", run: compactPaths };
|
|
156
|
+
if (hasFlag(args, NOT_PLAIN_SEARCH.long, NOT_PLAIN_SEARCH.short)) return undefined;
|
|
157
|
+
const plain = plainSearch(args, local);
|
|
158
|
+
return plain ?? { kind: "search", run: compactRg };
|
|
159
|
+
}
|
|
160
|
+
if (PATHS.has(program)) {
|
|
161
|
+
if (program === "find" ? !plainFind(args) : hasFlag(args, NOT_PLAIN_PATHS.long, NOT_PLAIN_PATHS.short)) return undefined;
|
|
162
|
+
return { kind: "paths", run: compactPaths };
|
|
163
|
+
}
|
|
164
|
+
if (program === "git") return pickGit(args);
|
|
165
|
+
if (program === "cargo") return pickCargo(args);
|
|
166
|
+
if (program === "tsc" || ((program === "npx" || program === "bunx") && args[0] === "tsc")) return { kind: "build", run: compactTsc };
|
|
167
|
+
if (program === "gradle" || program === "gradlew") return { kind: "build", run: compactGradle };
|
|
168
|
+
if (program === "go" && args[0] === "test") return hasFlag(args, ["-json", "--json"]) ? undefined : { kind: "tests", run: compactGoTest };
|
|
169
|
+
if (program === "bun" && args[0] === "test") return { kind: "tests", run: compactBunTest };
|
|
170
|
+
if (program === "tree" || ((program === "eza" || program === "exa" || program === "lsd") && hasFlag(args, ["--tree"], ["T"]))) return hasFlag(args, ["-J", "--json", "-H", "--html"]) ? undefined : { kind: "tree", run: compactTree };
|
|
171
|
+
if (program === "dust") return hasFlag(args, ["--output-json"], ["j"]) ? undefined : { kind: "listing", run: compactDust };
|
|
172
|
+
if (LISTING.has(program)) {
|
|
173
|
+
if (!hasFlag(args, ["--long"], ["l"])) return undefined;
|
|
174
|
+
// Other column sets (inode, blocks, group only, full time, numeric ids, octal) do not parse.
|
|
175
|
+
if (hasFlag(args, ["--inode", "--blocks", "--size", "--full-time", "--time-style", "--numeric-uid-gid", "--git", "--header", "--octal-permissions", "--total-size", "--context"], ["i", "s", "n", "g", "o", "H", "Z"])) return undefined;
|
|
176
|
+
return { kind: "listing", run: compactLsd };
|
|
177
|
+
}
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** True when a native compactor takes care of `command`'s output (whatever the switches say). */
|
|
182
|
+
export const handles = (command: string): boolean => pick(command) !== undefined;
|
|
183
|
+
|
|
184
|
+
/** Compacts the output of `command`, or returns undefined to leave it exactly as it is. */
|
|
185
|
+
export function compact(command: string, output: string, opts: Options = {}): Result | undefined {
|
|
186
|
+
const { limits = DEFAULT_LIMITS, minChars = 400, maxRatio = 0.9, kinds = {}, failed = false, isFile } = opts;
|
|
187
|
+
if (output.length < minChars) return undefined;
|
|
188
|
+
const small = (sq: Squeezed | undefined): sq is Squeezed => sq !== undefined && sq.text.length <= output.length * maxRatio;
|
|
189
|
+
const allowed = (kind: Kind) => kinds[kind] !== false && (!failed || kind === "tests" || kind === "build");
|
|
190
|
+
const done = (sq: Squeezed, kind: Kind): Result => ({ text: sq.text, kind, truncated: sq.truncated, before: output.length, after: sq.text.length });
|
|
191
|
+
|
|
192
|
+
const chosen = pick(command, isFile);
|
|
193
|
+
if (chosen && allowed(chosen.kind)) {
|
|
194
|
+
const sq = chosen.run(output, limits);
|
|
195
|
+
if (small(sq)) return done(sq, chosen.kind);
|
|
196
|
+
}
|
|
197
|
+
if (isContent(command)) return undefined;
|
|
198
|
+
for (const s of SNIFFERS) {
|
|
199
|
+
if (!allowed(s.kind)) continue;
|
|
200
|
+
const sq = s.run(output, limits);
|
|
201
|
+
if (small(sq)) return done(sq, s.kind);
|
|
202
|
+
}
|
|
203
|
+
// Nothing specific fits: still drop colour codes, progress frames and repeated lines.
|
|
204
|
+
if (failed || kinds.generic === false) return undefined;
|
|
205
|
+
const sq = compactGeneric(output);
|
|
206
|
+
return small(sq) ? done(sq, "generic") : undefined;
|
|
207
|
+
}
|