claudex-export 0.1.0__py3-none-any.whl

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,223 @@
1
+ Metadata-Version: 2.4
2
+ Name: claudex-export
3
+ Version: 0.1.0
4
+ Summary: Export Codex and Claude Code sessions with a Textual browser and prompt previews
5
+ Author: matplo
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/matplo/claudex-export
8
+ Project-URL: Repository, https://github.com/matplo/claudex-export
9
+ Project-URL: Issues, https://github.com/matplo/claudex-export/issues
10
+ Project-URL: Changelog, https://github.com/matplo/claudex-export/releases
11
+ Keywords: codex,claude,sessions,markdown,html,textual
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: markdown-it-py<5,>=3
27
+ Requires-Dist: Pygments<3,>=2.17
28
+ Requires-Dist: textual<7,>=6.12
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest<10,>=8; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # Claudex Export
34
+
35
+ Export local Codex and Claude Code conversations to readable **standalone HTML**
36
+ or **Markdown**. Run `claudex-export`
37
+ without a filename to browse both providers in a full-screen Textual picker.
38
+ Preview the first and last 10 user prompts before choosing a session.
39
+
40
+ Requires Python 3.10 or later. No API key or service is needed.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ python -m pip install claudex-export
46
+ ```
47
+
48
+ Or install it as an isolated command-line application with
49
+ `pipx install claudex-export`. The commands `claudex-export`, `claude-export`,
50
+ `codex-export`, and the compatibility alias `session-export` launch the same
51
+ tool and support both providers. Python and its dependencies are sufficient; `henv` is
52
+ only used for development in this repository.
53
+
54
+ ## Usage
55
+
56
+ ```bash
57
+ # Browse and search local sessions; HTML is the default
58
+ claudex-export
59
+
60
+ # Browse only Claude Code sessions
61
+ claudex-export --source claude
62
+
63
+ # Auto-detect a Claude Code file and export both formats
64
+ claudex-export /path/to/claude-session.jsonl --format both -o exports
65
+
66
+ # Export a specified rollout
67
+ claudex-export /path/to/rollout.jsonl -o conversation.html
68
+
69
+ # Markdown, without tool calls/results
70
+ claudex-export /path/to/rollout.jsonl --format md --no-tools -o conversation.md
71
+
72
+ # Both formats, into a directory
73
+ claudex-export /path/to/rollout.jsonl --format both -o exports
74
+
75
+ # Include archived sessions, or use another Codex home
76
+ claudex-export --include-archived
77
+ claudex-export --codex-home /path/to/.codex
78
+ ```
79
+
80
+ The Textual picker supports arrow keys, Page Up/Down, and mouse navigation.
81
+ Press `/` to focus live search; search matches titles, directories, IDs, and
82
+ provider names. Enter in the search box returns focus to the results.
83
+
84
+ | Key | Action |
85
+ | --- | --- |
86
+ | `↑` / `↓` | Move between sessions |
87
+ | `Enter` on a session | Select and export |
88
+ | `v` | Preview the selected session's user prompts |
89
+ | `Esc` / `v` in preview | Return to the list |
90
+ | `e` in preview | Select and export the previewed session |
91
+ | `/` | Focus search |
92
+ | `Esc` in list | Clear search and focus the list |
93
+ | `q` / `Ctrl+C` | Cancel |
94
+
95
+ Previews are scrollable and loaded in a background worker. They show complete
96
+ prompt text with original numbering and timestamps: the first 10 and last 10,
97
+ without duplicates where those ranges overlap. For longer sessions a marker
98
+ shows how many middle prompts were omitted. Tool results, injected context, and
99
+ compaction summaries are excluded from the prompt preview. Images are counted,
100
+ not opened. Reading a preview does not write an export.
101
+
102
+ Use `--plain-picker` for the original numbered terminal picker (`/search`,
103
+ `n`/`p`, number to select, `q` to cancel); prompt previews require the Textual
104
+ picker. Without an interactive terminal, supply an explicit input file.
105
+
106
+ By default, exports are written to the current directory with a sanitized title
107
+ and session ID in the filename. `-o` is a file path for a single format and a
108
+ directory for `--format both`. Parent directories are created as needed.
109
+ Existing files require `--force`; the source session is never overwritten.
110
+
111
+ ## Supported content
112
+
113
+ - Native Codex rollout and Claude Code `.jsonl` files, containing one JSON
114
+ record per line. File content determines the format automatically; use
115
+ `--source codex` or `--source claude` to specify it explicitly.
116
+ - `.json` containing a single native session record or an array of records.
117
+ Codex records use `type`/`payload`; Claude Code uses `type`/`message` and
118
+ session metadata. Arbitrary chat JSON formats are not supported.
119
+ - User messages, assistant progress and final replies, tool inputs/results,
120
+ and markers for compaction/interruption, in recorded order.
121
+ - Native transcript records take priority over matching completion events.
122
+ Duplicates are matched by IDs and by content within each turn, one occurrence
123
+ at a time. Separate repeated messages remain present.
124
+ - System/developer messages, reasoning, usage records, and replacement history
125
+ from compaction are omitted. Instructions stored as actual **user** messages
126
+ remain part of the transcript.
127
+ - Tools invoked inside an orchestration tool may appear as separate execution
128
+ events as well as in that tool's output: these describe nested operations.
129
+ - Claude Code tool-use blocks pair with tool results by tool-use ID. Replayed
130
+ UUIDs are deduplicated while separate blocks sharing a message ID survive.
131
+ Thinking blocks, metadata, and compacted summaries are omitted. End-of-turn
132
+ replies use the light blue final-answer styling.
133
+
134
+ HTML includes responsive styling, highlighted code, tables, links, message
135
+ anchors, collapsed tool details, expand/collapse buttons, and a print button.
136
+ It uses embedded CSS/JavaScript and works offline. Printing expands tool details.
137
+ Markdown keeps conversation formatting and uses fenced blocks for tool data.
138
+
139
+ Embedded PNG, JPEG, GIF, and WebP data images are preserved. Other image
140
+ references become placeholders; the exporter does not fetch remote images or
141
+ read image paths from the transcript. Raw HTML is escaped in conversation prose;
142
+ HTML exports restrict scripts and resource loads with a Content Security Policy.
143
+
144
+ Codex discovery respects `--codex-home`, then `CODEX_HOME`, then `~/.codex`.
145
+ It scans `sessions/` (and optionally `archived_sessions/`) and uses
146
+ `session_index.jsonl` titles when available. Sessions are sorted by recent
147
+ activity. Each export covers one file; child/subagent sessions are not recursively
148
+ included. Claude Code discovery respects `--claude-home`, then
149
+ `CLAUDE_CONFIG_DIR`, then `~/.claude`, and reads main session files under
150
+ `projects/`. Custom or automatic session titles take priority over the first
151
+ prompt. Subagent logs are excluded from discovery; pass a subagent file explicitly
152
+ to export it. `--source all` (the default) combines both providers; archived
153
+ session inclusion applies to Codex. Source files and databases are never modified.
154
+
155
+ Unknown record types and malformed JSONL lines produce warnings. An incomplete
156
+ final line in a running session is skipped; rerun the export later to include it.
157
+ Local session formats can evolve, so inspect export notes when a newer record type
158
+ appears. Exports contain the selected conversation and tool data as stored;
159
+ there is no automatic secret redaction.
160
+
161
+ ## Development
162
+
163
+ ```bash
164
+ henv -n export_codex_session_dev -x python -m pip install -e '.[dev]'
165
+ henv -n export_codex_session_dev -x python -m pytest
166
+ ```
167
+
168
+ Tests use synthetic sessions, including headless Textual keyboard/preview tests.
169
+ Keep private session files and generated exports
170
+ out of source control; `exports/` is ignored for local previews.
171
+
172
+ ## Releases to PyPI
173
+
174
+ Pushing a version tag triggers `.github/workflows/release.yml`. It runs tests
175
+ on Python 3.10–3.14, builds an sdist and wheel, checks package metadata, and
176
+ smoke-tests an installation of the built wheel. The tag must exactly match
177
+ `v` followed by the package version. Only then does it publish to PyPI and
178
+ create a GitHub release with both distribution files attached.
179
+
180
+ ### One-time PyPI setup
181
+
182
+ In your PyPI account, open [Publishing](https://pypi.org/manage/account/publishing/)
183
+ and add a **pending GitHub publisher** with these exact values:
184
+
185
+ | Field | Value |
186
+ | --- | --- |
187
+ | PyPI project name | `claudex-export` |
188
+ | GitHub owner | `matplo` |
189
+ | Repository | `claudex-export` |
190
+ | Workflow filename | `release.yml` |
191
+ | Environment name | `pypi` |
192
+
193
+ If the PyPI project already exists in your account, add the same publisher
194
+ under that project's Publishing settings. The GitHub workflow uses OIDC
195
+ Trusted Publishing; no `PYPI_API_TOKEN` secret is needed. The GitHub environment
196
+ `pypi` must not require reviewer approval if you want fully automatic releases.
197
+ The PyPI project is created by the first successful publish, not by registering
198
+ the pending publisher.
199
+
200
+ ### Publish a version
201
+
202
+ Set `__version__` in `src/codex_export/__init__.py` (the single version source),
203
+ commit the change, and push a matching tag. For the initial `0.1.0` release:
204
+
205
+ ```bash
206
+ henv -n export_codex_session_dev -x git push origin main
207
+ henv -n export_codex_session_dev -x git tag -a v0.1.0 -m 'Release 0.1.0'
208
+ henv -n export_codex_session_dev -x git push origin v0.1.0
209
+ ```
210
+
211
+ For later releases, change the version and tag together. Prereleases such as
212
+ `0.2.0rc1` use `v0.2.0rc1`. PyPI versions are immutable: publish a new version
213
+ for changed artifacts. If publishing fails before upload, fix the configuration
214
+ and rerun the failed workflow jobs. If only GitHub release creation fails after
215
+ PyPI succeeds, rerun that failed job rather than publishing again.
216
+
217
+ See [PyPI's Trusted Publishing documentation](https://docs.pypi.org/trusted-publishers/)
218
+ for account setup details. Ordinary pushes to `main` and pull requests run the
219
+ same tests and packaging checks without publishing.
220
+
221
+ ## License
222
+
223
+ MIT; see [LICENSE](LICENSE).
@@ -0,0 +1,16 @@
1
+ claudex_export-0.1.0.dist-info/licenses/LICENSE,sha256=FdvapQASUK3J-7zKooV8mCVgxSTQvLoNS4JV7YAiDvw,1063
2
+ codex_export/__init__.py,sha256=9R4bR64UPYgLlZHQL12l1leIMILZWmL6haZegVRD2Ys,90
3
+ codex_export/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
4
+ codex_export/claude.py,sha256=f-hAdOYkBtfLzuTWy3pYoB_U32INEWY4NHMxVXVQETM,6412
5
+ codex_export/cli.py,sha256=y9yb7K4ebDoyUzMNXkV65DQmLFVHXMJpDXi8UOyUjLs,6372
6
+ codex_export/controls.js,sha256=3dGmFcrBSAKakF3XDCDsJtas7aVUKPjJTd2M5KvAg_E,807
7
+ codex_export/discovery.py,sha256=8xSNHGpyxoW7sm4dEhuQTZzRw398V-QPU7vB2OYZXVI,8430
8
+ codex_export/render.py,sha256=Rc_PNjHYCLjgUtVnri-OIXQ3uMURAlKmN9UmIiNkWX4,12298
9
+ codex_export/session.py,sha256=mTkASj_jBBMH1PTh0WHLhJDhoPvgzQt89ThcQdmFTww,15291
10
+ codex_export/style.css,sha256=bl4-sZWUM6CAy6n6TttQAoLoMXlbRt3hwKaw4rAeQ2M,4801
11
+ codex_export/tui.py,sha256=vSJUuY9jkGyeS5_VStr7PHsfW7pIJ0xsEWEbIfwnDE8,7354
12
+ claudex_export-0.1.0.dist-info/METADATA,sha256=di_1tadtP8XdrMLpmfHRttpnYX6g2wNlLoHyXrPcjgI,10129
13
+ claudex_export-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
14
+ claudex_export-0.1.0.dist-info/entry_points.txt,sha256=pPngFgo6bEoAO8St2fYq-TeQ0s00v70NHpdKthqXzk0,171
15
+ claudex_export-0.1.0.dist-info/top_level.txt,sha256=AzRVAIGQm38psLpvQo2ekzQVOldgc0PHjtIvU943xYA,13
16
+ claudex_export-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ claude-export = codex_export.cli:main
3
+ claudex-export = codex_export.cli:main
4
+ codex-export = codex_export.cli:main
5
+ session-export = codex_export.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 matplo
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 @@
1
+ codex_export
@@ -0,0 +1,3 @@
1
+ """Claudex Export: local Codex and Claude Code session exports."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
codex_export/claude.py ADDED
@@ -0,0 +1,125 @@
1
+ """Normalize Claude Code's local JSONL records into the shared transcript."""
2
+
3
+ from collections import Counter
4
+ from pathlib import Path
5
+
6
+ from .session import Entry, Session, SessionError, fallback_title, iter_records, pretty
7
+
8
+
9
+ def parts(content) -> tuple[str, list[str]]:
10
+ if isinstance(content, str):
11
+ return content, []
12
+ text, images = [], []
13
+ for block in content if isinstance(content, list) else []:
14
+ if not isinstance(block, dict):
15
+ continue
16
+ kind = block.get("type")
17
+ if kind == "text":
18
+ text.append(str(block.get("text", "")))
19
+ elif kind == "image":
20
+ source = block.get("source", {})
21
+ if isinstance(source, dict):
22
+ images.append(f"data:{source.get('media_type', '')};base64,{source.get('data', '')}" if source.get("type") == "base64" else str(source.get("url", "")))
23
+ elif kind not in {"thinking", "redacted_thinking", "tool_use", "tool_result"}:
24
+ text.append(f"[Unsupported content: {kind or 'unknown'}]")
25
+ return "\n\n".join(text), images
26
+
27
+
28
+ def read_claude_session(path: Path) -> Session:
29
+ session = Session(source=path, id=path.stem, provider="claude")
30
+ seen, calls, results = set(), {}, {}
31
+ ignored = Counter()
32
+ metadata = {"attachment", "file-history-snapshot", "file-history-delta", "queue-operation", "mode", "permission-mode", "bridge-session", "agent-name", "last-prompt", "atis-latch", "pr-link", "cost-state", "progress"}
33
+ count, custom_title = 0, ""
34
+ for position, record in enumerate(iter_records(path, session.warnings, require_payload=False)):
35
+ if "payload" in record:
36
+ raise SessionError("This file contains Codex records. Omit --source claude to auto-detect its format.")
37
+ count += 1
38
+ kind = record.get("type")
39
+ session.id = str(record.get("sessionId") or session.id)
40
+ session.cwd = str(record.get("cwd") or session.cwd)
41
+ timestamp = str(record.get("timestamp") or "")
42
+ if timestamp and not session.timestamp:
43
+ session.timestamp = timestamp
44
+ if kind in {"ai-title", "custom-title", "summary"}:
45
+ title = record.get("customTitle") or record.get("aiTitle") or record.get("summary")
46
+ if title:
47
+ session.title = str(title)
48
+ if kind == "custom-title":
49
+ custom_title = str(title)
50
+ continue
51
+ identity = str(record.get("uuid") or "")
52
+ if identity:
53
+ if identity in seen:
54
+ continue
55
+ seen.add(identity)
56
+ common = dict(timestamp=timestamp, position=position, id=identity)
57
+ if kind == "system":
58
+ if record.get("subtype") == "compact_boundary":
59
+ session.entries.append(Entry(kind="marker", text="Context compacted", **common))
60
+ continue
61
+ if kind == "continued-in":
62
+ session.entries.append(Entry(kind="marker", text=f"Conversation continued in session {record.get('continuedInSessionId', 'unknown')} (separate export).", **common))
63
+ continue
64
+ if kind in metadata:
65
+ continue
66
+ if kind not in {"user", "assistant"}:
67
+ ignored[str(kind)] += 1
68
+ continue
69
+ message = record.get("message")
70
+ if not isinstance(message, dict):
71
+ session.warnings.append(f"Skipped Claude record {position + 1}: message must be an object.")
72
+ continue
73
+ if record.get("isMeta") or record.get("isCompactSummary"):
74
+ continue
75
+ content = message.get("content", [])
76
+ blocks = content if isinstance(content, list) else [{"type": "text", "text": str(content)}]
77
+ # A Claude message ID may span multiple records/blocks. Only record UUIDs
78
+ # identify replayed records; deduplicating message IDs loses content.
79
+ phase = "final_answer" if message.get("stop_reason") in {"end_turn", "stop_sequence"} else "commentary"
80
+ for block in blocks:
81
+ if not isinstance(block, dict):
82
+ continue
83
+ block_type = block.get("type")
84
+ if block_type in {"thinking", "redacted_thinking"}:
85
+ continue
86
+ if block_type == "tool_use":
87
+ call_id = str(block.get("id") or "")
88
+ entry = Entry(kind="tool", name=str(block.get("name") or "Tool"), text=pretty(block.get("input", {})), call_id=call_id, **common)
89
+ if call_id and call_id in calls:
90
+ continue
91
+ if call_id:
92
+ calls[call_id] = entry
93
+ session.entries.append(entry)
94
+ elif block_type == "tool_result":
95
+ text, images = parts(block.get("content", ""))
96
+ if block.get("is_error"):
97
+ text = "Tool error\n" + text
98
+ entry = Entry(kind="tool", name="Unmatched tool result", result=text, images=images, call_id=str(block.get("tool_use_id") or ""), **common)
99
+ if entry.call_id:
100
+ results[entry.call_id] = entry
101
+ else:
102
+ session.entries.append(entry)
103
+ else:
104
+ text, images = parts([block])
105
+ if text or images:
106
+ # Adjacent content blocks in the same record form one message.
107
+ previous = session.entries[-1] if session.entries else None
108
+ if previous and previous.kind == kind and previous.position == position:
109
+ previous.text = "\n\n".join(filter(None, (previous.text, text)))
110
+ previous.images.extend(images)
111
+ else:
112
+ session.entries.append(Entry(kind=kind, text=text, images=images, phase=phase if kind == "assistant" else "", **common))
113
+ if not count:
114
+ raise SessionError("No valid Claude Code session records found.")
115
+ for call_id, result in results.items():
116
+ if call_id in calls:
117
+ calls[call_id].result = result.result
118
+ calls[call_id].images.extend(result.images)
119
+ else:
120
+ session.entries.append(result)
121
+ session.entries.sort(key=lambda e: e.position)
122
+ session.title = custom_title or session.title or fallback_title(session)
123
+ for kind, number in sorted(ignored.items()):
124
+ session.warnings.append(f"Skipped {number} unsupported Claude {kind} record(s).")
125
+ return session
codex_export/cli.py ADDED
@@ -0,0 +1,118 @@
1
+ """Command-line interface for Claudex Export and its aliases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ from pathlib import Path
8
+ import re
9
+ import sys
10
+ import tempfile
11
+ import unicodedata
12
+
13
+ from . import __version__
14
+ from .discovery import claude_home, codex_home, discover_all, pick, read_index, terminal_text
15
+ from .render import render_html, render_markdown
16
+ from .session import Session, SessionError, read_session
17
+
18
+
19
+ def parser() -> argparse.ArgumentParser:
20
+ result = argparse.ArgumentParser(description="Export a local Codex or Claude Code session to offline HTML or Markdown. Omit SESSION_FILE to browse local sessions.")
21
+ result.add_argument("session_file", nargs="?", type=Path, metavar="SESSION_FILE")
22
+ result.add_argument("--format", choices=("html", "md", "both"), default="html", help="output format (default: html)")
23
+ result.add_argument("-o", "--output", type=Path, help="output file; for --format both, an output directory")
24
+ result.add_argument("--no-tools", action="store_true", help="omit tool calls and results")
25
+ result.add_argument("--codex-home", type=Path, metavar="DIRECTORY", help="override CODEX_HOME (default: ~/.codex)")
26
+ result.add_argument("--claude-home", type=Path, metavar="DIRECTORY", help="override CLAUDE_CONFIG_DIR (default: ~/.claude)")
27
+ result.add_argument("--source", choices=("all", "codex", "claude"), default="all", help="filter picker or specify input format (default: all / auto-detect)")
28
+ result.add_argument("--plain-picker", action="store_true", help="use the original numbered picker instead of Textual")
29
+ result.add_argument("--include-archived", action="store_true", help="include archived sessions in the picker")
30
+ result.add_argument("--force", action="store_true", help="replace existing output files")
31
+ result.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
32
+ return result
33
+
34
+
35
+ def slug(text: str, limit: int) -> str:
36
+ text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode().lower()
37
+ return re.sub(r"[^a-z0-9]+", "-", text).strip("-")[:limit].rstrip("-") or "session"
38
+
39
+
40
+ def destinations(session: Session, format: str, output: Path | None) -> list[tuple[Path, str]]:
41
+ name = f"{slug(session.title, 70)}-{slug(session.id, 80)}"
42
+ formats = ("html", "md") if format == "both" else (format,)
43
+ if output and format != "both":
44
+ return [(output.expanduser(), format)]
45
+ directory = output.expanduser() if output else Path.cwd()
46
+ if directory.exists() and not directory.is_dir():
47
+ raise SessionError(f"Expected an output directory: {directory}")
48
+ return [(directory / f"{name}.{extension}", extension) for extension in formats]
49
+
50
+
51
+ def write_exports(session: Session, targets: list[tuple[Path, str]], include_tools: bool, force: bool) -> list[Path]:
52
+ # Check every destination before writing either format.
53
+ for path, _ in targets:
54
+ if path.resolve() == session.source.resolve() or (path.exists() and os.path.samefile(path, session.source)):
55
+ raise SessionError("Output cannot replace the source session, even with --force.")
56
+ if path.is_symlink():
57
+ raise SessionError(f"Refusing to replace a symbolic link: {path}")
58
+ if path.exists() and (not force or not path.is_file()):
59
+ raise SessionError(f"Output already exists: {path}. Use --force to replace a file.")
60
+ rendered = [(path, render_html(session, include_tools) if format == "html" else render_markdown(session, include_tools)) for path, format in targets]
61
+ written = []
62
+ for path, content in rendered:
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ # Stage on the destination filesystem. Link provides exclusive creation
65
+ # without a check/write race; replace handles explicit overwrites.
66
+ descriptor, temporary = tempfile.mkstemp(prefix=".codex-export-", dir=path.parent)
67
+ try:
68
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
69
+ stream.write(content)
70
+ if force:
71
+ os.replace(temporary, path)
72
+ else:
73
+ os.link(temporary, path)
74
+ written.append(path.resolve())
75
+ finally:
76
+ Path(temporary).unlink(missing_ok=True)
77
+ return written
78
+
79
+
80
+ def main(argv: list[str] | None = None) -> int:
81
+ args = parser().parse_args(argv)
82
+ home = codex_home(args.codex_home)
83
+ try:
84
+ selected = None
85
+ source = args.session_file
86
+ if source is None:
87
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
88
+ raise SessionError("No session file supplied. In a noninteractive terminal, pass a .jsonl or .json path: claudex-export SESSION_FILE")
89
+ candidates, warnings = discover_all(home, claude_home(args.claude_home), args.source, args.include_archived)
90
+ for warning in warnings:
91
+ print(f"Warning: {terminal_text(warning)}", file=sys.stderr)
92
+ if not candidates:
93
+ raise SessionError("No local sessions found. Supply a session file or use --codex-home / --claude-home.")
94
+ if args.plain_picker:
95
+ selected = pick(candidates)
96
+ else:
97
+ from .tui import pick_tui
98
+ selected = pick_tui(candidates)
99
+ if selected is None:
100
+ print("Cancelled; no export written.")
101
+ return 0
102
+ source = selected.path
103
+ session = read_session(source.expanduser(), "auto" if args.source == "all" else args.source)
104
+ indexed = read_index(home).get(session.id, {}).get("thread_name") if session.provider == "codex" else None
105
+ session.title = str(indexed or (selected.title if selected else session.title))
106
+ targets = destinations(session, args.format, args.output)
107
+ paths = write_exports(session, targets, not args.no_tools, args.force)
108
+ for warning in session.warnings:
109
+ print(f"Warning: {terminal_text(warning)}", file=sys.stderr)
110
+ for path in paths:
111
+ print(f"Exported: {terminal_text(str(path))}")
112
+ return 0
113
+ except KeyboardInterrupt:
114
+ print("\nCancelled.", file=sys.stderr)
115
+ return 130
116
+ except (OSError, UnicodeError, SessionError) as exc:
117
+ print(f"Error: {terminal_text(str(exc))}", file=sys.stderr)
118
+ return 1
@@ -0,0 +1,15 @@
1
+ document.getElementById('expand')?.addEventListener('click', () => {
2
+ document.querySelectorAll('details.tool').forEach(item => { item.open = true; });
3
+ });
4
+ document.getElementById('collapse')?.addEventListener('click', () => {
5
+ document.querySelectorAll('details.tool').forEach(item => { item.open = false; });
6
+ });
7
+ document.getElementById('print')?.addEventListener('click', () => { window.print(); });
8
+ let previouslyOpen = [];
9
+ window.addEventListener('beforeprint', () => {
10
+ previouslyOpen = [...document.querySelectorAll('details.tool')].map(item => item.open);
11
+ document.querySelectorAll('details.tool').forEach(item => { item.open = true; });
12
+ });
13
+ window.addEventListener('afterprint', () => {
14
+ document.querySelectorAll('details.tool').forEach((item, i) => { item.open = previouslyOpen[i]; });
15
+ });