claude-handoff 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.
- claude_handoff-0.1.0/LICENSE +21 -0
- claude_handoff-0.1.0/PKG-INFO +121 -0
- claude_handoff-0.1.0/README.md +104 -0
- claude_handoff-0.1.0/claude_handoff.egg-info/PKG-INFO +121 -0
- claude_handoff-0.1.0/claude_handoff.egg-info/SOURCES.txt +10 -0
- claude_handoff-0.1.0/claude_handoff.egg-info/dependency_links.txt +1 -0
- claude_handoff-0.1.0/claude_handoff.egg-info/entry_points.txt +2 -0
- claude_handoff-0.1.0/claude_handoff.egg-info/top_level.txt +1 -0
- claude_handoff-0.1.0/claude_handoff.py +619 -0
- claude_handoff-0.1.0/pyproject.toml +28 -0
- claude_handoff-0.1.0/setup.cfg +4 -0
- claude_handoff-0.1.0/tests/test_basic.py +98 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vasilispapg
|
|
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.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claude-handoff
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Summarize & export a Claude Code session into one clean handoff.md for Gemini, GPT, or another Claude.
|
|
5
|
+
Author: Vasilispapg
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Vasilispapg/claude-handoff
|
|
8
|
+
Keywords: claude,claude-code,export,handoff,llm,transcript
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Utilities
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# claude-handoff
|
|
19
|
+
|
|
20
|
+
**Summarize & export a Claude Code session into one clean `handoff.md` you can paste into Gemini, GPT, or another Claude — without the noise.**
|
|
21
|
+
|
|
22
|
+
Claude Code stores every session locally as JSONL (`~/.claude/projects/…/*.jsonl`), full of tool calls, tool results, thinking blocks and system reminders. Existing exporters dump all of that into markdown. `claude-handoff` instead produces a **handoff document**: the actual conversation, what files were touched, what commands ran, and (optionally) an LLM-written summary of goal / decisions / current state / next steps — so the next model can just continue the work.
|
|
23
|
+
|
|
24
|
+
- **Zero dependencies.** One Python file, stdlib only. Python 3.9+.
|
|
25
|
+
- **Deterministic by default.** No API call, no cost, works offline.
|
|
26
|
+
- **`--llm` when you want a real summary.** Claude, OpenAI or Gemini via your own API key.
|
|
27
|
+
- **Noise-free.** Drops tool results, thinking blocks, system reminders, subagent chatter, slash-command envelopes. Keeps user intent, assistant answers, files modified, commands run.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pipx install git+https://github.com/Vasilispapg/claude-handoff
|
|
33
|
+
# or just grab the file — it's a single stdlib-only script:
|
|
34
|
+
curl -O https://raw.githubusercontent.com/Vasilispapg/claude-handoff/main/claude_handoff.py
|
|
35
|
+
python3 claude_handoff.py --list
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
claude-handoff # latest session → handoff.md
|
|
42
|
+
claude-handoff --list # what sessions do I have?
|
|
43
|
+
claude-handoff --project myrepo # latest session of a specific project
|
|
44
|
+
claude-handoff path/to/session.jsonl -o - # explicit file → stdout
|
|
45
|
+
claude-handoff --include-tools # keep collapsed per-tool-call detail
|
|
46
|
+
|
|
47
|
+
# real LLM summary (goal / decisions / current state / next steps):
|
|
48
|
+
export ANTHROPIC_API_KEY=sk-...
|
|
49
|
+
claude-handoff --llm claude
|
|
50
|
+
claude-handoff --llm openai --model gpt-4o
|
|
51
|
+
claude-handoff --llm gemini --with-transcript # summary + cleaned transcript
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Then paste `handoff.md` into any other model. The document opens with instructions to the receiving assistant, so no extra prompting is needed.
|
|
55
|
+
|
|
56
|
+
## What the output looks like
|
|
57
|
+
|
|
58
|
+
```markdown
|
|
59
|
+
# Conversation handoff
|
|
60
|
+
|
|
61
|
+
> To the receiving assistant: … you are taking over …
|
|
62
|
+
|
|
63
|
+
## Session
|
|
64
|
+
- Project: /home/you/myapp (branch main)
|
|
65
|
+
- When: 2026-08-20 09:00 → 09:04
|
|
66
|
+
- Activity: 2 user messages, 4 assistant replies, 4 tool calls
|
|
67
|
+
|
|
68
|
+
## Files created / modified
|
|
69
|
+
- /home/you/myapp/auth.py
|
|
70
|
+
|
|
71
|
+
## Commands run
|
|
72
|
+
- python -m pytest tests/test_auth.py -q
|
|
73
|
+
|
|
74
|
+
## Conversation
|
|
75
|
+
### 🧑 User
|
|
76
|
+
the login breaks on unicode passwords…
|
|
77
|
+
### 🤖 Assistant
|
|
78
|
+
Found it — ascii encoding. Changed to utf-8, tests pass.
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Flags
|
|
82
|
+
|
|
83
|
+
| Flag | Meaning |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `--list` | list sessions (date, size, project, first prompt) |
|
|
86
|
+
| `--project NAME` | pick latest session whose project path contains NAME |
|
|
87
|
+
| `-o FILE` / `-o -` | output file / stdout (default `handoff.md`) |
|
|
88
|
+
| `--include-tools` | collapsed `<details>` blocks with each tool call |
|
|
89
|
+
| `--max-chars N` | cap the transcript section (default 80 000; keeps start + recent end) |
|
|
90
|
+
| `--llm claude\|openai\|gemini` | LLM summary instead of raw cleaned transcript |
|
|
91
|
+
| `--model ID` | override the LLM model |
|
|
92
|
+
| `--with-transcript` | with `--llm`, also append the cleaned transcript |
|
|
93
|
+
|
|
94
|
+
API keys are read from `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`/`GOOGLE_API_KEY`. Nothing is sent anywhere unless you pass `--llm`.
|
|
95
|
+
|
|
96
|
+
## Roadmap
|
|
97
|
+
|
|
98
|
+
- claude.ai web chat exports (`conversations.json` from the official data export) as input
|
|
99
|
+
- ChatGPT / Gemini exports as input (handoff in *both* directions)
|
|
100
|
+
- `--format json` for programmatic use
|
|
101
|
+
|
|
102
|
+
PRs welcome.
|
|
103
|
+
|
|
104
|
+
## How it compares
|
|
105
|
+
|
|
106
|
+
This space isn't empty — it's fragmented. Pick the tool that matches your situation:
|
|
107
|
+
|
|
108
|
+
- **Exporters** — [claude-conversation-extractor](https://github.com/ZeroSumQuant/claude-conversation-extractor), [claude-code-log](https://github.com/daaain/claude-code-log), [claude-code-transcripts](https://github.com/simonw/claude-code-transcripts), [claude-to-markdown](https://github.com/legoktm/claude-to-markdown) — turn transcripts into readable Markdown/HTML, tool noise included, no handoff framing.
|
|
109
|
+
- **Cross-CLI session movers** — [cli-continues](https://github.com/yigitkonur/cli-continues) (`npm i -g continues`) reads 16 coding CLIs' native session stores (Claude Code included) and injects a context doc into another *terminal* tool. Excellent for Claude Code → Codex/Cursor/Gemini CLI; but it can't target web chats, does no LLM summarization, and needs Node 22.5+.
|
|
110
|
+
- **In-session handoff skills/plugins** — [thepushkarp/handoff](https://github.com/thepushkarp/handoff), [claude-session-handoff](https://github.com/thenguyenvn90/claude-session-handoff), [claude-code-handoff](https://github.com/Sonovore/claude-code-handoff) — great *if* you remember to run them before the session ends; the model writes the summary using your session's context, and the output targets the next *Claude* session.
|
|
111
|
+
- **Browser extensions** — Handoff, LLM Context Bridge, ContextSwitch — transfer *web* chats between ChatGPT/Claude/Gemini; they can't see Claude Code sessions.
|
|
112
|
+
|
|
113
|
+
`claude-handoff` is the post-hoc, paste-anywhere corner of this map: it works on the JSONL *after* the fact — old sessions, crashed sessions, sessions that hit the usage limit — needs nothing installed in advance, costs zero tokens by default, can write a real summary when you ask for one (`--llm`), and produces a document any receiving model can pick up, including claude.ai, ChatGPT and Gemini in the browser or on your phone.
|
|
114
|
+
|
|
115
|
+
## Docs
|
|
116
|
+
|
|
117
|
+
[INDEX.md](INDEX.md) — file map · [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) — architecture, JSONL schema notes, design decisions · [AGENTS.md](AGENTS.md) — instructions for AI coding agents · [CONTRIBUTING.md](CONTRIBUTING.md) · [CHANGELOG.md](CHANGELOG.md)
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# claude-handoff
|
|
2
|
+
|
|
3
|
+
**Summarize & export a Claude Code session into one clean `handoff.md` you can paste into Gemini, GPT, or another Claude — without the noise.**
|
|
4
|
+
|
|
5
|
+
Claude Code stores every session locally as JSONL (`~/.claude/projects/…/*.jsonl`), full of tool calls, tool results, thinking blocks and system reminders. Existing exporters dump all of that into markdown. `claude-handoff` instead produces a **handoff document**: the actual conversation, what files were touched, what commands ran, and (optionally) an LLM-written summary of goal / decisions / current state / next steps — so the next model can just continue the work.
|
|
6
|
+
|
|
7
|
+
- **Zero dependencies.** One Python file, stdlib only. Python 3.9+.
|
|
8
|
+
- **Deterministic by default.** No API call, no cost, works offline.
|
|
9
|
+
- **`--llm` when you want a real summary.** Claude, OpenAI or Gemini via your own API key.
|
|
10
|
+
- **Noise-free.** Drops tool results, thinking blocks, system reminders, subagent chatter, slash-command envelopes. Keeps user intent, assistant answers, files modified, commands run.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pipx install git+https://github.com/Vasilispapg/claude-handoff
|
|
16
|
+
# or just grab the file — it's a single stdlib-only script:
|
|
17
|
+
curl -O https://raw.githubusercontent.com/Vasilispapg/claude-handoff/main/claude_handoff.py
|
|
18
|
+
python3 claude_handoff.py --list
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
claude-handoff # latest session → handoff.md
|
|
25
|
+
claude-handoff --list # what sessions do I have?
|
|
26
|
+
claude-handoff --project myrepo # latest session of a specific project
|
|
27
|
+
claude-handoff path/to/session.jsonl -o - # explicit file → stdout
|
|
28
|
+
claude-handoff --include-tools # keep collapsed per-tool-call detail
|
|
29
|
+
|
|
30
|
+
# real LLM summary (goal / decisions / current state / next steps):
|
|
31
|
+
export ANTHROPIC_API_KEY=sk-...
|
|
32
|
+
claude-handoff --llm claude
|
|
33
|
+
claude-handoff --llm openai --model gpt-4o
|
|
34
|
+
claude-handoff --llm gemini --with-transcript # summary + cleaned transcript
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then paste `handoff.md` into any other model. The document opens with instructions to the receiving assistant, so no extra prompting is needed.
|
|
38
|
+
|
|
39
|
+
## What the output looks like
|
|
40
|
+
|
|
41
|
+
```markdown
|
|
42
|
+
# Conversation handoff
|
|
43
|
+
|
|
44
|
+
> To the receiving assistant: … you are taking over …
|
|
45
|
+
|
|
46
|
+
## Session
|
|
47
|
+
- Project: /home/you/myapp (branch main)
|
|
48
|
+
- When: 2026-08-20 09:00 → 09:04
|
|
49
|
+
- Activity: 2 user messages, 4 assistant replies, 4 tool calls
|
|
50
|
+
|
|
51
|
+
## Files created / modified
|
|
52
|
+
- /home/you/myapp/auth.py
|
|
53
|
+
|
|
54
|
+
## Commands run
|
|
55
|
+
- python -m pytest tests/test_auth.py -q
|
|
56
|
+
|
|
57
|
+
## Conversation
|
|
58
|
+
### 🧑 User
|
|
59
|
+
the login breaks on unicode passwords…
|
|
60
|
+
### 🤖 Assistant
|
|
61
|
+
Found it — ascii encoding. Changed to utf-8, tests pass.
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Flags
|
|
65
|
+
|
|
66
|
+
| Flag | Meaning |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `--list` | list sessions (date, size, project, first prompt) |
|
|
69
|
+
| `--project NAME` | pick latest session whose project path contains NAME |
|
|
70
|
+
| `-o FILE` / `-o -` | output file / stdout (default `handoff.md`) |
|
|
71
|
+
| `--include-tools` | collapsed `<details>` blocks with each tool call |
|
|
72
|
+
| `--max-chars N` | cap the transcript section (default 80 000; keeps start + recent end) |
|
|
73
|
+
| `--llm claude\|openai\|gemini` | LLM summary instead of raw cleaned transcript |
|
|
74
|
+
| `--model ID` | override the LLM model |
|
|
75
|
+
| `--with-transcript` | with `--llm`, also append the cleaned transcript |
|
|
76
|
+
|
|
77
|
+
API keys are read from `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`/`GOOGLE_API_KEY`. Nothing is sent anywhere unless you pass `--llm`.
|
|
78
|
+
|
|
79
|
+
## Roadmap
|
|
80
|
+
|
|
81
|
+
- claude.ai web chat exports (`conversations.json` from the official data export) as input
|
|
82
|
+
- ChatGPT / Gemini exports as input (handoff in *both* directions)
|
|
83
|
+
- `--format json` for programmatic use
|
|
84
|
+
|
|
85
|
+
PRs welcome.
|
|
86
|
+
|
|
87
|
+
## How it compares
|
|
88
|
+
|
|
89
|
+
This space isn't empty — it's fragmented. Pick the tool that matches your situation:
|
|
90
|
+
|
|
91
|
+
- **Exporters** — [claude-conversation-extractor](https://github.com/ZeroSumQuant/claude-conversation-extractor), [claude-code-log](https://github.com/daaain/claude-code-log), [claude-code-transcripts](https://github.com/simonw/claude-code-transcripts), [claude-to-markdown](https://github.com/legoktm/claude-to-markdown) — turn transcripts into readable Markdown/HTML, tool noise included, no handoff framing.
|
|
92
|
+
- **Cross-CLI session movers** — [cli-continues](https://github.com/yigitkonur/cli-continues) (`npm i -g continues`) reads 16 coding CLIs' native session stores (Claude Code included) and injects a context doc into another *terminal* tool. Excellent for Claude Code → Codex/Cursor/Gemini CLI; but it can't target web chats, does no LLM summarization, and needs Node 22.5+.
|
|
93
|
+
- **In-session handoff skills/plugins** — [thepushkarp/handoff](https://github.com/thepushkarp/handoff), [claude-session-handoff](https://github.com/thenguyenvn90/claude-session-handoff), [claude-code-handoff](https://github.com/Sonovore/claude-code-handoff) — great *if* you remember to run them before the session ends; the model writes the summary using your session's context, and the output targets the next *Claude* session.
|
|
94
|
+
- **Browser extensions** — Handoff, LLM Context Bridge, ContextSwitch — transfer *web* chats between ChatGPT/Claude/Gemini; they can't see Claude Code sessions.
|
|
95
|
+
|
|
96
|
+
`claude-handoff` is the post-hoc, paste-anywhere corner of this map: it works on the JSONL *after* the fact — old sessions, crashed sessions, sessions that hit the usage limit — needs nothing installed in advance, costs zero tokens by default, can write a real summary when you ask for one (`--llm`), and produces a document any receiving model can pick up, including claude.ai, ChatGPT and Gemini in the browser or on your phone.
|
|
97
|
+
|
|
98
|
+
## Docs
|
|
99
|
+
|
|
100
|
+
[INDEX.md](INDEX.md) — file map · [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) — architecture, JSONL schema notes, design decisions · [AGENTS.md](AGENTS.md) — instructions for AI coding agents · [CONTRIBUTING.md](CONTRIBUTING.md) · [CHANGELOG.md](CHANGELOG.md)
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claude-handoff
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Summarize & export a Claude Code session into one clean handoff.md for Gemini, GPT, or another Claude.
|
|
5
|
+
Author: Vasilispapg
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Vasilispapg/claude-handoff
|
|
8
|
+
Keywords: claude,claude-code,export,handoff,llm,transcript
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Utilities
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# claude-handoff
|
|
19
|
+
|
|
20
|
+
**Summarize & export a Claude Code session into one clean `handoff.md` you can paste into Gemini, GPT, or another Claude — without the noise.**
|
|
21
|
+
|
|
22
|
+
Claude Code stores every session locally as JSONL (`~/.claude/projects/…/*.jsonl`), full of tool calls, tool results, thinking blocks and system reminders. Existing exporters dump all of that into markdown. `claude-handoff` instead produces a **handoff document**: the actual conversation, what files were touched, what commands ran, and (optionally) an LLM-written summary of goal / decisions / current state / next steps — so the next model can just continue the work.
|
|
23
|
+
|
|
24
|
+
- **Zero dependencies.** One Python file, stdlib only. Python 3.9+.
|
|
25
|
+
- **Deterministic by default.** No API call, no cost, works offline.
|
|
26
|
+
- **`--llm` when you want a real summary.** Claude, OpenAI or Gemini via your own API key.
|
|
27
|
+
- **Noise-free.** Drops tool results, thinking blocks, system reminders, subagent chatter, slash-command envelopes. Keeps user intent, assistant answers, files modified, commands run.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pipx install git+https://github.com/Vasilispapg/claude-handoff
|
|
33
|
+
# or just grab the file — it's a single stdlib-only script:
|
|
34
|
+
curl -O https://raw.githubusercontent.com/Vasilispapg/claude-handoff/main/claude_handoff.py
|
|
35
|
+
python3 claude_handoff.py --list
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
claude-handoff # latest session → handoff.md
|
|
42
|
+
claude-handoff --list # what sessions do I have?
|
|
43
|
+
claude-handoff --project myrepo # latest session of a specific project
|
|
44
|
+
claude-handoff path/to/session.jsonl -o - # explicit file → stdout
|
|
45
|
+
claude-handoff --include-tools # keep collapsed per-tool-call detail
|
|
46
|
+
|
|
47
|
+
# real LLM summary (goal / decisions / current state / next steps):
|
|
48
|
+
export ANTHROPIC_API_KEY=sk-...
|
|
49
|
+
claude-handoff --llm claude
|
|
50
|
+
claude-handoff --llm openai --model gpt-4o
|
|
51
|
+
claude-handoff --llm gemini --with-transcript # summary + cleaned transcript
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Then paste `handoff.md` into any other model. The document opens with instructions to the receiving assistant, so no extra prompting is needed.
|
|
55
|
+
|
|
56
|
+
## What the output looks like
|
|
57
|
+
|
|
58
|
+
```markdown
|
|
59
|
+
# Conversation handoff
|
|
60
|
+
|
|
61
|
+
> To the receiving assistant: … you are taking over …
|
|
62
|
+
|
|
63
|
+
## Session
|
|
64
|
+
- Project: /home/you/myapp (branch main)
|
|
65
|
+
- When: 2026-08-20 09:00 → 09:04
|
|
66
|
+
- Activity: 2 user messages, 4 assistant replies, 4 tool calls
|
|
67
|
+
|
|
68
|
+
## Files created / modified
|
|
69
|
+
- /home/you/myapp/auth.py
|
|
70
|
+
|
|
71
|
+
## Commands run
|
|
72
|
+
- python -m pytest tests/test_auth.py -q
|
|
73
|
+
|
|
74
|
+
## Conversation
|
|
75
|
+
### 🧑 User
|
|
76
|
+
the login breaks on unicode passwords…
|
|
77
|
+
### 🤖 Assistant
|
|
78
|
+
Found it — ascii encoding. Changed to utf-8, tests pass.
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Flags
|
|
82
|
+
|
|
83
|
+
| Flag | Meaning |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `--list` | list sessions (date, size, project, first prompt) |
|
|
86
|
+
| `--project NAME` | pick latest session whose project path contains NAME |
|
|
87
|
+
| `-o FILE` / `-o -` | output file / stdout (default `handoff.md`) |
|
|
88
|
+
| `--include-tools` | collapsed `<details>` blocks with each tool call |
|
|
89
|
+
| `--max-chars N` | cap the transcript section (default 80 000; keeps start + recent end) |
|
|
90
|
+
| `--llm claude\|openai\|gemini` | LLM summary instead of raw cleaned transcript |
|
|
91
|
+
| `--model ID` | override the LLM model |
|
|
92
|
+
| `--with-transcript` | with `--llm`, also append the cleaned transcript |
|
|
93
|
+
|
|
94
|
+
API keys are read from `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`/`GOOGLE_API_KEY`. Nothing is sent anywhere unless you pass `--llm`.
|
|
95
|
+
|
|
96
|
+
## Roadmap
|
|
97
|
+
|
|
98
|
+
- claude.ai web chat exports (`conversations.json` from the official data export) as input
|
|
99
|
+
- ChatGPT / Gemini exports as input (handoff in *both* directions)
|
|
100
|
+
- `--format json` for programmatic use
|
|
101
|
+
|
|
102
|
+
PRs welcome.
|
|
103
|
+
|
|
104
|
+
## How it compares
|
|
105
|
+
|
|
106
|
+
This space isn't empty — it's fragmented. Pick the tool that matches your situation:
|
|
107
|
+
|
|
108
|
+
- **Exporters** — [claude-conversation-extractor](https://github.com/ZeroSumQuant/claude-conversation-extractor), [claude-code-log](https://github.com/daaain/claude-code-log), [claude-code-transcripts](https://github.com/simonw/claude-code-transcripts), [claude-to-markdown](https://github.com/legoktm/claude-to-markdown) — turn transcripts into readable Markdown/HTML, tool noise included, no handoff framing.
|
|
109
|
+
- **Cross-CLI session movers** — [cli-continues](https://github.com/yigitkonur/cli-continues) (`npm i -g continues`) reads 16 coding CLIs' native session stores (Claude Code included) and injects a context doc into another *terminal* tool. Excellent for Claude Code → Codex/Cursor/Gemini CLI; but it can't target web chats, does no LLM summarization, and needs Node 22.5+.
|
|
110
|
+
- **In-session handoff skills/plugins** — [thepushkarp/handoff](https://github.com/thepushkarp/handoff), [claude-session-handoff](https://github.com/thenguyenvn90/claude-session-handoff), [claude-code-handoff](https://github.com/Sonovore/claude-code-handoff) — great *if* you remember to run them before the session ends; the model writes the summary using your session's context, and the output targets the next *Claude* session.
|
|
111
|
+
- **Browser extensions** — Handoff, LLM Context Bridge, ContextSwitch — transfer *web* chats between ChatGPT/Claude/Gemini; they can't see Claude Code sessions.
|
|
112
|
+
|
|
113
|
+
`claude-handoff` is the post-hoc, paste-anywhere corner of this map: it works on the JSONL *after* the fact — old sessions, crashed sessions, sessions that hit the usage limit — needs nothing installed in advance, costs zero tokens by default, can write a real summary when you ask for one (`--llm`), and produces a document any receiving model can pick up, including claude.ai, ChatGPT and Gemini in the browser or on your phone.
|
|
114
|
+
|
|
115
|
+
## Docs
|
|
116
|
+
|
|
117
|
+
[INDEX.md](INDEX.md) — file map · [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) — architecture, JSONL schema notes, design decisions · [AGENTS.md](AGENTS.md) — instructions for AI coding agents · [CONTRIBUTING.md](CONTRIBUTING.md) · [CHANGELOG.md](CHANGELOG.md)
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
claude_handoff.py
|
|
4
|
+
pyproject.toml
|
|
5
|
+
claude_handoff.egg-info/PKG-INFO
|
|
6
|
+
claude_handoff.egg-info/SOURCES.txt
|
|
7
|
+
claude_handoff.egg-info/dependency_links.txt
|
|
8
|
+
claude_handoff.egg-info/entry_points.txt
|
|
9
|
+
claude_handoff.egg-info/top_level.txt
|
|
10
|
+
tests/test_basic.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
claude_handoff
|
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""claude-handoff — summarize & export a Claude Code session for another LLM.
|
|
3
|
+
|
|
4
|
+
Reads Claude Code's local session transcripts (JSONL in ~/.claude/projects),
|
|
5
|
+
strips the tool-call noise, and produces a single clean handoff.md you can
|
|
6
|
+
paste into Gemini, GPT, another Claude — anything — so it can pick up where
|
|
7
|
+
the session left off.
|
|
8
|
+
|
|
9
|
+
Zero dependencies. Python 3.9+. MIT license.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
claude-handoff # latest session -> handoff.md
|
|
13
|
+
claude-handoff --list # list available sessions
|
|
14
|
+
claude-handoff --project myrepo # latest session of a project
|
|
15
|
+
claude-handoff path/to/session.jsonl -o - # explicit file -> stdout
|
|
16
|
+
claude-handoff --llm claude # real LLM summary (needs API key in env)
|
|
17
|
+
|
|
18
|
+
API keys (only needed with --llm):
|
|
19
|
+
ANTHROPIC_API_KEY | OPENAI_API_KEY | GEMINI_API_KEY (or GOOGLE_API_KEY)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
import urllib.request
|
|
30
|
+
import urllib.error
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
|
|
36
|
+
PROJECTS_DIR = Path(os.environ.get("CLAUDE_HOME", Path.home() / ".claude")) / "projects"
|
|
37
|
+
|
|
38
|
+
# Message-level caps (deterministic mode). Head+tail are kept when truncating.
|
|
39
|
+
USER_MSG_CAP = 8000
|
|
40
|
+
ASSISTANT_MSG_CAP = 5000
|
|
41
|
+
TOOL_LINE_CAP = 200
|
|
42
|
+
DEFAULT_MAX_CHARS = 80_000 # global cap on the transcript section
|
|
43
|
+
LLM_INPUT_CAP = 400_000 # cap on transcript sent to an LLM
|
|
44
|
+
|
|
45
|
+
DEFAULT_MODELS = {
|
|
46
|
+
"claude": "claude-sonnet-4-5",
|
|
47
|
+
"openai": "gpt-4o-mini",
|
|
48
|
+
"gemini": "gemini-2.5-flash",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
FILE_TOOLS_WRITE = {"Write", "Edit", "MultiEdit", "NotebookEdit"}
|
|
52
|
+
FILE_TOOLS_READ = {"Read"}
|
|
53
|
+
|
|
54
|
+
NOISE_RE = re.compile(
|
|
55
|
+
r"<system-reminder>.*?</system-reminder>"
|
|
56
|
+
r"|<command-name>.*?</command-name>"
|
|
57
|
+
r"|<command-message>.*?</command-message>"
|
|
58
|
+
r"|<command-args>.*?</command-args>"
|
|
59
|
+
r"|<local-command-stdout>.*?</local-command-stdout>"
|
|
60
|
+
r"|<local-command-stderr>.*?</local-command-stderr>",
|
|
61
|
+
re.DOTALL,
|
|
62
|
+
)
|
|
63
|
+
CAVEAT_RE = re.compile(r"^Caveat: The messages below were generated.*?$", re.MULTILINE)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --------------------------------------------------------------------------- #
|
|
67
|
+
# Session discovery
|
|
68
|
+
# --------------------------------------------------------------------------- #
|
|
69
|
+
|
|
70
|
+
def find_sessions(project_filter: str | None = None) -> list[Path]:
|
|
71
|
+
"""All session JSONL files, newest first."""
|
|
72
|
+
if not PROJECTS_DIR.is_dir():
|
|
73
|
+
return []
|
|
74
|
+
sessions = []
|
|
75
|
+
for proj in sorted(PROJECTS_DIR.iterdir()):
|
|
76
|
+
if not proj.is_dir():
|
|
77
|
+
continue
|
|
78
|
+
if project_filter and project_filter.lower() not in proj.name.lower():
|
|
79
|
+
continue
|
|
80
|
+
sessions.extend(p for p in proj.glob("*.jsonl") if p.stat().st_size > 0)
|
|
81
|
+
return sorted(sessions, key=lambda p: p.stat().st_mtime, reverse=True)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def first_prompt_of(path: Path) -> str:
|
|
85
|
+
"""Best-effort first human prompt of a session, for --list display."""
|
|
86
|
+
try:
|
|
87
|
+
with path.open(encoding="utf-8", errors="replace") as fh:
|
|
88
|
+
for line in fh:
|
|
89
|
+
try:
|
|
90
|
+
rec = json.loads(line)
|
|
91
|
+
except json.JSONDecodeError:
|
|
92
|
+
continue
|
|
93
|
+
if rec.get("type") == "last-prompt" and rec.get("lastPrompt"):
|
|
94
|
+
return one_line(rec["lastPrompt"], 80)
|
|
95
|
+
if rec.get("type") == "user" and not rec.get("isMeta"):
|
|
96
|
+
text = clean_text(user_text(rec.get("message") or {}))
|
|
97
|
+
if text:
|
|
98
|
+
return one_line(text, 80)
|
|
99
|
+
except OSError:
|
|
100
|
+
pass
|
|
101
|
+
return "(empty)"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def list_sessions(project_filter: str | None) -> None:
|
|
105
|
+
sessions = find_sessions(project_filter)
|
|
106
|
+
if not sessions:
|
|
107
|
+
print(f"No sessions found under {PROJECTS_DIR}", file=sys.stderr)
|
|
108
|
+
return
|
|
109
|
+
for p in sessions:
|
|
110
|
+
mtime = datetime.fromtimestamp(p.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
|
|
111
|
+
size_kb = p.stat().st_size // 1024
|
|
112
|
+
proj = p.parent.name.lstrip("-").replace("-", "/")
|
|
113
|
+
print(f"{mtime} {size_kb:>6} KB {p.stem[:8]} {proj}")
|
|
114
|
+
print(f" └─ {first_prompt_of(p)}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --------------------------------------------------------------------------- #
|
|
118
|
+
# Parsing
|
|
119
|
+
# --------------------------------------------------------------------------- #
|
|
120
|
+
|
|
121
|
+
def load_records(path: Path) -> list[dict]:
|
|
122
|
+
records = []
|
|
123
|
+
with path.open(encoding="utf-8", errors="replace") as fh:
|
|
124
|
+
for line in fh:
|
|
125
|
+
line = line.strip()
|
|
126
|
+
if not line:
|
|
127
|
+
continue
|
|
128
|
+
try:
|
|
129
|
+
records.append(json.loads(line))
|
|
130
|
+
except json.JSONDecodeError:
|
|
131
|
+
continue # tolerate partial/corrupt lines
|
|
132
|
+
return records
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def one_line(text: str, cap: int = 80) -> str:
|
|
136
|
+
text = " ".join(text.split())
|
|
137
|
+
return text[: cap - 1] + "…" if len(text) > cap else text
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def clean_text(text: str) -> str:
|
|
141
|
+
"""Strip system-reminder / slash-command envelopes and caveats."""
|
|
142
|
+
text = NOISE_RE.sub("", text)
|
|
143
|
+
text = CAVEAT_RE.sub("", text)
|
|
144
|
+
return text.strip()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def user_text(message: dict) -> str:
|
|
148
|
+
"""Human-visible text of a user message (string or block-list content)."""
|
|
149
|
+
content = message.get("content")
|
|
150
|
+
if isinstance(content, str):
|
|
151
|
+
return content
|
|
152
|
+
if isinstance(content, list):
|
|
153
|
+
parts = []
|
|
154
|
+
for block in content:
|
|
155
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
156
|
+
parts.append(block.get("text", ""))
|
|
157
|
+
elif isinstance(block, dict) and block.get("type") == "image":
|
|
158
|
+
parts.append("[image attached]")
|
|
159
|
+
return "\n".join(parts)
|
|
160
|
+
return ""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def tool_result_text(block: dict) -> str:
|
|
164
|
+
"""Plain text of a tool_result block (string or block-list content)."""
|
|
165
|
+
content = block.get("content")
|
|
166
|
+
if isinstance(content, str):
|
|
167
|
+
return content.strip()
|
|
168
|
+
if isinstance(content, list):
|
|
169
|
+
return "\n".join(
|
|
170
|
+
b.get("text", "") for b in content
|
|
171
|
+
if isinstance(b, dict) and b.get("type") == "text"
|
|
172
|
+
).strip()
|
|
173
|
+
return ""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def truncate(text: str, cap: int) -> str:
|
|
177
|
+
if len(text) <= cap:
|
|
178
|
+
return text
|
|
179
|
+
head, tail = int(cap * 0.7), int(cap * 0.2)
|
|
180
|
+
omitted = len(text) - head - tail
|
|
181
|
+
return (
|
|
182
|
+
f"{text[:head]}\n\n[... {omitted} chars omitted ...]\n\n{text[-tail:]}"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def tool_summary(name: str, tool_input: dict) -> tuple[str, str | None, str | None]:
|
|
187
|
+
"""One-line description of a tool call.
|
|
188
|
+
|
|
189
|
+
Returns (line, file_written, command).
|
|
190
|
+
"""
|
|
191
|
+
tool_input = tool_input or {}
|
|
192
|
+
file_written = command = None
|
|
193
|
+
if name in FILE_TOOLS_WRITE:
|
|
194
|
+
file_written = tool_input.get("file_path") or tool_input.get("notebook_path")
|
|
195
|
+
line = f"{name} → {file_written or '?'}"
|
|
196
|
+
elif name in FILE_TOOLS_READ:
|
|
197
|
+
line = f"{name} → {tool_input.get('file_path', '?')}"
|
|
198
|
+
elif name == "Bash":
|
|
199
|
+
command = one_line(tool_input.get("command", ""), TOOL_LINE_CAP)
|
|
200
|
+
desc = tool_input.get("description")
|
|
201
|
+
line = f"Bash: `{command}`" + (f" ({desc})" if desc else "")
|
|
202
|
+
elif name in ("WebSearch", "WebFetch"):
|
|
203
|
+
target = tool_input.get("query") or tool_input.get("url") or ""
|
|
204
|
+
line = f"{name}: {one_line(str(target), TOOL_LINE_CAP)}"
|
|
205
|
+
elif name in ("Grep", "Glob"):
|
|
206
|
+
line = f"{name}: {one_line(str(tool_input.get('pattern', '')), 100)}"
|
|
207
|
+
elif name in ("Task", "Agent"):
|
|
208
|
+
line = f"Subagent: {one_line(str(tool_input.get('description') or tool_input.get('prompt', '')), 120)}"
|
|
209
|
+
else:
|
|
210
|
+
line = name
|
|
211
|
+
return line, file_written, command
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def parse_session(path: Path) -> dict:
|
|
215
|
+
"""Parse a session JSONL into turns + metadata + activity stats."""
|
|
216
|
+
records = load_records(path)
|
|
217
|
+
|
|
218
|
+
meta = {
|
|
219
|
+
"session_id": None, "cwd": None, "git_branch": None,
|
|
220
|
+
"version": None, "models": set(), "first_ts": None, "last_ts": None,
|
|
221
|
+
"n_user": 0, "n_assistant": 0, "n_tools": 0, "summaries": [],
|
|
222
|
+
}
|
|
223
|
+
turns: list[dict] = [] # {"role", "text_parts", "tools"}
|
|
224
|
+
files_written: dict[str, int] = {}
|
|
225
|
+
files_read: dict[str, int] = {}
|
|
226
|
+
commands: list[str] = []
|
|
227
|
+
tool_names: dict[str, str] = {} # tool_use_id -> tool name
|
|
228
|
+
|
|
229
|
+
def current_assistant_turn() -> dict:
|
|
230
|
+
if turns and turns[-1]["role"] == "assistant":
|
|
231
|
+
return turns[-1]
|
|
232
|
+
turn = {"role": "assistant", "text_parts": [], "tools": []}
|
|
233
|
+
turns.append(turn)
|
|
234
|
+
return turn
|
|
235
|
+
|
|
236
|
+
for rec in records:
|
|
237
|
+
rtype = rec.get("type")
|
|
238
|
+
|
|
239
|
+
if rtype == "summary" and rec.get("summary"):
|
|
240
|
+
meta["summaries"].append(rec["summary"])
|
|
241
|
+
continue
|
|
242
|
+
if rtype not in ("user", "assistant"):
|
|
243
|
+
continue
|
|
244
|
+
if rec.get("isSidechain"):
|
|
245
|
+
continue # subagent branches — noise for a handoff
|
|
246
|
+
|
|
247
|
+
for key, field in (("session_id", "sessionId"), ("cwd", "cwd"),
|
|
248
|
+
("git_branch", "gitBranch"), ("version", "version")):
|
|
249
|
+
if rec.get(field) and not meta[key]:
|
|
250
|
+
meta[key] = rec[field]
|
|
251
|
+
ts = rec.get("timestamp")
|
|
252
|
+
if ts:
|
|
253
|
+
meta["first_ts"] = meta["first_ts"] or ts
|
|
254
|
+
meta["last_ts"] = ts
|
|
255
|
+
|
|
256
|
+
message = rec.get("message") or {}
|
|
257
|
+
|
|
258
|
+
if rtype == "assistant":
|
|
259
|
+
if message.get("model"):
|
|
260
|
+
meta["models"].add(message["model"])
|
|
261
|
+
content = message.get("content")
|
|
262
|
+
if not isinstance(content, list):
|
|
263
|
+
continue
|
|
264
|
+
turn = current_assistant_turn()
|
|
265
|
+
added_text = False
|
|
266
|
+
for block in content:
|
|
267
|
+
if not isinstance(block, dict):
|
|
268
|
+
continue
|
|
269
|
+
btype = block.get("type")
|
|
270
|
+
if btype == "text" and block.get("text", "").strip():
|
|
271
|
+
turn["text_parts"].append(block["text"].strip())
|
|
272
|
+
added_text = True
|
|
273
|
+
elif btype == "tool_use":
|
|
274
|
+
name = block.get("name", "?")
|
|
275
|
+
tool_names[block.get("id", "")] = name
|
|
276
|
+
# In SDK/Cowork sessions the assistant's prose is sent via
|
|
277
|
+
# this tool — recover it as normal assistant text.
|
|
278
|
+
if name == "SendUserMessage":
|
|
279
|
+
msg = (block.get("input") or {}).get("message", "")
|
|
280
|
+
if msg.strip():
|
|
281
|
+
turn["text_parts"].append(msg.strip())
|
|
282
|
+
added_text = True
|
|
283
|
+
continue
|
|
284
|
+
meta["n_tools"] += 1
|
|
285
|
+
line, fw, cmd = tool_summary(name, block.get("input"))
|
|
286
|
+
turn["tools"].append(line)
|
|
287
|
+
if fw:
|
|
288
|
+
files_written[fw] = files_written.get(fw, 0) + 1
|
|
289
|
+
if name in FILE_TOOLS_READ:
|
|
290
|
+
fr = (block.get("input") or {}).get("file_path")
|
|
291
|
+
if fr:
|
|
292
|
+
files_read[fr] = files_read.get(fr, 0) + 1
|
|
293
|
+
if cmd:
|
|
294
|
+
commands.append(cmd)
|
|
295
|
+
# thinking blocks are deliberately dropped
|
|
296
|
+
if added_text:
|
|
297
|
+
meta["n_assistant"] += 1
|
|
298
|
+
|
|
299
|
+
else: # user
|
|
300
|
+
if rec.get("isMeta"):
|
|
301
|
+
continue
|
|
302
|
+
content = message.get("content")
|
|
303
|
+
if isinstance(content, list) and any(
|
|
304
|
+
isinstance(b, dict) and b.get("type") == "tool_result"
|
|
305
|
+
for b in content
|
|
306
|
+
):
|
|
307
|
+
# Tool results echoed back are noise — except answers the
|
|
308
|
+
# human gave to AskUserQuestion, which are real user input.
|
|
309
|
+
for b in content:
|
|
310
|
+
if (isinstance(b, dict) and b.get("type") == "tool_result"
|
|
311
|
+
and tool_names.get(b.get("tool_use_id", ""))
|
|
312
|
+
== "AskUserQuestion"):
|
|
313
|
+
answer = tool_result_text(b)
|
|
314
|
+
if answer:
|
|
315
|
+
meta["n_user"] += 1
|
|
316
|
+
turns.append({"role": "user",
|
|
317
|
+
"text_parts": [answer],
|
|
318
|
+
"tools": []})
|
|
319
|
+
continue
|
|
320
|
+
text = clean_text(user_text(message))
|
|
321
|
+
if not text:
|
|
322
|
+
continue
|
|
323
|
+
meta["n_user"] += 1
|
|
324
|
+
turns.append({"role": "user", "text_parts": [text], "tools": []})
|
|
325
|
+
|
|
326
|
+
# collapse empty assistant turns (tool-only, no text) into markers
|
|
327
|
+
cleaned_turns = [
|
|
328
|
+
t for t in turns if t["text_parts"] or t["tools"]
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
"meta": meta,
|
|
333
|
+
"turns": cleaned_turns,
|
|
334
|
+
"files_written": files_written,
|
|
335
|
+
"files_read": files_read,
|
|
336
|
+
"commands": commands,
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# --------------------------------------------------------------------------- #
|
|
341
|
+
# Rendering
|
|
342
|
+
# --------------------------------------------------------------------------- #
|
|
343
|
+
|
|
344
|
+
def fmt_ts(ts: str | None) -> str:
|
|
345
|
+
if not ts:
|
|
346
|
+
return "?"
|
|
347
|
+
try:
|
|
348
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00")) \
|
|
349
|
+
.astimezone().strftime("%Y-%m-%d %H:%M")
|
|
350
|
+
except ValueError:
|
|
351
|
+
return ts
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def render_header(parsed: dict, source: Path) -> str:
|
|
355
|
+
m = parsed["meta"]
|
|
356
|
+
models = ", ".join(sorted(m["models"])) or "?"
|
|
357
|
+
lines = [
|
|
358
|
+
"# Conversation handoff",
|
|
359
|
+
"",
|
|
360
|
+
"> **To the receiving assistant:** this is the context of a working session",
|
|
361
|
+
"> between a human and another AI assistant (Claude). You are taking over.",
|
|
362
|
+
"> Read it, then continue the work — don't re-explain this document back,",
|
|
363
|
+
"> and don't redo completed steps unless asked.",
|
|
364
|
+
"",
|
|
365
|
+
"## Session",
|
|
366
|
+
"",
|
|
367
|
+
f"- **Project:** `{m['cwd'] or '?'}`" +
|
|
368
|
+
(f" (branch `{m['git_branch']}`)" if m["git_branch"] else ""),
|
|
369
|
+
f"- **When:** {fmt_ts(m['first_ts'])} → {fmt_ts(m['last_ts'])}",
|
|
370
|
+
f"- **Assistant model:** {models}",
|
|
371
|
+
f"- **Activity:** {m['n_user']} user messages, "
|
|
372
|
+
f"{m['n_assistant']} assistant replies, {m['n_tools']} tool calls",
|
|
373
|
+
f"- **Source:** `{source}`",
|
|
374
|
+
]
|
|
375
|
+
if m["summaries"]:
|
|
376
|
+
lines += ["", f"**Session title:** {m['summaries'][-1]}"]
|
|
377
|
+
return "\n".join(lines)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def render_activity(parsed: dict, max_commands: int = 30) -> str:
|
|
381
|
+
out = []
|
|
382
|
+
fw, fr, cmds = parsed["files_written"], parsed["files_read"], parsed["commands"]
|
|
383
|
+
if fw:
|
|
384
|
+
out += ["## Files created / modified", ""]
|
|
385
|
+
out += [f"- `{f}`" + (f" ({n}× edits)" if n > 1 else "")
|
|
386
|
+
for f, n in sorted(fw.items())]
|
|
387
|
+
out.append("")
|
|
388
|
+
if cmds:
|
|
389
|
+
deduped = list(dict.fromkeys(cmds))
|
|
390
|
+
shown = deduped[-max_commands:]
|
|
391
|
+
out += ["## Commands run", ""]
|
|
392
|
+
if len(deduped) > len(shown):
|
|
393
|
+
out.append(f"_(last {len(shown)} of {len(deduped)} distinct commands)_")
|
|
394
|
+
out.append("")
|
|
395
|
+
out += [f"- `{c}`" for c in shown]
|
|
396
|
+
out.append("")
|
|
397
|
+
if fr and not fw:
|
|
398
|
+
out += ["## Files read", ""]
|
|
399
|
+
out += [f"- `{f}`" for f in sorted(fr)][:20]
|
|
400
|
+
out.append("")
|
|
401
|
+
return "\n".join(out).rstrip()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def render_transcript(parsed: dict, include_tools: bool,
|
|
405
|
+
max_chars: int) -> str:
|
|
406
|
+
blocks = []
|
|
407
|
+
for turn in parsed["turns"]:
|
|
408
|
+
text = "\n\n".join(turn["text_parts"]).strip()
|
|
409
|
+
if turn["role"] == "user":
|
|
410
|
+
blocks.append("### 🧑 User\n\n" + truncate(text, USER_MSG_CAP))
|
|
411
|
+
else:
|
|
412
|
+
parts = []
|
|
413
|
+
if text:
|
|
414
|
+
parts.append(truncate(text, ASSISTANT_MSG_CAP))
|
|
415
|
+
if include_tools and turn["tools"]:
|
|
416
|
+
tool_lines = "\n".join(f"- {t}" for t in turn["tools"])
|
|
417
|
+
parts.append(f"<details><summary>{len(turn['tools'])} tool "
|
|
418
|
+
f"calls</summary>\n\n{tool_lines}\n\n</details>")
|
|
419
|
+
elif turn["tools"] and not text:
|
|
420
|
+
parts.append(f"_[{len(turn['tools'])} tool calls]_")
|
|
421
|
+
if parts:
|
|
422
|
+
blocks.append("### 🤖 Assistant\n\n" + "\n\n".join(parts))
|
|
423
|
+
|
|
424
|
+
body = "\n\n".join(blocks)
|
|
425
|
+
if len(body) > max_chars:
|
|
426
|
+
# Keep the opening (goal-setting) and the recent end (current state).
|
|
427
|
+
head, tail = int(max_chars * 0.35), int(max_chars * 0.6)
|
|
428
|
+
omitted = len(body) - head - tail
|
|
429
|
+
body = (f"{body[:head]}\n\n---\n\n_[... middle of the conversation "
|
|
430
|
+
f"omitted ({omitted} chars). The beginning sets the goal; "
|
|
431
|
+
f"what follows is the most recent state. ...]_\n\n---\n\n"
|
|
432
|
+
f"{body[-tail:]}")
|
|
433
|
+
return "## Conversation\n\n" + body
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def render_footer() -> str:
|
|
437
|
+
return ("---\n\n_Exported with [claude-handoff]"
|
|
438
|
+
"(https://github.com/Vasilispapg/claude-handoff) — continue from here._")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def build_deterministic(parsed: dict, source: Path, include_tools: bool,
|
|
442
|
+
max_chars: int) -> str:
|
|
443
|
+
sections = [
|
|
444
|
+
render_header(parsed, source),
|
|
445
|
+
render_activity(parsed),
|
|
446
|
+
render_transcript(parsed, include_tools, max_chars),
|
|
447
|
+
render_footer(),
|
|
448
|
+
]
|
|
449
|
+
return "\n\n".join(s for s in sections if s.strip()) + "\n"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# --------------------------------------------------------------------------- #
|
|
453
|
+
# LLM summarization (--llm)
|
|
454
|
+
# --------------------------------------------------------------------------- #
|
|
455
|
+
|
|
456
|
+
SUMMARY_PROMPT = """\
|
|
457
|
+
Below is the cleaned transcript of a working session between a human and an AI \
|
|
458
|
+
coding assistant. Write a handoff document in markdown so that a different AI \
|
|
459
|
+
assistant can continue the work seamlessly. Use exactly these sections:
|
|
460
|
+
|
|
461
|
+
## Goal
|
|
462
|
+
## Key decisions (and why)
|
|
463
|
+
## Current state (what is done, what works)
|
|
464
|
+
## Files & artifacts touched
|
|
465
|
+
## Next steps / open questions
|
|
466
|
+
## Constraints & user preferences
|
|
467
|
+
|
|
468
|
+
Rules: be specific; preserve exact file paths, commands, identifiers, URLs and \
|
|
469
|
+
version numbers; quote short code snippets only when essential; do not invent \
|
|
470
|
+
anything not present in the transcript; do not address the human; write it for \
|
|
471
|
+
the next assistant. Answer in the language the user writes in.
|
|
472
|
+
|
|
473
|
+
TRANSCRIPT:
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def http_json(url: str, payload: dict, headers: dict) -> dict:
|
|
478
|
+
req = urllib.request.Request(
|
|
479
|
+
url, data=json.dumps(payload).encode(),
|
|
480
|
+
headers={"Content-Type": "application/json", **headers},
|
|
481
|
+
)
|
|
482
|
+
try:
|
|
483
|
+
with urllib.request.urlopen(req, timeout=300) as resp:
|
|
484
|
+
return json.load(resp)
|
|
485
|
+
except urllib.error.HTTPError as e:
|
|
486
|
+
detail = e.read().decode(errors="replace")[:500]
|
|
487
|
+
raise SystemExit(f"LLM API error {e.code}: {detail}") from e
|
|
488
|
+
except urllib.error.URLError as e:
|
|
489
|
+
raise SystemExit(f"LLM API unreachable: {e.reason}") from e
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def llm_summarize(provider: str, model: str | None, transcript: str) -> str:
|
|
493
|
+
model = model or DEFAULT_MODELS[provider]
|
|
494
|
+
prompt = SUMMARY_PROMPT + truncate(transcript, LLM_INPUT_CAP)
|
|
495
|
+
|
|
496
|
+
if provider == "claude":
|
|
497
|
+
key = os.environ.get("ANTHROPIC_API_KEY")
|
|
498
|
+
if not key:
|
|
499
|
+
raise SystemExit("Set ANTHROPIC_API_KEY to use --llm claude")
|
|
500
|
+
data = http_json(
|
|
501
|
+
"https://api.anthropic.com/v1/messages",
|
|
502
|
+
{"model": model, "max_tokens": 4096,
|
|
503
|
+
"messages": [{"role": "user", "content": prompt}]},
|
|
504
|
+
{"x-api-key": key, "anthropic-version": "2023-06-01"},
|
|
505
|
+
)
|
|
506
|
+
return "".join(b.get("text", "") for b in data.get("content", []))
|
|
507
|
+
|
|
508
|
+
if provider == "openai":
|
|
509
|
+
key = os.environ.get("OPENAI_API_KEY")
|
|
510
|
+
if not key:
|
|
511
|
+
raise SystemExit("Set OPENAI_API_KEY to use --llm openai")
|
|
512
|
+
data = http_json(
|
|
513
|
+
"https://api.openai.com/v1/chat/completions",
|
|
514
|
+
{"model": model,
|
|
515
|
+
"messages": [{"role": "user", "content": prompt}]},
|
|
516
|
+
{"Authorization": f"Bearer {key}"},
|
|
517
|
+
)
|
|
518
|
+
return data["choices"][0]["message"]["content"]
|
|
519
|
+
|
|
520
|
+
if provider == "gemini":
|
|
521
|
+
key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
|
|
522
|
+
if not key:
|
|
523
|
+
raise SystemExit("Set GEMINI_API_KEY to use --llm gemini")
|
|
524
|
+
data = http_json(
|
|
525
|
+
f"https://generativelanguage.googleapis.com/v1beta/models/"
|
|
526
|
+
f"{model}:generateContent",
|
|
527
|
+
{"contents": [{"parts": [{"text": prompt}]}]},
|
|
528
|
+
{"x-goog-api-key": key},
|
|
529
|
+
)
|
|
530
|
+
return data["candidates"][0]["content"]["parts"][0]["text"]
|
|
531
|
+
|
|
532
|
+
raise SystemExit(f"Unknown provider: {provider}")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def build_llm(parsed: dict, source: Path, provider: str, model: str | None,
|
|
536
|
+
with_transcript: bool, max_chars: int) -> str:
|
|
537
|
+
transcript = render_transcript(parsed, include_tools=True,
|
|
538
|
+
max_chars=LLM_INPUT_CAP)
|
|
539
|
+
activity = render_activity(parsed)
|
|
540
|
+
summary = llm_summarize(provider, model,
|
|
541
|
+
activity + "\n\n" + transcript)
|
|
542
|
+
sections = [render_header(parsed, source), summary.strip()]
|
|
543
|
+
if with_transcript:
|
|
544
|
+
sections.append(render_transcript(parsed, include_tools=False,
|
|
545
|
+
max_chars=max_chars))
|
|
546
|
+
sections.append(render_footer())
|
|
547
|
+
return "\n\n".join(sections) + "\n"
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
# --------------------------------------------------------------------------- #
|
|
551
|
+
# CLI
|
|
552
|
+
# --------------------------------------------------------------------------- #
|
|
553
|
+
|
|
554
|
+
def main(argv: list[str] | None = None) -> None:
|
|
555
|
+
ap = argparse.ArgumentParser(
|
|
556
|
+
prog="claude-handoff",
|
|
557
|
+
description="Summarize & export a Claude Code session for another LLM.",
|
|
558
|
+
)
|
|
559
|
+
ap.add_argument("session", nargs="?",
|
|
560
|
+
help="path to a session .jsonl (default: latest session)")
|
|
561
|
+
ap.add_argument("--list", action="store_true",
|
|
562
|
+
help="list available sessions and exit")
|
|
563
|
+
ap.add_argument("--project", metavar="NAME",
|
|
564
|
+
help="pick latest session whose project path contains NAME")
|
|
565
|
+
ap.add_argument("-o", "--output", default="handoff.md",
|
|
566
|
+
help="output file, or '-' for stdout (default: handoff.md)")
|
|
567
|
+
ap.add_argument("--include-tools", action="store_true",
|
|
568
|
+
help="include collapsed per-tool-call detail in transcript")
|
|
569
|
+
ap.add_argument("--max-chars", type=int, default=DEFAULT_MAX_CHARS,
|
|
570
|
+
help=f"cap transcript section size (default {DEFAULT_MAX_CHARS})")
|
|
571
|
+
ap.add_argument("--llm", choices=["claude", "openai", "gemini"],
|
|
572
|
+
help="summarize with an LLM instead of deterministic export")
|
|
573
|
+
ap.add_argument("--model", help="override the LLM model id for --llm")
|
|
574
|
+
ap.add_argument("--with-transcript", action="store_true",
|
|
575
|
+
help="with --llm: also append the cleaned transcript")
|
|
576
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
577
|
+
args = ap.parse_args(argv)
|
|
578
|
+
|
|
579
|
+
if args.list:
|
|
580
|
+
list_sessions(args.project)
|
|
581
|
+
return
|
|
582
|
+
|
|
583
|
+
if args.session:
|
|
584
|
+
source = Path(args.session).expanduser()
|
|
585
|
+
if not source.is_file():
|
|
586
|
+
raise SystemExit(f"Not a file: {source}")
|
|
587
|
+
else:
|
|
588
|
+
sessions = find_sessions(args.project)
|
|
589
|
+
if not sessions:
|
|
590
|
+
raise SystemExit(
|
|
591
|
+
f"No sessions found under {PROJECTS_DIR}"
|
|
592
|
+
+ (f" matching '{args.project}'" if args.project else "")
|
|
593
|
+
+ ". Pass a .jsonl path explicitly, or run --list.")
|
|
594
|
+
source = sessions[0]
|
|
595
|
+
print(f"Using latest session: {source}", file=sys.stderr)
|
|
596
|
+
|
|
597
|
+
parsed = parse_session(source)
|
|
598
|
+
if not parsed["turns"]:
|
|
599
|
+
raise SystemExit("Session parsed but contains no conversation turns.")
|
|
600
|
+
|
|
601
|
+
if args.llm:
|
|
602
|
+
doc = build_llm(parsed, source, args.llm, args.model,
|
|
603
|
+
args.with_transcript, args.max_chars)
|
|
604
|
+
else:
|
|
605
|
+
doc = build_deterministic(parsed, source, args.include_tools,
|
|
606
|
+
args.max_chars)
|
|
607
|
+
|
|
608
|
+
if args.output == "-":
|
|
609
|
+
sys.stdout.write(doc)
|
|
610
|
+
else:
|
|
611
|
+
out = Path(args.output)
|
|
612
|
+
out.write_text(doc, encoding="utf-8")
|
|
613
|
+
n_user = parsed["meta"]["n_user"]
|
|
614
|
+
print(f"Wrote {out} ({len(doc):,} chars, {n_user} user messages"
|
|
615
|
+
f"{', LLM-summarized' if args.llm else ''})", file=sys.stderr)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
if __name__ == "__main__":
|
|
619
|
+
main()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "claude-handoff"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Summarize & export a Claude Code session into one clean handoff.md for Gemini, GPT, or another Claude."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Vasilispapg" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
keywords = ["claude", "claude-code", "export", "handoff", "llm", "transcript"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Utilities",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/Vasilispapg/claude-handoff"
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
claude-handoff = "claude_handoff:main"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools]
|
|
28
|
+
py-modules = ["claude_handoff"]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Basic tests for claude_handoff. Run: python3 -m unittest discover -s tests"""
|
|
2
|
+
import sys
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
7
|
+
sys.path.insert(0, str(ROOT))
|
|
8
|
+
|
|
9
|
+
import claude_handoff as ch # noqa: E402
|
|
10
|
+
|
|
11
|
+
FIXTURE = ROOT / "tests" / "fixtures" / "classic_session.jsonl"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ParseTests(unittest.TestCase):
|
|
15
|
+
@classmethod
|
|
16
|
+
def setUpClass(cls):
|
|
17
|
+
cls.parsed = ch.parse_session(FIXTURE)
|
|
18
|
+
|
|
19
|
+
def test_meta(self):
|
|
20
|
+
m = self.parsed["meta"]
|
|
21
|
+
self.assertEqual(m["session_id"], "abc123")
|
|
22
|
+
self.assertEqual(m["cwd"], "/home/vspapg/myapp")
|
|
23
|
+
self.assertEqual(m["git_branch"], "main")
|
|
24
|
+
self.assertIn("claude-sonnet-4-5", m["models"])
|
|
25
|
+
self.assertEqual(m["n_user"], 2) # meta + tool_result msgs excluded
|
|
26
|
+
self.assertEqual(m["n_assistant"], 4) # text-bearing records only
|
|
27
|
+
self.assertEqual(m["summaries"], ["Fix login bug in auth.py"])
|
|
28
|
+
|
|
29
|
+
def test_noise_filtered(self):
|
|
30
|
+
text = str(self.parsed["turns"])
|
|
31
|
+
self.assertNotIn("command-name", text) # slash-command envelope
|
|
32
|
+
self.assertNotIn("subagent chatter", text) # sidechain dropped
|
|
33
|
+
self.assertNotIn("Caveat:", text)
|
|
34
|
+
self.assertNotIn("3 passed", text) # tool result dropped
|
|
35
|
+
|
|
36
|
+
def test_activity_extraction(self):
|
|
37
|
+
self.assertIn("/home/vspapg/myapp/auth.py", self.parsed["files_written"])
|
|
38
|
+
self.assertTrue(any("pytest" in c for c in self.parsed["commands"]))
|
|
39
|
+
self.assertTrue(any("git add" in c for c in self.parsed["commands"]))
|
|
40
|
+
|
|
41
|
+
def test_turn_merging(self):
|
|
42
|
+
roles = [t["role"] for t in self.parsed["turns"]]
|
|
43
|
+
self.assertEqual(roles, ["user", "assistant", "user", "assistant"])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RenderTests(unittest.TestCase):
|
|
47
|
+
def setUp(self):
|
|
48
|
+
self.parsed = ch.parse_session(FIXTURE)
|
|
49
|
+
|
|
50
|
+
def test_deterministic_document(self):
|
|
51
|
+
doc = ch.build_deterministic(self.parsed, FIXTURE,
|
|
52
|
+
include_tools=True, max_chars=80_000)
|
|
53
|
+
for expected in ("# Conversation handoff", "## Session",
|
|
54
|
+
"## Files created / modified", "## Commands run",
|
|
55
|
+
"## Conversation", "auth.py", "🧑 User",
|
|
56
|
+
"🤖 Assistant", "<details>"):
|
|
57
|
+
self.assertIn(expected, doc)
|
|
58
|
+
|
|
59
|
+
def test_tools_hidden_by_default(self):
|
|
60
|
+
doc = ch.build_deterministic(self.parsed, FIXTURE,
|
|
61
|
+
include_tools=False, max_chars=80_000)
|
|
62
|
+
self.assertNotIn("<details>", doc)
|
|
63
|
+
|
|
64
|
+
def test_global_truncation_keeps_head_and_tail(self):
|
|
65
|
+
doc = ch.render_transcript(self.parsed, include_tools=False,
|
|
66
|
+
max_chars=150)
|
|
67
|
+
self.assertIn("omitted", doc)
|
|
68
|
+
self.assertIn("το login σπάει"[:10], doc) # opening survives
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class HelperTests(unittest.TestCase):
|
|
72
|
+
def test_truncate_short_passthrough(self):
|
|
73
|
+
self.assertEqual(ch.truncate("abc", 10), "abc")
|
|
74
|
+
|
|
75
|
+
def test_truncate_long_keeps_ends(self):
|
|
76
|
+
text = "A" * 500 + "MID" + "Z" * 500
|
|
77
|
+
out = ch.truncate(text, 100)
|
|
78
|
+
self.assertLess(len(out), len(text))
|
|
79
|
+
self.assertTrue(out.startswith("A"))
|
|
80
|
+
self.assertTrue(out.endswith("Z"))
|
|
81
|
+
self.assertIn("omitted", out)
|
|
82
|
+
|
|
83
|
+
def test_clean_text(self):
|
|
84
|
+
raw = "<system-reminder>noise</system-reminder>hello"
|
|
85
|
+
self.assertEqual(ch.clean_text(raw), "hello")
|
|
86
|
+
|
|
87
|
+
def test_tool_result_text_variants(self):
|
|
88
|
+
self.assertEqual(ch.tool_result_text({"content": " x "}), "x")
|
|
89
|
+
self.assertEqual(
|
|
90
|
+
ch.tool_result_text(
|
|
91
|
+
{"content": [{"type": "text", "text": "a"},
|
|
92
|
+
{"type": "text", "text": "b"}]}),
|
|
93
|
+
"a\nb")
|
|
94
|
+
self.assertEqual(ch.tool_result_text({"content": None}), "")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
unittest.main()
|