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.
Files changed (50) hide show
  1. codigest-0.5.0/.gitattributes +1 -0
  2. codigest-0.5.0/.github/workflows/publish.yml +51 -0
  3. {codigest-0.4.2 → codigest-0.5.0}/.github/workflows/tests.yml +2 -2
  4. {codigest-0.4.2 → codigest-0.5.0}/CITATION.cff +1 -1
  5. {codigest-0.4.2 → codigest-0.5.0}/PKG-INFO +11 -7
  6. {codigest-0.4.2 → codigest-0.5.0}/README.md +9 -5
  7. {codigest-0.4.2 → codigest-0.5.0}/pyproject.toml +2 -2
  8. {codigest-0.4.2 → codigest-0.5.0}/server.json +2 -2
  9. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/mcp.py +11 -5
  10. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/term.py +110 -30
  11. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/termstore.py +15 -1
  12. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/shell/hook.ps1 +1 -1
  13. codigest-0.5.0/src/codigest/shell/hook.sh +135 -0
  14. codigest-0.5.0/tests/test_term_install.py +61 -0
  15. {codigest-0.4.2 → codigest-0.5.0}/tests/test_termstore.py +14 -0
  16. {codigest-0.4.2 → codigest-0.5.0}/uv.lock +2 -2
  17. codigest-0.4.2/.github/workflows/publish.yml +0 -73
  18. {codigest-0.4.2 → codigest-0.5.0}/.gitignore +0 -0
  19. {codigest-0.4.2 → codigest-0.5.0}/.python-version +0 -0
  20. {codigest-0.4.2 → codigest-0.5.0}/LICENSE +0 -0
  21. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/__init__.py +0 -0
  22. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/__main__.py +0 -0
  23. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/cap.py +0 -0
  24. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/clean.py +0 -0
  25. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/diff.py +0 -0
  26. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/digest.py +0 -0
  27. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/init.py +0 -0
  28. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/scan.py +0 -0
  29. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/semdiff.py +0 -0
  30. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/status.py +0 -0
  31. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/commands/tree.py +0 -0
  32. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/config.py +0 -0
  33. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/cache.py +0 -0
  34. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/common.py +0 -0
  35. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/git_ops.py +0 -0
  36. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/output.py +0 -0
  37. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/processor.py +0 -0
  38. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/prompts.py +0 -0
  39. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/redact.py +0 -0
  40. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/resolver.py +0 -0
  41. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/runner.py +0 -0
  42. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/scanner.py +0 -0
  43. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/semdiff.py +0 -0
  44. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/shadow.py +0 -0
  45. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/shellhist.py +0 -0
  46. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/structure.py +0 -0
  47. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/tags.py +0 -0
  48. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/core/tokenizer.py +0 -0
  49. {codigest-0.4.2 → codigest-0.5.0}/src/codigest/main.py +0 -0
  50. {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
@@ -9,7 +9,7 @@ jobs:
9
9
  test:
10
10
  runs-on: ubuntu-latest
11
11
  steps:
12
- - uses: actions/checkout@v4
13
- - uses: astral-sh/setup-uv@v5
12
+ - uses: actions/checkout@v7
13
+ - uses: astral-sh/setup-uv@v10.3.0
14
14
  - run: uv python install 3.15
15
15
  - run: uv run --with pytest pytest -q
@@ -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.2
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.2
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
@@ -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 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
@@ -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.4.2"
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.2",
10
+ "version": "0.5.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "codigest",
15
- "version": "0.4.2",
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 = termstore.load_entries()
85
- if args.get("failed"):
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
- return termstore.render(entries[-count:])
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 = [e for e in entries if e.exit_code not in (None, 0)]
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
- log.print("[yellow]No failed commands captured.[/yellow]")
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() -> None:
153
- """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.
154
174
  try:
155
- 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
+ }
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 hook install
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
- if not (profiles := _powershell_profiles()):
219
- 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).")
220
291
  raise typer.Exit(code=1)
221
292
 
222
- block = f"{_BEGIN}\nif (Test-Path '{HOOK_PATH}') {{ . '{HOOK_PATH}' }}\n{_END}\n"
223
- for profile in profiles:
293
+ sh = _posix(SH_HOOK_PATH)
294
+ for target in targets:
295
+ is_ps = target.suffix == ".ps1"
224
296
  text = ""
225
- if profile.exists():
297
+ if target.exists():
226
298
  try:
227
- text = profile.read_bytes().decode("utf-8-sig")
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] {profile} (not UTF-8; edit it by hand)")
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
- 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"
235
312
  elif new == text:
236
313
  continue
237
- profile.parent.mkdir(parents=True, exist_ok=True)
238
- # BOM so Windows PowerShell 5.1 reads non-ASCII paths correctly.
239
- profile.write_bytes(new.encode("utf-8-sig"))
240
- 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}")
241
318
 
242
319
  if add:
243
- console.print("Active in new PowerShell windows. Enable in this window now with:")
244
- console.print(f" . '{HOOK_PATH}'", highlight=False)
245
- 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]")
246
326
 
247
327
 
248
328
  @app.command("install")
249
- def install() -> None:
250
- """Hook PowerShell so every command's output is captured automatically."""
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 PowerShell capture hook from your profile."""
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
- # ponytail: no file lock. Concurrent hook ingests can drop an entry appended
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
- # ponytail: denylist heuristic for programs that need a real TTY (editors, pagers,
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.4.2"
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