dogear 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.
- dogear-0.1.0/.gitignore +7 -0
- dogear-0.1.0/LICENSE +21 -0
- dogear-0.1.0/PKG-INFO +181 -0
- dogear-0.1.0/README.md +158 -0
- dogear-0.1.0/pyproject.toml +52 -0
- dogear-0.1.0/src/dogear/__init__.py +3 -0
- dogear-0.1.0/src/dogear/__main__.py +5 -0
- dogear-0.1.0/src/dogear/agents/__init__.py +81 -0
- dogear-0.1.0/src/dogear/agents/claude.py +134 -0
- dogear-0.1.0/src/dogear/agents/codex.py +113 -0
- dogear-0.1.0/src/dogear/assets/claude_skill.md +35 -0
- dogear-0.1.0/src/dogear/assets/codex_openai.yaml +2 -0
- dogear-0.1.0/src/dogear/assets/codex_skill.md +32 -0
- dogear-0.1.0/src/dogear/cli.py +499 -0
- dogear-0.1.0/src/dogear/config.py +54 -0
- dogear-0.1.0/src/dogear/detect.py +33 -0
- dogear-0.1.0/src/dogear/errors.py +2 -0
- dogear-0.1.0/src/dogear/favorites.py +84 -0
- dogear-0.1.0/src/dogear/jsonl.py +25 -0
- dogear-0.1.0/src/dogear/launch.py +40 -0
- dogear-0.1.0/src/dogear/paths.py +36 -0
- dogear-0.1.0/src/dogear/picker.py +61 -0
- dogear-0.1.0/src/dogear/proctree.py +227 -0
- dogear-0.1.0/src/dogear/render.py +91 -0
- dogear-0.1.0/src/dogear/setup_cmd.py +47 -0
- dogear-0.1.0/src/dogear/store.py +134 -0
- dogear-0.1.0/tests/__init__.py +0 -0
- dogear-0.1.0/tests/conftest.py +36 -0
- dogear-0.1.0/tests/fakes.py +106 -0
- dogear-0.1.0/tests/test_agent_claude.py +105 -0
- dogear-0.1.0/tests/test_agent_codex.py +74 -0
- dogear-0.1.0/tests/test_cli_add.py +138 -0
- dogear-0.1.0/tests/test_cli_manage.py +152 -0
- dogear-0.1.0/tests/test_cli_open.py +91 -0
- dogear-0.1.0/tests/test_cli_version.py +24 -0
- dogear-0.1.0/tests/test_config.py +42 -0
- dogear-0.1.0/tests/test_detect.py +86 -0
- dogear-0.1.0/tests/test_favorites.py +81 -0
- dogear-0.1.0/tests/test_launch.py +52 -0
- dogear-0.1.0/tests/test_minor_fixes.py +269 -0
- dogear-0.1.0/tests/test_packaging.py +55 -0
- dogear-0.1.0/tests/test_paths.py +34 -0
- dogear-0.1.0/tests/test_picker.py +75 -0
- dogear-0.1.0/tests/test_plugin.py +57 -0
- dogear-0.1.0/tests/test_proctree.py +56 -0
- dogear-0.1.0/tests/test_render.py +43 -0
- dogear-0.1.0/tests/test_review_fixes.py +122 -0
- dogear-0.1.0/tests/test_setup.py +73 -0
- dogear-0.1.0/tests/test_store.py +95 -0
dogear-0.1.0/.gitignore
ADDED
dogear-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oriol Martí
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
dogear-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dogear
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bookmark, save and resume Claude Code and Codex sessions from any folder: a tiny session manager with names, tags and notes.
|
|
5
|
+
Project-URL: Homepage, https://github.com/orimarti/dogear
|
|
6
|
+
Project-URL: Issues, https://github.com/orimarti/dogear/issues
|
|
7
|
+
Author: Oriol Martí
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai-agents,anthropic,bookmark,bookmarks,claude,claude-code,cli,codex,codex-cli,openai-codex,resume,session-manager,sessions
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# dogear: bookmark and resume Claude Code & Codex sessions
|
|
25
|
+
|
|
26
|
+
> Bookmark, save and resume Claude Code and Codex sessions from any folder. A tiny session manager with names, tags and notes.
|
|
27
|
+
|
|
28
|
+
You have hundreds of Claude sessions and Codex sessions spread over many
|
|
29
|
+
folders, and a few of them matter. dogear keeps a short, named list of those —
|
|
30
|
+
like folding the corner of a page — and resumes any of them with one command,
|
|
31
|
+
from anywhere: it changes to the session's folder and runs `claude --resume` or
|
|
32
|
+
`codex resume` for you.
|
|
33
|
+
|
|
34
|
+
- Save the session you're in without leaving it: `/dogear` (Claude Code), `$dogear` (Codex), or `! dogear add`.
|
|
35
|
+
- Save any recent session from a normal terminal through a picker.
|
|
36
|
+
- One list for both agents, with your own names, tags and notes.
|
|
37
|
+
- `dogear` alone: pick a saved session, land in its folder, resumed.
|
|
38
|
+
- Python 3.11+, standard library only. macOS, Linux and Windows. No network access, ever.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
uv tool install dogear # or: pipx install dogear
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then, optionally, add the in-session command:
|
|
47
|
+
|
|
48
|
+
| Where | How | You type |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| Claude Code, as a plugin | `/plugin install dogear --marketplace orimarti/dogear` (or `/plugin marketplace add orimarti/dogear` then `/plugin install dogear@dogear`) | `/dogear:dogear` |
|
|
51
|
+
| Claude Code, as a personal skill | `dogear setup claude` | `/dogear` |
|
|
52
|
+
| Codex | `dogear setup codex` | `$dogear` |
|
|
53
|
+
|
|
54
|
+
Plain words work too: `/dogear save it as jobs, tag work` or just "dogear this
|
|
55
|
+
session as jobs" — the agent turns it into the right `dogear add` flags (and
|
|
56
|
+
renames the favourite if the session was already saved).
|
|
57
|
+
|
|
58
|
+
Use the plugin **or** `dogear setup claude`, not both. The plugin only provides
|
|
59
|
+
the slash command; the `dogear` CLI still comes from PyPI (the command tells you
|
|
60
|
+
if it's missing).
|
|
61
|
+
|
|
62
|
+
## 30-second tour
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
# inside a Claude Code or Codex session
|
|
66
|
+
/dogear jobs-api -t work # or $dogear … in Codex, or: ! dogear add jobs-api -t work
|
|
67
|
+
|
|
68
|
+
# later, from any terminal
|
|
69
|
+
dogear ls
|
|
70
|
+
# # LABEL AGENT FOLDER TITLE TAGS NOTE
|
|
71
|
+
# 1 jobs-api claude ~/code/jobs New jobs api work
|
|
72
|
+
# 2 docs-site codex ~/code/site Docs refresh
|
|
73
|
+
dogear open jobs-api # cd ~/code/jobs && claude --resume <id>
|
|
74
|
+
dogear # or pick from a list (uses fzf if installed)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
From a plain terminal, `dogear add` shows your 50 most recent sessions (both
|
|
78
|
+
agents) to choose from; `dogear add <session-id>` saves one directly (a unique
|
|
79
|
+
prefix of the id is enough).
|
|
80
|
+
|
|
81
|
+
| Command | What it does |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| `dogear add [SESSION\|LABEL] [-l LABEL] [-n NOTE] [-t TAG]... [--args FLAGS] [--agent A]` | Save a session. A word that isn't a session id (8+ hex characters) is taken as the label for the current session. Repeat `-t` (or use `-t a,b`) for several tags. Giving a label for a session that's already saved renames it. |
|
|
84
|
+
| `dogear ls [-t TAG] [--agent A] [--json]` | List favourites, newest first. `missing` marks sessions that no longer exist. |
|
|
85
|
+
| `dogear open REF [-- FLAGS]` | Resume a favourite in its folder. Flags after `--` replace its saved flags for this launch (`--` alone: no flags). |
|
|
86
|
+
| `dogear rename REF NEW` · `note REF TEXT` · `tag REF +a -b` · `edit REF --args FLAGS` · `rm REF ...` | Manage favourites. `REF` is a label or the number from `dogear ls`. |
|
|
87
|
+
| `dogear prune [--yes]` | Remove favourites whose session is gone. |
|
|
88
|
+
| `dogear setup claude\|codex [--force]` | Install the in-session command. |
|
|
89
|
+
| `dogear config [--path]` | Show the effective configuration. |
|
|
90
|
+
|
|
91
|
+
Flags that start with `-` need quoting with a space or an `=`:
|
|
92
|
+
`--args "--model opus"` or `--args=--verbose`.
|
|
93
|
+
|
|
94
|
+
## Configuration
|
|
95
|
+
|
|
96
|
+
Optional, at `~/.config/dogear/config.toml` (`$XDG_CONFIG_HOME/dogear`;
|
|
97
|
+
`%APPDATA%\dogear` on Windows; `$DOGEAR_HOME` overrides). Favourites live in
|
|
98
|
+
`favorites.json` next to it, so there's one folder to back up.
|
|
99
|
+
|
|
100
|
+
```toml
|
|
101
|
+
[claude]
|
|
102
|
+
command = "claude" # e.g. "claude --dangerously-skip-permissions"
|
|
103
|
+
|
|
104
|
+
[codex]
|
|
105
|
+
command = "codex" # e.g. "codex --full-auto"
|
|
106
|
+
|
|
107
|
+
[picker]
|
|
108
|
+
use_fzf = "auto" # auto | always | never
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Shell aliases are invisible to dogear.** If you run `alias cc='claude --some-flag'`,
|
|
112
|
+
dogear can't see it — put the flags in `command` above instead. Per-favourite
|
|
113
|
+
flags (`--args`) are added after it.
|
|
114
|
+
|
|
115
|
+
## How it finds your sessions
|
|
116
|
+
|
|
117
|
+
dogear reads the agents' own files and never writes to them (the only exception
|
|
118
|
+
is `dogear setup`, when you ask for it). These are internal formats, not public
|
|
119
|
+
APIs, and may change between agent versions:
|
|
120
|
+
|
|
121
|
+
- **Claude Code**: transcripts in `~/.claude/projects/*/<id>.jsonl` (title from
|
|
122
|
+
`/rename`, else the generated title, else your first prompt); running sessions
|
|
123
|
+
in `~/.claude/sessions/<pid>.json`. `$CLAUDE_CONFIG_DIR` is respected.
|
|
124
|
+
- **Codex**: rollouts in `~/.codex/sessions/YYYY/MM/DD/rollout-*-<id>.jsonl`,
|
|
125
|
+
titles in `~/.codex/session_index.jsonl`. `$CODEX_HOME` is respected.
|
|
126
|
+
|
|
127
|
+
To save "the current session", dogear walks up its parent processes to the
|
|
128
|
+
nearest `claude` or `codex` process and asks which session it is running. If it
|
|
129
|
+
can't tell for sure it refuses rather than guess, and shows the candidates. On
|
|
130
|
+
Windows this detection is best effort: if it fails, use `dogear add <session-id>`.
|
|
131
|
+
|
|
132
|
+
Resuming runs `claude --resume <id>` / `codex resume <id>` from the session's
|
|
133
|
+
folder. dogear replaces itself with the agent (on Windows it runs it and waits),
|
|
134
|
+
so your shell is where you left it when the agent exits.
|
|
135
|
+
|
|
136
|
+
## FAQ
|
|
137
|
+
|
|
138
|
+
### How do I resume a Claude Code session from another folder?
|
|
139
|
+
|
|
140
|
+
Save it once with `/dogear my-label` (or `dogear add` from a terminal). From
|
|
141
|
+
then on, `dogear open my-label` works from any directory: dogear changes to the
|
|
142
|
+
folder the session was started in and runs `claude --resume <id>` there.
|
|
143
|
+
|
|
144
|
+
### How do I resume a Codex session?
|
|
145
|
+
|
|
146
|
+
The same way. `$dogear my-label` inside Codex (after `dogear setup codex`), or
|
|
147
|
+
`dogear add` from a terminal, saves it; `dogear open my-label` runs
|
|
148
|
+
`codex resume <id>` in the right folder. Claude Code and Codex sessions share
|
|
149
|
+
one list.
|
|
150
|
+
|
|
151
|
+
### How do I save or bookmark a Claude Code session?
|
|
152
|
+
|
|
153
|
+
Inside the session, type `/dogear` with an optional name, tags or a note, in
|
|
154
|
+
flags or plain words: `/dogear jobs-api -t work` or `/dogear save it as jobs-api, tag work`.
|
|
155
|
+
From a terminal, `dogear add` lets you pick from your 50 most recent sessions.
|
|
156
|
+
|
|
157
|
+
### How is this different from `claude --resume` or `codex resume`?
|
|
158
|
+
|
|
159
|
+
Those pickers list every past session; dogear is the short list you chose to
|
|
160
|
+
keep, with names you'll remember, across both agents, and it starts each
|
|
161
|
+
session from the right folder. It uses the agents' own resume commands, so
|
|
162
|
+
nothing about the sessions themselves changes.
|
|
163
|
+
|
|
164
|
+
### Does dogear upload or modify my sessions?
|
|
165
|
+
|
|
166
|
+
No. It only reads the agents' files, never writes to them (except installing
|
|
167
|
+
the in-session command when you run `dogear setup`), and makes no network
|
|
168
|
+
requests.
|
|
169
|
+
|
|
170
|
+
## Development
|
|
171
|
+
|
|
172
|
+
```sh
|
|
173
|
+
uv sync
|
|
174
|
+
uv run pytest
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Tests use synthetic fixtures only; they never read your real `~/.claude` or `~/.codex`.
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
MIT
|
dogear-0.1.0/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# dogear: bookmark and resume Claude Code & Codex sessions
|
|
2
|
+
|
|
3
|
+
> Bookmark, save and resume Claude Code and Codex sessions from any folder. A tiny session manager with names, tags and notes.
|
|
4
|
+
|
|
5
|
+
You have hundreds of Claude sessions and Codex sessions spread over many
|
|
6
|
+
folders, and a few of them matter. dogear keeps a short, named list of those —
|
|
7
|
+
like folding the corner of a page — and resumes any of them with one command,
|
|
8
|
+
from anywhere: it changes to the session's folder and runs `claude --resume` or
|
|
9
|
+
`codex resume` for you.
|
|
10
|
+
|
|
11
|
+
- Save the session you're in without leaving it: `/dogear` (Claude Code), `$dogear` (Codex), or `! dogear add`.
|
|
12
|
+
- Save any recent session from a normal terminal through a picker.
|
|
13
|
+
- One list for both agents, with your own names, tags and notes.
|
|
14
|
+
- `dogear` alone: pick a saved session, land in its folder, resumed.
|
|
15
|
+
- Python 3.11+, standard library only. macOS, Linux and Windows. No network access, ever.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
uv tool install dogear # or: pipx install dogear
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then, optionally, add the in-session command:
|
|
24
|
+
|
|
25
|
+
| Where | How | You type |
|
|
26
|
+
| --- | --- | --- |
|
|
27
|
+
| Claude Code, as a plugin | `/plugin install dogear --marketplace orimarti/dogear` (or `/plugin marketplace add orimarti/dogear` then `/plugin install dogear@dogear`) | `/dogear:dogear` |
|
|
28
|
+
| Claude Code, as a personal skill | `dogear setup claude` | `/dogear` |
|
|
29
|
+
| Codex | `dogear setup codex` | `$dogear` |
|
|
30
|
+
|
|
31
|
+
Plain words work too: `/dogear save it as jobs, tag work` or just "dogear this
|
|
32
|
+
session as jobs" — the agent turns it into the right `dogear add` flags (and
|
|
33
|
+
renames the favourite if the session was already saved).
|
|
34
|
+
|
|
35
|
+
Use the plugin **or** `dogear setup claude`, not both. The plugin only provides
|
|
36
|
+
the slash command; the `dogear` CLI still comes from PyPI (the command tells you
|
|
37
|
+
if it's missing).
|
|
38
|
+
|
|
39
|
+
## 30-second tour
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
# inside a Claude Code or Codex session
|
|
43
|
+
/dogear jobs-api -t work # or $dogear … in Codex, or: ! dogear add jobs-api -t work
|
|
44
|
+
|
|
45
|
+
# later, from any terminal
|
|
46
|
+
dogear ls
|
|
47
|
+
# # LABEL AGENT FOLDER TITLE TAGS NOTE
|
|
48
|
+
# 1 jobs-api claude ~/code/jobs New jobs api work
|
|
49
|
+
# 2 docs-site codex ~/code/site Docs refresh
|
|
50
|
+
dogear open jobs-api # cd ~/code/jobs && claude --resume <id>
|
|
51
|
+
dogear # or pick from a list (uses fzf if installed)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
From a plain terminal, `dogear add` shows your 50 most recent sessions (both
|
|
55
|
+
agents) to choose from; `dogear add <session-id>` saves one directly (a unique
|
|
56
|
+
prefix of the id is enough).
|
|
57
|
+
|
|
58
|
+
| Command | What it does |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| `dogear add [SESSION\|LABEL] [-l LABEL] [-n NOTE] [-t TAG]... [--args FLAGS] [--agent A]` | Save a session. A word that isn't a session id (8+ hex characters) is taken as the label for the current session. Repeat `-t` (or use `-t a,b`) for several tags. Giving a label for a session that's already saved renames it. |
|
|
61
|
+
| `dogear ls [-t TAG] [--agent A] [--json]` | List favourites, newest first. `missing` marks sessions that no longer exist. |
|
|
62
|
+
| `dogear open REF [-- FLAGS]` | Resume a favourite in its folder. Flags after `--` replace its saved flags for this launch (`--` alone: no flags). |
|
|
63
|
+
| `dogear rename REF NEW` · `note REF TEXT` · `tag REF +a -b` · `edit REF --args FLAGS` · `rm REF ...` | Manage favourites. `REF` is a label or the number from `dogear ls`. |
|
|
64
|
+
| `dogear prune [--yes]` | Remove favourites whose session is gone. |
|
|
65
|
+
| `dogear setup claude\|codex [--force]` | Install the in-session command. |
|
|
66
|
+
| `dogear config [--path]` | Show the effective configuration. |
|
|
67
|
+
|
|
68
|
+
Flags that start with `-` need quoting with a space or an `=`:
|
|
69
|
+
`--args "--model opus"` or `--args=--verbose`.
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
Optional, at `~/.config/dogear/config.toml` (`$XDG_CONFIG_HOME/dogear`;
|
|
74
|
+
`%APPDATA%\dogear` on Windows; `$DOGEAR_HOME` overrides). Favourites live in
|
|
75
|
+
`favorites.json` next to it, so there's one folder to back up.
|
|
76
|
+
|
|
77
|
+
```toml
|
|
78
|
+
[claude]
|
|
79
|
+
command = "claude" # e.g. "claude --dangerously-skip-permissions"
|
|
80
|
+
|
|
81
|
+
[codex]
|
|
82
|
+
command = "codex" # e.g. "codex --full-auto"
|
|
83
|
+
|
|
84
|
+
[picker]
|
|
85
|
+
use_fzf = "auto" # auto | always | never
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Shell aliases are invisible to dogear.** If you run `alias cc='claude --some-flag'`,
|
|
89
|
+
dogear can't see it — put the flags in `command` above instead. Per-favourite
|
|
90
|
+
flags (`--args`) are added after it.
|
|
91
|
+
|
|
92
|
+
## How it finds your sessions
|
|
93
|
+
|
|
94
|
+
dogear reads the agents' own files and never writes to them (the only exception
|
|
95
|
+
is `dogear setup`, when you ask for it). These are internal formats, not public
|
|
96
|
+
APIs, and may change between agent versions:
|
|
97
|
+
|
|
98
|
+
- **Claude Code**: transcripts in `~/.claude/projects/*/<id>.jsonl` (title from
|
|
99
|
+
`/rename`, else the generated title, else your first prompt); running sessions
|
|
100
|
+
in `~/.claude/sessions/<pid>.json`. `$CLAUDE_CONFIG_DIR` is respected.
|
|
101
|
+
- **Codex**: rollouts in `~/.codex/sessions/YYYY/MM/DD/rollout-*-<id>.jsonl`,
|
|
102
|
+
titles in `~/.codex/session_index.jsonl`. `$CODEX_HOME` is respected.
|
|
103
|
+
|
|
104
|
+
To save "the current session", dogear walks up its parent processes to the
|
|
105
|
+
nearest `claude` or `codex` process and asks which session it is running. If it
|
|
106
|
+
can't tell for sure it refuses rather than guess, and shows the candidates. On
|
|
107
|
+
Windows this detection is best effort: if it fails, use `dogear add <session-id>`.
|
|
108
|
+
|
|
109
|
+
Resuming runs `claude --resume <id>` / `codex resume <id>` from the session's
|
|
110
|
+
folder. dogear replaces itself with the agent (on Windows it runs it and waits),
|
|
111
|
+
so your shell is where you left it when the agent exits.
|
|
112
|
+
|
|
113
|
+
## FAQ
|
|
114
|
+
|
|
115
|
+
### How do I resume a Claude Code session from another folder?
|
|
116
|
+
|
|
117
|
+
Save it once with `/dogear my-label` (or `dogear add` from a terminal). From
|
|
118
|
+
then on, `dogear open my-label` works from any directory: dogear changes to the
|
|
119
|
+
folder the session was started in and runs `claude --resume <id>` there.
|
|
120
|
+
|
|
121
|
+
### How do I resume a Codex session?
|
|
122
|
+
|
|
123
|
+
The same way. `$dogear my-label` inside Codex (after `dogear setup codex`), or
|
|
124
|
+
`dogear add` from a terminal, saves it; `dogear open my-label` runs
|
|
125
|
+
`codex resume <id>` in the right folder. Claude Code and Codex sessions share
|
|
126
|
+
one list.
|
|
127
|
+
|
|
128
|
+
### How do I save or bookmark a Claude Code session?
|
|
129
|
+
|
|
130
|
+
Inside the session, type `/dogear` with an optional name, tags or a note, in
|
|
131
|
+
flags or plain words: `/dogear jobs-api -t work` or `/dogear save it as jobs-api, tag work`.
|
|
132
|
+
From a terminal, `dogear add` lets you pick from your 50 most recent sessions.
|
|
133
|
+
|
|
134
|
+
### How is this different from `claude --resume` or `codex resume`?
|
|
135
|
+
|
|
136
|
+
Those pickers list every past session; dogear is the short list you chose to
|
|
137
|
+
keep, with names you'll remember, across both agents, and it starts each
|
|
138
|
+
session from the right folder. It uses the agents' own resume commands, so
|
|
139
|
+
nothing about the sessions themselves changes.
|
|
140
|
+
|
|
141
|
+
### Does dogear upload or modify my sessions?
|
|
142
|
+
|
|
143
|
+
No. It only reads the agents' files, never writes to them (except installing
|
|
144
|
+
the in-session command when you run `dogear setup`), and makes no network
|
|
145
|
+
requests.
|
|
146
|
+
|
|
147
|
+
## Development
|
|
148
|
+
|
|
149
|
+
```sh
|
|
150
|
+
uv sync
|
|
151
|
+
uv run pytest
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Tests use synthetic fixtures only; they never read your real `~/.claude` or `~/.codex`.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dogear"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Bookmark, save and resume Claude Code and Codex sessions from any folder: a tiny session manager with names, tags and notes."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Oriol Martí" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"claude-code", "claude", "anthropic", "codex", "codex-cli", "openai-codex",
|
|
16
|
+
"sessions", "session-manager", "resume", "bookmark", "bookmarks", "cli", "ai-agents",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Environment :: Console",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Topic :: Software Development",
|
|
28
|
+
"Topic :: Utilities",
|
|
29
|
+
]
|
|
30
|
+
dependencies = []
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/orimarti/dogear"
|
|
34
|
+
Issues = "https://github.com/orimarti/dogear/issues"
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
dogear = "dogear.cli:main"
|
|
38
|
+
|
|
39
|
+
[dependency-groups]
|
|
40
|
+
dev = ["pytest>=8"]
|
|
41
|
+
|
|
42
|
+
[tool.hatch.version]
|
|
43
|
+
path = "src/dogear/__init__.py"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
only-include = ["src", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/dogear"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Agent adapters. Adding an agent means adding one module with an Adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Protocol
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from dogear.proctree import ProcessTable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Session:
|
|
15
|
+
agent: str
|
|
16
|
+
session_id: str
|
|
17
|
+
cwd: str
|
|
18
|
+
title: str
|
|
19
|
+
mtime: float
|
|
20
|
+
path: Path | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Adapter(Protocol):
|
|
24
|
+
name: str
|
|
25
|
+
|
|
26
|
+
def find(self, prefix: str) -> list[Session]: ...
|
|
27
|
+
def get(self, session_id: str) -> Session | None: ...
|
|
28
|
+
def exists(self, session_id: str) -> bool: ...
|
|
29
|
+
def recent(self, limit: int) -> list[Session]: ...
|
|
30
|
+
def is_agent_process(self, command: str) -> bool: ...
|
|
31
|
+
def detect(self, pid: int, table: "ProcessTable") -> list[Session]: ...
|
|
32
|
+
def resume_args(self, session_id: str) -> list[str]: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def command_basename(command: str) -> str:
|
|
36
|
+
"""'C:\\x\\claude.exe' -> 'claude'; '-zsh' -> 'zsh' (login shells)."""
|
|
37
|
+
base = command.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1]
|
|
38
|
+
if base.lower().endswith(".exe"):
|
|
39
|
+
base = base[:-4]
|
|
40
|
+
return base.lstrip("-")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def newest_by_id(paths_with_ids: list[tuple[str, Path]]) -> dict[str, Path]:
|
|
44
|
+
"""Several files can exist for one session id; keep the newest (spec §11b)."""
|
|
45
|
+
best: dict[str, tuple[float, Path]] = {}
|
|
46
|
+
for sid, p in paths_with_ids:
|
|
47
|
+
try:
|
|
48
|
+
m = p.stat().st_mtime
|
|
49
|
+
except OSError:
|
|
50
|
+
continue
|
|
51
|
+
if sid not in best or m > best[sid][0]:
|
|
52
|
+
best[sid] = (m, p)
|
|
53
|
+
return {sid: p for sid, (m, p) in best.items()}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def newest_first(by_id: dict[str, Path], limit: int) -> list[tuple[str, Path]]:
|
|
57
|
+
"""Sort by mtime without failing when a file disappears meanwhile (agents prune old ones)."""
|
|
58
|
+
stamped = []
|
|
59
|
+
for sid, p in by_id.items():
|
|
60
|
+
try:
|
|
61
|
+
stamped.append((p.stat().st_mtime, sid, p))
|
|
62
|
+
except OSError:
|
|
63
|
+
continue
|
|
64
|
+
stamped.sort(key=lambda t: t[0], reverse=True)
|
|
65
|
+
return [(sid, p) for _, sid, p in stamped[:limit]]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_adapters() -> dict[str, Adapter]:
|
|
69
|
+
from dogear.agents.claude import ClaudeAdapter
|
|
70
|
+
from dogear.agents.codex import CodexAdapter
|
|
71
|
+
|
|
72
|
+
return {"claude": ClaudeAdapter(), "codex": CodexAdapter()}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def find_sessions(prefix: str, agent: str | None = None) -> list[Session]:
|
|
76
|
+
out: list[Session] = []
|
|
77
|
+
prefix = prefix.lower() # session ids are lowercase UUIDs
|
|
78
|
+
for name, adapter in get_adapters().items():
|
|
79
|
+
if agent is None or agent == name:
|
|
80
|
+
out.extend(adapter.find(prefix))
|
|
81
|
+
return out
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Claude Code session store (read-only). Internal files observed on Claude Code 2.1.x."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import glob
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from dogear.agents import Session, command_basename, newest_by_id, newest_first
|
|
11
|
+
from dogear.paths import claude_home
|
|
12
|
+
|
|
13
|
+
TITLE_LEN = 60
|
|
14
|
+
SESSION_FILE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _prompt_text(message: object) -> str:
|
|
18
|
+
if not isinstance(message, dict):
|
|
19
|
+
return ""
|
|
20
|
+
content = message.get("content")
|
|
21
|
+
if isinstance(content, str):
|
|
22
|
+
text = content
|
|
23
|
+
elif isinstance(content, list):
|
|
24
|
+
text = next(
|
|
25
|
+
(b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"),
|
|
26
|
+
"",
|
|
27
|
+
)
|
|
28
|
+
else:
|
|
29
|
+
return ""
|
|
30
|
+
text = " ".join(str(text).split())
|
|
31
|
+
if text.startswith("<"): # command wrappers and other meta content, not a real prompt
|
|
32
|
+
return ""
|
|
33
|
+
return text
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _shorten(text: str) -> str:
|
|
37
|
+
return text if len(text) <= TITLE_LEN else text[: TITLE_LEN - 1].rstrip() + "…"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ClaudeAdapter:
|
|
41
|
+
name = "claude"
|
|
42
|
+
|
|
43
|
+
def __init__(self, home: Path | None = None):
|
|
44
|
+
self.home = home or claude_home()
|
|
45
|
+
|
|
46
|
+
# store -----------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def _transcripts(self, prefix: str = "") -> dict[str, Path]:
|
|
49
|
+
# only the part below the store root is a pattern; the root may contain [ ] * ?
|
|
50
|
+
pattern = f"*/{glob.escape(prefix)}*.jsonl"
|
|
51
|
+
# sub-agent and other side files (e.g. agent-*.jsonl) are not resumable sessions
|
|
52
|
+
found = [(p.stem, p) for p in (self.home / "projects").glob(pattern) if SESSION_FILE.match(p.stem)]
|
|
53
|
+
return newest_by_id(found)
|
|
54
|
+
|
|
55
|
+
def _session(self, sid: str, path: Path) -> Session:
|
|
56
|
+
custom = ai = prompt = ""
|
|
57
|
+
cwd = ""
|
|
58
|
+
for obj in self._wanted_lines(path, lambda: not cwd, lambda: not prompt):
|
|
59
|
+
t = obj.get("type")
|
|
60
|
+
if not cwd and isinstance(obj.get("cwd"), str):
|
|
61
|
+
cwd = obj["cwd"]
|
|
62
|
+
if t == "custom-title" and isinstance(obj.get("customTitle"), str):
|
|
63
|
+
custom = obj["customTitle"]
|
|
64
|
+
elif t == "ai-title" and isinstance(obj.get("aiTitle"), str):
|
|
65
|
+
ai = obj["aiTitle"]
|
|
66
|
+
elif t == "user" and not prompt and not obj.get("isMeta"):
|
|
67
|
+
prompt = _prompt_text(obj.get("message"))
|
|
68
|
+
title = custom.strip() or ai.strip() or (_shorten(prompt) if prompt else "(untitled)")
|
|
69
|
+
try:
|
|
70
|
+
mtime = path.stat().st_mtime
|
|
71
|
+
except OSError:
|
|
72
|
+
mtime = 0.0
|
|
73
|
+
return Session("claude", sid, cwd, title, mtime, path)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _wanted_lines(path: Path, need_cwd, need_prompt):
|
|
77
|
+
"""Parse only the lines that can matter: transcripts are large and most lines
|
|
78
|
+
are messages, so skip JSON-decoding them once cwd and the first prompt are known."""
|
|
79
|
+
try:
|
|
80
|
+
f = path.open("rb")
|
|
81
|
+
except OSError:
|
|
82
|
+
return
|
|
83
|
+
with f:
|
|
84
|
+
for raw in f:
|
|
85
|
+
if not (b'-title"' in raw or need_cwd() or (need_prompt() and b'"user"' in raw)):
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
obj = json.loads(raw)
|
|
89
|
+
except ValueError:
|
|
90
|
+
continue
|
|
91
|
+
if isinstance(obj, dict):
|
|
92
|
+
yield obj
|
|
93
|
+
|
|
94
|
+
def find(self, prefix: str) -> list[Session]:
|
|
95
|
+
return [self._session(sid, p) for sid, p in sorted(self._transcripts(prefix).items())]
|
|
96
|
+
|
|
97
|
+
def get(self, session_id: str) -> Session | None:
|
|
98
|
+
p = self._transcripts(session_id).get(session_id)
|
|
99
|
+
return self._session(session_id, p) if p else None
|
|
100
|
+
|
|
101
|
+
def exists(self, session_id: str) -> bool:
|
|
102
|
+
return session_id in self._transcripts(session_id)
|
|
103
|
+
|
|
104
|
+
def recent(self, limit: int) -> list[Session]:
|
|
105
|
+
return [self._session(sid, p) for sid, p in newest_first(self._transcripts(), limit)]
|
|
106
|
+
|
|
107
|
+
# processes -------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def is_agent_process(self, command: str) -> bool:
|
|
110
|
+
norm = command.replace("\\", "/")
|
|
111
|
+
return (
|
|
112
|
+
command_basename(command) == "claude"
|
|
113
|
+
or "/claude/versions/" in norm
|
|
114
|
+
or "/@anthropic-ai/claude-code/" in norm
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def detect(self, pid: int, table) -> list[Session]:
|
|
118
|
+
"""Claude writes sessions/<pid>.json for each live process; stale files are ignored."""
|
|
119
|
+
try:
|
|
120
|
+
live = json.loads((self.home / "sessions" / f"{pid}.json").read_text(encoding="utf-8"))
|
|
121
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
122
|
+
return []
|
|
123
|
+
if not isinstance(live, dict) or live.get("pid") != pid or not isinstance(live.get("sessionId"), str):
|
|
124
|
+
return []
|
|
125
|
+
sid = live["sessionId"]
|
|
126
|
+
found = self.get(sid)
|
|
127
|
+
if found:
|
|
128
|
+
return [found]
|
|
129
|
+
cwd = live.get("cwd") if isinstance(live.get("cwd"), str) else ""
|
|
130
|
+
name = live.get("name") if isinstance(live.get("name"), str) and live.get("name") else "(untitled)"
|
|
131
|
+
return [Session("claude", sid, cwd, name, 0.0, None)]
|
|
132
|
+
|
|
133
|
+
def resume_args(self, session_id: str) -> list[str]:
|
|
134
|
+
return ["--resume", session_id]
|