codigest 0.4.2__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.5.0/.github/workflows/publish.yml +51 -0
- {codigest-0.4.2 → codigest-0.5.0}/.github/workflows/tests.yml +2 -2
- {codigest-0.4.2 → codigest-0.5.0}/CITATION.cff +1 -1
- {codigest-0.4.2 → codigest-0.5.0}/PKG-INFO +11 -7
- {codigest-0.4.2 → codigest-0.5.0}/README.md +9 -5
- {codigest-0.4.2 → codigest-0.5.0}/pyproject.toml +2 -2
- {codigest-0.4.2 → codigest-0.5.0}/server.json +2 -2
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/mcp.py +11 -5
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/term.py +110 -30
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/termstore.py +15 -1
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/shell/hook.ps1 +1 -1
- 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.2 → codigest-0.5.0}/tests/test_termstore.py +14 -0
- {codigest-0.4.2 → codigest-0.5.0}/uv.lock +2 -2
- codigest-0.4.2/.github/workflows/publish.yml +0 -73
- {codigest-0.4.2 → codigest-0.5.0}/.gitignore +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/.python-version +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/LICENSE +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/__init__.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/__main__.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/cap.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/clean.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/diff.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/digest.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/init.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/scan.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/semdiff.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/status.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/tree.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/config.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/cache.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/common.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/git_ops.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/output.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/processor.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/prompts.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/redact.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/resolver.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/runner.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/scanner.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/semdiff.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/shadow.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/shellhist.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/structure.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/tags.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/tokenizer.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/main.py +0 -0
- {codigest-0.4.2 → codigest-0.5.0}/src/codigest/options.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*.sh text eol=lf
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
pypi:
|
|
10
|
+
name: Build and publish to PyPI
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
environment: release
|
|
13
|
+
permissions:
|
|
14
|
+
id-token: write # PyPI Trusted Publishing
|
|
15
|
+
contents: read
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v7
|
|
18
|
+
- uses: astral-sh/setup-uv@v10.3.0
|
|
19
|
+
- run: uv python install 3.15
|
|
20
|
+
- run: uv build
|
|
21
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
22
|
+
|
|
23
|
+
mcp:
|
|
24
|
+
name: Publish to the MCP registry
|
|
25
|
+
needs: pypi
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
permissions:
|
|
28
|
+
id-token: write # GitHub OIDC login for io.github.NaNIsaac/*
|
|
29
|
+
contents: read
|
|
30
|
+
steps:
|
|
31
|
+
- uses: actions/checkout@v7
|
|
32
|
+
|
|
33
|
+
- name: Set server.json version from the tag
|
|
34
|
+
run: |
|
|
35
|
+
VERSION="${GITHUB_REF_NAME#v}"
|
|
36
|
+
jq --arg v "$VERSION" '.version = $v | .packages[0].version = $v' server.json > server.tmp
|
|
37
|
+
mv server.tmp server.json
|
|
38
|
+
# The registry verifies the package on PyPI; wait until the release is visible.
|
|
39
|
+
for i in $(seq 30); do
|
|
40
|
+
curl -sf "https://pypi.org/pypi/codigest/$VERSION/json" > /dev/null && exit 0
|
|
41
|
+
sleep 10
|
|
42
|
+
done
|
|
43
|
+
echo "codigest $VERSION not visible on PyPI" && exit 1
|
|
44
|
+
|
|
45
|
+
- name: Install mcp-publisher
|
|
46
|
+
run: curl -sL https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz | tar xz mcp-publisher
|
|
47
|
+
|
|
48
|
+
- name: Publish
|
|
49
|
+
run: |
|
|
50
|
+
./mcp-publisher login github-oidc
|
|
51
|
+
./mcp-publisher publish
|
|
@@ -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
|
|
@@ -238,6 +241,7 @@ cdg term # last command
|
|
|
238
241
|
cdg term -n 3 # last 3 commands
|
|
239
242
|
cdg term -i 2 # only the command from 2 runs ago
|
|
240
243
|
cdg term -f # last failed command
|
|
244
|
+
cdg term --here -n 3 # last 3 commands run in this project
|
|
241
245
|
|
|
242
246
|
cdg term path # where captures are stored (override with CODIGEST_HOME)
|
|
243
247
|
cdg term clear # delete all captures
|
|
@@ -249,7 +253,7 @@ cdg term clear # delete all captures
|
|
|
249
253
|
|
|
250
254
|
| Tool | Returns |
|
|
251
255
|
| --- | --- |
|
|
252
|
-
| `terminal_history` | Recent commands with output and exit code (`count`, `failed`) |
|
|
256
|
+
| `terminal_history` | Recent commands with output and exit code (`count`, `failed`, `here`: only this project, default on) |
|
|
253
257
|
| `scan` | Project snapshot (`targets`, `resolve`, `message`) |
|
|
254
258
|
| `diff` / `semdiff` | Text / structural changes since the anchor |
|
|
255
259
|
| `digest` | Signatures and docstrings only |
|
|
@@ -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
|
|
@@ -212,6 +215,7 @@ cdg term # last command
|
|
|
212
215
|
cdg term -n 3 # last 3 commands
|
|
213
216
|
cdg term -i 2 # only the command from 2 runs ago
|
|
214
217
|
cdg term -f # last failed command
|
|
218
|
+
cdg term --here -n 3 # last 3 commands run in this project
|
|
215
219
|
|
|
216
220
|
cdg term path # where captures are stored (override with CODIGEST_HOME)
|
|
217
221
|
cdg term clear # delete all captures
|
|
@@ -223,7 +227,7 @@ cdg term clear # delete all captures
|
|
|
223
227
|
|
|
224
228
|
| Tool | Returns |
|
|
225
229
|
| --- | --- |
|
|
226
|
-
| `terminal_history` | Recent commands with output and exit code (`count`, `failed`) |
|
|
230
|
+
| `terminal_history` | Recent commands with output and exit code (`count`, `failed`, `here`: only this project, default on) |
|
|
227
231
|
| `scan` | Project snapshot (`targets`, `resolve`, `message`) |
|
|
228
232
|
| `diff` / `semdiff` | Text / structural changes since the anchor |
|
|
229
233
|
| `digest` | Signatures and docstrings only |
|
|
@@ -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"
|
|
@@ -15,6 +15,7 @@ exactly what a human would paste, and stdout stays clean for the protocol.
|
|
|
15
15
|
import json
|
|
16
16
|
import subprocess
|
|
17
17
|
import sys
|
|
18
|
+
from pathlib import Path
|
|
18
19
|
from importlib.metadata import PackageNotFoundError, version
|
|
19
20
|
from typing import Any
|
|
20
21
|
|
|
@@ -40,6 +41,11 @@ TOOLS: list[JSON] = [
|
|
|
40
41
|
"properties": {
|
|
41
42
|
"count": {"type": "integer", "minimum": 1, "default": 1, "description": "How many recent commands"},
|
|
42
43
|
"failed": {"type": "boolean", "default": False, "description": "Only commands that exited non-zero"},
|
|
44
|
+
"here": {
|
|
45
|
+
"type": "boolean",
|
|
46
|
+
"default": True,
|
|
47
|
+
"description": "Only commands run in the server's working directory (the project) or below",
|
|
48
|
+
},
|
|
43
49
|
},
|
|
44
50
|
},
|
|
45
51
|
},
|
|
@@ -81,12 +87,12 @@ TOOLS: list[JSON] = [
|
|
|
81
87
|
|
|
82
88
|
def _terminal_history(args: JSON) -> str:
|
|
83
89
|
count = int(args.get("count", 1))
|
|
84
|
-
entries
|
|
85
|
-
|
|
86
|
-
entries = [e for e in entries if e.exit_code not in (None, 0)]
|
|
87
|
-
if not entries:
|
|
90
|
+
if not (entries := termstore.load_entries()):
|
|
91
|
+
# Nothing captured at all: native shell history still names the commands.
|
|
88
92
|
entries, _ = shellhist.read_recent(count)
|
|
89
|
-
|
|
93
|
+
return termstore.render(entries)
|
|
94
|
+
under = Path.cwd() if args.get("here", True) else None
|
|
95
|
+
return termstore.render(termstore.select(entries, failed=bool(args.get("failed")), under=under)[-count:])
|
|
90
96
|
|
|
91
97
|
|
|
92
98
|
def _cli(command: str, args: JSON) -> str:
|
|
@@ -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
|
|
|
@@ -40,6 +41,11 @@ FailedOption = Annotated[
|
|
|
40
41
|
typer.Option("-f", "--failed", help="Only commands that exited non-zero"),
|
|
41
42
|
]
|
|
42
43
|
|
|
44
|
+
HereOption = Annotated[
|
|
45
|
+
bool,
|
|
46
|
+
typer.Option("--here", help="Only commands run in the current directory or below"),
|
|
47
|
+
]
|
|
48
|
+
|
|
43
49
|
HistoryOnlyOption = Annotated[
|
|
44
50
|
bool,
|
|
45
51
|
typer.Option(
|
|
@@ -68,6 +74,7 @@ def handle(
|
|
|
68
74
|
count: CountOption = 1,
|
|
69
75
|
index: IndexOption = None,
|
|
70
76
|
failed: FailedOption = False,
|
|
77
|
+
here: HereOption = False,
|
|
71
78
|
history_only: HistoryOnlyOption = False,
|
|
72
79
|
copy: CopyOption = True,
|
|
73
80
|
stdout: StdoutOption = False,
|
|
@@ -80,6 +87,7 @@ def handle(
|
|
|
80
87
|
cdg term -n 3 # the last 3 commands
|
|
81
88
|
cdg term -i 3 # only the command from 3 runs ago
|
|
82
89
|
cdg term -f # the last failed command
|
|
90
|
+
cdg term --here -n 3 # the last 3 commands run in this project
|
|
83
91
|
"""
|
|
84
92
|
if ctx.invoked_subcommand:
|
|
85
93
|
return
|
|
@@ -107,10 +115,11 @@ def handle(
|
|
|
107
115
|
_diagnose(log)
|
|
108
116
|
raise typer.Exit(code=1)
|
|
109
117
|
|
|
110
|
-
if failed:
|
|
111
|
-
entries =
|
|
118
|
+
if failed or here:
|
|
119
|
+
entries = termstore.select(entries, failed=failed, under=Path.cwd() if here else None)
|
|
112
120
|
if not entries:
|
|
113
|
-
|
|
121
|
+
what = " and ".join(w for w, on in (("failed", failed), ("in this directory", here)) if on)
|
|
122
|
+
log.print(f"[yellow]No captured commands match ({what}).[/yellow]")
|
|
114
123
|
raise typer.Exit(code=1)
|
|
115
124
|
|
|
116
125
|
if index:
|
|
@@ -149,16 +158,44 @@ def path() -> None:
|
|
|
149
158
|
|
|
150
159
|
|
|
151
160
|
@app.command("ingest", hidden=True)
|
|
152
|
-
def ingest(
|
|
153
|
-
|
|
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.
|
|
154
174
|
try:
|
|
155
|
-
|
|
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
|
+
}
|
|
156
190
|
termstore.append_entry(termstore.Entry.from_dict(data))
|
|
157
191
|
except Exception:
|
|
158
192
|
# The hook discards our stderr; leave a trail `cdg term` can surface.
|
|
159
193
|
with _ingest_log().open("a", encoding="utf-8") as f:
|
|
160
194
|
f.write(f"--- {termstore.now_iso()}\n{traceback.format_exc()}")
|
|
161
195
|
raise typer.Exit(code=1)
|
|
196
|
+
finally:
|
|
197
|
+
if file:
|
|
198
|
+
file.unlink(missing_ok=True)
|
|
162
199
|
|
|
163
200
|
|
|
164
201
|
def _ingest_log() -> Path:
|
|
@@ -173,18 +210,36 @@ def _diagnose(log) -> None:
|
|
|
173
210
|
"[dim]Capture hook is not active in this shell. Run `cdg term install` "
|
|
174
211
|
"and open a new window, or enable it now with:[/dim]"
|
|
175
212
|
)
|
|
176
|
-
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)
|
|
177
215
|
if (errors := termstore.get_term_dir() / "ingest-errors.log").exists():
|
|
178
216
|
log.print(f"[red]Capture errors were logged:[/red] {errors}", highlight=False)
|
|
179
217
|
|
|
180
218
|
|
|
181
219
|
# --------------------------------------------------------------------------
|
|
182
|
-
# PowerShell
|
|
220
|
+
# Hook install (PowerShell profiles, ~/.bashrc, ~/.zshrc)
|
|
183
221
|
# --------------------------------------------------------------------------
|
|
184
222
|
|
|
185
223
|
HOOK_PATH = Path(__file__).resolve().parent.parent / "shell" / "hook.ps1"
|
|
224
|
+
SH_HOOK_PATH = HOOK_PATH.with_name("hook.sh")
|
|
186
225
|
_BEGIN = "# >>> codigest terminal capture >>>"
|
|
187
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()
|
|
188
243
|
|
|
189
244
|
|
|
190
245
|
def _powershell_profiles() -> list[Path]:
|
|
@@ -204,6 +259,21 @@ def _powershell_profiles() -> list[Path]:
|
|
|
204
259
|
return profiles
|
|
205
260
|
|
|
206
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
|
+
|
|
207
277
|
def _strip_block(text: str) -> str:
|
|
208
278
|
start = text.find(_BEGIN)
|
|
209
279
|
end = text.find(_END)
|
|
@@ -213,45 +283,55 @@ def _strip_block(text: str) -> str:
|
|
|
213
283
|
return rest + "\n" if rest else ""
|
|
214
284
|
|
|
215
285
|
|
|
216
|
-
def _rewrite_profiles(add: bool) -> None:
|
|
286
|
+
def _rewrite_profiles(add: bool, shell: str) -> None:
|
|
217
287
|
console = Console()
|
|
218
|
-
|
|
219
|
-
|
|
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).")
|
|
220
291
|
raise typer.Exit(code=1)
|
|
221
292
|
|
|
222
|
-
|
|
223
|
-
for
|
|
293
|
+
sh = _posix(SH_HOOK_PATH)
|
|
294
|
+
for target in targets:
|
|
295
|
+
is_ps = target.suffix == ".ps1"
|
|
224
296
|
text = ""
|
|
225
|
-
if
|
|
297
|
+
if target.exists():
|
|
226
298
|
try:
|
|
227
|
-
text =
|
|
299
|
+
text = target.read_bytes().decode("utf-8-sig")
|
|
228
300
|
except UnicodeDecodeError:
|
|
229
301
|
# Rewriting a non-UTF-8 profile would corrupt it.
|
|
230
|
-
console.print(f"[yellow]Skipped[/yellow] {
|
|
302
|
+
console.print(f"[yellow]Skipped[/yellow] {target} (not UTF-8; edit it by hand)")
|
|
231
303
|
continue
|
|
232
304
|
new = _strip_block(text)
|
|
233
305
|
if add:
|
|
234
|
-
|
|
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"
|
|
235
312
|
elif new == text:
|
|
236
313
|
continue
|
|
237
|
-
|
|
238
|
-
# BOM so Windows PowerShell 5.1 reads non-ASCII paths correctly.
|
|
239
|
-
|
|
240
|
-
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}")
|
|
241
318
|
|
|
242
319
|
if add:
|
|
243
|
-
console.print("Active in new
|
|
244
|
-
|
|
245
|
-
|
|
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]")
|
|
246
326
|
|
|
247
327
|
|
|
248
328
|
@app.command("install")
|
|
249
|
-
def install() -> None:
|
|
250
|
-
"""Hook
|
|
251
|
-
_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)
|
|
252
332
|
|
|
253
333
|
|
|
254
334
|
@app.command("uninstall")
|
|
255
|
-
def uninstall() -> None:
|
|
256
|
-
"""Remove the
|
|
257
|
-
_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)
|
|
@@ -131,6 +131,20 @@ def _truncate(text: str) -> tuple[str, bool]:
|
|
|
131
131
|
# Read / write
|
|
132
132
|
# --------------------------------------------------------------------------
|
|
133
133
|
|
|
134
|
+
def _inside(path: str, root: Path) -> bool:
|
|
135
|
+
path, base = (os.path.normcase(os.path.abspath(p)) for p in (path, root))
|
|
136
|
+
return path == base or path.startswith(base.rstrip(os.sep) + os.sep)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def select(entries: list[Entry], *, failed: bool = False, under: Path | None = None) -> list[Entry]:
|
|
140
|
+
"""Keeps entries that exited non-zero (`failed`) and/or ran inside `under` or below it."""
|
|
141
|
+
if failed:
|
|
142
|
+
entries = [e for e in entries if e.exit_code not in (None, 0)]
|
|
143
|
+
if under is not None:
|
|
144
|
+
entries = [e for e in entries if e.cwd and _inside(e.cwd, under)]
|
|
145
|
+
return entries
|
|
146
|
+
|
|
147
|
+
|
|
134
148
|
def load_entries() -> list[Entry]:
|
|
135
149
|
"""Loads all stored entries, oldest first. Corrupt lines are skipped."""
|
|
136
150
|
path = get_history_path()
|
|
@@ -183,7 +197,7 @@ def _trim(path: Path) -> None:
|
|
|
183
197
|
"""Rewrites the log keeping only the most recent MAX_ENTRIES lines."""
|
|
184
198
|
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
185
199
|
lines = f.readlines()
|
|
186
|
-
#
|
|
200
|
+
# No file lock: Concurrent hook ingests can drop an entry appended
|
|
187
201
|
# between read and replace; the slack makes rewrites (and that window) rare.
|
|
188
202
|
if len(lines) <= MAX_ENTRIES + _TRIM_SLACK:
|
|
189
203
|
return
|
|
@@ -74,7 +74,7 @@ if (-not (Get-Module PSReadLine) -and $Host.Name -eq 'ConsoleHost') {
|
|
|
74
74
|
}
|
|
75
75
|
if (-not (Get-Module PSReadLine)) { return }
|
|
76
76
|
|
|
77
|
-
#
|
|
77
|
+
# Denylist heuristic for programs that need a real TTY (editors, pagers,
|
|
78
78
|
# REPLs, ssh). Extend with $env:CODIGEST_CAP_SKIP (a regex) when one slips through.
|
|
79
79
|
$global:__cdgSkip = '^\s*(vim?|nvim|nano|emacs|less|more|ssh|htop|btop|top|fzf|lazygit|claude|codex|wsl|exit)(\.exe)?\b' +
|
|
80
80
|
'|^\s*(cdg|codigest)(\.exe)?\s+(term|mcp)\b' +
|
|
@@ -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"
|
|
@@ -32,6 +32,20 @@ def test_mcp_lists_tools_and_ignores_notifications():
|
|
|
32
32
|
assert mcp.handle_message({"jsonrpc": "2.0", "id": 2, "method": "nope"})["error"]["code"] == -32601
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
|
|
36
|
+
def test_select_filters_failed_and_directory(tmp_path):
|
|
37
|
+
project, other = tmp_path / "proj", tmp_path / "proj-other"
|
|
38
|
+
entries = [
|
|
39
|
+
Entry(command="a", exit_code=1, cwd=str(project / "sub")),
|
|
40
|
+
Entry(command="b", exit_code=0, cwd=str(project)),
|
|
41
|
+
Entry(command="c", exit_code=2, cwd=str(other)), # shares the prefix, not the directory
|
|
42
|
+
Entry(command="d", exit_code=None, cwd=""),
|
|
43
|
+
]
|
|
44
|
+
assert [e.command for e in termstore.select(entries, under=project)] == ["a", "b"]
|
|
45
|
+
assert [e.command for e in termstore.select(entries, failed=True)] == ["a", "c"]
|
|
46
|
+
assert [e.command for e in termstore.select(entries, failed=True, under=project)] == ["a"]
|
|
47
|
+
|
|
48
|
+
|
|
35
49
|
if __name__ == "__main__":
|
|
36
50
|
test_crlf_output_survives_normalization()
|
|
37
51
|
test_progress_redraw_keeps_last_frame()
|
|
@@ -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" },
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
name: Publish Python distributions to PyPI and GitHub Pages
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
tags:
|
|
6
|
-
- 'v*' # v0.3.0 태그 푸시 시 배포 실행
|
|
7
|
-
|
|
8
|
-
jobs:
|
|
9
|
-
build-n-publish:
|
|
10
|
-
name: Build and publish Python distributions to PyPI
|
|
11
|
-
runs-on: ubuntu-latest
|
|
12
|
-
# v* 태그일 때만 실행
|
|
13
|
-
if: startsWith(github.ref, 'refs/tags/v')
|
|
14
|
-
environment: release
|
|
15
|
-
permissions:
|
|
16
|
-
id-token: write # Trusted Publishing을 위한 필수 권한
|
|
17
|
-
contents: read
|
|
18
|
-
|
|
19
|
-
steps:
|
|
20
|
-
- uses: actions/checkout@v4
|
|
21
|
-
|
|
22
|
-
# uv 설치 (가장 빠르고 확실한 방법)
|
|
23
|
-
- name: Install uv
|
|
24
|
-
uses: astral-sh/setup-uv@v5
|
|
25
|
-
|
|
26
|
-
# Python 3.14 설정 (uv 이용)
|
|
27
|
-
- name: Set up Python
|
|
28
|
-
run: uv python install 3.15
|
|
29
|
-
|
|
30
|
-
# 의존성 설치 및 빌드 (uv build 하나로 해결)
|
|
31
|
-
- name: Build package
|
|
32
|
-
run: uv build
|
|
33
|
-
|
|
34
|
-
# PyPI 배포 (기존 형식 유지)
|
|
35
|
-
- name: Publish package distributions to PyPI
|
|
36
|
-
uses: pypa/gh-action-pypi-publish@release/v1
|
|
37
|
-
|
|
38
|
-
deploy-docs:
|
|
39
|
-
name: Build and deploy Sphinx docs to GitHub Pages
|
|
40
|
-
runs-on: ubuntu-latest
|
|
41
|
-
if: github.ref == 'refs/heads/main'
|
|
42
|
-
permissions:
|
|
43
|
-
contents: write
|
|
44
|
-
steps:
|
|
45
|
-
- uses: actions/checkout@v4
|
|
46
|
-
|
|
47
|
-
- name: Install uv
|
|
48
|
-
uses: astral-sh/setup-uv@v5
|
|
49
|
-
|
|
50
|
-
- name: Set up Python
|
|
51
|
-
run: uv python install 3.15
|
|
52
|
-
|
|
53
|
-
- name: Install Python and Sphinx dependencies
|
|
54
|
-
run: |
|
|
55
|
-
uv pip install --system --upgrade pip
|
|
56
|
-
if [ -f docs/requirements.txt ]; then uv pip install --system -r docs/requirements.txt; fi
|
|
57
|
-
|
|
58
|
-
- name: Build HTML docs with Sphinx
|
|
59
|
-
run: |
|
|
60
|
-
if [ -d "docs" ]; then
|
|
61
|
-
sphinx-build -b html docs/source docs/html
|
|
62
|
-
touch docs/html/.nojekyll
|
|
63
|
-
else
|
|
64
|
-
echo "No docs folder found, skipping build."
|
|
65
|
-
mkdir -p docs/html
|
|
66
|
-
touch docs/html/.nojekyll
|
|
67
|
-
fi
|
|
68
|
-
|
|
69
|
-
- name: Deploy to GitHub Pages
|
|
70
|
-
uses: peaceiris/actions-gh-pages@v3
|
|
71
|
-
with:
|
|
72
|
-
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
73
|
-
publish_dir: docs/html
|
|
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
|