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.
Files changed (49) hide show
  1. dogear-0.1.0/.gitignore +7 -0
  2. dogear-0.1.0/LICENSE +21 -0
  3. dogear-0.1.0/PKG-INFO +181 -0
  4. dogear-0.1.0/README.md +158 -0
  5. dogear-0.1.0/pyproject.toml +52 -0
  6. dogear-0.1.0/src/dogear/__init__.py +3 -0
  7. dogear-0.1.0/src/dogear/__main__.py +5 -0
  8. dogear-0.1.0/src/dogear/agents/__init__.py +81 -0
  9. dogear-0.1.0/src/dogear/agents/claude.py +134 -0
  10. dogear-0.1.0/src/dogear/agents/codex.py +113 -0
  11. dogear-0.1.0/src/dogear/assets/claude_skill.md +35 -0
  12. dogear-0.1.0/src/dogear/assets/codex_openai.yaml +2 -0
  13. dogear-0.1.0/src/dogear/assets/codex_skill.md +32 -0
  14. dogear-0.1.0/src/dogear/cli.py +499 -0
  15. dogear-0.1.0/src/dogear/config.py +54 -0
  16. dogear-0.1.0/src/dogear/detect.py +33 -0
  17. dogear-0.1.0/src/dogear/errors.py +2 -0
  18. dogear-0.1.0/src/dogear/favorites.py +84 -0
  19. dogear-0.1.0/src/dogear/jsonl.py +25 -0
  20. dogear-0.1.0/src/dogear/launch.py +40 -0
  21. dogear-0.1.0/src/dogear/paths.py +36 -0
  22. dogear-0.1.0/src/dogear/picker.py +61 -0
  23. dogear-0.1.0/src/dogear/proctree.py +227 -0
  24. dogear-0.1.0/src/dogear/render.py +91 -0
  25. dogear-0.1.0/src/dogear/setup_cmd.py +47 -0
  26. dogear-0.1.0/src/dogear/store.py +134 -0
  27. dogear-0.1.0/tests/__init__.py +0 -0
  28. dogear-0.1.0/tests/conftest.py +36 -0
  29. dogear-0.1.0/tests/fakes.py +106 -0
  30. dogear-0.1.0/tests/test_agent_claude.py +105 -0
  31. dogear-0.1.0/tests/test_agent_codex.py +74 -0
  32. dogear-0.1.0/tests/test_cli_add.py +138 -0
  33. dogear-0.1.0/tests/test_cli_manage.py +152 -0
  34. dogear-0.1.0/tests/test_cli_open.py +91 -0
  35. dogear-0.1.0/tests/test_cli_version.py +24 -0
  36. dogear-0.1.0/tests/test_config.py +42 -0
  37. dogear-0.1.0/tests/test_detect.py +86 -0
  38. dogear-0.1.0/tests/test_favorites.py +81 -0
  39. dogear-0.1.0/tests/test_launch.py +52 -0
  40. dogear-0.1.0/tests/test_minor_fixes.py +269 -0
  41. dogear-0.1.0/tests/test_packaging.py +55 -0
  42. dogear-0.1.0/tests/test_paths.py +34 -0
  43. dogear-0.1.0/tests/test_picker.py +75 -0
  44. dogear-0.1.0/tests/test_plugin.py +57 -0
  45. dogear-0.1.0/tests/test_proctree.py +56 -0
  46. dogear-0.1.0/tests/test_render.py +43 -0
  47. dogear-0.1.0/tests/test_review_fixes.py +122 -0
  48. dogear-0.1.0/tests/test_setup.py +73 -0
  49. dogear-0.1.0/tests/test_store.py +95 -0
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
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,3 @@
1
+ """dogear: favourite, list and resume Claude Code and Codex sessions."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from dogear.cli import main
4
+
5
+ sys.exit(main())
@@ -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]