claudenator 1.0.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.
- claudenator-1.0.0/.gitignore +12 -0
- claudenator-1.0.0/LICENSE.md +18 -0
- claudenator-1.0.0/PKG-INFO +90 -0
- claudenator-1.0.0/README.md +60 -0
- claudenator-1.0.0/claudenator/__init__.py +18 -0
- claudenator-1.0.0/claudenator/__main__.py +6 -0
- claudenator-1.0.0/claudenator/cli/__init__.py +1 -0
- claudenator-1.0.0/claudenator/cli/main.py +205 -0
- claudenator-1.0.0/claudenator/core/__init__.py +1 -0
- claudenator-1.0.0/claudenator/core/cache.py +198 -0
- claudenator-1.0.0/claudenator/core/config.py +284 -0
- claudenator-1.0.0/claudenator/core/errors.py +146 -0
- claudenator-1.0.0/claudenator/core/format.py +370 -0
- claudenator-1.0.0/claudenator/core/live.py +145 -0
- claudenator-1.0.0/claudenator/core/model.py +333 -0
- claudenator-1.0.0/claudenator/core/scan.py +321 -0
- claudenator-1.0.0/claudenator/core/settings.py +187 -0
- claudenator-1.0.0/claudenator/core/stats.py +140 -0
- claudenator-1.0.0/claudenator/core/store.py +399 -0
- claudenator-1.0.0/claudenator/core/trash.py +350 -0
- claudenator-1.0.0/claudenator/tui/__init__.py +1 -0
- claudenator-1.0.0/claudenator/tui/about.py +93 -0
- claudenator-1.0.0/claudenator/tui/app.py +624 -0
- claudenator-1.0.0/claudenator/tui/claudenator.tcss +287 -0
- claudenator-1.0.0/claudenator/tui/confirm.py +64 -0
- claudenator-1.0.0/claudenator/tui/panes.py +1266 -0
- claudenator-1.0.0/claudenator/tui/settings.py +296 -0
- claudenator-1.0.0/pyproject.toml +71 -0
- claudenator-1.0.0/tests/__init__.py +1 -0
- claudenator-1.0.0/tests/conftest.py +71 -0
- claudenator-1.0.0/tests/fabricate.py +497 -0
- claudenator-1.0.0/tests/test_cache.py +132 -0
- claudenator-1.0.0/tests/test_cli.py +528 -0
- claudenator-1.0.0/tests/test_config.py +237 -0
- claudenator-1.0.0/tests/test_format.py +532 -0
- claudenator-1.0.0/tests/test_layers.py +60 -0
- claudenator-1.0.0/tests/test_live.py +215 -0
- claudenator-1.0.0/tests/test_purge.py +185 -0
- claudenator-1.0.0/tests/test_restore.py +431 -0
- claudenator-1.0.0/tests/test_settings.py +92 -0
- claudenator-1.0.0/tests/test_stats.py +146 -0
- claudenator-1.0.0/tests/test_store.py +665 -0
- claudenator-1.0.0/tests/test_trash.py +490 -0
- claudenator-1.0.0/tests/test_trash_list.py +175 -0
- claudenator-1.0.0/tests/test_tui.py +3792 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marcin Orlowski <https://marcinOrlowski.com/>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
6
|
+
associated documentation files (the "Software"), to deal in the Software without restriction,
|
|
7
|
+
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
|
8
|
+
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
12
|
+
portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
|
15
|
+
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
16
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
|
|
17
|
+
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
|
18
|
+
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: claudenator
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The only Claude Code session manager you needca
|
|
5
|
+
Project-URL: Homepage, https://github.com/MarcinOrlowski/claudenator
|
|
6
|
+
Project-URL: Issues, https://github.com/MarcinOrlowski/claudenator/issues
|
|
7
|
+
Author-email: Marcin Orlowski <mail@marcinOrlowski.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE.md
|
|
10
|
+
Keywords: claude,claude-code,claude-sessions,cli,session-management,sessions,tui
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: qrcat<2,>=1
|
|
20
|
+
Requires-Dist: textual<9,>=8
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: black; extra == 'dev'
|
|
23
|
+
Requires-Dist: flake8; extra == 'dev'
|
|
24
|
+
Requires-Dist: flake8-docstrings; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
26
|
+
Provides-Extra: release
|
|
27
|
+
Requires-Dist: build>=1.2; extra == 'release'
|
|
28
|
+
Requires-Dist: twine>=6; extra == 'release'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+

