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.
Files changed (49) hide show
  1. codigest-0.5.0/.gitattributes +1 -0
  2. {codigest-0.4.3 → codigest-0.5.0}/CITATION.cff +1 -1
  3. {codigest-0.4.3 → codigest-0.5.0}/PKG-INFO +9 -6
  4. {codigest-0.4.3 → codigest-0.5.0}/README.md +7 -4
  5. {codigest-0.4.3 → codigest-0.5.0}/pyproject.toml +2 -2
  6. {codigest-0.4.3 → codigest-0.5.0}/server.json +2 -2
  7. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/term.py +99 -27
  8. codigest-0.5.0/src/codigest/shell/hook.sh +135 -0
  9. codigest-0.5.0/tests/test_term_install.py +61 -0
  10. {codigest-0.4.3 → codigest-0.5.0}/uv.lock +2 -2
  11. {codigest-0.4.3 → codigest-0.5.0}/.github/workflows/publish.yml +0 -0
  12. {codigest-0.4.3 → codigest-0.5.0}/.github/workflows/tests.yml +0 -0
  13. {codigest-0.4.3 → codigest-0.5.0}/.gitignore +0 -0
  14. {codigest-0.4.3 → codigest-0.5.0}/.python-version +0 -0
  15. {codigest-0.4.3 → codigest-0.5.0}/LICENSE +0 -0
  16. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/__init__.py +0 -0
  17. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/__main__.py +0 -0
  18. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/cap.py +0 -0
  19. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/clean.py +0 -0
  20. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/diff.py +0 -0
  21. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/digest.py +0 -0
  22. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/init.py +0 -0
  23. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/mcp.py +0 -0
  24. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/scan.py +0 -0
  25. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/semdiff.py +0 -0
  26. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/status.py +0 -0
  27. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/commands/tree.py +0 -0
  28. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/config.py +0 -0
  29. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/cache.py +0 -0
  30. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/common.py +0 -0
  31. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/git_ops.py +0 -0
  32. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/output.py +0 -0
  33. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/processor.py +0 -0
  34. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/prompts.py +0 -0
  35. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/redact.py +0 -0
  36. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/resolver.py +0 -0
  37. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/runner.py +0 -0
  38. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/scanner.py +0 -0
  39. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/semdiff.py +0 -0
  40. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/shadow.py +0 -0
  41. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/shellhist.py +0 -0
  42. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/structure.py +0 -0
  43. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/tags.py +0 -0
  44. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/termstore.py +0 -0
  45. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/core/tokenizer.py +0 -0
  46. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/main.py +0 -0
  47. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/options.py +0 -0
  48. {codigest-0.4.3 → codigest-0.5.0}/src/codigest/shell/hook.ps1 +0 -0
  49. {codigest-0.4.3 → codigest-0.5.0}/tests/test_termstore.py +0 -0
@@ -0,0 +1 @@
1
+ *.sh text eol=lf
@@ -14,7 +14,7 @@ authors:
14
14
  repository-code: "https://github.com/NaNIsaac/codigest"
15
15
  url: "https://pypi.org/project/codigest/"
16
16
  license: MIT
17
- version: 0.4.3
17
+ version: 0.5.0
18
18
  date-released: "2026-10-15"
19
19
  keywords:
20
20
  - LLM
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codigest
3
- Version: 0.4.3
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 PowerShell profile so *every* command is captured without the `cap` prefix. Commands run in your current session (`cd`, variables and functions behave normally). Editors, pagers, REPLs and `ssh` are skipped automatically.
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`. Remove: `cdg term uninstall`.
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 # PowerShell: capture everything automatically
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 PowerShell profile so *every* command is captured without the `cap` prefix. Commands run in your current session (`cd`, variables and functions behave normally). Editors, pagers, REPLs and `ssh` are skipped automatically.
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`. Remove: `cdg term uninstall`.
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 # PowerShell: capture everything automatically
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.4.3"
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.4.3",
10
+ "version": "0.5.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "codigest",
15
- "version": "0.4.3",
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() -> None:
161
- """Store one JSON entry from stdin (called by the shell hook)."""
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
- data = json.loads(sys.stdin.buffer.read().decode("utf-8-sig"))
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 hook install
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
- if not (profiles := _powershell_profiles()):
227
- console.print("[red][Error][/red] No PowerShell found (pwsh / powershell).")
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
- block = f"{_BEGIN}\nif (Test-Path '{HOOK_PATH}') {{ . '{HOOK_PATH}' }}\n{_END}\n"
231
- for profile in profiles:
293
+ sh = _posix(SH_HOOK_PATH)
294
+ for target in targets:
295
+ is_ps = target.suffix == ".ps1"
232
296
  text = ""
233
- if profile.exists():
297
+ if target.exists():
234
298
  try:
235
- text = profile.read_bytes().decode("utf-8-sig")
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] {profile} (not UTF-8; edit it by hand)")
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
- new = (new.rstrip("\n") + "\n\n" if new.strip() else "") + block
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
- profile.parent.mkdir(parents=True, exist_ok=True)
246
- # BOM so Windows PowerShell 5.1 reads non-ASCII paths correctly.
247
- profile.write_bytes(new.encode("utf-8-sig"))
248
- console.print(f"[green]{'Installed' if add else 'Removed'}[/green] {profile}")
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 PowerShell windows. Enable in this window now with:")
252
- console.print(f" . '{HOOK_PATH}'", highlight=False)
253
- console.print("[dim]Skip one command: start it with a space. Disable: $env:CODIGEST_NOCAP = 1[/dim]")
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 PowerShell so every command's output is captured automatically."""
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 PowerShell capture hook from your profile."""
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.4.3"
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