codigest 0.4.3__tar.gz → 0.5.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.
- codigest-0.5.0/.gitattributes +1 -0
- {codigest-0.4.3 → codigest-0.5.0}/CITATION.cff +1 -1
- {codigest-0.4.3 → codigest-0.5.0}/PKG-INFO +9 -6
- {codigest-0.4.3 → codigest-0.5.0}/README.md +7 -4
- {codigest-0.4.3 → codigest-0.5.0}/pyproject.toml +2 -2
- {codigest-0.4.3 → codigest-0.5.0}/server.json +2 -2
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/term.py +99 -27
- codigest-0.5.0/src/codigest/shell/hook.sh +135 -0
- codigest-0.5.0/tests/test_term_install.py +61 -0
- {codigest-0.4.3 → codigest-0.5.0}/uv.lock +2 -2
- {codigest-0.4.3 → codigest-0.5.0}/.github/workflows/publish.yml +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/.github/workflows/tests.yml +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/.gitignore +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/.python-version +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/LICENSE +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/__init__.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/__main__.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/cap.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/clean.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/diff.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/digest.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/init.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/mcp.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/scan.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/semdiff.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/status.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/tree.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/config.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/cache.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/common.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/git_ops.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/output.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/processor.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/prompts.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/redact.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/resolver.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/runner.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/scanner.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/semdiff.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/shadow.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/shellhist.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/structure.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/tags.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/termstore.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/tokenizer.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/main.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/options.py +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/src/codigest/shell/hook.ps1 +0 -0
- {codigest-0.4.3 → codigest-0.5.0}/tests/test_termstore.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*.sh text eol=lf
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: codigest
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Human-Centered Codebase Context Manager for LLMs
|
|
5
5
|
Project-URL: Homepage, https://github.com/NaNIsaac/codigest
|
|
6
6
|
Project-URL: Repository, https://github.com/NaNIsaac/codigest
|
|
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.15
|
|
16
16
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
17
|
Classifier: Topic :: Utilities
|
|
18
|
-
Requires-Python: >=3.15
|
|
18
|
+
Requires-Python: >=3.15.0b1
|
|
19
19
|
Requires-Dist: loguru
|
|
20
20
|
Requires-Dist: pathspec
|
|
21
21
|
Requires-Dist: pyperclip
|
|
@@ -83,6 +83,8 @@ Install Codigest globally. `uv` will automatically manage the required Python 3.
|
|
|
83
83
|
uv tool install codigest
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
+
Codigest needs Python 3.15. If `uv` reports a syntax error on `lazy import`, it picked an early 3.15 alpha: run `uv self update` (or upgrade uv) and install again.
|
|
87
|
+
|
|
86
88
|
### Step 3: Update Path (Important!)
|
|
87
89
|
|
|
88
90
|
To run `codigest` (or `cdg`) from any terminal, ensure your tools directory is in your PATH.
|
|
@@ -219,15 +221,16 @@ Runs a command transparently (output still streams live) and records its command
|
|
|
219
221
|
* **Safety:** Known secret shapes (API keys, tokens, `password=...`) are redacted before anything touches disk. Output of credential commands (`env`, `printenv`, `gh auth token`, ...) is never stored.
|
|
220
222
|
* **Fallback:** Commands not run through `cap` are read from the native shell history (PowerShell, bash, zsh, fish), without output.
|
|
221
223
|
|
|
222
|
-
**Automatic capture (PowerShell):** `cdg term install` hooks your
|
|
224
|
+
**Automatic capture (PowerShell, bash, zsh):** `cdg term install` hooks your shells so *every* command is captured without the `cap` prefix. It writes to your PowerShell profile, `~/.bashrc` and `~/.zshrc`, for whichever of these shells is installed; pick one with `--shell powershell|bash|zsh|all`. On Windows, Git Bash counts as bash. Commands run in your current session (`cd`, variables and functions behave normally) and your history shows them as typed. Editors, pagers, REPLs and `ssh` are skipped automatically.
|
|
223
225
|
|
|
224
226
|
* Skip one command: start the line with a space.
|
|
225
|
-
* Skip more programs: `$env:CODIGEST_CAP_SKIP = '^\s*(myrepl|mytui)\b'`
|
|
226
|
-
* Disable for a session: `$env:CODIGEST_NOCAP = 1
|
|
227
|
+
* Skip more programs: `$env:CODIGEST_CAP_SKIP = '^\s*(myrepl|mytui)\b'` (bash/zsh: `export CODIGEST_CAP_SKIP='^(myrepl|mytui)( |$)'`, an extended regex)
|
|
228
|
+
* Disable for a session: `$env:CODIGEST_NOCAP = 1` (bash/zsh: `export CODIGEST_NOCAP=1`). Remove: `cdg term uninstall`.
|
|
227
229
|
* Trade-off: captured programs write to a pipe, not a TTY, so some lose colors or progress bars.
|
|
230
|
+
* bash (4+) and zsh do not capture multi-line commands (loops typed over several lines, heredocs), background jobs (`cmd &`) or lines using `!` history expansion; those run normally.
|
|
228
231
|
|
|
229
232
|
```bash
|
|
230
|
-
cdg term install #
|
|
233
|
+
cdg term install # capture everything automatically (PowerShell, bash, zsh)
|
|
231
234
|
|
|
232
235
|
# Run and capture (prefix with -- so flags reach the command)
|
|
233
236
|
cdg cap -- pytest -q
|
|
@@ -57,6 +57,8 @@ Install Codigest globally. `uv` will automatically manage the required Python 3.
|
|
|
57
57
|
uv tool install codigest
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
Codigest needs Python 3.15. If `uv` reports a syntax error on `lazy import`, it picked an early 3.15 alpha: run `uv self update` (or upgrade uv) and install again.
|
|
61
|
+
|
|
60
62
|
### Step 3: Update Path (Important!)
|
|
61
63
|
|
|
62
64
|
To run `codigest` (or `cdg`) from any terminal, ensure your tools directory is in your PATH.
|
|
@@ -193,15 +195,16 @@ Runs a command transparently (output still streams live) and records its command
|
|
|
193
195
|
* **Safety:** Known secret shapes (API keys, tokens, `password=...`) are redacted before anything touches disk. Output of credential commands (`env`, `printenv`, `gh auth token`, ...) is never stored.
|
|
194
196
|
* **Fallback:** Commands not run through `cap` are read from the native shell history (PowerShell, bash, zsh, fish), without output.
|
|
195
197
|
|
|
196
|
-
**Automatic capture (PowerShell):** `cdg term install` hooks your
|
|
198
|
+
**Automatic capture (PowerShell, bash, zsh):** `cdg term install` hooks your shells so *every* command is captured without the `cap` prefix. It writes to your PowerShell profile, `~/.bashrc` and `~/.zshrc`, for whichever of these shells is installed; pick one with `--shell powershell|bash|zsh|all`. On Windows, Git Bash counts as bash. Commands run in your current session (`cd`, variables and functions behave normally) and your history shows them as typed. Editors, pagers, REPLs and `ssh` are skipped automatically.
|
|
197
199
|
|
|
198
200
|
* Skip one command: start the line with a space.
|
|
199
|
-
* Skip more programs: `$env:CODIGEST_CAP_SKIP = '^\s*(myrepl|mytui)\b'`
|
|
200
|
-
* Disable for a session: `$env:CODIGEST_NOCAP = 1
|
|
201
|
+
* Skip more programs: `$env:CODIGEST_CAP_SKIP = '^\s*(myrepl|mytui)\b'` (bash/zsh: `export CODIGEST_CAP_SKIP='^(myrepl|mytui)( |$)'`, an extended regex)
|
|
202
|
+
* Disable for a session: `$env:CODIGEST_NOCAP = 1` (bash/zsh: `export CODIGEST_NOCAP=1`). Remove: `cdg term uninstall`.
|
|
201
203
|
* Trade-off: captured programs write to a pipe, not a TTY, so some lose colors or progress bars.
|
|
204
|
+
* bash (4+) and zsh do not capture multi-line commands (loops typed over several lines, heredocs), background jobs (`cmd &`) or lines using `!` history expansion; those run normally.
|
|
202
205
|
|
|
203
206
|
```bash
|
|
204
|
-
cdg term install #
|
|
207
|
+
cdg term install # capture everything automatically (PowerShell, bash, zsh)
|
|
205
208
|
|
|
206
209
|
# Run and capture (prefix with -- so flags reach the command)
|
|
207
210
|
cdg cap -- pytest -q
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "codigest"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "Human-Centered Codebase Context Manager for LLMs"
|
|
5
5
|
authors = [{ name = "Isaac Yong", email = "isaacyong@naver.com" }]
|
|
6
6
|
readme = "README.md"
|
|
7
|
-
requires-python = ">=3.15"
|
|
7
|
+
requires-python = ">=3.15.0b1"
|
|
8
8
|
license = { text = "MIT" }
|
|
9
9
|
dependencies = [
|
|
10
10
|
"typer",
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/NaNIsaac/codigest",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.5.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "codigest",
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.5.0",
|
|
16
16
|
"runtimeHint": "uvx",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import os
|
|
5
5
|
import sys
|
|
6
6
|
import traceback
|
|
7
|
+
from datetime import datetime
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Annotated
|
|
9
10
|
|
|
@@ -157,16 +158,44 @@ def path() -> None:
|
|
|
157
158
|
|
|
158
159
|
|
|
159
160
|
@app.command("ingest", hidden=True)
|
|
160
|
-
def ingest(
|
|
161
|
-
|
|
161
|
+
def ingest(
|
|
162
|
+
file: Annotated[Path | None, typer.Option(help="Command, NUL, output; deleted afterwards")] = None,
|
|
163
|
+
exit_code: Annotated[int | None, typer.Option()] = None,
|
|
164
|
+
cwd: Annotated[str, typer.Option()] = "",
|
|
165
|
+
shell: Annotated[str, typer.Option()] = "",
|
|
166
|
+
start: Annotated[str, typer.Option(help="Epoch seconds")] = "",
|
|
167
|
+
end: Annotated[str, typer.Option(help="Epoch seconds")] = "",
|
|
168
|
+
) -> None:
|
|
169
|
+
"""
|
|
170
|
+
Store one entry (called by the shell hook): JSON on stdin (PowerShell),
|
|
171
|
+
or options plus a capture file (bash / zsh).
|
|
172
|
+
"""
|
|
173
|
+
# The command travels in the file, not argv: click expands $VARS in argv on Windows.
|
|
162
174
|
try:
|
|
163
|
-
|
|
175
|
+
if file is None:
|
|
176
|
+
data = json.loads(sys.stdin.buffer.read().decode("utf-8-sig"))
|
|
177
|
+
else:
|
|
178
|
+
command, _, output = file.read_bytes().partition(b"\0")
|
|
179
|
+
# Locales with a decimal comma format $EPOCHREALTIME as 12,5.
|
|
180
|
+
t0, t1 = (float(t.replace(",", ".")) for t in (start or "0", end or start or "0"))
|
|
181
|
+
data = {
|
|
182
|
+
"command": command.decode("utf-8", "replace"),
|
|
183
|
+
"output": output.decode("utf-8", "replace"),
|
|
184
|
+
"exit_code": exit_code,
|
|
185
|
+
"cwd": str(Path(cwd)) if cwd else "", # Git Bash passes D:/x
|
|
186
|
+
"shell": shell,
|
|
187
|
+
"started_at": datetime.fromtimestamp(t0).astimezone().isoformat(timespec="seconds"),
|
|
188
|
+
"duration_ms": max(0, round((t1 - t0) * 1000)),
|
|
189
|
+
}
|
|
164
190
|
termstore.append_entry(termstore.Entry.from_dict(data))
|
|
165
191
|
except Exception:
|
|
166
192
|
# The hook discards our stderr; leave a trail `cdg term` can surface.
|
|
167
193
|
with _ingest_log().open("a", encoding="utf-8") as f:
|
|
168
194
|
f.write(f"--- {termstore.now_iso()}\n{traceback.format_exc()}")
|
|
169
195
|
raise typer.Exit(code=1)
|
|
196
|
+
finally:
|
|
197
|
+
if file:
|
|
198
|
+
file.unlink(missing_ok=True)
|
|
170
199
|
|
|
171
200
|
|
|
172
201
|
def _ingest_log() -> Path:
|
|
@@ -181,18 +210,36 @@ def _diagnose(log) -> None:
|
|
|
181
210
|
"[dim]Capture hook is not active in this shell. Run `cdg term install` "
|
|
182
211
|
"and open a new window, or enable it now with:[/dim]"
|
|
183
212
|
)
|
|
184
|
-
log.print(f"[dim] . '{HOOK_PATH}'[/dim]", highlight=False)
|
|
213
|
+
log.print(f"[dim] PowerShell: . '{HOOK_PATH}'[/dim]", highlight=False)
|
|
214
|
+
log.print(f"[dim] bash / zsh: . '{_posix(SH_HOOK_PATH)}'[/dim]", highlight=False)
|
|
185
215
|
if (errors := termstore.get_term_dir() / "ingest-errors.log").exists():
|
|
186
216
|
log.print(f"[red]Capture errors were logged:[/red] {errors}", highlight=False)
|
|
187
217
|
|
|
188
218
|
|
|
189
219
|
# --------------------------------------------------------------------------
|
|
190
|
-
# PowerShell
|
|
220
|
+
# Hook install (PowerShell profiles, ~/.bashrc, ~/.zshrc)
|
|
191
221
|
# --------------------------------------------------------------------------
|
|
192
222
|
|
|
193
223
|
HOOK_PATH = Path(__file__).resolve().parent.parent / "shell" / "hook.ps1"
|
|
224
|
+
SH_HOOK_PATH = HOOK_PATH.with_name("hook.sh")
|
|
194
225
|
_BEGIN = "# >>> codigest terminal capture >>>"
|
|
195
226
|
_END = "# <<< codigest terminal capture <<<"
|
|
227
|
+
_SHELLS = ("auto", "powershell", "bash", "zsh", "all")
|
|
228
|
+
|
|
229
|
+
ShellOption = Annotated[
|
|
230
|
+
str,
|
|
231
|
+
typer.Option(
|
|
232
|
+
"--shell",
|
|
233
|
+
help="powershell, bash, zsh or all (default: every one installed)",
|
|
234
|
+
),
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _posix(path: Path) -> str:
|
|
239
|
+
"""C:/x/y -> /c/x/y, the form Git Bash understands."""
|
|
240
|
+
if path.drive.endswith(":"):
|
|
241
|
+
return f"/{path.drive[0].lower()}{path.as_posix()[2:]}"
|
|
242
|
+
return path.as_posix()
|
|
196
243
|
|
|
197
244
|
|
|
198
245
|
def _powershell_profiles() -> list[Path]:
|
|
@@ -212,6 +259,21 @@ def _powershell_profiles() -> list[Path]:
|
|
|
212
259
|
return profiles
|
|
213
260
|
|
|
214
261
|
|
|
262
|
+
def _targets(shell: str) -> list[Path]:
|
|
263
|
+
"""Startup files for the requested shells; `auto` picks the installed ones."""
|
|
264
|
+
if shell not in _SHELLS:
|
|
265
|
+
raise typer.BadParameter(f"choose one of: {', '.join(_SHELLS)}", param_hint="--shell")
|
|
266
|
+
wants = lambda name: shell in (name, "all") or (shell == "auto" and shutil.which(name))
|
|
267
|
+
targets = []
|
|
268
|
+
if shell in ("powershell", "all", "auto"):
|
|
269
|
+
targets += _powershell_profiles()
|
|
270
|
+
if wants("bash"):
|
|
271
|
+
targets.append(Path.home() / ".bashrc")
|
|
272
|
+
if wants("zsh"):
|
|
273
|
+
targets.append(Path(os.environ.get("ZDOTDIR") or Path.home()) / ".zshrc")
|
|
274
|
+
return targets
|
|
275
|
+
|
|
276
|
+
|
|
215
277
|
def _strip_block(text: str) -> str:
|
|
216
278
|
start = text.find(_BEGIN)
|
|
217
279
|
end = text.find(_END)
|
|
@@ -221,45 +283,55 @@ def _strip_block(text: str) -> str:
|
|
|
221
283
|
return rest + "\n" if rest else ""
|
|
222
284
|
|
|
223
285
|
|
|
224
|
-
def _rewrite_profiles(add: bool) -> None:
|
|
286
|
+
def _rewrite_profiles(add: bool, shell: str) -> None:
|
|
225
287
|
console = Console()
|
|
226
|
-
|
|
227
|
-
|
|
288
|
+
# Removing from a file that has no block is a no-op, so uninstall checks every shell.
|
|
289
|
+
if not (targets := _targets(shell if add or shell != "auto" else "all")):
|
|
290
|
+
console.print("[red][Error][/red] No supported shell found (PowerShell, bash, zsh).")
|
|
228
291
|
raise typer.Exit(code=1)
|
|
229
292
|
|
|
230
|
-
|
|
231
|
-
for
|
|
293
|
+
sh = _posix(SH_HOOK_PATH)
|
|
294
|
+
for target in targets:
|
|
295
|
+
is_ps = target.suffix == ".ps1"
|
|
232
296
|
text = ""
|
|
233
|
-
if
|
|
297
|
+
if target.exists():
|
|
234
298
|
try:
|
|
235
|
-
text =
|
|
299
|
+
text = target.read_bytes().decode("utf-8-sig")
|
|
236
300
|
except UnicodeDecodeError:
|
|
237
301
|
# Rewriting a non-UTF-8 profile would corrupt it.
|
|
238
|
-
console.print(f"[yellow]Skipped[/yellow] {
|
|
302
|
+
console.print(f"[yellow]Skipped[/yellow] {target} (not UTF-8; edit it by hand)")
|
|
239
303
|
continue
|
|
240
304
|
new = _strip_block(text)
|
|
241
305
|
if add:
|
|
242
|
-
|
|
306
|
+
line = (
|
|
307
|
+
f"if (Test-Path '{HOOK_PATH}') {{ . '{HOOK_PATH}' }}"
|
|
308
|
+
if is_ps
|
|
309
|
+
else f"if [ -f '{sh}' ]; then . '{sh}'; fi"
|
|
310
|
+
)
|
|
311
|
+
new = (new.rstrip("\n") + "\n\n" if new.strip() else "") + f"{_BEGIN}\n{line}\n{_END}\n"
|
|
243
312
|
elif new == text:
|
|
244
313
|
continue
|
|
245
|
-
|
|
246
|
-
# BOM so Windows PowerShell 5.1 reads non-ASCII paths correctly.
|
|
247
|
-
|
|
248
|
-
console.print(f"[green]{'Installed' if add else 'Removed'}[/green] {
|
|
314
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
# BOM so Windows PowerShell 5.1 reads non-ASCII paths correctly; a BOM would break bash.
|
|
316
|
+
target.write_bytes(new.encode("utf-8-sig" if is_ps else "utf-8"))
|
|
317
|
+
console.print(f"[green]{'Installed' if add else 'Removed'}[/green] {target}")
|
|
249
318
|
|
|
250
319
|
if add:
|
|
251
|
-
console.print("Active in new
|
|
252
|
-
|
|
253
|
-
|
|
320
|
+
console.print("Active in new shell windows. Enable in this one now with:")
|
|
321
|
+
if any(t.suffix == ".ps1" for t in targets):
|
|
322
|
+
console.print(f" PowerShell: . '{HOOK_PATH}'", highlight=False)
|
|
323
|
+
if any(t.suffix != ".ps1" for t in targets):
|
|
324
|
+
console.print(f" bash / zsh: . '{sh}'", highlight=False)
|
|
325
|
+
console.print("[dim]Skip one command: start it with a space. Disable: set CODIGEST_NOCAP=1[/dim]")
|
|
254
326
|
|
|
255
327
|
|
|
256
328
|
@app.command("install")
|
|
257
|
-
def install() -> None:
|
|
258
|
-
"""Hook
|
|
259
|
-
_rewrite_profiles(add=True)
|
|
329
|
+
def install(shell: ShellOption = "auto") -> None:
|
|
330
|
+
"""Hook your shells so every command's output is captured automatically."""
|
|
331
|
+
_rewrite_profiles(add=True, shell=shell)
|
|
260
332
|
|
|
261
333
|
|
|
262
334
|
@app.command("uninstall")
|
|
263
|
-
def uninstall() -> None:
|
|
264
|
-
"""Remove the
|
|
265
|
-
_rewrite_profiles(add=False)
|
|
335
|
+
def uninstall(shell: ShellOption = "auto") -> None:
|
|
336
|
+
"""Remove the capture hook from your shell startup files."""
|
|
337
|
+
_rewrite_profiles(add=False, shell=shell)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Codigest terminal capture hook for bash (4+) and zsh. Installed by `cdg term install`.
|
|
2
|
+
#
|
|
3
|
+
# On Enter, the accepted line is rewritten to `__cdg_pre <line>; __cdg_run`.
|
|
4
|
+
# `__cdg_run` is an alias, so the line runs through `eval` in the current
|
|
5
|
+
# shell: `cd`, variables, functions and `$1` behave exactly as typed. Output is
|
|
6
|
+
# teed into a private temp file and handed to `cdg term ingest` in a background
|
|
7
|
+
# process (redaction happens there, before anything is stored).
|
|
8
|
+
#
|
|
9
|
+
# Opt out for one command: start the line with a space.
|
|
10
|
+
# Opt out entirely: export CODIGEST_NOCAP=1
|
|
11
|
+
|
|
12
|
+
[ -n "${CODIGEST_NOCAP-}" ] && return 0
|
|
13
|
+
case $- in *i*) ;; *) return 0 ;; esac
|
|
14
|
+
if [ -n "${ZSH_VERSION-}" ]; then
|
|
15
|
+
__cdg_shell=zsh
|
|
16
|
+
zmodload zsh/datetime 2>/dev/null
|
|
17
|
+
elif [ -n "${BASH_VERSION-}" ] && [ "${BASH_VERSINFO[0]}" -ge 4 ]; then
|
|
18
|
+
__cdg_shell=bash
|
|
19
|
+
else
|
|
20
|
+
return 0
|
|
21
|
+
fi
|
|
22
|
+
__cdg_exe=$(command -v cdg 2>/dev/null) || return 0
|
|
23
|
+
|
|
24
|
+
# Raw output may hold secrets until ingest redacts it: keep it owner-only.
|
|
25
|
+
__cdg_dir=${TMPDIR:-/tmp}/codigest-${UID:-0}
|
|
26
|
+
[ -d "$__cdg_dir" ] || mkdir -m 700 "$__cdg_dir" 2>/dev/null
|
|
27
|
+
{ [ -d "$__cdg_dir" ] && [ -O "$__cdg_dir" ] && [ ! -L "$__cdg_dir" ]; } || return 0
|
|
28
|
+
|
|
29
|
+
# Denylist heuristic for programs that need a real TTY (editors, pagers,
|
|
30
|
+
# REPLs, ssh). Extend with CODIGEST_CAP_SKIP (an extended regex).
|
|
31
|
+
__cdg_skip='^[[:space:]]*(vim?|nvim|nano|emacs|less|more|man|ssh|htop|btop|top|fzf|lazygit|tmux|screen|claude|codex|wsl|exit|exec|fg|bg)(\.exe)?([[:space:]]|$)'
|
|
32
|
+
__cdg_skip+='|^[[:space:]]*(cdg|codigest)(\.exe)?[[:space:]]+(term|mcp)([[:space:]]|$)'
|
|
33
|
+
__cdg_skip+='|^[[:space:]]*(python3?|py|ipython|node|pwsh|powershell|bash|zsh|sh|fish|cmd)(\.exe)?[[:space:]]*$'
|
|
34
|
+
__cdg_skip+='|^[[:space:]]*git[[:space:]]+(rebase[[:space:]]+-i|add[[:space:]]+-[pi]|mergetool)'
|
|
35
|
+
# A background job would hold the pipe open; a one-line heredoc reads its body later.
|
|
36
|
+
__cdg_skip+='|(^|[^&<>|])&($|[^&>])|(^|[^<])<<([^<]|$)'
|
|
37
|
+
__cdg_commit='^[[:space:]]*git[[:space:]]+commit'
|
|
38
|
+
__cdg_msg='[[:space:]]-[[:alnum:]]*[mF]'
|
|
39
|
+
|
|
40
|
+
__cdg_now() {
|
|
41
|
+
if [ -n "${EPOCHREALTIME-}" ]; then __cdg_t=$EPOCHREALTIME
|
|
42
|
+
else printf -v __cdg_t '%(%s)T' -1; fi
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
__cdg_wants() {
|
|
46
|
+
[ -z "${CODIGEST_NOCAP-}" ] || return 1
|
|
47
|
+
case $1 in '' | [[:space:]]*) return 1 ;; esac
|
|
48
|
+
[[ $1 =~ $__cdg_skip ]] && return 1
|
|
49
|
+
[[ -n ${CODIGEST_CAP_SKIP-} && $1 =~ $CODIGEST_CAP_SKIP ]] && return 1
|
|
50
|
+
[[ $1 =~ $__cdg_commit && ! $1 =~ $__cdg_msg ]] && return 1
|
|
51
|
+
# Quoting the line would stop `!!`-style history expansion.
|
|
52
|
+
if [ "$__cdg_shell" = zsh ]; then [[ -o banghist && $1 == *!* ]] && return 1
|
|
53
|
+
else [[ $- == *H* && $1 == *!* ]] && return 1; fi
|
|
54
|
+
# Incomplete input (open loop, quote...) goes through untouched to the continuation prompt.
|
|
55
|
+
if [ "$__cdg_shell" = zsh ]; then zsh -f -n -c "$1" 2>/dev/null
|
|
56
|
+
else BASH_ENV= "$BASH" -n -c "$1" 2>/dev/null; fi
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
__cdg_pre() {
|
|
60
|
+
local s=$?
|
|
61
|
+
__cdg_cmd=$1
|
|
62
|
+
__cdg_file=$__cdg_dir/$$.$RANDOM$RANDOM
|
|
63
|
+
printf '%s\0' "$1" >| "$__cdg_file" # ingest reads the command from here, output follows
|
|
64
|
+
__cdg_unbuf=${PYTHONUNBUFFERED-}
|
|
65
|
+
__cdg_unbuf_set=${PYTHONUNBUFFERED+1}
|
|
66
|
+
export PYTHONUNBUFFERED=1 # piped Python would otherwise block-buffer live output
|
|
67
|
+
__cdg_bg=$!
|
|
68
|
+
__cdg_now; __cdg_t0=$__cdg_t
|
|
69
|
+
__cdg_busy=1
|
|
70
|
+
return $s
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
__cdg_post() {
|
|
74
|
+
local s=$?
|
|
75
|
+
[ -n "${__cdg_busy-}" ] || return $s
|
|
76
|
+
__cdg_busy=
|
|
77
|
+
__cdg_now
|
|
78
|
+
# bash does not wait for process substitutions; zsh does for a brace group.
|
|
79
|
+
[ "$__cdg_shell" = bash ] && [ "$!" != "$__cdg_bg" ] && wait "$!" 2>/dev/null
|
|
80
|
+
if [ -n "$__cdg_unbuf_set" ]; then export PYTHONUNBUFFERED=$__cdg_unbuf; else unset PYTHONUNBUFFERED; fi
|
|
81
|
+
( "$__cdg_exe" term ingest --file="$__cdg_file" --exit-code="$s" --cwd="$PWD" \
|
|
82
|
+
--shell="$__cdg_shell" --start="$__cdg_t0" --end="$__cdg_t" </dev/null >/dev/null 2>&1 & )
|
|
83
|
+
return $s
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# tee ignores Ctrl+C so it keeps recording what the interrupted program prints last.
|
|
87
|
+
alias __cdg_run='{ eval "$__cdg_cmd"; } > >(trap "" INT; exec tee -a -- "$__cdg_file") 2>&1; __cdg_post'
|
|
88
|
+
|
|
89
|
+
if [ "$__cdg_shell" = zsh ]; then
|
|
90
|
+
__cdg_accept() {
|
|
91
|
+
if [[ -z $PREBUFFER ]] && __cdg_wants "$BUFFER"; then
|
|
92
|
+
print -sr -- "$BUFFER"
|
|
93
|
+
BUFFER="__cdg_pre ${(qq)BUFFER}; __cdg_run"
|
|
94
|
+
fi
|
|
95
|
+
zle __cdg_orig_accept
|
|
96
|
+
}
|
|
97
|
+
# Keep the wrapper out of history; the original line was added above.
|
|
98
|
+
__cdg_addhistory() { [[ $1 != '__cdg_pre '* ]]; }
|
|
99
|
+
if [ -z "${__cdg_hooked-}" ]; then
|
|
100
|
+
__cdg_hooked=1
|
|
101
|
+
zle -A accept-line __cdg_orig_accept
|
|
102
|
+
zle -N accept-line __cdg_accept
|
|
103
|
+
zshaddhistory_functions=(__cdg_addhistory $zshaddhistory_functions)
|
|
104
|
+
# Ctrl+C skips the trailing __cdg_post; finish the entry before the prompt.
|
|
105
|
+
precmd_functions=(__cdg_post $precmd_functions)
|
|
106
|
+
fi
|
|
107
|
+
else
|
|
108
|
+
__cdg_rewrite() {
|
|
109
|
+
if [ -z "${__cdg_cont-}" ] && __cdg_wants "$READLINE_LINE"; then
|
|
110
|
+
history -s -- "$READLINE_LINE"
|
|
111
|
+
printf -v READLINE_LINE '__cdg_pre %q; __cdg_run' "$READLINE_LINE"
|
|
112
|
+
fi
|
|
113
|
+
# Any further Enter before the next prompt is a continuation line.
|
|
114
|
+
__cdg_cont=1
|
|
115
|
+
}
|
|
116
|
+
__cdg_prompt() {
|
|
117
|
+
__cdg_post
|
|
118
|
+
local s=$?
|
|
119
|
+
__cdg_cont=
|
|
120
|
+
return $s
|
|
121
|
+
}
|
|
122
|
+
if [ -z "${__cdg_hooked-}" ]; then
|
|
123
|
+
__cdg_hooked=1
|
|
124
|
+
HISTIGNORE="__cdg_pre *${HISTIGNORE:+:$HISTIGNORE}"
|
|
125
|
+
PROMPT_COMMAND="__cdg_prompt${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
|
|
126
|
+
for __cdg_m in emacs vi-insert vi-command; do
|
|
127
|
+
bind -m "$__cdg_m" -x '"\C-x\C-c": __cdg_rewrite'
|
|
128
|
+
bind -m "$__cdg_m" '"\C-m": "\C-x\C-c\C-j"'
|
|
129
|
+
done
|
|
130
|
+
unset __cdg_m
|
|
131
|
+
fi
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
# Lets `cdg term` tell whether this shell is hooked.
|
|
135
|
+
export CODIGEST_HOOK=1
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from typer.testing import CliRunner
|
|
4
|
+
|
|
5
|
+
from codigest.commands import term
|
|
6
|
+
from codigest.core import termstore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_ingest_options_and_output_file(tmp_path, monkeypatch):
|
|
10
|
+
monkeypatch.setenv("CODIGEST_HOME", str(tmp_path / "home"))
|
|
11
|
+
out = tmp_path / "out"
|
|
12
|
+
out.write_bytes("echo 'a' \"b\" $HOME *.py | y\0".encode() + 'say "hi" — ünïcode\n'.encode())
|
|
13
|
+
result = CliRunner().invoke(term.app, [
|
|
14
|
+
"ingest", f"--file={out}", "--exit-code=3", f"--cwd={tmp_path}",
|
|
15
|
+
"--shell=bash", "--start=1700000000,25", "--end=1700000001.5",
|
|
16
|
+
])
|
|
17
|
+
assert result.exit_code == 0, result.output
|
|
18
|
+
[entry] = termstore.load_entries()
|
|
19
|
+
assert entry.command == "echo 'a' \"b\" $HOME *.py | y"
|
|
20
|
+
assert entry.output == 'say "hi" — ünïcode'
|
|
21
|
+
assert (entry.exit_code, entry.shell, entry.duration_ms) == (3, "bash", 1250)
|
|
22
|
+
assert entry.cwd == str(tmp_path) and entry.started_at[:2] == "20" and entry.started_at[-6] in "+-"
|
|
23
|
+
assert not out.exists()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_ingest_stdin_json_still_works(tmp_path, monkeypatch):
|
|
27
|
+
monkeypatch.setenv("CODIGEST_HOME", str(tmp_path))
|
|
28
|
+
result = CliRunner().invoke(term.app, ["ingest"], input='{"command": "ls", "output": "x", "exit_code": 0}')
|
|
29
|
+
assert result.exit_code == 0
|
|
30
|
+
assert [e.command for e in termstore.load_entries()] == ["ls"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_install_uninstall_rc_files(tmp_path, monkeypatch):
|
|
34
|
+
monkeypatch.setattr(term.Path, "home", lambda: tmp_path)
|
|
35
|
+
monkeypatch.delenv("ZDOTDIR", raising=False)
|
|
36
|
+
monkeypatch.setattr(term, "_powershell_profiles", lambda: [])
|
|
37
|
+
monkeypatch.setattr(term.shutil, "which", lambda name: "/bin/bash" if name == "bash" else None)
|
|
38
|
+
bashrc, zshrc = tmp_path / ".bashrc", tmp_path / ".zshrc"
|
|
39
|
+
bashrc.write_bytes(b"alias g=git\n")
|
|
40
|
+
runner = CliRunner()
|
|
41
|
+
|
|
42
|
+
assert runner.invoke(term.app, ["install"]).exit_code == 0 # auto: bash only
|
|
43
|
+
assert runner.invoke(term.app, ["install"]).exit_code == 0 # idempotent
|
|
44
|
+
text = bashrc.read_bytes().decode()
|
|
45
|
+
assert text.startswith("alias g=git\n\n" + term._BEGIN) and text.count(term._BEGIN) == 1
|
|
46
|
+
assert f". '{term._posix(term.SH_HOOK_PATH)}'" in text and "\r" not in text
|
|
47
|
+
assert not text.startswith("\ufeff") and not zshrc.exists()
|
|
48
|
+
|
|
49
|
+
assert runner.invoke(term.app, ["install", "--shell", "zsh"]).exit_code == 0
|
|
50
|
+
assert term._BEGIN in zshrc.read_text()
|
|
51
|
+
assert runner.invoke(term.app, ["install", "--shell", "fish"]).exit_code != 0
|
|
52
|
+
|
|
53
|
+
assert runner.invoke(term.app, ["uninstall"]).exit_code == 0 # auto removes everywhere
|
|
54
|
+
assert bashrc.read_bytes() == b"alias g=git\n"
|
|
55
|
+
assert zshrc.read_text() == ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_posix_hook_path():
|
|
59
|
+
assert term._posix(Path("C:/Users/me/hook.sh")).endswith("/Users/me/hook.sh")
|
|
60
|
+
if Path("C:/x").drive:
|
|
61
|
+
assert term._posix(Path(r"C:\Users\me\hook.sh")) == "/c/Users/me/hook.sh"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
version = 1
|
|
2
2
|
revision = 3
|
|
3
|
-
requires-python = ">=3.15"
|
|
3
|
+
requires-python = ">=3.15.0"
|
|
4
4
|
|
|
5
5
|
[[package]]
|
|
6
6
|
name = "click"
|
|
@@ -16,7 +16,7 @@ wheels = [
|
|
|
16
16
|
|
|
17
17
|
[[package]]
|
|
18
18
|
name = "codigest"
|
|
19
|
-
version = "0.
|
|
19
|
+
version = "0.5.0"
|
|
20
20
|
source = { editable = "." }
|
|
21
21
|
dependencies = [
|
|
22
22
|
{ name = "loguru" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|