pi-squeeze 0.0.0-stage → 0.2.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 +115 -2
- package/extensions/command/parse.ts +82 -0
- package/extensions/core/adb.ts +231 -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 +332 -0
- package/extensions/core/config.ts +109 -0
- package/extensions/core/diff.ts +93 -0
- package/extensions/core/docker.ts +58 -0
- package/extensions/core/dust.ts +24 -0
- package/extensions/core/elide.ts +31 -0
- package/extensions/core/files.ts +29 -0
- package/extensions/core/generic.ts +40 -0
- package/extensions/core/gitlog.ts +75 -0
- package/extensions/core/gitstatus.ts +52 -0
- package/extensions/core/gradle.ts +269 -0
- package/extensions/core/gradlereports.ts +78 -0
- package/extensions/core/install.ts +122 -0
- package/extensions/core/javac.ts +44 -0
- package/extensions/core/jest.ts +38 -0
- package/extensions/core/jvm.ts +18 -0
- package/extensions/core/lint.ts +199 -0
- package/extensions/core/logs.ts +106 -0
- package/extensions/core/lsd.ts +64 -0
- package/extensions/core/maven.ts +122 -0
- package/extensions/core/paths.ts +50 -0
- package/extensions/core/pytest.ts +37 -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 +51 -0
- package/extensions/core/tsc.ts +49 -0
- package/extensions/core/xcode.ts +123 -0
- package/extensions/squeeze.ts +122 -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,116 @@
|
|
|
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, search, listings, tests, builds (cargo, gradle, Maven, javac, Xcode, tsc, docker), Android (adb, lint, detekt), linters, package installs and logs, 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` / `ack` | 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, configuration-cache, JVM and `--scan` chatter goes; reported files lose their shared directory |
|
|
43
|
+
| `bun test`, `go test` | passing tests counted, not listed; failures and totals stay |
|
|
44
|
+
| `pytest` | session header, progress dots and `PASSED` lines go; failures, warnings and the short summary stay; full-width `====` banners shrink |
|
|
45
|
+
| `jest`, `vitest` | passing tests and files go, skipped ones are counted; failures with their diff and frame, and the totals, stay |
|
|
46
|
+
| `xcodebuild`, `swift build` / `test` | build steps (`CompileSwift`, `Ld`, `CodeSign` and their command lines) and progress counted, not listed; warnings grouped by message; errors keep frame and notes; passing XCTest / Swift Testing tests counted; the `** BUILD SUCCEEDED **` line stays |
|
|
47
|
+
| `eslint`, `biome`, `ruff`, `flake8`, `mypy`, `pylint`, `ktlint`, `detekt`, `swiftlint`, `shellcheck`, `flutter analyze` | findings grouped by rule and message, each location once (`error eqeqeq: … (5)` then `m.js:2:46 m1.js:2:47 …`), the shared directory said once; frames and fix suggestions go |
|
|
48
|
+
| `npm` / `pnpm` / `yarn` / `bun install`, `pip install`, `uv sync`, `pod install`, `bundle install`, `flutter pub get` | download and progress lines go; per-package lines become one line (`Collecting (9): requests, rich, …`); deprecation warnings become one list of names; errors and the summary stay |
|
|
49
|
+
| `docker build` | `[internal]` steps, layer downloads, `CACHED` / `DONE` lines go; each step keeps its header and what it printed |
|
|
50
|
+
| `docker logs`, `docker compose logs`, `kubectl logs`, `journalctl`, `adb logcat` (not `-f` / `--follow`) | 3+ lines that differ only in timestamps and numbers keep the first and last; framework stack frames fold; logcat drops the date and thread id; past the limit the start goes, not the end |
|
|
51
|
+
| `gradle` on Android | also: Kotlin / KSP warnings, detekt / ktlint findings and Android Lint issues grouped by message with their locations (lint's source lines, explanations and baseline advice go); repeated warnings and `--warning-mode all` deprecations said once with a count, without the upgrade-guide links; R8 `Missing class` and `Duplicate class` lines become one list of classes; "Searched in the following locations" becomes a count; `A failure occurred while executing …` wrappers go and the failure summary does not repeat what was printed above it; framework frames fold |
|
|
52
|
+
| `gradle tasks`, `gradle projects`, `dependencyInsight`, `dependencies` | tasks: names per group, descriptions go; projects: every path once, grouped by the first segment (`:api (261): …`); insight: the attribute tables go; dependencies: cut like a tree |
|
|
53
|
+
| `mvn`, `./mvnw`, `mvnd` | `Downloading` / `Downloaded` lines and their progress counted; `[INFO]` lines go except the result, the test totals and modules that failed; a passing test class takes its output (Spring banners, logs) with it; errors said once, the `[Help 1]` footer goes; `dependency:tree` is cut like a tree |
|
|
54
|
+
| `javac` | errors keep their source line, caret and `symbol:` / `location:`; warnings grouped by kind (`warning rawtypes: found raw type: List (2)`); `Recompile with -Xlint` notes go |
|
|
55
|
+
| a JVM stack trace from anything (`java -jar`, a test JVM, a log) | framework frames (java., kotlin., android., org.junit., org.springframework.…) fold to `… N framework frames`; the exception, each `Caused by:`, your own frames and `... N more` stay; also when the command failed |
|
|
56
|
+
| `adb exec-out uiautomator dump /dev/tty` | one line per node that says something, indented by nesting, with where to tap: `TextView "Network & internet" #title @375,608`; empty wrappers and off-screen nodes go |
|
|
57
|
+
| `adb shell getprop`, `settings list …` | keys that share a prefix written once, every value kept: `aaudio: hw_burst_min_usec=2000 mmap_policy=2` |
|
|
58
|
+
| `adb shell ps -A`, `top -n 1` | kernel threads and idle processes counted, not listed; column padding goes |
|
|
59
|
+
| `adb shell dumpsys …` | lines deeper than an entry's own go with a count; 3+ lines of the same shape keep their ends |
|
|
60
|
+
| `adb shell pm list packages [-f]` | grouped by the first two name segments; with `-f`, name then path, hashed install directories elided |
|
|
61
|
+
| `adb logcat` (`threadtime`, `time`, `brief`) | as logs above; with `adb -s <serial>` too |
|
|
62
|
+
| `… \| tail -50`, `… \| head -n 20` | the command before the pipe picks the compactor |
|
|
63
|
+
| 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) |
|
|
64
|
+
| `git`, `cargo`, `docker`… | rewritten through [rtk](https://github.com/rtk-ai/rtk) when it is installed (switch: `/squeeze rtk off`) |
|
|
65
|
+
|
|
66
|
+
Output with a marker of its own (a `tsc` error list, a gradle or Maven build, `javac` errors, a JVM stack trace, a `uiautomator` dump, a `bun test` run, rustc diagnostics) is recognised whatever ran it: `just check`, `npm run build`, `make test`, `… 2>&1`.
|
|
67
|
+
A **failed** command is compacted too, but only for test and build output, where the errors are the point.
|
|
68
|
+
`git -C dir …`, `git --no-pager …`, `--color=never` and `cargo +nightly …` are read as the plain command.
|
|
69
|
+
|
|
70
|
+
Safety rules:
|
|
71
|
+
|
|
72
|
+
* Pipes, redirects, chains and `--json`/`-c`/`-l`/`-x` style flags are never touched: the output is no longer the tool's own format.
|
|
73
|
+
* A line that does not parse makes the whole output pass through.
|
|
74
|
+
* The result must be clearly smaller (under 90%) and the output at least 400 characters.
|
|
75
|
+
* 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.
|
|
76
|
+
* `read`, `edit` and `write` are never changed.
|
|
77
|
+
|
|
78
|
+
## Commands
|
|
79
|
+
|
|
80
|
+
| Command | |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `/squeeze` or `/squeeze status` | the settings and the tokens saved so far |
|
|
83
|
+
| `/squeeze [on\|off]` | switch everything |
|
|
84
|
+
| `/squeeze [search\|paths\|listing\|diff\|tests\|git\|build\|lint\|install\|logs\|tree\|generic] [on\|off]` | switch one compactor |
|
|
85
|
+
| `/squeeze rtk [on\|off]` | rewrite git, cargo, docker… through [rtk](https://github.com/rtk-ai/rtk), when installed |
|
|
86
|
+
| `/squeeze min <chars>` | smallest output worth compacting (default 400) |
|
|
87
|
+
|
|
88
|
+
Settings are remembered in `<pi agent dir>/squeeze.json` (`PI_CODING_AGENT_DIR`, else `~/.pi/agent`); it also holds
|
|
89
|
+
`lineChars`, `perGroup` and `total`, the limits of a compacted result. `PI_SQUEEZE=off` disables it at start.
|
|
90
|
+
The status line shows the tokens saved.
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
bun install
|
|
96
|
+
just check # typecheck + tests (95% coverage gate) + pack check + nu script tests
|
|
97
|
+
just --list # everything else: fuzz, audit, release, remotes
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Layout:
|
|
101
|
+
|
|
102
|
+
| Path | What |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `extensions/squeeze.ts` | the extension: pure functions plus a thin default export |
|
|
105
|
+
| `tests/` | `bun test`: `core` (pure logic), `extension` (hooks), `fuzz` (randomised properties) |
|
|
106
|
+
| `scripts/` | Nushell only, each with a test in `scripts/tests/` |
|
|
107
|
+
| `.github/`, `.gitea/` | CI, release, nightlies on both hosts |
|
|
108
|
+
|
|
109
|
+
Releases run in CI: **Actions → Release → Run workflow** with a bump (`patch`, `minor`, `major`, or
|
|
110
|
+
`current` for the version already in `package.json`), or `just release-ci patch`. The workflow runs the
|
|
111
|
+
quality gate, bumps `package.json`, regenerates `CHANGELOG.md` with git-cliff, commits, tags, pushes and
|
|
112
|
+
publishes to npm (secret `NPM_TOKEN`). Locally, `just release patch` does the same up to the tag push.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
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,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `adb shell pm list packages`: the `package:` prefix goes and names are grouped by their
|
|
3
|
+
* first two segments, each name still complete once its group is read:
|
|
4
|
+
*
|
|
5
|
+
* com.google.android (3): apps.messaging gms gsf
|
|
6
|
+
*/
|
|
7
|
+
import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
8
|
+
import { shape } from "./logs.ts";
|
|
9
|
+
|
|
10
|
+
export function compactPackages(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
11
|
+
const lines = output.split("\n").map((l) => l.trimEnd());
|
|
12
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
13
|
+
if (!lines.length || !lines.every((l) => /^package:[\w.]+$/.test(l))) return undefined;
|
|
14
|
+
const groups = new Map<string, string[]>();
|
|
15
|
+
for (const l of lines.map((l) => l.slice("package:".length)).sort()) {
|
|
16
|
+
const parts = l.split(".");
|
|
17
|
+
const key = parts.length > 2 ? parts.slice(0, 2).join(".") : "";
|
|
18
|
+
groups.set(key, [...(groups.get(key) ?? []), key ? parts.slice(2).join(".") : l]);
|
|
19
|
+
}
|
|
20
|
+
const out = [`${lines.length} packages`];
|
|
21
|
+
// A group line may run long: it is still one line of names, cut only far past the usual width.
|
|
22
|
+
const width = limits.lineChars * 8;
|
|
23
|
+
let cut = false;
|
|
24
|
+
for (const [key, names] of groups) {
|
|
25
|
+
const line = key ? `${key} (${names.length}): ${names.join(" ")}` : names.join(" ");
|
|
26
|
+
cut ||= line.length > width;
|
|
27
|
+
out.push(cutLine(line, width));
|
|
28
|
+
}
|
|
29
|
+
const capped = capLines(out, limits.total);
|
|
30
|
+
return { text: capped.text, truncated: cut || capped.truncated };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `adb shell pm list packages -f`: `package:/path/base.apk=name` → `name /path`, hashed install dirs elided. */
|
|
34
|
+
export function compactPackagePaths(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
35
|
+
const lines = output.split("\n").map((l) => l.trimEnd());
|
|
36
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
37
|
+
const rows = lines.map((l) => /^package:(\/\S+)=([\w.]+)$/.exec(l));
|
|
38
|
+
if (!rows.length || rows.some((r) => !r)) return undefined;
|
|
39
|
+
const out = rows
|
|
40
|
+
.map((r) => `${r![2]} ${r![1]!.replace(/\/~~[^/]+==\/[\w.]+-[^/]+==\//, "/…/")}`)
|
|
41
|
+
.sort()
|
|
42
|
+
.map((l) => cutLine(l, limits.lineChars));
|
|
43
|
+
const capped = capLines([`${rows.length} packages`, ...out], limits.total);
|
|
44
|
+
return { text: capped.text, truncated: capped.truncated };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A value as `key=value` shows it: quoted when empty or holding spaces. */
|
|
48
|
+
const shown = (v: string) => (v === "" || /\s/.test(v) ? JSON.stringify(v) : v);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Properties and settings (`adb shell getprop`, `settings list global|secure|system`):
|
|
52
|
+
* keys that share a prefix are written once, values all kept:
|
|
53
|
+
*
|
|
54
|
+
* ro.build: version.release=16 version.sdk=36 type=user
|
|
55
|
+
*/
|
|
56
|
+
export function compactProperties(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
57
|
+
const raw = output.split("\n").map((l) => l.trimEnd());
|
|
58
|
+
while (raw.length && raw.at(-1) === "") raw.pop();
|
|
59
|
+
// A getprop value may span lines: they belong to the `[key]: [` line above.
|
|
60
|
+
const lines: string[] = [];
|
|
61
|
+
for (const l of raw) {
|
|
62
|
+
if (lines.length && /^\[[^\]]+\]: \[/.test(lines.at(-1)!) && !/\]$/.test(lines.at(-1)!)) lines[lines.length - 1] += `\\n${l}`;
|
|
63
|
+
else lines.push(l);
|
|
64
|
+
}
|
|
65
|
+
if (lines.length < 5) return undefined;
|
|
66
|
+
const getprop = lines.every((l) => /^\[[^\]]+\]: \[.*\]$/.test(l));
|
|
67
|
+
const pairs = lines.map((l) => (getprop ? /^\[([^\]]+)\]: \[(.*)\]$/.exec(l) : /^([\w.:-]+)=(.*)$/.exec(l)));
|
|
68
|
+
if (pairs.some((p) => !p)) return undefined;
|
|
69
|
+
const sep = getprop ? "." : "_";
|
|
70
|
+
const groups = new Map<string, string[]>();
|
|
71
|
+
for (const p of pairs) {
|
|
72
|
+
const key = p![1]!;
|
|
73
|
+
// getprop keys group by their first two segments (`ro.build`), settings by the first word.
|
|
74
|
+
const parts = key.split(sep);
|
|
75
|
+
const n = Math.min(getprop ? 2 : 1, parts.length - 1);
|
|
76
|
+
const [head, tail] = n > 0 && parts[0] ? [parts.slice(0, n).join(sep), parts.slice(n).join(sep)] : ["", key];
|
|
77
|
+
groups.set(head, [...(groups.get(head) ?? []), `${tail}=${shown(p![2]!)}`]);
|
|
78
|
+
}
|
|
79
|
+
let cut = false;
|
|
80
|
+
const out: string[] = [];
|
|
81
|
+
const item = (i: string) => {
|
|
82
|
+
cut ||= i.length > limits.lineChars;
|
|
83
|
+
return cutLine(i, limits.lineChars);
|
|
84
|
+
};
|
|
85
|
+
for (const [head, items] of groups) {
|
|
86
|
+
if (!head || items.length === 1) {
|
|
87
|
+
out.push(...items.map((i) => item(head ? `${head}${sep}${i}` : i)));
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
// Long groups wrap rather than lose values: a new line repeats the prefix.
|
|
91
|
+
let line = `${head}:`;
|
|
92
|
+
for (const i of items.map(item)) {
|
|
93
|
+
if (line.length + i.length + 1 > limits.lineChars * 2 && line !== `${head}:`) (out.push(line), (line = `${head}:`));
|
|
94
|
+
line += ` ${i}`;
|
|
95
|
+
}
|
|
96
|
+
out.push(line);
|
|
97
|
+
}
|
|
98
|
+
const capped = capLines(out, limits.total);
|
|
99
|
+
return { text: capped.text, truncated: cut || capped.truncated };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Process lists (`adb shell ps -A`, `adb shell top -n 1`): kernel threads (no memory of their
|
|
104
|
+
* own) and idle rows of `top` are counted, not listed; column padding goes.
|
|
105
|
+
*/
|
|
106
|
+
export function compactProcesses(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
107
|
+
const lines = output.split("\n").map((l) => l.trimEnd());
|
|
108
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
109
|
+
const header = lines.findIndex((l) => /^\s*(?:USER\s+PID\s+PPID|PID\s+USER\s+PR\s+NI)\b/.test(l));
|
|
110
|
+
if (header < 0 || lines.length - header < 5) return undefined;
|
|
111
|
+
const cols = lines[header]!.trim().split(/\s+/);
|
|
112
|
+
const ps = cols[0] === "USER";
|
|
113
|
+
const vsz = cols.indexOf("VSZ");
|
|
114
|
+
const cpu = cols.findIndex((c) => /%CPU/.test(c));
|
|
115
|
+
let hidden = 0;
|
|
116
|
+
const rows: string[] = [];
|
|
117
|
+
for (const l of lines.slice(header + 1)) {
|
|
118
|
+
const f = l.trim().split(/\s+/);
|
|
119
|
+
// ps: kernel threads have no virtual memory; top: rows that used no CPU.
|
|
120
|
+
const idle = ps ? f[vsz] === "0" : Number(f[cpu + (cols[cpu] === "S[%CPU]" ? 1 : 0)]) === 0;
|
|
121
|
+
if (idle) hidden++;
|
|
122
|
+
else rows.push(cutLine(f.join(" "), limits.lineChars));
|
|
123
|
+
}
|
|
124
|
+
const squeeze = (l: string) => l.trim().replace(/\s{2,}/g, " ");
|
|
125
|
+
const out = [...lines.slice(0, header).map(squeeze), squeeze(lines[header]!), ...rows];
|
|
126
|
+
if (hidden) out.push(`… ${hidden} ${ps ? "kernel threads" : "idle processes"} not listed`);
|
|
127
|
+
const capped = capLines(out, limits.total);
|
|
128
|
+
return { text: capped.text, truncated: hidden > 0 || capped.truncated };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Indent deeper than this goes in a dumpsys report (sections, then their entries, stay). */
|
|
132
|
+
const DUMPSYS_DEPTH = 8;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `adb shell dumpsys …`: lines indented deeper than an entry's own line go (with a count),
|
|
136
|
+
* runs of 3+ lines of the same shape keep their ends, and long reports are capped.
|
|
137
|
+
*/
|
|
138
|
+
export function compactDumpsys(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
139
|
+
const lines = output.split("\n").map((l) => l.trimEnd());
|
|
140
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
141
|
+
if (lines.length < 20) return undefined;
|
|
142
|
+
const out: string[] = [];
|
|
143
|
+
let deeper = 0;
|
|
144
|
+
const flush = () => {
|
|
145
|
+
if (deeper) out.push(`${" ".repeat(DUMPSYS_DEPTH + 2)}… [+${deeper} deeper lines]`);
|
|
146
|
+
deeper = 0;
|
|
147
|
+
};
|
|
148
|
+
for (const l of lines) {
|
|
149
|
+
if (l.length - l.trimStart().length > DUMPSYS_DEPTH) {
|
|
150
|
+
deeper++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
flush();
|
|
154
|
+
out.push(l);
|
|
155
|
+
}
|
|
156
|
+
flush();
|
|
157
|
+
const runs: string[] = [];
|
|
158
|
+
for (let i = 0; i < out.length; ) {
|
|
159
|
+
const s = shape(out[i]!);
|
|
160
|
+
let j = i + 1;
|
|
161
|
+
while (j < out.length && shape(out[j]!) === s) j++;
|
|
162
|
+
runs.push(...(j - i >= 3 ? [out[i]!, `${out[i]!.match(/^\s*/)![0]}… [${j - i - 2} similar]`, out[j - 1]!] : out.slice(i, j)));
|
|
163
|
+
i = j;
|
|
164
|
+
}
|
|
165
|
+
const capped = capLines(
|
|
166
|
+
runs.map((l) => cutLine(l, limits.lineChars)),
|
|
167
|
+
limits.total,
|
|
168
|
+
);
|
|
169
|
+
return { text: capped.text, truncated: true };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const ENTITY: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
173
|
+
const unescape = (s: string) => s.replace(/&(amp|lt|gt|quot|apos|#(\d+));/g, (_, e: string, n?: string) => (n ? String.fromCodePoint(Number(n)) : ENTITY[e]!));
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* `uiautomator dump` XML (`adb exec-out uiautomator dump /dev/tty`): one line per node that
|
|
177
|
+
* says something (text, description, id, or something to tap), indented by nesting, with its
|
|
178
|
+
* centre to tap at; empty wrappers and off-screen nodes go.
|
|
179
|
+
*
|
|
180
|
+
* Button "OK" #ok click @540,1820
|
|
181
|
+
*/
|
|
182
|
+
export function compactUiDump(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
183
|
+
if (!/<hierarchy\b/.test(output)) return undefined;
|
|
184
|
+
const out: string[] = [];
|
|
185
|
+
/** For every open node: did it print (and so indent its children)? */
|
|
186
|
+
const open: boolean[] = [];
|
|
187
|
+
let nodes = 0;
|
|
188
|
+
for (const m of output.matchAll(/<node\b([^>]*?)(\/?)>|<\/node>/g)) {
|
|
189
|
+
if (!m[1] && !m[2] && m[0] === "</node>") {
|
|
190
|
+
open.pop();
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
nodes++;
|
|
194
|
+
const a: Record<string, string> = {};
|
|
195
|
+
for (const [, k, v] of m[1]!.matchAll(/([\w-]+)="([^"]*)"/g)) a[k!] = unescape(v!);
|
|
196
|
+
const b = /^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$/.exec(a.bounds ?? "");
|
|
197
|
+
const [x1, y1, x2, y2] = (b ?? []).slice(1).map(Number) as number[];
|
|
198
|
+
const visible = !!b && x2! > x1! && y2! > y1!;
|
|
199
|
+
const id = (a["resource-id"] ?? "").replace(/^.*:id\//, "");
|
|
200
|
+
const flags = [
|
|
201
|
+
a.clickable === "true" && "click",
|
|
202
|
+
a["long-clickable"] === "true" && "long-click",
|
|
203
|
+
a.scrollable === "true" && "scroll",
|
|
204
|
+
a.checkable === "true" && (a.checked === "true" ? "checked" : "unchecked"),
|
|
205
|
+
a.selected === "true" && "selected",
|
|
206
|
+
a.focused === "true" && "focused",
|
|
207
|
+
a.enabled === "false" && "disabled",
|
|
208
|
+
a.password === "true" && "password",
|
|
209
|
+
].filter(Boolean);
|
|
210
|
+
// An id alone names a leaf worth finding (an icon, an input); on a container it is layout.
|
|
211
|
+
const says = visible && (a.text || a["content-desc"] || a.hint || flags.length || (id && m[2]));
|
|
212
|
+
if (says) {
|
|
213
|
+
const depth = open.filter(Boolean).length;
|
|
214
|
+
const parts = [
|
|
215
|
+
(a.class ?? "").split(".").pop(),
|
|
216
|
+
a.text && JSON.stringify(a.text),
|
|
217
|
+
a["content-desc"] && `desc=${JSON.stringify(a["content-desc"])}`,
|
|
218
|
+
a.hint && `hint=${JSON.stringify(a.hint)}`,
|
|
219
|
+
id && `#${id}`,
|
|
220
|
+
...flags,
|
|
221
|
+
`@${Math.round((x1! + x2!) / 2)},${Math.round((y1! + y2!) / 2)}`,
|
|
222
|
+
].filter(Boolean);
|
|
223
|
+
out.push(cutLine(`${" ".repeat(depth)}${parts.join(" ")}`, limits.lineChars));
|
|
224
|
+
}
|
|
225
|
+
if (!m[2]) open.push(!!says);
|
|
226
|
+
}
|
|
227
|
+
if (!nodes) return undefined;
|
|
228
|
+
const pkg = /package="([^"]+)"/.exec(output)?.[1];
|
|
229
|
+
const capped = capLines([`ui: ${out.length} of ${nodes} nodes${pkg ? ` (${pkg})` : ""}`, ...out], limits.total);
|
|
230
|
+
return { text: capped.text, truncated: true };
|
|
231
|
+
}
|
|
@@ -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
|
+
}
|