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.
@@ -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,2 @@
1
+ [console_scripts]
2
+ claude-handoff = claude_handoff:main
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()