reclaude 0.1.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.
- reclaude-0.1.0/.github/workflows/publish.yml +26 -0
- reclaude-0.1.0/.gitignore +4 -0
- reclaude-0.1.0/CLAUDE.md +51 -0
- reclaude-0.1.0/LICENSE +24 -0
- reclaude-0.1.0/PKG-INFO +72 -0
- reclaude-0.1.0/README.md +49 -0
- reclaude-0.1.0/pyproject.toml +42 -0
- reclaude-0.1.0/src/reclaude/__init__.py +476 -0
- reclaude-0.1.0/src/reclaude/__main__.py +4 -0
- reclaude-0.1.0/tests/test_reclaude.py +300 -0
- reclaude-0.1.0/uv.lock +156 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: astral-sh/setup-uv@v5
|
|
14
|
+
- run: uv run --group dev pytest -q
|
|
15
|
+
|
|
16
|
+
publish:
|
|
17
|
+
needs: test
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
environment: pypi
|
|
20
|
+
permissions:
|
|
21
|
+
id-token: write
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
- uses: astral-sh/setup-uv@v5
|
|
25
|
+
- run: uv build
|
|
26
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
reclaude-0.1.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
7
|
+
`reclaude` is a stdlib-only Python curses picker for resuming Claude Code sessions. It reads `~/.claude/history.jsonl`, shows recent project directories as an expandable tree (sessions inline), locks directories that already have a running claude session, can resurrect sessions from deleted git worktrees, then chdirs and execs `claude`. Packaged for PyPI; installed with `uv tool install reclaude` (or `uv tool install --editable .` for hacking), which puts the `reclaude` console script on `PATH`.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
python3 -m pytest -q # run all tests (from repo root)
|
|
13
|
+
python3 -m pytest tests/test_reclaude.py::test_mung_path -v # single test
|
|
14
|
+
uv run --group dev pytest -q # tests in an isolated env (as CI does)
|
|
15
|
+
uv build # build sdist + wheel into dist/
|
|
16
|
+
reclaude # run (needs a real TTY)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
No third-party runtime dependencies (Python 3.10+ stdlib; pytest for tests). `[tool.pytest.ini_options] pythonpath = ["src"]` in `pyproject.toml` lets `python3 -m pytest` import the package from the repo root without an install. Releases publish to PyPI via `.github/workflows/publish.yml` (trusted publishing) on a `v*` tag push.
|
|
20
|
+
|
|
21
|
+
## Architecture
|
|
22
|
+
|
|
23
|
+
`reclaude` is a `src/` package (`src/reclaude/`): `__init__.py` holds all the logic, deliberately split into two halves (it can be broken into separate modules — e.g. pure `core.py` + curses `tui.py` — without touching packaging); `__main__.py` wires up `python -m reclaude`. The two halves of `__init__.py`:
|
|
24
|
+
|
|
25
|
+
1. **Pure functions (top half, unit-tested):** `parse_history` → `group_by_home` builds per-directory groups of sessions; `classify_dir` tags each dir live / orphan-worktree / gone; `live_sessions` finds busy dirs + running session ids; `flatten_rows` turns groups + UI state (expansion, typed filter, age window, missing toggle) into the visible row list; `_row_spans` renders a row as `(text, colorkey)` spans. All filesystem/proc access is injectable (`isdir=`, `transcript_exists=`, `proc_root=`, `sessions_dir=`) so tests are hermetic — keep it that way.
|
|
26
|
+
2. **Curses layer (bottom half, no unit tests):** `init_colors` maps colorkeys → curses attrs (monochrome fallback), `_draw` renders span rows, `run_picker` is the event loop returning a launch tuple, `main` execs claude. Verified via throwaway fake-stdscr harnesses (scripted `getch`, recorded `addnstr`) in /tmp — never committed — plus manual testing by Bryce.
|
|
27
|
+
|
|
28
|
+
Data flow: `history.jsonl` → entries → groups (sessions attributed to their **home** dir) → rows → spans → screen; picker returns `("resume", path, id)` or `("worktree", repo, name, id)` → `os.chdir` + `os.execvp`.
|
|
29
|
+
|
|
30
|
+
### Invariants worth protecting
|
|
31
|
+
|
|
32
|
+
- **Display = action.** A dir row shows the time/prompt of `vis_sessions[0]` and Enter resumes exactly that session id. Never reintroduce `claude --continue` — it can disagree with what's displayed.
|
|
33
|
+
- **Filters before the MAX_DIRS cap** in `flatten_rows`, so hiding noise surfaces older live dirs.
|
|
34
|
+
- **Printable keys feed the incremental filter.** New shortcuts must be control keys (Ctrl-W=23 toggles missing dirs, Ctrl-T=20 cycles the age window); `q` quits only when the filter is empty.
|
|
35
|
+
- `COLOR_KEYS` must cover every key `_row_spans` emits.
|
|
36
|
+
- All `addnstr` calls write at most `maxx - 1` columns and are wrapped in `try/except curses.error` (tiny terminals, bottom-right quirk).
|
|
37
|
+
|
|
38
|
+
## Empirically verified Claude Code facts (the whole design rests on these)
|
|
39
|
+
|
|
40
|
+
- `claude --resume <id>` / `--continue` only find transcripts under `~/.claude/projects/<munged-cwd>/` for the **current** directory. Munging: `/` and `.` → `-` (deterministic; un-munging is ambiguous — only ever mung).
|
|
41
|
+
- A session's transcript lives under the directory the session **started** in and never moves, even if the session later changed cwd (EnterWorktree etc.). Hence `group_by_home`: first project in history = the only resumable location.
|
|
42
|
+
- Deleted worktree sessions resurrect via `cd <repo> && claude --worktree <name> --resume <id>` — claude recreates `<repo>/.claude/worktrees/<name>` (branch `worktree-<name>`, base per `worktree.baseRef`) and finds the transcript because the path matches again.
|
|
43
|
+
- `~/.claude/sessions/<pid>.json` describes live claude processes (`{pid, sessionId, cwd, ...}`). Stale files survive crashes — always validate `/proc/<pid>/comm == "claude"` before trusting one.
|
|
44
|
+
- `history.jsonl` lines: `{"display", "pastedContents", "timestamp"(ms), "project", "sessionId"}`; `display` can contain newlines/tabs (flattened in `parse_history`).
|
|
45
|
+
|
|
46
|
+
If claude changes any of this, re-verify empirically (cheap probe: `claude --resume <id> --fork-session --model haiku --print "Reply with only the word ok"` from the directory under test; clean up the forked transcript afterwards).
|
|
47
|
+
|
|
48
|
+
## Workflow conventions
|
|
49
|
+
|
|
50
|
+
- TDD for pure functions; the curses layer changes get fake-stdscr smoke tests instead.
|
|
51
|
+
- Commits are conventional-commit style (`feat:`, `fix:`, `chore:`, `polish:`). No standalone `docs:` or `test:` commits — documentation and test changes ride along in the feature or bugfix commit they belong to.
|
reclaude-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
BSD 2-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bryce Boe
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
16
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
17
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
18
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
19
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
20
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
21
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
22
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
23
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
24
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
reclaude-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: reclaude
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Curses picker for resuming Claude Code sessions
|
|
5
|
+
Project-URL: Homepage, https://github.com/bboe/reclaude
|
|
6
|
+
Project-URL: Repository, https://github.com/bboe/reclaude
|
|
7
|
+
Project-URL: Issues, https://github.com/bboe/reclaude/issues
|
|
8
|
+
Author-email: Bryce Boe <bbzbryce@gmail.com>
|
|
9
|
+
License-Expression: BSD-2-Clause
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: claude,claude-code,curses,resume,tui
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console :: Curses
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: POSIX
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# reclaude
|
|
25
|
+
|
|
26
|
+
A keyboard-driven curses picker for resuming [Claude Code](https://claude.com/claude-code)
|
|
27
|
+
sessions. It reads `~/.claude/history.jsonl` and shows your recent project directories as an
|
|
28
|
+
expandable tree with sessions inline, so you can jump back into a conversation without
|
|
29
|
+
remembering its session id.
|
|
30
|
+
|
|
31
|
+
- **Tree view** of recent directories; expand one to see its individual sessions, each shown
|
|
32
|
+
with its time and opening prompt.
|
|
33
|
+
- **Live-session locks** — directories with a running `claude` process are marked so you don't
|
|
34
|
+
collide with an active session.
|
|
35
|
+
- **Worktree resurrection** — sessions from deleted git worktrees can be brought back; reclaude
|
|
36
|
+
re-runs them via `claude --worktree`, which recreates the worktree and finds the transcript.
|
|
37
|
+
- On selection it `chdir`s into the directory and `exec`s `claude --resume <id>` (display always
|
|
38
|
+
matches the action — the row you see is the session you get).
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv tool install reclaude # recommended
|
|
44
|
+
pipx install reclaude
|
|
45
|
+
pip install reclaude
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
reclaude # or: python -m reclaude
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
| Key | Action |
|
|
55
|
+
| -------------- | ------------------------------------------------- |
|
|
56
|
+
| `↑` / `↓` | Move selection |
|
|
57
|
+
| `Enter` | Expand a directory, or resume the selected session |
|
|
58
|
+
| *type* | Incrementally filter by directory / prompt text |
|
|
59
|
+
| `Ctrl-W` | Toggle showing directories whose path is gone |
|
|
60
|
+
| `Ctrl-T` | Cycle the age window (how far back to look) |
|
|
61
|
+
| `Backspace` | Delete a filter character |
|
|
62
|
+
| `q` / `Esc` | Quit (`q` quits only when the filter is empty) |
|
|
63
|
+
|
|
64
|
+
## Requirements
|
|
65
|
+
|
|
66
|
+
- A POSIX system with an interactive terminal (TTY).
|
|
67
|
+
- [Claude Code](https://claude.com/claude-code) installed and on your `PATH`.
|
|
68
|
+
- Python 3.10+. No third-party dependencies (Python standard library only).
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
BSD-2-Clause. See [LICENSE](LICENSE).
|
reclaude-0.1.0/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# reclaude
|
|
2
|
+
|
|
3
|
+
A keyboard-driven curses picker for resuming [Claude Code](https://claude.com/claude-code)
|
|
4
|
+
sessions. It reads `~/.claude/history.jsonl` and shows your recent project directories as an
|
|
5
|
+
expandable tree with sessions inline, so you can jump back into a conversation without
|
|
6
|
+
remembering its session id.
|
|
7
|
+
|
|
8
|
+
- **Tree view** of recent directories; expand one to see its individual sessions, each shown
|
|
9
|
+
with its time and opening prompt.
|
|
10
|
+
- **Live-session locks** — directories with a running `claude` process are marked so you don't
|
|
11
|
+
collide with an active session.
|
|
12
|
+
- **Worktree resurrection** — sessions from deleted git worktrees can be brought back; reclaude
|
|
13
|
+
re-runs them via `claude --worktree`, which recreates the worktree and finds the transcript.
|
|
14
|
+
- On selection it `chdir`s into the directory and `exec`s `claude --resume <id>` (display always
|
|
15
|
+
matches the action — the row you see is the session you get).
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uv tool install reclaude # recommended
|
|
21
|
+
pipx install reclaude
|
|
22
|
+
pip install reclaude
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
reclaude # or: python -m reclaude
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
| Key | Action |
|
|
32
|
+
| -------------- | ------------------------------------------------- |
|
|
33
|
+
| `↑` / `↓` | Move selection |
|
|
34
|
+
| `Enter` | Expand a directory, or resume the selected session |
|
|
35
|
+
| *type* | Incrementally filter by directory / prompt text |
|
|
36
|
+
| `Ctrl-W` | Toggle showing directories whose path is gone |
|
|
37
|
+
| `Ctrl-T` | Cycle the age window (how far back to look) |
|
|
38
|
+
| `Backspace` | Delete a filter character |
|
|
39
|
+
| `q` / `Esc` | Quit (`q` quits only when the filter is empty) |
|
|
40
|
+
|
|
41
|
+
## Requirements
|
|
42
|
+
|
|
43
|
+
- A POSIX system with an interactive terminal (TTY).
|
|
44
|
+
- [Claude Code](https://claude.com/claude-code) installed and on your `PATH`.
|
|
45
|
+
- Python 3.10+. No third-party dependencies (Python standard library only).
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
BSD-2-Clause. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "reclaude"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Curses picker for resuming Claude Code sessions"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "BSD-2-Clause"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Bryce Boe", email = "bbzbryce@gmail.com" }]
|
|
14
|
+
keywords = ["claude", "claude-code", "curses", "tui", "resume"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console :: Curses",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: POSIX",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Utilities",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/bboe/reclaude"
|
|
29
|
+
Repository = "https://github.com/bboe/reclaude"
|
|
30
|
+
Issues = "https://github.com/bboe/reclaude/issues"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
reclaude = "reclaude:main"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/reclaude"]
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = ["pytest"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""reclaude: curses picker for recent Claude Code sessions.
|
|
2
|
+
|
|
3
|
+
Reads ~/.claude/history.jsonl, shows recent project directories as an
|
|
4
|
+
expandable tree (sessions inline under each directory), marks directories
|
|
5
|
+
with a running claude session as locked, resurrects sessions from deleted
|
|
6
|
+
git worktrees via `claude --worktree`, then chdirs and execs claude.
|
|
7
|
+
"""
|
|
8
|
+
import curses
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
HISTORY_PATH = os.path.expanduser("~/.claude/history.jsonl")
|
|
16
|
+
MAX_DIRS = 30
|
|
17
|
+
PROJECTS_DIR = os.path.expanduser("~/.claude/projects")
|
|
18
|
+
SESSIONS_DIR = os.path.expanduser("~/.claude/sessions")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_history(lines):
|
|
22
|
+
"""Parse history.jsonl lines into entry dicts, skipping malformed lines."""
|
|
23
|
+
entries = []
|
|
24
|
+
for line in lines:
|
|
25
|
+
try:
|
|
26
|
+
obj = json.loads(line)
|
|
27
|
+
except (json.JSONDecodeError, ValueError):
|
|
28
|
+
continue
|
|
29
|
+
if not isinstance(obj, dict):
|
|
30
|
+
continue
|
|
31
|
+
project = obj.get("project")
|
|
32
|
+
session_id = obj.get("sessionId")
|
|
33
|
+
ts = obj.get("timestamp")
|
|
34
|
+
display = obj.get("display")
|
|
35
|
+
if not (isinstance(project, str) and isinstance(session_id, str)
|
|
36
|
+
and isinstance(ts, (int, float)) and not isinstance(ts, bool)):
|
|
37
|
+
continue
|
|
38
|
+
if isinstance(display, str):
|
|
39
|
+
display = " ".join(display.split())
|
|
40
|
+
else:
|
|
41
|
+
display = ""
|
|
42
|
+
entries.append({
|
|
43
|
+
"project": project,
|
|
44
|
+
"session_id": session_id,
|
|
45
|
+
"ts": ts,
|
|
46
|
+
"display": display,
|
|
47
|
+
})
|
|
48
|
+
return entries
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def mung_path(path):
|
|
52
|
+
"""Munged ~/.claude/projects dir name for a path: '/' and '.' become '-'."""
|
|
53
|
+
return path.replace("/", "-").replace(".", "-")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def transcript_path(home_dir, session_id, projects_dir=None):
|
|
57
|
+
return os.path.join(projects_dir or PROJECTS_DIR,
|
|
58
|
+
mung_path(home_dir), session_id + ".jsonl")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def transcript_exists(home_dir, session_id, projects_dir=None):
|
|
62
|
+
return os.path.isfile(transcript_path(home_dir, session_id, projects_dir))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def group_by_home(entries, transcript_exists=transcript_exists):
|
|
66
|
+
"""Group sessions under their home dir (first project seen), newest first.
|
|
67
|
+
|
|
68
|
+
A session's transcript lives where the session started, so that first
|
|
69
|
+
directory is the only place `claude --resume` can find it. Sessions whose
|
|
70
|
+
transcript no longer exists are dropped. Each group:
|
|
71
|
+
{"path", "last_ts", "sessions": [{"session_id", "ts", "display"}, ...]}
|
|
72
|
+
with sessions newest-first.
|
|
73
|
+
"""
|
|
74
|
+
sessions = {}
|
|
75
|
+
for e in entries:
|
|
76
|
+
s = sessions.setdefault(e["session_id"],
|
|
77
|
+
{"home": e["project"], "ts": 0, "display": ""})
|
|
78
|
+
if e["ts"] >= s["ts"]:
|
|
79
|
+
s["ts"] = e["ts"]
|
|
80
|
+
s["display"] = e["display"]
|
|
81
|
+
dirs = {}
|
|
82
|
+
for sid, s in sessions.items():
|
|
83
|
+
if not transcript_exists(s["home"], sid):
|
|
84
|
+
continue
|
|
85
|
+
dirs.setdefault(s["home"], []).append(
|
|
86
|
+
{"session_id": sid, "ts": s["ts"], "display": s["display"]})
|
|
87
|
+
groups = []
|
|
88
|
+
for path, sess in dirs.items():
|
|
89
|
+
sess.sort(key=lambda s: -s["ts"])
|
|
90
|
+
groups.append({"path": path, "last_ts": sess[0]["ts"], "sessions": sess})
|
|
91
|
+
groups.sort(key=lambda g: -g["last_ts"])
|
|
92
|
+
return groups
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
WORKTREE_RE = re.compile(r"^(?P<repo>.+)/\.claude/worktrees/(?P<name>[^/]+)$")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def classify_dir(path, isdir=os.path.isdir):
|
|
99
|
+
"""Classify a session home dir.
|
|
100
|
+
|
|
101
|
+
Returns (kind, repo, name): kind is "live" (dir exists), "orphan-worktree"
|
|
102
|
+
(dir gone but it was <repo>/.claude/worktrees/<name> and <repo> exists —
|
|
103
|
+
resumable via `claude --worktree <name> --resume <id>` from <repo>), or
|
|
104
|
+
"gone". repo/name are None unless kind == "orphan-worktree".
|
|
105
|
+
"""
|
|
106
|
+
if isdir(path):
|
|
107
|
+
return ("live", None, None)
|
|
108
|
+
m = WORKTREE_RE.match(path)
|
|
109
|
+
if m and isdir(m.group("repo")):
|
|
110
|
+
return ("orphan-worktree", m.group("repo"), m.group("name"))
|
|
111
|
+
return ("gone", None, None)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def find_busy_dirs(proc_root="/proc"):
|
|
115
|
+
"""Return the set of realpath cwds of running `claude` processes."""
|
|
116
|
+
busy = set()
|
|
117
|
+
try:
|
|
118
|
+
names = os.listdir(proc_root)
|
|
119
|
+
except OSError:
|
|
120
|
+
return busy
|
|
121
|
+
for name in names:
|
|
122
|
+
if not name.isdigit():
|
|
123
|
+
continue
|
|
124
|
+
base = os.path.join(proc_root, name)
|
|
125
|
+
try:
|
|
126
|
+
with open(os.path.join(base, "comm")) as f:
|
|
127
|
+
if f.read().strip() != "claude":
|
|
128
|
+
continue
|
|
129
|
+
busy.add(os.path.realpath(os.path.join(base, "cwd")))
|
|
130
|
+
except OSError:
|
|
131
|
+
continue # process exited, or not ours to read
|
|
132
|
+
return busy
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def live_sessions(sessions_dir=None, proc_root="/proc"):
|
|
136
|
+
"""Busy dirs and running session ids from ~/.claude/sessions records.
|
|
137
|
+
|
|
138
|
+
Each <pid>.json record counts only if /proc/<pid>/comm is "claude" (stale
|
|
139
|
+
files survive crashes). Falls back to scanning /proc when the records
|
|
140
|
+
yield nothing, so a claude started outside the session tracker still
|
|
141
|
+
locks its directory (running ids unknown in that case).
|
|
142
|
+
"""
|
|
143
|
+
busy, running = set(), set()
|
|
144
|
+
try:
|
|
145
|
+
names = os.listdir(sessions_dir or SESSIONS_DIR)
|
|
146
|
+
except OSError:
|
|
147
|
+
names = []
|
|
148
|
+
for name in names:
|
|
149
|
+
if not name.endswith(".json"):
|
|
150
|
+
continue
|
|
151
|
+
try:
|
|
152
|
+
with open(os.path.join(sessions_dir or SESSIONS_DIR, name)) as f:
|
|
153
|
+
rec = json.load(f)
|
|
154
|
+
except (OSError, ValueError):
|
|
155
|
+
continue
|
|
156
|
+
if not isinstance(rec, dict):
|
|
157
|
+
continue
|
|
158
|
+
pid, cwd, sid = rec.get("pid"), rec.get("cwd"), rec.get("sessionId")
|
|
159
|
+
if not (isinstance(pid, int) and isinstance(cwd, str)):
|
|
160
|
+
continue
|
|
161
|
+
try:
|
|
162
|
+
with open(os.path.join(proc_root, str(pid), "comm")) as f:
|
|
163
|
+
if f.read().strip() != "claude":
|
|
164
|
+
continue
|
|
165
|
+
except OSError:
|
|
166
|
+
continue
|
|
167
|
+
busy.add(os.path.realpath(cwd))
|
|
168
|
+
if isinstance(sid, str):
|
|
169
|
+
running.add(sid)
|
|
170
|
+
if not busy:
|
|
171
|
+
busy = find_busy_dirs(proc_root)
|
|
172
|
+
return busy, running
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def relative_time(ts_ms, now_ms):
|
|
176
|
+
"""Compact age like '5s', '3m', '7h', '2d'."""
|
|
177
|
+
secs = max(0, int((now_ms - ts_ms) / 1000))
|
|
178
|
+
if secs < 60:
|
|
179
|
+
return f"{secs}s"
|
|
180
|
+
if secs < 3600:
|
|
181
|
+
return f"{secs // 60}m"
|
|
182
|
+
if secs < 86400:
|
|
183
|
+
return f"{secs // 3600}h"
|
|
184
|
+
return f"{secs // 86400}d"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def abbreviate_path(path, home):
|
|
188
|
+
if path == home:
|
|
189
|
+
return "~"
|
|
190
|
+
if path.startswith(home + os.sep):
|
|
191
|
+
return "~" + path[len(home):]
|
|
192
|
+
return path
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def truncate(s, width):
|
|
196
|
+
if width <= 0:
|
|
197
|
+
return ""
|
|
198
|
+
if len(s) <= width:
|
|
199
|
+
return s
|
|
200
|
+
return s[: width - 1] + "…"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _is_busy(path, busy):
|
|
204
|
+
return os.path.realpath(path) in busy
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def flatten_rows(groups, expanded, filt, home, busy, running_ids,
|
|
208
|
+
isdir=os.path.isdir, show_missing=True, min_ts=None):
|
|
209
|
+
"""Flatten groups + expansion state into the visible row list.
|
|
210
|
+
|
|
211
|
+
Dir row: {"kind": "dir", "group", "vis_sessions", "cls", "repo",
|
|
212
|
+
"name", "busy"}
|
|
213
|
+
Session row: {"kind": "session", "group", "session", "cls", "repo",
|
|
214
|
+
"name", "busy", "running"}.
|
|
215
|
+
A session is visible iff it passes the age window (min_ts) and the text
|
|
216
|
+
filter — a dir-path match admits all its sessions, otherwise the prompt
|
|
217
|
+
text must contain the filter (both case-insensitive). A dir is shown iff
|
|
218
|
+
it has visible sessions and passes the missing-dir filter; dirs are
|
|
219
|
+
capped at MAX_DIRS. Expanded dirs render only their visible sessions.
|
|
220
|
+
"""
|
|
221
|
+
f = filt.lower()
|
|
222
|
+
kept = []
|
|
223
|
+
for g in groups:
|
|
224
|
+
path_match = f in abbreviate_path(g["path"], home).lower()
|
|
225
|
+
vis = [s for s in g["sessions"]
|
|
226
|
+
if (min_ts is None or s["ts"] >= min_ts)
|
|
227
|
+
and (path_match or f in s["display"].lower())]
|
|
228
|
+
if not vis:
|
|
229
|
+
continue
|
|
230
|
+
cls, repo, name = classify_dir(g["path"], isdir)
|
|
231
|
+
if not show_missing and cls != "live":
|
|
232
|
+
continue
|
|
233
|
+
kept.append((g, vis, cls, repo, name))
|
|
234
|
+
rows = []
|
|
235
|
+
for g, vis, cls, repo, name in kept[:MAX_DIRS]:
|
|
236
|
+
b = _is_busy(g["path"], busy)
|
|
237
|
+
rows.append({"kind": "dir", "group": g, "vis_sessions": vis,
|
|
238
|
+
"cls": cls, "repo": repo, "name": name, "busy": b})
|
|
239
|
+
if g["path"] in expanded:
|
|
240
|
+
for s in vis:
|
|
241
|
+
rows.append({"kind": "session", "group": g, "session": s,
|
|
242
|
+
"cls": cls, "repo": repo, "name": name, "busy": b,
|
|
243
|
+
"running": s["session_id"] in running_ids})
|
|
244
|
+
return rows
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# COLOR_KEYS must cover every key that _row_spans emits.
|
|
248
|
+
COLOR_KEYS = ("time", "path", "running", "orphan", "gone", "flash", "text")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def init_colors():
|
|
252
|
+
"""Map color keys to curses attributes; monochrome fallback."""
|
|
253
|
+
attrs = {k: curses.A_NORMAL for k in COLOR_KEYS}
|
|
254
|
+
attrs["path"] = curses.A_BOLD
|
|
255
|
+
attrs["gone"] = curses.A_DIM
|
|
256
|
+
try:
|
|
257
|
+
curses.start_color()
|
|
258
|
+
curses.use_default_colors()
|
|
259
|
+
if not curses.has_colors():
|
|
260
|
+
return attrs
|
|
261
|
+
for i, (key, color) in enumerate([
|
|
262
|
+
("time", curses.COLOR_CYAN),
|
|
263
|
+
("running", curses.COLOR_YELLOW),
|
|
264
|
+
("orphan", curses.COLOR_MAGENTA),
|
|
265
|
+
("flash", curses.COLOR_RED)], start=1):
|
|
266
|
+
curses.init_pair(i, color, -1)
|
|
267
|
+
attrs[key] = curses.color_pair(i)
|
|
268
|
+
except curses.error:
|
|
269
|
+
pass
|
|
270
|
+
return attrs
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _draw(stdscr, title, render_rows, sel, top, footer, footer_attr, attrs):
|
|
274
|
+
"""render_rows: list of (spans, extra_attr). Returns new scroll `top`."""
|
|
275
|
+
stdscr.erase()
|
|
276
|
+
maxy, maxx = stdscr.getmaxyx()
|
|
277
|
+
# maxy==1: header only; maxy==2: header (y=0) + footer (y=1), no body rows.
|
|
278
|
+
body = max(0, maxy - 2)
|
|
279
|
+
if sel < top:
|
|
280
|
+
top = sel
|
|
281
|
+
elif body > 0 and sel >= top + body:
|
|
282
|
+
top = sel - body + 1
|
|
283
|
+
try:
|
|
284
|
+
stdscr.addnstr(0, 0, truncate(title, maxx - 1), maxx - 1, curses.A_BOLD)
|
|
285
|
+
except curses.error:
|
|
286
|
+
pass
|
|
287
|
+
for i, (spans, extra) in enumerate(render_rows[top:top + body]):
|
|
288
|
+
row_attr = extra | (curses.A_REVERSE if top + i == sel else 0)
|
|
289
|
+
x = 0
|
|
290
|
+
for text, key in spans:
|
|
291
|
+
avail = maxx - 1 - x
|
|
292
|
+
if avail <= 0:
|
|
293
|
+
break
|
|
294
|
+
t = truncate(text, avail)
|
|
295
|
+
try:
|
|
296
|
+
stdscr.addnstr(1 + i, x, t, avail, attrs.get(key, curses.A_NORMAL) | row_attr)
|
|
297
|
+
except curses.error:
|
|
298
|
+
pass
|
|
299
|
+
x += len(t)
|
|
300
|
+
if x < maxx - 1: # pad so the selection bar spans the line
|
|
301
|
+
try:
|
|
302
|
+
stdscr.addnstr(1 + i, x, " " * (maxx - 1 - x), maxx - 1 - x,
|
|
303
|
+
row_attr)
|
|
304
|
+
except curses.error:
|
|
305
|
+
pass
|
|
306
|
+
if maxy >= 2:
|
|
307
|
+
try:
|
|
308
|
+
stdscr.addnstr(maxy - 1, 0, truncate(footer, maxx - 1), maxx - 1,
|
|
309
|
+
footer_attr)
|
|
310
|
+
except curses.error:
|
|
311
|
+
pass
|
|
312
|
+
stdscr.refresh()
|
|
313
|
+
return top
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _row_spans(row, now_ms, home):
|
|
317
|
+
"""Render a flatten_rows row as [(text, colorkey)] spans.
|
|
318
|
+
|
|
319
|
+
Color keys: "time", "path", "running", "orphan", "gone", "text" — mapped
|
|
320
|
+
to curses attributes by init_colors().
|
|
321
|
+
"""
|
|
322
|
+
if row["kind"] == "dir":
|
|
323
|
+
g = row["group"]
|
|
324
|
+
vis = row["vis_sessions"]
|
|
325
|
+
ts = vis[0]["ts"] if vis else g["last_ts"]
|
|
326
|
+
spans = [(f"{relative_time(ts, now_ms):>4} ", "time"),
|
|
327
|
+
(abbreviate_path(g["path"], home), "path")]
|
|
328
|
+
if row["busy"]:
|
|
329
|
+
spans.append((" [running]", "running"))
|
|
330
|
+
if row["cls"] == "orphan-worktree":
|
|
331
|
+
spans.append((" [worktree gone]", "orphan"))
|
|
332
|
+
elif row["cls"] == "gone":
|
|
333
|
+
spans.append((" [gone]", "gone"))
|
|
334
|
+
last = row["vis_sessions"][0]["display"] if row["vis_sessions"] else ""
|
|
335
|
+
if last:
|
|
336
|
+
spans.append((f" — {last}", "text"))
|
|
337
|
+
return spans
|
|
338
|
+
s = row["session"]
|
|
339
|
+
spans = [(" ", "text"),
|
|
340
|
+
(f"{relative_time(s['ts'], now_ms):>4} ", "time"),
|
|
341
|
+
(s["display"] or "(no prompt)", "text")]
|
|
342
|
+
if row.get("running"):
|
|
343
|
+
spans.append((" [running]", "running"))
|
|
344
|
+
return spans
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
AGE_WINDOWS = [("all", None), ("1mo", 30 * 86400_000), ("1w", 7 * 86400_000),
|
|
348
|
+
("1d", 86400_000), ("1h", 3600_000)]
|
|
349
|
+
HELP = ("↑↓ move · ⏎ resume · →/⇥ expand · ← collapse · ^W missing · "
|
|
350
|
+
"^T age · type to filter · q quit")
|
|
351
|
+
FLASH_BUSY = "that directory already has a claude session running"
|
|
352
|
+
FLASH_GONE = "directory no longer exists"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _launch(row, session_id):
|
|
356
|
+
if row["cls"] == "orphan-worktree":
|
|
357
|
+
return ("worktree", row["repo"], row["name"], session_id)
|
|
358
|
+
return ("resume", row["group"]["path"], session_id)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def run_picker(stdscr, groups, busy, running_ids):
|
|
362
|
+
"""Returns ('resume', path, id) | ('worktree', repo, name, id) | None."""
|
|
363
|
+
try:
|
|
364
|
+
curses.curs_set(0)
|
|
365
|
+
except curses.error:
|
|
366
|
+
pass
|
|
367
|
+
stdscr.keypad(True)
|
|
368
|
+
attrs = init_colors()
|
|
369
|
+
home = os.path.expanduser("~")
|
|
370
|
+
|
|
371
|
+
sel, top, filt, flash = 0, 0, "", ""
|
|
372
|
+
show_missing, age_idx = True, 0
|
|
373
|
+
expanded = set()
|
|
374
|
+
|
|
375
|
+
while True:
|
|
376
|
+
now_ms = int(time.time() * 1000)
|
|
377
|
+
label, window = AGE_WINDOWS[age_idx]
|
|
378
|
+
min_ts = now_ms - window if window is not None else None
|
|
379
|
+
rows = flatten_rows(groups, expanded, filt, home, busy, running_ids,
|
|
380
|
+
show_missing=show_missing, min_ts=min_ts)
|
|
381
|
+
render = [(_row_spans(r, now_ms, home),
|
|
382
|
+
curses.A_DIM if (r["busy"] or r["cls"] == "gone") else 0)
|
|
383
|
+
for r in rows]
|
|
384
|
+
if flash:
|
|
385
|
+
footer, footer_attr, flash = flash, attrs["flash"], ""
|
|
386
|
+
elif filt:
|
|
387
|
+
footer, footer_attr = f"filter: {filt}▏", curses.A_DIM
|
|
388
|
+
else:
|
|
389
|
+
footer, footer_attr = HELP, curses.A_DIM
|
|
390
|
+
n = len(rows)
|
|
391
|
+
sel = max(0, min(sel, n - 1)) if n else 0
|
|
392
|
+
title = "reclaude — recent sessions"
|
|
393
|
+
if window is not None:
|
|
394
|
+
title += f" · ≤{label}"
|
|
395
|
+
if not show_missing:
|
|
396
|
+
title += " · missing hidden"
|
|
397
|
+
top = _draw(stdscr, title, render, sel, top, footer, footer_attr, attrs)
|
|
398
|
+
|
|
399
|
+
key = stdscr.getch()
|
|
400
|
+
if key == curses.KEY_UP:
|
|
401
|
+
sel = max(0, sel - 1)
|
|
402
|
+
elif key == curses.KEY_DOWN:
|
|
403
|
+
sel = min(n - 1, sel + 1) if n else 0
|
|
404
|
+
elif key in (curses.KEY_ENTER, 10, 13) and n:
|
|
405
|
+
r = rows[sel]
|
|
406
|
+
if r["busy"]:
|
|
407
|
+
flash = FLASH_BUSY
|
|
408
|
+
elif r["cls"] == "gone":
|
|
409
|
+
flash = FLASH_GONE
|
|
410
|
+
elif r["kind"] == "session":
|
|
411
|
+
return _launch(r, r["session"]["session_id"])
|
|
412
|
+
else:
|
|
413
|
+
return _launch(r, r["vis_sessions"][0]["session_id"])
|
|
414
|
+
elif key in (curses.KEY_RIGHT, ord("\t")) and n:
|
|
415
|
+
if rows[sel]["kind"] == "dir":
|
|
416
|
+
expanded.add(rows[sel]["group"]["path"])
|
|
417
|
+
elif key == curses.KEY_LEFT and n:
|
|
418
|
+
r = rows[sel]
|
|
419
|
+
if r["kind"] == "session":
|
|
420
|
+
for i in range(sel - 1, -1, -1):
|
|
421
|
+
if rows[i]["kind"] == "dir" and rows[i]["group"] is r["group"]:
|
|
422
|
+
sel = i
|
|
423
|
+
break
|
|
424
|
+
else:
|
|
425
|
+
expanded.discard(r["group"]["path"])
|
|
426
|
+
elif key == 27: # Esc
|
|
427
|
+
if filt:
|
|
428
|
+
filt, sel, top = "", 0, 0
|
|
429
|
+
else:
|
|
430
|
+
return None
|
|
431
|
+
elif key in (curses.KEY_BACKSPACE, 127, 8):
|
|
432
|
+
if filt:
|
|
433
|
+
filt, sel, top = filt[:-1], 0, 0
|
|
434
|
+
elif key == 23: # Ctrl-W: toggle missing dirs
|
|
435
|
+
show_missing = not show_missing
|
|
436
|
+
sel, top = 0, 0
|
|
437
|
+
elif key == 20: # Ctrl-T: cycle age filter
|
|
438
|
+
age_idx = (age_idx + 1) % len(AGE_WINDOWS)
|
|
439
|
+
sel, top = 0, 0
|
|
440
|
+
elif 32 <= key < 127:
|
|
441
|
+
ch = chr(key)
|
|
442
|
+
if ch == "q" and not filt:
|
|
443
|
+
return None
|
|
444
|
+
filt, sel, top = filt + ch, 0, 0
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def main():
|
|
448
|
+
try:
|
|
449
|
+
with open(HISTORY_PATH, encoding="utf-8") as f:
|
|
450
|
+
entries = parse_history(f)
|
|
451
|
+
except OSError as e:
|
|
452
|
+
sys.exit(f"reclaude: cannot read {HISTORY_PATH}: {e}")
|
|
453
|
+
groups = group_by_home(entries)
|
|
454
|
+
if not groups:
|
|
455
|
+
sys.exit("reclaude: no resumable sessions found in history")
|
|
456
|
+
busy, running_ids = live_sessions()
|
|
457
|
+
if not sys.stdout.isatty():
|
|
458
|
+
sys.exit("reclaude: needs an interactive terminal")
|
|
459
|
+
os.environ.setdefault("ESCDELAY", "25")
|
|
460
|
+
result = curses.wrapper(run_picker, groups, busy, running_ids)
|
|
461
|
+
if result is None:
|
|
462
|
+
return
|
|
463
|
+
if result[0] == "worktree":
|
|
464
|
+
_, path, name, session_id = result
|
|
465
|
+
argv = ["claude", "--worktree", name, "--resume", session_id]
|
|
466
|
+
else:
|
|
467
|
+
_, path, session_id = result
|
|
468
|
+
argv = ["claude", "--resume", session_id]
|
|
469
|
+
try:
|
|
470
|
+
os.chdir(path)
|
|
471
|
+
except OSError as e:
|
|
472
|
+
sys.exit(f"reclaude: cannot chdir to {path}: {e}")
|
|
473
|
+
try:
|
|
474
|
+
os.execvp("claude", argv)
|
|
475
|
+
except OSError as e:
|
|
476
|
+
sys.exit(f"reclaude: cannot exec claude: {e}")
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import reclaude as cr
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_parse_history_basic():
|
|
8
|
+
lines = [
|
|
9
|
+
'{"display":"fix bug","pastedContents":{},"timestamp":1000,"project":"/p/a","sessionId":"s1"}',
|
|
10
|
+
'{"display":"add feat","timestamp":2000,"project":"/p/b","sessionId":"s2"}',
|
|
11
|
+
]
|
|
12
|
+
entries = cr.parse_history(lines)
|
|
13
|
+
assert entries == [
|
|
14
|
+
{"project": "/p/a", "session_id": "s1", "ts": 1000, "display": "fix bug"},
|
|
15
|
+
{"project": "/p/b", "session_id": "s2", "ts": 2000, "display": "add feat"},
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_parse_history_skips_garbage():
|
|
20
|
+
lines = [
|
|
21
|
+
"not json at all",
|
|
22
|
+
'{"display":"missing fields"}',
|
|
23
|
+
'"a json string, not an object"',
|
|
24
|
+
'{"display":null,"timestamp":3000,"project":"/p/c","sessionId":"s3"}',
|
|
25
|
+
"",
|
|
26
|
+
]
|
|
27
|
+
entries = cr.parse_history(lines)
|
|
28
|
+
# Only the entry with all required fields survives; null display becomes ""
|
|
29
|
+
assert entries == [{"project": "/p/c", "session_id": "s3", "ts": 3000, "display": ""}]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _e(project, sid, ts, display=""):
|
|
33
|
+
return {"project": project, "session_id": sid, "ts": ts, "display": display}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_group_by_home_attribution_and_order():
|
|
37
|
+
entries = [
|
|
38
|
+
_e("/p/a", "s1", 1000, "first"),
|
|
39
|
+
_e("/p/b", "s1", 5000, "moved"), # session moved dirs: home stays /p/a
|
|
40
|
+
_e("/p/a", "s2", 3000, "second"),
|
|
41
|
+
_e("/p/c", "s3", 9000, "newest"),
|
|
42
|
+
]
|
|
43
|
+
groups = cr.group_by_home(entries, transcript_exists=lambda h, s: True)
|
|
44
|
+
assert [g["path"] for g in groups] == ["/p/c", "/p/a"]
|
|
45
|
+
a = groups[1]
|
|
46
|
+
assert a["last_ts"] == 5000
|
|
47
|
+
assert [s["session_id"] for s in a["sessions"]] == ["s1", "s2"]
|
|
48
|
+
assert a["sessions"][0]["display"] == "moved"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_group_by_home_drops_sessions_without_transcript():
|
|
52
|
+
entries = [_e("/p/a", "s1", 1000), _e("/p/a", "s2", 2000), _e("/p/b", "s3", 3000)]
|
|
53
|
+
groups = cr.group_by_home(entries, transcript_exists=lambda h, sid: sid != "s2")
|
|
54
|
+
assert [g["path"] for g in groups] == ["/p/b", "/p/a"]
|
|
55
|
+
assert [s["session_id"] for s in groups[1]["sessions"]] == ["s1"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_group_by_home_drops_empty_groups():
|
|
59
|
+
entries = [_e("/p/a", "s1", 1000)]
|
|
60
|
+
assert cr.group_by_home(entries, transcript_exists=lambda h, s: False) == []
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _fake_proc(tmp_path, pid, comm, cwd_target):
|
|
64
|
+
p = tmp_path / str(pid)
|
|
65
|
+
p.mkdir()
|
|
66
|
+
(p / "comm").write_text(comm + "\n")
|
|
67
|
+
(p / "cwd").symlink_to(cwd_target)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_find_busy_dirs(tmp_path):
|
|
71
|
+
work = tmp_path / "work"
|
|
72
|
+
work.mkdir()
|
|
73
|
+
other = tmp_path / "other"
|
|
74
|
+
other.mkdir()
|
|
75
|
+
_fake_proc(tmp_path, 100, "claude", work)
|
|
76
|
+
_fake_proc(tmp_path, 200, "vim", other) # wrong comm: ignored
|
|
77
|
+
(tmp_path / "self").mkdir() # non-numeric entry: ignored
|
|
78
|
+
broken = tmp_path / "300"
|
|
79
|
+
broken.mkdir() # numeric but no comm file: ignored
|
|
80
|
+
busy = cr.find_busy_dirs(proc_root=str(tmp_path))
|
|
81
|
+
assert busy == {os.path.realpath(str(work))}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_find_busy_dirs_missing_proc_root():
|
|
85
|
+
assert cr.find_busy_dirs(proc_root="/nonexistent-proc") == set()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_relative_time():
|
|
89
|
+
now = 10_000_000_000_000
|
|
90
|
+
assert cr.relative_time(now - 5_000, now) == "5s"
|
|
91
|
+
assert cr.relative_time(now - 90_000, now) == "1m"
|
|
92
|
+
assert cr.relative_time(now - 3 * 3600_000, now) == "3h"
|
|
93
|
+
assert cr.relative_time(now - 49 * 3600_000, now) == "2d"
|
|
94
|
+
assert cr.relative_time(now + 5_000, now) == "0s" # clock skew clamps to 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_abbreviate_path():
|
|
98
|
+
assert cr.abbreviate_path("/home/u/proj", "/home/u") == "~/proj"
|
|
99
|
+
assert cr.abbreviate_path("/home/u", "/home/u") == "~"
|
|
100
|
+
assert cr.abbreviate_path("/home/uother/x", "/home/u") == "/home/uother/x"
|
|
101
|
+
assert cr.abbreviate_path("/etc/x", "/home/u") == "/etc/x"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_parse_history_flattens_control_chars():
|
|
105
|
+
lines = ['{"display":"line one\\nline two\\tend","timestamp":1,"project":"/p","sessionId":"s"}']
|
|
106
|
+
assert cr.parse_history(lines)[0]["display"] == "line one line two end"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_truncate():
|
|
110
|
+
assert cr.truncate("hello", 10) == "hello"
|
|
111
|
+
assert cr.truncate("hello world", 8) == "hello w…"
|
|
112
|
+
assert cr.truncate("hi", 0) == ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_mung_path():
|
|
116
|
+
assert cr.mung_path("/home/u/scratch") == "-home-u-scratch"
|
|
117
|
+
assert cr.mung_path("/home/u/repo/.claude/worktrees/a1") == \
|
|
118
|
+
"-home-u-repo--claude-worktrees-a1"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_transcript_path():
|
|
122
|
+
p = cr.transcript_path("/home/u/scratch", "abc", projects_dir="/pp")
|
|
123
|
+
assert p == "/pp/-home-u-scratch/abc.jsonl"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_transcript_exists(tmp_path):
|
|
127
|
+
d = tmp_path / "-home-u-x"
|
|
128
|
+
d.mkdir()
|
|
129
|
+
(d / "s1.jsonl").write_text("{}")
|
|
130
|
+
assert cr.transcript_exists("/home/u/x", "s1", projects_dir=str(tmp_path))
|
|
131
|
+
assert not cr.transcript_exists("/home/u/x", "s2", projects_dir=str(tmp_path))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_classify_dir():
|
|
135
|
+
assert cr.classify_dir("/x", isdir=lambda p: True) == ("live", None, None)
|
|
136
|
+
assert cr.classify_dir("/r/.claude/worktrees/a1", isdir=lambda p: p == "/r") == \
|
|
137
|
+
("orphan-worktree", "/r", "a1")
|
|
138
|
+
assert cr.classify_dir("/gone/dir", isdir=lambda p: False) == ("gone", None, None)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_classify_dir_worktree_repo_also_gone():
|
|
142
|
+
assert cr.classify_dir("/r/.claude/worktrees/a1", isdir=lambda p: False) == \
|
|
143
|
+
("gone", None, None)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _fake_session_file(dirpath, pid, sid, cwd):
|
|
147
|
+
(dirpath / f"{pid}.json").write_text(
|
|
148
|
+
json.dumps({"pid": pid, "sessionId": sid, "cwd": str(cwd)}))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_live_sessions(tmp_path):
|
|
152
|
+
sdir = tmp_path / "sessions"
|
|
153
|
+
sdir.mkdir()
|
|
154
|
+
proc = tmp_path / "proc"
|
|
155
|
+
proc.mkdir()
|
|
156
|
+
work = tmp_path / "work"
|
|
157
|
+
work.mkdir()
|
|
158
|
+
_fake_session_file(sdir, 100, "s-live", work) # live claude
|
|
159
|
+
p = proc / "100"
|
|
160
|
+
p.mkdir()
|
|
161
|
+
(p / "comm").write_text("claude\n")
|
|
162
|
+
_fake_session_file(sdir, 200, "s-stale", work) # pid not running
|
|
163
|
+
_fake_session_file(sdir, 300, "s-vim", work) # pid alive, not claude
|
|
164
|
+
p = proc / "300"
|
|
165
|
+
p.mkdir()
|
|
166
|
+
(p / "comm").write_text("vim\n")
|
|
167
|
+
(sdir / "400.json").write_text("not json") # malformed
|
|
168
|
+
busy, running = cr.live_sessions(sessions_dir=str(sdir), proc_root=str(proc))
|
|
169
|
+
assert busy == {os.path.realpath(str(work))}
|
|
170
|
+
assert running == {"s-live"}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_live_sessions_fallback_to_proc_scan(tmp_path):
|
|
174
|
+
proc = tmp_path / "proc"
|
|
175
|
+
proc.mkdir()
|
|
176
|
+
work = tmp_path / "work"
|
|
177
|
+
work.mkdir()
|
|
178
|
+
p = proc / "500"
|
|
179
|
+
p.mkdir()
|
|
180
|
+
(p / "comm").write_text("claude\n")
|
|
181
|
+
(p / "cwd").symlink_to(work)
|
|
182
|
+
busy, running = cr.live_sessions(sessions_dir=str(tmp_path / "missing"),
|
|
183
|
+
proc_root=str(proc))
|
|
184
|
+
assert busy == {os.path.realpath(str(work))}
|
|
185
|
+
assert running == set()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _g(path, sessions):
|
|
189
|
+
return {"path": path, "last_ts": sessions[0]["ts"], "sessions": sessions}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _s(sid, ts, display=""):
|
|
193
|
+
return {"session_id": sid, "ts": ts, "display": display}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_flatten_rows_expansion_filter_running():
|
|
197
|
+
g1 = _g("/p/a", [_s("s1", 2000, "x"), _s("s2", 1000, "y")])
|
|
198
|
+
g2 = _g("/p/b", [_s("s3", 500, "z")])
|
|
199
|
+
rows = cr.flatten_rows([g1, g2], expanded={"/p/a"}, filt="", home="/h",
|
|
200
|
+
busy=set(), running_ids={"s2"}, isdir=lambda p: True)
|
|
201
|
+
assert [(r["kind"], r.get("session", {}).get("session_id")) for r in rows] == [
|
|
202
|
+
("dir", None), ("session", "s1"), ("session", "s2"), ("dir", None)]
|
|
203
|
+
assert rows[2]["running"] is True and rows[1]["running"] is False
|
|
204
|
+
rows = cr.flatten_rows([g1, g2], expanded={"/p/a"}, filt="B", home="/h",
|
|
205
|
+
busy=set(), running_ids=set(), isdir=lambda p: True)
|
|
206
|
+
assert len(rows) == 1 and rows[0]["group"] is g2
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_flatten_rows_busy_and_classification():
|
|
210
|
+
g = _g("/r/.claude/worktrees/a1", [_s("s1", 100)])
|
|
211
|
+
rows = cr.flatten_rows([g], expanded={"/r/.claude/worktrees/a1"}, filt="",
|
|
212
|
+
home="/h", busy={"/r/.claude/worktrees/a1"},
|
|
213
|
+
running_ids=set(), isdir=lambda p: p == "/r")
|
|
214
|
+
assert rows[0]["cls"] == "orphan-worktree"
|
|
215
|
+
assert (rows[0]["repo"], rows[0]["name"]) == ("/r", "a1")
|
|
216
|
+
assert rows[0]["busy"] is True and rows[1]["busy"] is True
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_row_spans_dir():
|
|
220
|
+
g = _g("/h/proj", [_s("s1", 60_000, "newest"), _s("s0", 0, "older")])
|
|
221
|
+
row = {"kind": "dir", "group": g, "vis_sessions": g["sessions"][1:],
|
|
222
|
+
"cls": "live", "repo": None, "name": None, "busy": True}
|
|
223
|
+
# time and prompt come from the newest VISIBLE session (s0), not s1
|
|
224
|
+
assert cr._row_spans(row, now_ms=120_000, home="/h") == [
|
|
225
|
+
(" 2m ", "time"), ("~/proj", "path"),
|
|
226
|
+
(" [running]", "running"), (" — older", "text")]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_row_spans_badges_and_session():
|
|
230
|
+
g = _g("/r/.claude/worktrees/a1", [_s("s1", 0, "")])
|
|
231
|
+
row = {"kind": "dir", "group": g, "vis_sessions": g["sessions"], "cls": "orphan-worktree",
|
|
232
|
+
"repo": "/r", "name": "a1", "busy": False}
|
|
233
|
+
assert (" [worktree gone]", "orphan") in cr._row_spans(row, 0, "/h")
|
|
234
|
+
row["cls"] = "gone"
|
|
235
|
+
assert (" [gone]", "gone") in cr._row_spans(row, 0, "/h")
|
|
236
|
+
srow = {"kind": "session", "group": g, "session": _s("s1", 0, ""),
|
|
237
|
+
"cls": "live", "repo": None, "name": None, "busy": False,
|
|
238
|
+
"running": True}
|
|
239
|
+
assert cr._row_spans(srow, 0, "/h") == [
|
|
240
|
+
(" ", "text"), (" 0s ", "time"),
|
|
241
|
+
("(no prompt)", "text"), (" [running]", "running")]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_flatten_rows_show_missing_and_min_ts():
|
|
245
|
+
live = _g("/p/live", [_s("s1", 5000)])
|
|
246
|
+
orphan = _g("/r/.claude/worktrees/w1", [_s("s2", 4000)])
|
|
247
|
+
gone = _g("/gone/x", [_s("s3", 3000)])
|
|
248
|
+
isdir = lambda p: p in ("/p/live", "/r")
|
|
249
|
+
rows = cr.flatten_rows([live, orphan, gone], expanded=set(), filt="",
|
|
250
|
+
home="/h", busy=set(), running_ids=set(),
|
|
251
|
+
isdir=isdir, show_missing=False)
|
|
252
|
+
assert [r["group"] for r in rows] == [live]
|
|
253
|
+
rows = cr.flatten_rows([live, orphan, gone], expanded=set(), filt="",
|
|
254
|
+
home="/h", busy=set(), running_ids=set(),
|
|
255
|
+
isdir=isdir, min_ts=4000)
|
|
256
|
+
assert [r["group"] for r in rows] == [live, orphan] # boundary ts kept
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_flatten_rows_filters_apply_before_cap():
|
|
260
|
+
gone_groups = [_g(f"/gone/{i}", [_s(f"g{i}", 10_000 - i)])
|
|
261
|
+
for i in range(cr.MAX_DIRS)]
|
|
262
|
+
live_old = _g("/p/old", [_s("old", 1)])
|
|
263
|
+
rows = cr.flatten_rows(gone_groups + [live_old], expanded=set(), filt="",
|
|
264
|
+
home="/h", busy=set(), running_ids=set(),
|
|
265
|
+
isdir=lambda p: p == "/p/old", show_missing=False)
|
|
266
|
+
assert len(rows) == 1 and rows[0]["group"] is live_old
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_flatten_rows_prompt_text_match():
|
|
270
|
+
g1 = _g("/p/a", [_s("s1", 2000, "fix the parser"), _s("s2", 1000, "other")])
|
|
271
|
+
g2 = _g("/p/b", [_s("s3", 500, "unrelated")])
|
|
272
|
+
rows = cr.flatten_rows([g1, g2], expanded={"/p/a", "/p/b"}, filt="PARSER",
|
|
273
|
+
home="/h", busy=set(), running_ids=set(),
|
|
274
|
+
isdir=lambda p: True)
|
|
275
|
+
# dir surfaces on prompt match though its path doesn't match the filter;
|
|
276
|
+
# only the matching session renders; g2 is fully hidden
|
|
277
|
+
assert [(r["kind"], r.get("session", {}).get("session_id")) for r in rows] == [
|
|
278
|
+
("dir", None), ("session", "s1")]
|
|
279
|
+
assert rows[0]["vis_sessions"] == [g1["sessions"][0]]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_flatten_rows_age_filters_sessions_and_dir():
|
|
283
|
+
g = _g("/p/a", [_s("new", 5000, "recent"), _s("mid", 3000, "middle"),
|
|
284
|
+
_s("old", 1000, "ancient")])
|
|
285
|
+
rows = cr.flatten_rows([g], expanded={"/p/a"}, filt="", home="/h",
|
|
286
|
+
busy=set(), running_ids=set(), isdir=lambda p: True,
|
|
287
|
+
min_ts=3000)
|
|
288
|
+
assert [r.get("session", {}).get("session_id") for r in rows] == [
|
|
289
|
+
None, "new", "mid"] # "old" hidden; boundary kept
|
|
290
|
+
assert rows[0]["vis_sessions"][0]["session_id"] == "new"
|
|
291
|
+
assert cr.flatten_rows([g], expanded=set(), filt="", home="/h",
|
|
292
|
+
busy=set(), running_ids=set(), isdir=lambda p: True,
|
|
293
|
+
min_ts=6000) == [] # no survivor -> dir hidden
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_flatten_rows_dir_top_reflects_filter():
|
|
297
|
+
g = _g("/p/a", [_s("s1", 2000, "alpha"), _s("s2", 1000, "beta")])
|
|
298
|
+
rows = cr.flatten_rows([g], expanded=set(), filt="beta", home="/h",
|
|
299
|
+
busy=set(), running_ids=set(), isdir=lambda p: True)
|
|
300
|
+
assert rows[0]["vis_sessions"][0]["session_id"] == "s2"
|
reclaude-0.1.0/uv.lock
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.10"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "colorama"
|
|
7
|
+
version = "0.4.6"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
10
|
+
wheels = [
|
|
11
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[package]]
|
|
15
|
+
name = "exceptiongroup"
|
|
16
|
+
version = "1.3.1"
|
|
17
|
+
source = { registry = "https://pypi.org/simple" }
|
|
18
|
+
dependencies = [
|
|
19
|
+
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
|
20
|
+
]
|
|
21
|
+
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
|
22
|
+
wheels = [
|
|
23
|
+
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[[package]]
|
|
27
|
+
name = "iniconfig"
|
|
28
|
+
version = "2.3.0"
|
|
29
|
+
source = { registry = "https://pypi.org/simple" }
|
|
30
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
|
31
|
+
wheels = [
|
|
32
|
+
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[[package]]
|
|
36
|
+
name = "packaging"
|
|
37
|
+
version = "26.2"
|
|
38
|
+
source = { registry = "https://pypi.org/simple" }
|
|
39
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
|
40
|
+
wheels = [
|
|
41
|
+
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[[package]]
|
|
45
|
+
name = "pluggy"
|
|
46
|
+
version = "1.6.0"
|
|
47
|
+
source = { registry = "https://pypi.org/simple" }
|
|
48
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
|
49
|
+
wheels = [
|
|
50
|
+
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[[package]]
|
|
54
|
+
name = "pygments"
|
|
55
|
+
version = "2.20.0"
|
|
56
|
+
source = { registry = "https://pypi.org/simple" }
|
|
57
|
+
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
|
58
|
+
wheels = [
|
|
59
|
+
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
[[package]]
|
|
63
|
+
name = "pytest"
|
|
64
|
+
version = "9.0.3"
|
|
65
|
+
source = { registry = "https://pypi.org/simple" }
|
|
66
|
+
dependencies = [
|
|
67
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
68
|
+
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
|
69
|
+
{ name = "iniconfig" },
|
|
70
|
+
{ name = "packaging" },
|
|
71
|
+
{ name = "pluggy" },
|
|
72
|
+
{ name = "pygments" },
|
|
73
|
+
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
|
74
|
+
]
|
|
75
|
+
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
|
76
|
+
wheels = [
|
|
77
|
+
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
[[package]]
|
|
81
|
+
name = "reclaude"
|
|
82
|
+
version = "0.1.0"
|
|
83
|
+
source = { editable = "." }
|
|
84
|
+
|
|
85
|
+
[package.dev-dependencies]
|
|
86
|
+
dev = [
|
|
87
|
+
{ name = "pytest" },
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
[package.metadata]
|
|
91
|
+
|
|
92
|
+
[package.metadata.requires-dev]
|
|
93
|
+
dev = [{ name = "pytest" }]
|
|
94
|
+
|
|
95
|
+
[[package]]
|
|
96
|
+
name = "tomli"
|
|
97
|
+
version = "2.4.1"
|
|
98
|
+
source = { registry = "https://pypi.org/simple" }
|
|
99
|
+
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
|
100
|
+
wheels = [
|
|
101
|
+
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
|
102
|
+
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
|
103
|
+
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
|
104
|
+
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
|
105
|
+
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
|
106
|
+
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
|
107
|
+
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
|
108
|
+
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
|
109
|
+
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
|
110
|
+
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
|
111
|
+
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
|
112
|
+
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
|
113
|
+
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
|
114
|
+
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
|
115
|
+
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
|
116
|
+
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
|
117
|
+
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
|
118
|
+
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
|
119
|
+
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
|
120
|
+
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
|
121
|
+
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
|
122
|
+
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
|
123
|
+
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
|
124
|
+
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
|
125
|
+
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
|
126
|
+
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
|
127
|
+
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
|
128
|
+
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
|
129
|
+
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
|
130
|
+
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
|
131
|
+
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
|
132
|
+
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
|
133
|
+
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
|
134
|
+
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
|
135
|
+
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
|
136
|
+
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
|
137
|
+
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
|
138
|
+
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
|
139
|
+
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
|
140
|
+
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
|
141
|
+
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
|
142
|
+
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
|
143
|
+
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
|
144
|
+
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
|
145
|
+
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
|
146
|
+
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
[[package]]
|
|
150
|
+
name = "typing-extensions"
|
|
151
|
+
version = "4.15.0"
|
|
152
|
+
source = { registry = "https://pypi.org/simple" }
|
|
153
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
|
154
|
+
wheels = [
|
|
155
|
+
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
|
156
|
+
]
|