tersetrim 0.2.0__tar.gz
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.
- tersetrim-0.2.0/LICENSE +21 -0
- tersetrim-0.2.0/PKG-INFO +78 -0
- tersetrim-0.2.0/README.md +65 -0
- tersetrim-0.2.0/pyproject.toml +23 -0
- tersetrim-0.2.0/setup.cfg +4 -0
- tersetrim-0.2.0/tersetrim.egg-info/PKG-INFO +78 -0
- tersetrim-0.2.0/tersetrim.egg-info/SOURCES.txt +9 -0
- tersetrim-0.2.0/tersetrim.egg-info/dependency_links.txt +1 -0
- tersetrim-0.2.0/tersetrim.egg-info/entry_points.txt +2 -0
- tersetrim-0.2.0/tersetrim.egg-info/top_level.txt +1 -0
- tersetrim-0.2.0/tersetrim.py +262 -0
tersetrim-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Terse Labs
|
|
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.
|
tersetrim-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tersetrim
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Token-optimizing command wrapper for AI coding agents — compacts CLI output, argv-safe, no junk files.
|
|
5
|
+
Author: Terse Labs
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/terse-labs/tersetrim
|
|
8
|
+
Keywords: llm,ai-agent,cli,tokens,claude-code,developer-tools
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# tersetrim
|
|
15
|
+
|
|
16
|
+
**Trim your shell output. Save your tokens.**
|
|
17
|
+
|
|
18
|
+
*The first tool from **Terse Labs** — lean developer tools for the AI-agent era.*
|
|
19
|
+
|
|
20
|
+
A token-optimizing command wrapper for AI coding agents (Claude Code, Cursor, and friends). It runs
|
|
21
|
+
your shell commands and compacts their verbose output so the agent reads far fewer tokens — without
|
|
22
|
+
ever corrupting an argument or spraying junk files into your working directory.
|
|
23
|
+
|
|
24
|
+
## Why
|
|
25
|
+
|
|
26
|
+
LLM coding agents pay per token and blow through context on noisy tool output. A 20-line `git status`
|
|
27
|
+
or a screenful of `git log` is mostly ceremony the model doesn't need. `tersetrim` compacts that output
|
|
28
|
+
to the signal, so every command costs the agent less.
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install -e . # from the tersetrim/ dir; PyPI publish pending
|
|
34
|
+
tersetrim git status # runs it, prints a compact summary + a savings line on stderr
|
|
35
|
+
tersetrim git log -n 20 # one line per commit
|
|
36
|
+
tersetrim --stats # cumulative tokens saved so far
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Prefix `tersetrim` to any command. Commands it doesn't have a compactor for pass through untouched —
|
|
40
|
+
it never changes what a command *does*, only what the agent *reads*.
|
|
41
|
+
|
|
42
|
+
## Example (real output)
|
|
43
|
+
|
|
44
|
+
`tersetrim git log -n 2` on this repo:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
[tersetrim] ~110->32 tok (70% saved)
|
|
48
|
+
3cbb2eed4 chore: Terse Labs launch
|
|
49
|
+
8a1879e9f tersetrim v0.2.0 — argv-safe token-optimizing command wrapper for AI coding agents
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The full multi-line `git log` (commit / Author / Date / blank / message per commit) becomes one
|
|
53
|
+
`<short-hash> <subject>` line each — **70% fewer tokens** even on this tiny 2-commit log (longer logs save more), no commit dropped.
|
|
54
|
+
|
|
55
|
+
`git status` collapses to `N changed:` + one porcelain line per file; `ls -l` becomes `size name`
|
|
56
|
+
(perms/owner/date dropped); `docker ps` / `docker images` keep only the columns an agent reasons about
|
|
57
|
+
(names/status/image/ports, repo/tag/id/size) and drop the id/command/created noise.
|
|
58
|
+
|
|
59
|
+
## Why argv-safe matters
|
|
60
|
+
|
|
61
|
+
Some command wrappers **reconstruct your command into a shell string and re-exec it**. A quoted `>`
|
|
62
|
+
or `/` inside an argument then becomes a shell redirect, spraying 0-byte junk files and directories
|
|
63
|
+
into your cwd — a real bug class we hit in production before building this.
|
|
64
|
+
|
|
65
|
+
tersetrim runs the **argv list directly** (`shell=False`), so the OS receives your arguments verbatim.
|
|
66
|
+
That class of bug is **structurally impossible** — on Windows, macOS, and Linux alike. Its self-check
|
|
67
|
+
asserts exactly this: an argument containing `>` and `/` round-trips untouched and creates no file.
|
|
68
|
+
|
|
69
|
+
## Roadmap
|
|
70
|
+
|
|
71
|
+
- **v0.2** — `docker ps` / `docker images` ✓; next: `kubectl`, `npm ls`, `pip list`, `git diff --stat`
|
|
72
|
+
- **v0.3** — a hook that auto-wraps an agent's commands (no per-command prefix)
|
|
73
|
+
- **v0.4** — per-tool profiles + user-defined compactors
|
|
74
|
+
- **v1.0** — a compactor plugin ecosystem
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# tersetrim
|
|
2
|
+
|
|
3
|
+
**Trim your shell output. Save your tokens.**
|
|
4
|
+
|
|
5
|
+
*The first tool from **Terse Labs** — lean developer tools for the AI-agent era.*
|
|
6
|
+
|
|
7
|
+
A token-optimizing command wrapper for AI coding agents (Claude Code, Cursor, and friends). It runs
|
|
8
|
+
your shell commands and compacts their verbose output so the agent reads far fewer tokens — without
|
|
9
|
+
ever corrupting an argument or spraying junk files into your working directory.
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
LLM coding agents pay per token and blow through context on noisy tool output. A 20-line `git status`
|
|
14
|
+
or a screenful of `git log` is mostly ceremony the model doesn't need. `tersetrim` compacts that output
|
|
15
|
+
to the signal, so every command costs the agent less.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install -e . # from the tersetrim/ dir; PyPI publish pending
|
|
21
|
+
tersetrim git status # runs it, prints a compact summary + a savings line on stderr
|
|
22
|
+
tersetrim git log -n 20 # one line per commit
|
|
23
|
+
tersetrim --stats # cumulative tokens saved so far
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Prefix `tersetrim` to any command. Commands it doesn't have a compactor for pass through untouched —
|
|
27
|
+
it never changes what a command *does*, only what the agent *reads*.
|
|
28
|
+
|
|
29
|
+
## Example (real output)
|
|
30
|
+
|
|
31
|
+
`tersetrim git log -n 2` on this repo:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
[tersetrim] ~110->32 tok (70% saved)
|
|
35
|
+
3cbb2eed4 chore: Terse Labs launch
|
|
36
|
+
8a1879e9f tersetrim v0.2.0 — argv-safe token-optimizing command wrapper for AI coding agents
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The full multi-line `git log` (commit / Author / Date / blank / message per commit) becomes one
|
|
40
|
+
`<short-hash> <subject>` line each — **70% fewer tokens** even on this tiny 2-commit log (longer logs save more), no commit dropped.
|
|
41
|
+
|
|
42
|
+
`git status` collapses to `N changed:` + one porcelain line per file; `ls -l` becomes `size name`
|
|
43
|
+
(perms/owner/date dropped); `docker ps` / `docker images` keep only the columns an agent reasons about
|
|
44
|
+
(names/status/image/ports, repo/tag/id/size) and drop the id/command/created noise.
|
|
45
|
+
|
|
46
|
+
## Why argv-safe matters
|
|
47
|
+
|
|
48
|
+
Some command wrappers **reconstruct your command into a shell string and re-exec it**. A quoted `>`
|
|
49
|
+
or `/` inside an argument then becomes a shell redirect, spraying 0-byte junk files and directories
|
|
50
|
+
into your cwd — a real bug class we hit in production before building this.
|
|
51
|
+
|
|
52
|
+
tersetrim runs the **argv list directly** (`shell=False`), so the OS receives your arguments verbatim.
|
|
53
|
+
That class of bug is **structurally impossible** — on Windows, macOS, and Linux alike. Its self-check
|
|
54
|
+
asserts exactly this: an argument containing `>` and `/` round-trips untouched and creates no file.
|
|
55
|
+
|
|
56
|
+
## Roadmap
|
|
57
|
+
|
|
58
|
+
- **v0.2** — `docker ps` / `docker images` ✓; next: `kubectl`, `npm ls`, `pip list`, `git diff --stat`
|
|
59
|
+
- **v0.3** — a hook that auto-wraps an agent's commands (no per-command prefix)
|
|
60
|
+
- **v0.4** — per-tool profiles + user-defined compactors
|
|
61
|
+
- **v1.0** — a compactor plugin ecosystem
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tersetrim"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Token-optimizing command wrapper for AI coding agents — compacts CLI output, argv-safe, no junk files."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["llm", "ai-agent", "cli", "tokens", "claude-code", "developer-tools"]
|
|
13
|
+
authors = [{ name = "Terse Labs" }]
|
|
14
|
+
dependencies = [] # stdlib-only by design (ponytail: no deps for what argv+re do)
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
tersetrim = "tersetrim:main"
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/terse-labs/tersetrim"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools]
|
|
23
|
+
py-modules = ["tersetrim"]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tersetrim
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Token-optimizing command wrapper for AI coding agents — compacts CLI output, argv-safe, no junk files.
|
|
5
|
+
Author: Terse Labs
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/terse-labs/tersetrim
|
|
8
|
+
Keywords: llm,ai-agent,cli,tokens,claude-code,developer-tools
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# tersetrim
|
|
15
|
+
|
|
16
|
+
**Trim your shell output. Save your tokens.**
|
|
17
|
+
|
|
18
|
+
*The first tool from **Terse Labs** — lean developer tools for the AI-agent era.*
|
|
19
|
+
|
|
20
|
+
A token-optimizing command wrapper for AI coding agents (Claude Code, Cursor, and friends). It runs
|
|
21
|
+
your shell commands and compacts their verbose output so the agent reads far fewer tokens — without
|
|
22
|
+
ever corrupting an argument or spraying junk files into your working directory.
|
|
23
|
+
|
|
24
|
+
## Why
|
|
25
|
+
|
|
26
|
+
LLM coding agents pay per token and blow through context on noisy tool output. A 20-line `git status`
|
|
27
|
+
or a screenful of `git log` is mostly ceremony the model doesn't need. `tersetrim` compacts that output
|
|
28
|
+
to the signal, so every command costs the agent less.
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install -e . # from the tersetrim/ dir; PyPI publish pending
|
|
34
|
+
tersetrim git status # runs it, prints a compact summary + a savings line on stderr
|
|
35
|
+
tersetrim git log -n 20 # one line per commit
|
|
36
|
+
tersetrim --stats # cumulative tokens saved so far
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Prefix `tersetrim` to any command. Commands it doesn't have a compactor for pass through untouched —
|
|
40
|
+
it never changes what a command *does*, only what the agent *reads*.
|
|
41
|
+
|
|
42
|
+
## Example (real output)
|
|
43
|
+
|
|
44
|
+
`tersetrim git log -n 2` on this repo:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
[tersetrim] ~110->32 tok (70% saved)
|
|
48
|
+
3cbb2eed4 chore: Terse Labs launch
|
|
49
|
+
8a1879e9f tersetrim v0.2.0 — argv-safe token-optimizing command wrapper for AI coding agents
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The full multi-line `git log` (commit / Author / Date / blank / message per commit) becomes one
|
|
53
|
+
`<short-hash> <subject>` line each — **70% fewer tokens** even on this tiny 2-commit log (longer logs save more), no commit dropped.
|
|
54
|
+
|
|
55
|
+
`git status` collapses to `N changed:` + one porcelain line per file; `ls -l` becomes `size name`
|
|
56
|
+
(perms/owner/date dropped); `docker ps` / `docker images` keep only the columns an agent reasons about
|
|
57
|
+
(names/status/image/ports, repo/tag/id/size) and drop the id/command/created noise.
|
|
58
|
+
|
|
59
|
+
## Why argv-safe matters
|
|
60
|
+
|
|
61
|
+
Some command wrappers **reconstruct your command into a shell string and re-exec it**. A quoted `>`
|
|
62
|
+
or `/` inside an argument then becomes a shell redirect, spraying 0-byte junk files and directories
|
|
63
|
+
into your cwd — a real bug class we hit in production before building this.
|
|
64
|
+
|
|
65
|
+
tersetrim runs the **argv list directly** (`shell=False`), so the OS receives your arguments verbatim.
|
|
66
|
+
That class of bug is **structurally impossible** — on Windows, macOS, and Linux alike. Its self-check
|
|
67
|
+
asserts exactly this: an argument containing `>` and `/` round-trips untouched and creates no file.
|
|
68
|
+
|
|
69
|
+
## Roadmap
|
|
70
|
+
|
|
71
|
+
- **v0.2** — `docker ps` / `docker images` ✓; next: `kubectl`, `npm ls`, `pip list`, `git diff --stat`
|
|
72
|
+
- **v0.3** — a hook that auto-wraps an agent's commands (no per-command prefix)
|
|
73
|
+
- **v0.4** — per-tool profiles + user-defined compactors
|
|
74
|
+
- **v1.0** — a compactor plugin ecosystem
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tersetrim
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""tersetrim — a token-optimizing command wrapper for AI coding agents. A Terse Labs tool.
|
|
3
|
+
|
|
4
|
+
Shrinks verbose CLI output so an LLM reads fewer tokens — without the bug class that plagues
|
|
5
|
+
shell-string rewrappers: a tool that rebuilds your command into a SHELL STRING and re-execs it
|
|
6
|
+
turns a quoted '>' or '/' inside an argument into a redirect, spraying junk files/dirs into your
|
|
7
|
+
working directory. tersetrim NEVER reconstructs a shell string — it runs the argv list directly,
|
|
8
|
+
so that class of bug is STRUCTURALLY IMPOSSIBLE, on every platform.
|
|
9
|
+
|
|
10
|
+
tersetrim git status # runs `git status`, prints a compact summary + a token-savings line
|
|
11
|
+
tersetrim git log -n 20 # compacts to one line per commit
|
|
12
|
+
tersetrim --stats # cumulative tokens saved so far
|
|
13
|
+
tersetrim --self-check
|
|
14
|
+
|
|
15
|
+
Design: a command has an optional COMPACTOR (compact its output); unknown commands pass through
|
|
16
|
+
untouched. Compaction is pure text, so it can never change what the command DID — only what the
|
|
17
|
+
agent READS. A tokens≈chars/4 estimate is logged to a ledger so savings are measurable.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
_LEDGER = Path(os.environ.get("TERSETRIM_LEDGER", Path.home() / ".tersetrim" / "savings.jsonl"))
|
|
30
|
+
|
|
31
|
+
# A git PORCELAIN status line: two status codes from the git set, a space, a path. Prose lines from
|
|
32
|
+
# plain `git status` ("On branch main") do NOT match, so the compactor never counts prose as a file.
|
|
33
|
+
_PORCELAIN = re.compile(r"^[ MADRCU?!]{2} \S")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _compact_git_status(raw: str) -> str:
|
|
37
|
+
"""`git status` -> one line per changed file + a count; passes prose through if no porcelain lines."""
|
|
38
|
+
files = [l for l in raw.splitlines() if _PORCELAIN.match(l)]
|
|
39
|
+
if not files:
|
|
40
|
+
return "clean working tree" if "nothing to commit" in raw else raw
|
|
41
|
+
return f"{len(files)} changed:\n" + "\n".join(f" {l.strip()}" for l in files)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _compact_git_log(raw: str) -> str:
|
|
45
|
+
"""Full `git log` (commit/Author/Date/blank/message blocks) -> one '<short> <subject>' per commit.
|
|
46
|
+
Leaves already-oneline output untouched. Never drops a commit; only trims per-commit prose."""
|
|
47
|
+
commits = []
|
|
48
|
+
cur = {}
|
|
49
|
+
for line in raw.splitlines():
|
|
50
|
+
if line.startswith("commit "):
|
|
51
|
+
if cur:
|
|
52
|
+
commits.append(cur)
|
|
53
|
+
cur = {"hash": line.split()[1][:9], "subject": ""}
|
|
54
|
+
elif line.startswith(" ") and cur and not cur["subject"]:
|
|
55
|
+
cur["subject"] = line.strip()
|
|
56
|
+
if cur:
|
|
57
|
+
commits.append(cur)
|
|
58
|
+
if not commits: # not the multi-line format (already --oneline etc.)
|
|
59
|
+
return raw
|
|
60
|
+
return "\n".join(f"{c['hash']} {c['subject']}" for c in commits)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _compact_ls_long(raw: str) -> str:
|
|
64
|
+
"""`ls -l`/`ls -la` -> 'size name', dropping perms/links/owner/group/date (noise for an agent).
|
|
65
|
+
Keeps the 'total' header out; leaves non-long output (plain `ls`) untouched."""
|
|
66
|
+
out = []
|
|
67
|
+
matched = False
|
|
68
|
+
for line in raw.splitlines():
|
|
69
|
+
if line.startswith("total "):
|
|
70
|
+
continue
|
|
71
|
+
# perms(10) links owner group size mon day time/year name...
|
|
72
|
+
m = re.match(r"^[-dlbcps][-rwxsStT]{9}[.+]?\s+\d+\s+\S+\s+\S+\s+(\d+)\s+\S+\s+\S+\s+\S+\s+(.+)$", line)
|
|
73
|
+
if m:
|
|
74
|
+
matched = True
|
|
75
|
+
out.append(f"{m.group(1):>10} {m.group(2)}")
|
|
76
|
+
else:
|
|
77
|
+
out.append(line)
|
|
78
|
+
return "\n".join(out) if matched else raw
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _compact_docker_ps(raw: str) -> str:
|
|
82
|
+
"""`docker ps` is very wide (id, image, command, created, status, ports, names). Keep the SIGNAL
|
|
83
|
+
— NAMES, STATUS, IMAGE, PORTS — and drop id/command/created. Header-driven so column widths/order
|
|
84
|
+
can't break it; passes non-table output (errors, `-q`) through untouched."""
|
|
85
|
+
lines = raw.splitlines()
|
|
86
|
+
if not lines or "CONTAINER ID" not in lines[0]:
|
|
87
|
+
return raw
|
|
88
|
+
hdr = lines[0]
|
|
89
|
+
# Parse ALL columns by header position (must track every one, or a slice bleeds an untracked
|
|
90
|
+
# column into a kept one), then emit only NAMES/STATUS/IMAGE/PORTS.
|
|
91
|
+
heads = [h for h in ("CONTAINER ID", "IMAGE", "COMMAND", "CREATED", "STATUS", "PORTS", "NAMES")
|
|
92
|
+
if h in hdr]
|
|
93
|
+
if "NAMES" not in heads or "STATUS" not in heads:
|
|
94
|
+
return raw
|
|
95
|
+
heads.sort(key=lambda h: hdr.find(h))
|
|
96
|
+
starts = [hdr.find(h) for h in heads] + [len(hdr) + 9999]
|
|
97
|
+
keep = ("NAMES", "STATUS", "IMAGE", "PORTS")
|
|
98
|
+
out = [" ".join(k for k in keep if k in heads)]
|
|
99
|
+
for line in lines[1:]:
|
|
100
|
+
if not line.strip():
|
|
101
|
+
continue
|
|
102
|
+
vals = {heads[i]: line[starts[i]:starts[i + 1]].strip() for i in range(len(heads))}
|
|
103
|
+
out.append(" ".join(vals.get(c, "") for c in keep if c in heads).rstrip())
|
|
104
|
+
return "\n".join(out)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _compact_docker_images(raw: str) -> str:
|
|
108
|
+
"""`docker images` is wide (repo, tag, id, created, size). Keep REPOSITORY/TAG/IMAGE ID/SIZE
|
|
109
|
+
— the identity + footprint an agent reasons about — and drop CREATED (verbose relative time,
|
|
110
|
+
low signal). Header-driven like docker ps; passes non-table output (errors, `-q`) through."""
|
|
111
|
+
lines = raw.splitlines()
|
|
112
|
+
if not lines or "REPOSITORY" not in lines[0] or "SIZE" not in lines[0]:
|
|
113
|
+
return raw
|
|
114
|
+
hdr = lines[0]
|
|
115
|
+
heads = [h for h in ("REPOSITORY", "TAG", "IMAGE ID", "CREATED", "SIZE") if h in hdr]
|
|
116
|
+
heads.sort(key=lambda h: hdr.find(h))
|
|
117
|
+
starts = [hdr.find(h) for h in heads] + [len(hdr) + 9999]
|
|
118
|
+
keep = ("REPOSITORY", "TAG", "IMAGE ID", "SIZE")
|
|
119
|
+
out = [" ".join(k for k in keep if k in heads)]
|
|
120
|
+
for line in lines[1:]:
|
|
121
|
+
if not line.strip():
|
|
122
|
+
continue
|
|
123
|
+
vals = {heads[i]: line[starts[i]:starts[i + 1]].strip() for i in range(len(heads))}
|
|
124
|
+
out.append(" ".join(vals.get(c, "") for c in keep if c in heads).rstrip())
|
|
125
|
+
return "\n".join(out)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _compactor_for(argv: list[str]):
|
|
129
|
+
joined = tuple(a for a in argv if not a.startswith("-")) # ignore flags for the key
|
|
130
|
+
for n in (2, 1):
|
|
131
|
+
key = joined[:n]
|
|
132
|
+
if key in _COMPACTORS:
|
|
133
|
+
return _COMPACTORS[key]
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
_COMPACTORS = {
|
|
138
|
+
("git", "status"): _compact_git_status,
|
|
139
|
+
("git", "log"): _compact_git_log,
|
|
140
|
+
("ls",): _compact_ls_long,
|
|
141
|
+
("docker", "ps"): _compact_docker_ps,
|
|
142
|
+
("docker", "images"): _compact_docker_images,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _est_tokens(s: str) -> int:
|
|
147
|
+
return max(0, len(s) // 4) # ponytail: chars/4 is the standard rough token estimate
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _record_saving(cmd: str, raw: str, out: str) -> None:
|
|
151
|
+
try:
|
|
152
|
+
_LEDGER.parent.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
saved = _est_tokens(raw) - _est_tokens(out)
|
|
154
|
+
with _LEDGER.open("a", encoding="utf-8") as f:
|
|
155
|
+
f.write(json.dumps({"ts": int(time.time()), "cmd": cmd,
|
|
156
|
+
"raw_tok": _est_tokens(raw), "out_tok": _est_tokens(out),
|
|
157
|
+
"saved_tok": saved}) + "\n")
|
|
158
|
+
except Exception: # noqa: BLE001 — telemetry must never break the wrapper
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def run(argv: list[str], _runner=None) -> tuple[int, str]:
|
|
163
|
+
"""Run argv DIRECTLY (never via a shell string) and return (returncode, output_for_agent).
|
|
164
|
+
_runner is a test seam returning (rc, stdout)."""
|
|
165
|
+
if not argv:
|
|
166
|
+
return 2, "tersetrim: no command given"
|
|
167
|
+
if _runner is not None:
|
|
168
|
+
rc, raw = _runner(argv)
|
|
169
|
+
else:
|
|
170
|
+
# shell=False + a list => the OS gets argv verbatim; no quoting/redirect reinterpretation.
|
|
171
|
+
p = subprocess.run(argv, capture_output=True, text=True)
|
|
172
|
+
rc, raw = p.returncode, (p.stdout or "") + (p.stderr or "")
|
|
173
|
+
comp = _compactor_for(argv)
|
|
174
|
+
out = comp(raw) if comp else raw
|
|
175
|
+
if comp and raw:
|
|
176
|
+
_record_saving(" ".join(argv), raw, out)
|
|
177
|
+
saved = _est_tokens(raw) - _est_tokens(out)
|
|
178
|
+
pct = (100 * saved // _est_tokens(raw)) if _est_tokens(raw) else 0
|
|
179
|
+
sys.stderr.write(f"[tersetrim] ~{_est_tokens(raw)}->{_est_tokens(out)} tok ({pct}% saved)\n")
|
|
180
|
+
return rc, out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _stats() -> int:
|
|
184
|
+
if not _LEDGER.exists():
|
|
185
|
+
print("tersetrim: no savings recorded yet (run some commands through it first)")
|
|
186
|
+
return 0
|
|
187
|
+
n = saved = 0
|
|
188
|
+
for line in _LEDGER.read_text(encoding="utf-8").splitlines():
|
|
189
|
+
try:
|
|
190
|
+
saved += int(json.loads(line).get("saved_tok", 0))
|
|
191
|
+
n += 1
|
|
192
|
+
except (json.JSONDecodeError, ValueError):
|
|
193
|
+
continue
|
|
194
|
+
print(f"tersetrim: {n} commands compacted, ~{saved:,} tokens saved cumulatively")
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _self_check() -> int:
|
|
199
|
+
import tempfile
|
|
200
|
+
# 1. THE rtk BUG CANNOT HAPPEN: an argument with '>' and '/' passes through as one argv element.
|
|
201
|
+
marker = os.path.join(tempfile.gettempdir(), "tersetrim_selfcheck_marker")
|
|
202
|
+
if os.path.exists(marker):
|
|
203
|
+
os.unlink(marker)
|
|
204
|
+
hostile = f"a > {marker} and x/inf/) fragment"
|
|
205
|
+
rc, out = run(["python", "-c", "import sys; sys.stdout.write(sys.argv[1])", hostile])
|
|
206
|
+
assert rc == 0 and out == hostile, f"argv passthrough corrupted the argument: {out!r}"
|
|
207
|
+
assert not os.path.exists(marker), "tersetrim created a redirect file — the shell-rewrap redirect bug is present!"
|
|
208
|
+
# 2. git status: counts porcelain lines, never prose, honest on clean
|
|
209
|
+
raw = "On branch main\nChanges not staged for commit:\n M a.py\n M b.py\n\nno changes added\n"
|
|
210
|
+
got = run(["git", "status"], _runner=lambda a: (0, raw))[1]
|
|
211
|
+
assert got.startswith("2 changed") and "a.py" in got and "On branch" not in got, got
|
|
212
|
+
assert run(["git", "status"], _runner=lambda a: (0, "nothing to commit, working tree clean\n"))[1] == "clean working tree"
|
|
213
|
+
# 3. git log: multi-line -> one line per commit, never drops a commit
|
|
214
|
+
glog = ("commit abc1234567\nAuthor: X\nDate: today\n\n first subject\n\n"
|
|
215
|
+
"commit def8901234\nAuthor: Y\nDate: today\n\n second subject\n")
|
|
216
|
+
gl = run(["git", "log"], _runner=lambda a: (0, glog))[1]
|
|
217
|
+
assert gl == "abc123456 first subject\ndef890123 second subject", gl
|
|
218
|
+
assert run(["git", "log", "--oneline"], _runner=lambda a: (0, "abc123 x\n"))[1] == "abc123 x\n" # already-oneline untouched
|
|
219
|
+
# 4. ls -l: 'size name', drops perms/owner/date; plain ls untouched
|
|
220
|
+
lsl = "total 8\n-rw-r--r-- 1 me grp 1234 Jul 15 10:00 file.py\ndrwxr-xr-x 2 me grp 4096 Jul 15 10:00 dir\n"
|
|
221
|
+
lo = run(["ls", "-la"], _runner=lambda a: (0, lsl))[1]
|
|
222
|
+
assert "1234 file.py" in lo and "rw-r" not in lo and "total" not in lo, lo
|
|
223
|
+
assert run(["ls"], _runner=lambda a: (0, "a.py\nb.py\n"))[1] == "a.py\nb.py\n" # plain ls untouched
|
|
224
|
+
# 5. docker ps: keep NAMES/STATUS/IMAGE/PORTS, drop id/command/created; non-table untouched
|
|
225
|
+
dps = ("CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n"
|
|
226
|
+
"abc123def456 nginx:latest \"/docker-e\" 2 hours ago Up 2 hours 0.0.0.0:80->80/tcp web\n")
|
|
227
|
+
dc = run(["docker", "ps"], _runner=lambda a: (0, dps))[1]
|
|
228
|
+
assert "web" in dc and "Up 2 hours" in dc and "nginx:latest" in dc, dc
|
|
229
|
+
assert "abc123def456" not in dc and "docker-e" not in dc, "compactor must drop id/command"
|
|
230
|
+
assert run(["docker", "ps", "-q"], _runner=lambda a: (0, "abc123\n"))[1] == "abc123\n" # non-table untouched
|
|
231
|
+
# 5b. docker images: keep REPOSITORY/TAG/IMAGE ID/SIZE, drop CREATED; non-table untouched
|
|
232
|
+
dim = ("REPOSITORY TAG IMAGE ID CREATED SIZE\n"
|
|
233
|
+
"nginx latest abc123def456 2 weeks ago 187MB\n")
|
|
234
|
+
di = run(["docker", "images"], _runner=lambda a: (0, dim))[1]
|
|
235
|
+
assert "nginx" in di and "187MB" in di and "abc123def456" in di, di
|
|
236
|
+
assert "2 weeks ago" not in di and "CREATED" not in di, "docker images must drop CREATED"
|
|
237
|
+
assert run(["docker", "images", "-q"], _runner=lambda a: (0, "abc123\n"))[1] == "abc123\n" # non-table untouched
|
|
238
|
+
# 6. unknown command passes through untouched
|
|
239
|
+
assert run(["echo", "hi"], _runner=lambda a: (0, "hi\n"))[1] == "hi\n"
|
|
240
|
+
# 6. flags don't break compactor lookup (git -C . status still compacts)
|
|
241
|
+
assert _compactor_for(["git", "status"]) is _compact_git_status
|
|
242
|
+
print("tersetrim self-check: PASS (argv passthrough quote/redirect-safe — shell-rewrap junk-file bug impossible; "
|
|
243
|
+
"git-status/git-log/ls-l compactors shrink without losing signal or fabricating state)")
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def main() -> int:
|
|
248
|
+
args = sys.argv[1:]
|
|
249
|
+
if args == ["--self-check"]:
|
|
250
|
+
return _self_check()
|
|
251
|
+
if args == ["--stats"]:
|
|
252
|
+
return _stats()
|
|
253
|
+
if not args or args == ["--help"]:
|
|
254
|
+
print(__doc__.strip())
|
|
255
|
+
return 0
|
|
256
|
+
rc, out = run(args)
|
|
257
|
+
sys.stdout.write(out if (not out or out.endswith("\n")) else out + "\n")
|
|
258
|
+
return rc
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
sys.exit(main())
|