|
|
32
|
+
|
|
33
|
+
[](https://pypi.org/project/claudenator/)
|
|
34
|
+
[](https://www.python.org/)
|
|
35
|
+
[](LICENSE.md)
|
|
36
|
+
|
|
37
|
+
## The only Claude Code session manager you need
|
|
38
|
+
|
|
39
|
+
Claude Code writes a lot. Every session leaves a transcript from the main session and its subagents.
|
|
40
|
+
Then goes the environment, file history, jobs and todos. That's tons of disk. Additionally,
|
|
41
|
+
it auto-purges all the sessions older than 30 days, but if you decide to prevent that and keep some
|
|
42
|
+
old sessions for longer, it can stack up a massive pile pretty quickly.
|
|
43
|
+
|
|
44
|
+
This is where Claudenator comes to the rescue. It shows all your sessions, grouped by project
|
|
45
|
+
folder, sorts them by whatever column you like and shows tons of additional information that
|
|
46
|
+
would help you manage them and purge only real garbage.
|
|
47
|
+
|
|
48
|
+

|
|
49
|
+
|
|
50
|
+
## Features
|
|
51
|
+
|
|
52
|
+
* Shows every session, its title, project, size, date, messages, model, git branch, tools and moar.
|
|
53
|
+
* A details pane under the table, and a full-screen view for the whole story.
|
|
54
|
+
* Order the table by any column and narrow it to what you look for with a filter.
|
|
55
|
+
* A deleted session first goes to a Trash so it disappears but is not yet gone and can be either
|
|
56
|
+
restored or purged for good.
|
|
57
|
+
* Comes with "Deep scan" feature, that process the full transcript to give you more stats for nerds.
|
|
58
|
+
* Configurable TUI with themes, layout, time formats and more.
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
The [pipx](https://pypi.org/project/pipx/) is the recommended way to install it:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# Install pipx if not present
|
|
66
|
+
sudo apt install -y pipx
|
|
67
|
+
|
|
68
|
+
# Install the tool
|
|
69
|
+
pipx install claudenator
|
|
70
|
+
|
|
71
|
+
# Upgrade existing installation
|
|
72
|
+
pipx upgrade claudenator
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
To get the latest code, install straight from the repository:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Install the current "master" branch
|
|
79
|
+
pipx install --force git+https://github.com/MarcinOrlowski/claudenator.git
|
|
80
|
+
|
|
81
|
+
# Install the "dev" branch or any other branch, tag or commit
|
|
82
|
+
pipx install --force git+https://github.com/MarcinOrlowski/claudenator.git@dev
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The `--force` option is only needed if there's already existing Claudenator installation present.
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
* Written and copyrighted ©2026 by Marcin Orlowski <mail (#) marcinorlowski (.) com>
|
|
90
|
+
* This is open-source software licensed under the [MIT license](http://opensource.org/licenses/MIT)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/claudenator/)
|
|
4
|
+
[](https://www.python.org/)
|
|
5
|
+
[](LICENSE.md)
|
|
6
|
+
|
|
7
|
+
## The only Claude Code session manager you need
|
|
8
|
+
|
|
9
|
+
Claude Code writes a lot. Every session leaves a transcript from the main session and its subagents.
|
|
10
|
+
Then goes the environment, file history, jobs and todos. That's tons of disk. Additionally,
|
|
11
|
+
it auto-purges all the sessions older than 30 days, but if you decide to prevent that and keep some
|
|
12
|
+
old sessions for longer, it can stack up a massive pile pretty quickly.
|
|
13
|
+
|
|
14
|
+
This is where Claudenator comes to the rescue. It shows all your sessions, grouped by project
|
|
15
|
+
folder, sorts them by whatever column you like and shows tons of additional information that
|
|
16
|
+
would help you manage them and purge only real garbage.
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
* Shows every session, its title, project, size, date, messages, model, git branch, tools and moar.
|
|
23
|
+
* A details pane under the table, and a full-screen view for the whole story.
|
|
24
|
+
* Order the table by any column and narrow it to what you look for with a filter.
|
|
25
|
+
* A deleted session first goes to a Trash so it disappears but is not yet gone and can be either
|
|
26
|
+
restored or purged for good.
|
|
27
|
+
* Comes with "Deep scan" feature, that process the full transcript to give you more stats for nerds.
|
|
28
|
+
* Configurable TUI with themes, layout, time formats and more.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
The [pipx](https://pypi.org/project/pipx/) is the recommended way to install it:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Install pipx if not present
|
|
36
|
+
sudo apt install -y pipx
|
|
37
|
+
|
|
38
|
+
# Install the tool
|
|
39
|
+
pipx install claudenator
|
|
40
|
+
|
|
41
|
+
# Upgrade existing installation
|
|
42
|
+
pipx upgrade claudenator
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
To get the latest code, install straight from the repository:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Install the current "master" branch
|
|
49
|
+
pipx install --force git+https://github.com/MarcinOrlowski/claudenator.git
|
|
50
|
+
|
|
51
|
+
# Install the "dev" branch or any other branch, tag or commit
|
|
52
|
+
pipx install --force git+https://github.com/MarcinOrlowski/claudenator.git@dev
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The `--force` option is only needed if there's already existing Claudenator installation present.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
* Written and copyrighted ©2026 by Marcin Orlowski <mail (#) marcinorlowski (.) com>
|
|
60
|
+
* This is open-source software licensed under the [MIT license](http://opensource.org/licenses/MIT)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
##################################################################################
|
|
3
|
+
#
|
|
4
|
+
# Claudenator by Marcin Orlowski
|
|
5
|
+
# The only Claude Code session manager you need.
|
|
6
|
+
#
|
|
7
|
+
# @author Marcin Orlowski <mail@marcinOrlowski.com>
|
|
8
|
+
# Copyright ©2026 Marcin Orlowski <MarcinOrlowski.com>
|
|
9
|
+
# @link https://github.com/MarcinOrlowski/claudenator
|
|
10
|
+
#
|
|
11
|
+
##################################################################################
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__title__ = "claudenator"
|
|
15
|
+
__version__ = "1.0.0"
|
|
16
|
+
__description__ = "The only Claude Code session manager you need"
|
|
17
|
+
__author__ = "Marcin Orlowski"
|
|
18
|
+
__url__ = "https://github.com/MarcinOrlowski/claudenator"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI"""
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
##################################################################################
|
|
3
|
+
#
|
|
4
|
+
# Claudenator by Marcin Orlowski
|
|
5
|
+
# The only Claude Code session manager you need.
|
|
6
|
+
#
|
|
7
|
+
# @author Marcin Orlowski <mail@marcinOrlowski.com>
|
|
8
|
+
# Copyright ©2026 Marcin Orlowski <MarcinOrlowski.com>
|
|
9
|
+
# @link https://github.com/MarcinOrlowski/claudenator
|
|
10
|
+
#
|
|
11
|
+
##################################################################################
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from claudenator import __version__
|
|
22
|
+
from claudenator.core.config import apply_file
|
|
23
|
+
from claudenator.core.errors import ClaudenatorError
|
|
24
|
+
from claudenator.core.format import Formatter, plural_of
|
|
25
|
+
from claudenator.core.model import Session
|
|
26
|
+
from claudenator.core.settings import Settings
|
|
27
|
+
from claudenator.core.store import SessionStore
|
|
28
|
+
|
|
29
|
+
TITLE_WIDTH = 48
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def short_title(session: Session) -> str:
|
|
33
|
+
"""The title in truncated form."""
|
|
34
|
+
text = session.title
|
|
35
|
+
if len(text) > TITLE_WIDTH:
|
|
36
|
+
text = text[: TITLE_WIDTH - 3].rstrip() + "…"
|
|
37
|
+
return text
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def open_screen(settings: Settings, notes: list[str]) -> int:
|
|
41
|
+
"""Run the TUI."""
|
|
42
|
+
from claudenator.tui.app import run
|
|
43
|
+
|
|
44
|
+
return run(settings, notes)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
48
|
+
"""The argument parser for the ``claudenator`` command."""
|
|
49
|
+
defaults = Settings()
|
|
50
|
+
parser = argparse.ArgumentParser(
|
|
51
|
+
prog="claudenator",
|
|
52
|
+
description="Look at Claude Code sessions and remove the ones you do not want.",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--claude-dir",
|
|
59
|
+
type=Path,
|
|
60
|
+
metavar="DIR",
|
|
61
|
+
help=f"Claude Code's data folder (default: {defaults.claude_dir})",
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--data-dir",
|
|
65
|
+
type=Path,
|
|
66
|
+
metavar="DIR",
|
|
67
|
+
help=f"claudenator's own folder for the Trash and the cache (default: {defaults.data_dir})",
|
|
68
|
+
)
|
|
69
|
+
# The process table. Only a test points this anywhere but /proc.
|
|
70
|
+
parser.add_argument("--proc-dir", type=Path, metavar="DIR", help=argparse.SUPPRESS)
|
|
71
|
+
commands = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
72
|
+
|
|
73
|
+
list_parser = commands.add_parser("list", help="list every session")
|
|
74
|
+
list_parser.add_argument("--json", action="store_true", help="print JSON")
|
|
75
|
+
|
|
76
|
+
info_parser = commands.add_parser("info", help="show one session in full")
|
|
77
|
+
info_parser.add_argument(
|
|
78
|
+
"session_id", metavar="ID", help="a session id, or a unique prefix of one"
|
|
79
|
+
)
|
|
80
|
+
info_parser.add_argument("--json", action="store_true", help="print JSON")
|
|
81
|
+
|
|
82
|
+
scan_parser = commands.add_parser(
|
|
83
|
+
"scan", help="read every transcript in full and cache its figures"
|
|
84
|
+
)
|
|
85
|
+
scan_parser.add_argument(
|
|
86
|
+
"--force",
|
|
87
|
+
action="store_true",
|
|
88
|
+
help="read a transcript again even when its cached figures are fresh",
|
|
89
|
+
)
|
|
90
|
+
return parser
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def settings_from(args: argparse.Namespace) -> tuple[Settings, list[str]]:
|
|
94
|
+
"""A settings object, and a note for every fault in the settings file."""
|
|
95
|
+
settings = Settings()
|
|
96
|
+
notes = apply_file(settings)
|
|
97
|
+
if args.claude_dir is not None:
|
|
98
|
+
settings.claude_dir = args.claude_dir.expanduser()
|
|
99
|
+
if args.data_dir is not None:
|
|
100
|
+
settings.data_dir = args.data_dir.expanduser()
|
|
101
|
+
if args.proc_dir is not None:
|
|
102
|
+
settings.proc_dir = args.proc_dir.expanduser()
|
|
103
|
+
return settings, notes
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_list(store: SessionStore, args: argparse.Namespace, fmt: Formatter) -> int:
|
|
107
|
+
"""Print every session, one per line."""
|
|
108
|
+
sessions = store.list_sessions()
|
|
109
|
+
if args.json:
|
|
110
|
+
print(json.dumps([session.to_dict() for session in sessions], indent=2))
|
|
111
|
+
return 0
|
|
112
|
+
if not sessions:
|
|
113
|
+
print(f"No sessions found under {store.settings.claude_dir}")
|
|
114
|
+
return 0
|
|
115
|
+
rows = [
|
|
116
|
+
(
|
|
117
|
+
session.id[:8],
|
|
118
|
+
fmt.marks(session),
|
|
119
|
+
fmt.list_timestamp(session.last_used),
|
|
120
|
+
fmt.size(session.size),
|
|
121
|
+
short_title(session),
|
|
122
|
+
session.project_path,
|
|
123
|
+
)
|
|
124
|
+
for session in sessions
|
|
125
|
+
]
|
|
126
|
+
header = ("ID", "STS", "LAST USED", "SIZE", "TITLE", "PROJECT")
|
|
127
|
+
widths = [max(len(row[i]) for row in (header, *rows)) for i in range(5)]
|
|
128
|
+
for row in (header, *rows):
|
|
129
|
+
cells = [row[i].ljust(widths[i]) for i in range(5)]
|
|
130
|
+
print(" ".join(cells + [row[5]]).rstrip())
|
|
131
|
+
total = sum(session.size for session in sessions)
|
|
132
|
+
noun = "session" if len(sessions) == 1 else "sessions"
|
|
133
|
+
print()
|
|
134
|
+
print(f"{len(sessions)} {noun}, {fmt.size(total)} on disk")
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_info(store: SessionStore, args: argparse.Namespace, fmt: Formatter) -> int:
|
|
139
|
+
"""Print one session in full."""
|
|
140
|
+
details = store.details(args.session_id)
|
|
141
|
+
if args.json:
|
|
142
|
+
print(json.dumps(details.to_dict(), indent=2))
|
|
143
|
+
return 0
|
|
144
|
+
lines = fmt.describe(details)
|
|
145
|
+
width = max(len(label) for label, _ in lines) + 1
|
|
146
|
+
for label, value in lines:
|
|
147
|
+
print(f"{(label + ':').ljust(width)} {value}")
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cmd_scan(store: SessionStore, args: argparse.Namespace, fmt: Formatter) -> int:
|
|
152
|
+
"""Deep-scan every session and cache the figures. One line per session."""
|
|
153
|
+
sessions = store.list_sessions()
|
|
154
|
+
if not sessions:
|
|
155
|
+
print(f"No sessions found under {store.settings.claude_dir}")
|
|
156
|
+
return 0
|
|
157
|
+
read = kept = failed = 0
|
|
158
|
+
for result in store.scan_many(sessions, force=args.force):
|
|
159
|
+
short = result.session.id[:8]
|
|
160
|
+
if result.error is not None or result.figures is None:
|
|
161
|
+
failed += 1
|
|
162
|
+
print(f"{short} {result.error}", file=sys.stderr)
|
|
163
|
+
continue
|
|
164
|
+
figures = result.figures
|
|
165
|
+
if result.fresh:
|
|
166
|
+
kept += 1
|
|
167
|
+
else:
|
|
168
|
+
read += 1
|
|
169
|
+
turns = f"{fmt.count(figures.turns)} {plural_of('turn', figures.turns)}"
|
|
170
|
+
tokens = f"{fmt.count(figures.tokens)} {plural_of('token', figures.tokens)}"
|
|
171
|
+
length = fmt.duration(figures.duration)
|
|
172
|
+
state = "cached" if result.fresh else "scanned"
|
|
173
|
+
print(f"{short} {turns} {tokens} {length} {state}")
|
|
174
|
+
dropped = store.cache.forget_missing()
|
|
175
|
+
print()
|
|
176
|
+
print(
|
|
177
|
+
f"{fmt.scan_summary(read, kept, failed)}, "
|
|
178
|
+
f"{dropped} gone from the disk and forgotten"
|
|
179
|
+
)
|
|
180
|
+
print(f"Cache: {store.settings.cache_file}")
|
|
181
|
+
return 1 if failed else 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def main(argv: list[str] | None = None) -> int:
|
|
185
|
+
"""Run the command line. Returns the exit code."""
|
|
186
|
+
parser = build_parser()
|
|
187
|
+
args = parser.parse_args(argv)
|
|
188
|
+
settings, notes = settings_from(args)
|
|
189
|
+
if args.command is None:
|
|
190
|
+
return open_screen(settings, notes)
|
|
191
|
+
for note in notes:
|
|
192
|
+
print(f"claudenator: {note}", file=sys.stderr)
|
|
193
|
+
store = SessionStore(settings)
|
|
194
|
+
fmt = Formatter(settings)
|
|
195
|
+
try:
|
|
196
|
+
if args.command == "list":
|
|
197
|
+
return cmd_list(store, args, fmt)
|
|
198
|
+
if args.command == "info":
|
|
199
|
+
return cmd_info(store, args, fmt)
|
|
200
|
+
if args.command == "scan":
|
|
201
|
+
return cmd_scan(store, args, fmt)
|
|
202
|
+
except ClaudenatorError as error:
|
|
203
|
+
print(f"claudenator: {error}", file=sys.stderr)
|
|
204
|
+
return 1
|
|
205
|
+
parser.error(f"unknown command {args.command}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core: CC session data handling"""
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
##################################################################################
|
|
3
|
+
#
|
|
4
|
+
# Claudenator by Marcin Orlowski
|
|
5
|
+
# The only Claude Code session manager you need.
|
|
6
|
+
#
|
|
7
|
+
# @author Marcin Orlowski <mail@marcinOrlowski.com>
|
|
8
|
+
# Copyright ©2026 Marcin Orlowski <MarcinOrlowski.com>
|
|
9
|
+
# @link https://github.com/MarcinOrlowski/claudenator
|
|
10
|
+
#
|
|
11
|
+
##################################################################################
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import sqlite3
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from claudenator.core.errors import CacheDamaged
|
|
23
|
+
from claudenator.core.model import Figures
|
|
24
|
+
from claudenator.core.settings import Settings
|
|
25
|
+
|
|
26
|
+
# One row per transcript, found by its path. The size and change time say
|
|
27
|
+
# which copy of the file the row describes. The row holds our own numbers
|
|
28
|
+
# and nothing the user wrote. A time is stored whole, so what comes back
|
|
29
|
+
# is what went in.
|
|
30
|
+
SCHEMA = """
|
|
31
|
+
CREATE TABLE IF NOT EXISTS figures (
|
|
32
|
+
path TEXT PRIMARY KEY,
|
|
33
|
+
size INTEGER NOT NULL,
|
|
34
|
+
mtime_ns INTEGER NOT NULL,
|
|
35
|
+
scanned_at TEXT NOT NULL,
|
|
36
|
+
turns INTEGER NOT NULL,
|
|
37
|
+
input_tokens INTEGER NOT NULL,
|
|
38
|
+
output_tokens INTEGER NOT NULL,
|
|
39
|
+
cache_read_tokens INTEGER NOT NULL,
|
|
40
|
+
cache_write_tokens INTEGER NOT NULL,
|
|
41
|
+
models TEXT NOT NULL,
|
|
42
|
+
tools TEXT NOT NULL,
|
|
43
|
+
first_at TEXT,
|
|
44
|
+
last_at TEXT
|
|
45
|
+
)
|
|
46
|
+
"""
|
|
47
|
+
COLUMNS = (
|
|
48
|
+
"path",
|
|
49
|
+
"size",
|
|
50
|
+
"mtime_ns",
|
|
51
|
+
"scanned_at",
|
|
52
|
+
"turns",
|
|
53
|
+
"input_tokens",
|
|
54
|
+
"output_tokens",
|
|
55
|
+
"cache_read_tokens",
|
|
56
|
+
"cache_write_tokens",
|
|
57
|
+
"models",
|
|
58
|
+
"tools",
|
|
59
|
+
"first_at",
|
|
60
|
+
"last_at",
|
|
61
|
+
)
|
|
62
|
+
# How long a write waits for another copy of the tool to finish its own.
|
|
63
|
+
LOCK_WAIT_SECONDS = 5.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _moment(value: Any) -> datetime | None:
|
|
67
|
+
return datetime.fromisoformat(value) if isinstance(value, str) else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _pairs(value: Any) -> tuple[tuple[str, int], ...]:
|
|
71
|
+
"""The name and count pairs stored as one JSON list, in their stored order."""
|
|
72
|
+
return tuple((str(name), int(count)) for name, count in json.loads(value))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _row_of(figures: Figures) -> tuple[Any, ...]:
|
|
76
|
+
return (
|
|
77
|
+
str(figures.transcript_path),
|
|
78
|
+
figures.transcript_size,
|
|
79
|
+
figures.transcript_mtime_ns,
|
|
80
|
+
figures.scanned_at.isoformat(),
|
|
81
|
+
figures.turns,
|
|
82
|
+
figures.input_tokens,
|
|
83
|
+
figures.output_tokens,
|
|
84
|
+
figures.cache_read_tokens,
|
|
85
|
+
figures.cache_write_tokens,
|
|
86
|
+
json.dumps(figures.models),
|
|
87
|
+
json.dumps(figures.tools),
|
|
88
|
+
figures.first_at.isoformat() if figures.first_at else None,
|
|
89
|
+
figures.last_at.isoformat() if figures.last_at else None,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _is_stale(transcript: Path, row: tuple[Any, ...]) -> bool:
|
|
94
|
+
"""True when the file's size or change time differ from the row's, or it is gone."""
|
|
95
|
+
try:
|
|
96
|
+
stat = transcript.stat()
|
|
97
|
+
except OSError:
|
|
98
|
+
return True
|
|
99
|
+
return stat.st_size != row[1] or stat.st_mtime_ns != row[2]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _figures_of(row: tuple[Any, ...], stale: bool) -> Figures:
|
|
103
|
+
return Figures(
|
|
104
|
+
transcript_path=Path(row[0]),
|
|
105
|
+
transcript_size=row[1],
|
|
106
|
+
transcript_mtime_ns=row[2],
|
|
107
|
+
scanned_at=datetime.fromisoformat(row[3]),
|
|
108
|
+
turns=row[4],
|
|
109
|
+
input_tokens=row[5],
|
|
110
|
+
output_tokens=row[6],
|
|
111
|
+
cache_read_tokens=row[7],
|
|
112
|
+
cache_write_tokens=row[8],
|
|
113
|
+
models=_pairs(row[9]),
|
|
114
|
+
tools=_pairs(row[10]),
|
|
115
|
+
first_at=_moment(row[11]),
|
|
116
|
+
last_at=_moment(row[12]),
|
|
117
|
+
stale=stale,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Cache:
|
|
122
|
+
"""The numbers a deep scan found, kept in one SQLite file in the tool's own folder.
|
|
123
|
+
|
|
124
|
+
A row is found by the transcript's path. A transcript that changed since
|
|
125
|
+
its scan still gives its row back, marked stale, so the old numbers stay
|
|
126
|
+
on view until a new scan replaces them.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, settings: Settings) -> None:
|
|
130
|
+
self.path = settings.cache_file
|
|
131
|
+
|
|
132
|
+
def _connect(self) -> sqlite3.Connection:
|
|
133
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
connection = sqlite3.connect(self.path, timeout=LOCK_WAIT_SECONDS)
|
|
135
|
+
try:
|
|
136
|
+
connection.execute(SCHEMA)
|
|
137
|
+
except sqlite3.DatabaseError as error:
|
|
138
|
+
connection.close()
|
|
139
|
+
raise CacheDamaged(self.path, error) from error
|
|
140
|
+
return connection
|
|
141
|
+
|
|
142
|
+
def get(self, transcript: Path) -> Figures | None:
|
|
143
|
+
"""The figures of one transcript, or None when it was never scanned.
|
|
144
|
+
|
|
145
|
+
The figures are stale when the file's size or change time differ from
|
|
146
|
+
the ones the scan saw, or when the file is gone. A damaged cache reads
|
|
147
|
+
as an empty one, so a list or a details pane still works. The next
|
|
148
|
+
``put`` names the damage.
|
|
149
|
+
"""
|
|
150
|
+
if not self.path.exists():
|
|
151
|
+
return None
|
|
152
|
+
names = ", ".join(COLUMNS)
|
|
153
|
+
try:
|
|
154
|
+
with self._connect() as connection:
|
|
155
|
+
row = connection.execute(
|
|
156
|
+
f"SELECT {names} FROM figures WHERE path = ?", (str(transcript),)
|
|
157
|
+
).fetchone()
|
|
158
|
+
except CacheDamaged:
|
|
159
|
+
return None
|
|
160
|
+
return _figures_of(row, _is_stale(transcript, row)) if row else None
|
|
161
|
+
|
|
162
|
+
def get_all(self) -> dict[Path, Figures]:
|
|
163
|
+
"""The figures of every transcript ever scanned, by path, in one read.
|
|
164
|
+
|
|
165
|
+
Each is marked stale the same way ``get`` marks it. A missing or a
|
|
166
|
+
damaged cache gives an empty dict.
|
|
167
|
+
"""
|
|
168
|
+
if not self.path.exists():
|
|
169
|
+
return {}
|
|
170
|
+
names = ", ".join(COLUMNS)
|
|
171
|
+
try:
|
|
172
|
+
with self._connect() as connection:
|
|
173
|
+
rows = connection.execute(f"SELECT {names} FROM figures").fetchall()
|
|
174
|
+
except CacheDamaged:
|
|
175
|
+
return {}
|
|
176
|
+
return {
|
|
177
|
+
Path(row[0]): _figures_of(row, _is_stale(Path(row[0]), row)) for row in rows
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
def put(self, figures: Figures) -> None:
|
|
181
|
+
"""Remember the figures of one transcript, in place of any older ones."""
|
|
182
|
+
marks = ", ".join("?" for _ in COLUMNS)
|
|
183
|
+
names = ", ".join(COLUMNS)
|
|
184
|
+
with self._connect() as connection:
|
|
185
|
+
connection.execute(
|
|
186
|
+
f"INSERT OR REPLACE INTO figures ({names}) VALUES ({marks})",
|
|
187
|
+
_row_of(figures),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def forget_missing(self) -> int:
|
|
191
|
+
"""Drop the rows of transcripts that are no longer on the disk. Returns how many."""
|
|
192
|
+
if not self.path.exists():
|
|
193
|
+
return 0
|
|
194
|
+
with self._connect() as connection:
|
|
195
|
+
paths = [row[0] for row in connection.execute("SELECT path FROM figures")]
|
|
196
|
+
gone = [(path,) for path in paths if not Path(path).exists()]
|
|
197
|
+
connection.executemany("DELETE FROM figures WHERE path = ?", gone)
|
|
198
|
+
return len(gone)
|