Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@
"Bash(echo \"=== hexstrike remaining routes ===\" && jq '.routes[] | \"\\\\\\(.method\\) \\\\\\(.path\\) -> \\\\\\(.handler\\)\"' /tmp/cbm-test/patched-hex-arch.json && echo \"\" && echo \"=== hexstrike remaining api-layer packages ===\" && jq '.layers[] | select\\(.layer == \"api\"\\) | \"\\\\\\(.name\\): \\\\\\(.reason\\)\"' /tmp/cbm-test/patched-hex-arch.json | head -5)",
"Bash(echo \"=== hexstrike baseline routes ===\" && jq '.routes[] | \"\\\\\\(.method\\) \\\\\\(.path\\) -> \\\\\\(.handler\\)\"' /tmp/cbm-test/baseline-hex-arch.json)",
"Bash(echo \"=== CBM baseline routes ===\" && jq '.routes[] | \"\\\\\\(.method\\) \\\\\\(.path\\) -> \\\\\\(.handler\\)\"' /tmp/cbm-test/baseline-cbm-arch.json)",
"Bash(rm -f ~/.cache/codebase-memory-mcp/lump-apps-*.db* && cp ~/.cache/codebase-memory-mcp/baseline-snapshot/*.db ~/.cache/codebase-memory-mcp/ 2>/dev/null; echo \"DBs restored\")"
"Bash(rm -f ~/.cache/codebase-memory-mcp/lump-apps-*.db* && cp ~/.cache/codebase-memory-mcp/baseline-snapshot/*.db ~/.cache/codebase-memory-mcp/ 2>/dev/null; echo \"DBs restored\")",
"Bash(gh release:*)",
"Bash(gh api:*)"
]
},
"enableAllProjectMcpServers": true,
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ jobs:
runs-on: ${{ matrix.os }}

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: "3.11"

Expand All @@ -37,7 +37,7 @@ jobs:
run: pyinstaller conv-tool.spec

- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.artifact }}
path: ${{ matrix.binary }}
Expand All @@ -52,7 +52,7 @@ jobs:

steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: artifacts

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: "3.11"

Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ conversations.json
user.json
*.zip
output/
output.bak/
*.zip
tmp/
tmp_*/
release_instructions.md

# Sample data — tracked in git (screenshots for README)
!sample_data/
Expand Down
95 changes: 95 additions & 0 deletions BINARY_USAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# conv-tool — Binary Usage

`conv-tool` is a self-contained executable (no Python required) that converts DeepSeek, Claude, and ChatGPT conversation exports into HTML, Markdown, or JSON.

## Download

Grab the latest binary for your platform from the [Releases page](https://github.com/ngallodev-software/conversation-export-workbench/releases/latest):

| Platform | File |
|---|---|
| Linux (x86_64) | `conv-tool-linux` |
| macOS | `conv-tool-macos` |
| Windows | `conv-tool-windows.exe` |

## Setup

**Linux / macOS** — make executable once:
```bash
chmod +x conv-tool-linux # or conv-tool-macos
```

**Windows** — double-click opens interactive mode, or run from a terminal:
```cmd
conv-tool-windows.exe [options]
```

## Interactive mode (recommended for first use)

Drop the binary in the same folder as your export `.zip` or `conversations.json`, then run with no arguments:

```bash
./conv-tool-linux
```

The tool scans for archives and JSON files, prompts before each action, and optionally builds the SPA viewer at the end.

## CLI reference

```
conv-tool [options]
```

| Option | Default | Description |
|---|---|---|
| `--input FILE` | `conversations.json` | Path to input JSON or `.zip` archive |
| `--output DIR` | `output/<provider>/` | Where to write output files |
| `--provider NAME` | auto-detected | Force provider: `deepseek` \| `claude` \| `chatgpt` |
| `--format FORMAT` | `html` | Output format: `html` \| `md` \| `json` |
| `--id ID` | all | Export only the conversation with this ID |
| `--list` | — | Print all conversations with IDs and exit |
| `--combined` | — | Write all conversations to a single file |
| `--yes` / `-y` | prompt | Overwrite existing files without prompting |

## Common examples

```bash
# List available conversations
./conv-tool-linux --list

# Export all as HTML (auto-detects provider)
./conv-tool-linux --format html --yes

# Export directly from a zip
./conv-tool-linux --input ~/Downloads/claude-export.zip --format html --yes

# Export a single conversation by ID
./conv-tool-linux --id <uuid>

# One combined HTML file for all conversations
./conv-tool-linux --format html --combined --yes

# Force provider when auto-detection is ambiguous
./conv-tool-linux --provider deepseek --format md
```

## SPA viewer

The binary also bundles the SPA generator. After exporting, build the interactive viewer:

```bash
./conv-tool-linux --spa --output output/ --yes
```

Then serve it (requires a real HTTP server for `fetch()`):

```bash
python3 -m http.server 8080 --directory output/
# Open http://localhost:8080
```

## Notes

- The binary is a PyInstaller bundle — no Python installation required.
- Config and template files (`config/`, `provider_templates/`) are embedded; no extra files needed alongside the binary.
- For source install, advanced configuration, or adding custom providers, see the [full README](README.md).
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
A Python CLI with no Python package dependencies that converts **DeepSeek**, **Claude (Anthropic)**, and **ChatGPT (OpenAI)** conversation exports into styled HTML, Markdown, or cleaned JSON — with a built-in interactive single-page viewer.

> **Quick start:** see [QUICKSTART.md](QUICKSTART.md)
> **Pre-built binary (no Python needed):** see [BINARY_USAGE.md](BINARY_USAGE.md)

![All providers view](readme_assets/sample-all.png)

Expand All @@ -27,7 +28,21 @@ A Python CLI with no Python package dependencies that converts **DeepSeek**, **C

---

## Requirements
## Download pre-built binary

No Python required. Grab the latest `conv-tool` binary for your platform from the [Releases page](https://github.com/ngallodev-software/conversation-export-workbench/releases/latest):

| Platform | File |
|---|---|
| Linux (x86_64) | `conv-tool-linux` |
| macOS | `conv-tool-macos` |
| Windows | `conv-tool-windows.exe` |

See [BINARY_USAGE.md](BINARY_USAGE.md) for setup and usage instructions.

---

## Requirements (source install)

- Python **3.10 or newer** (uses `match`-free type hints; no third-party packages)

Expand Down
56 changes: 43 additions & 13 deletions format_conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,17 +151,21 @@ def _extract_zip(zip_path: Path, cwd: Path) -> list[Path]:
return found


def _process_json(json_path: Path, fmt: str, yes: bool):
def _process_json(json_path: Path, fmt: str, yes: bool, allow_prompt: bool = True):
"""Load, detect, and export a single conversations.json."""
print(f"\nProcessing {json_path} …")
data = load_conversations(str(json_path))
fmt_mod = detect_provider(data)
if not fmt_mod:
print(f" Could not auto-detect provider.")
ans = input(" Enter provider [deepseek/claude/chatgpt]: ").strip().lower()
fmt_mod = {"deepseek": deepseek, "claude": claude, "chatgpt": chatgpt}.get(ans)
if not fmt_mod:
print(" Unknown provider — skipping.")
if allow_prompt and sys.stdin.isatty():
ans = input(" Enter provider [deepseek/claude/chatgpt]: ").strip().lower()
fmt_mod = {"deepseek": deepseek, "claude": claude, "chatgpt": chatgpt}.get(ans)
if not fmt_mod:
print(" Unknown provider — skipping.")
return
else:
print(" Non-interactive mode: skipping file. Use --provider with --input to force.")
return

print(f" Provider: {fmt_mod.PROVIDER} ({len(data)} conversations)")
Expand Down Expand Up @@ -208,6 +212,36 @@ def output_stem(conv: dict, idx: int, used: set[str]) -> str:
safe_write(out_path, content, yes)


def non_interactive_mode():
"""
Zero-args non-interactive mode:
auto-discover zips/conversations.json and process all with defaults.
"""
cwd = Path(".")
print("No arguments given — non-interactive auto mode (format=html, overwrite=yes).\n")

zips = sorted(cwd.glob("*.zip"))
json_candidates: list[Path] = []
if zips:
print(f"Found {len(zips)} zip file(s):")
for z in zips:
print(f" {z.name}")
json_candidates.extend(_extract_zip(z, cwd))
print()

all_json = list(cwd.glob("conversations.json"))
for p in json_candidates:
if p not in all_json:
all_json.append(p)

if not all_json:
print("No conversations.json files found. Nothing to do.")
return

for json_path in all_json:
_process_json(json_path, fmt="html", yes=True, allow_prompt=False)


def interactive_mode():
"""Zero-args interactive mode: discover zips and json files, prompt to process."""
cwd = Path(".")
Expand Down Expand Up @@ -271,14 +305,10 @@ def interactive_mode():
def main():
# Zero-args → interactive mode
if len(sys.argv) == 1:
if not sys.stdin.isatty():
print(
"No arguments provided and no interactive input is available. "
"Use --input <conversations.json|zip> and --yes for non-interactive runs.",
file=sys.stderr,
)
sys.exit(2)
interactive_mode()
if sys.stdin.isatty():
interactive_mode()
else:
non_interactive_mode()
return

parser = argparse.ArgumentParser(
Expand Down
6 changes: 3 additions & 3 deletions formatters/chatgpt.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,9 @@ def conv_to_html_body(conv: dict) -> str:
]

for msg in messages:
html = _message_to_html(msg)
if html:
parts.append(html)
message_html = _message_to_html(msg)
if message_html:
parts.append(message_html)

return "\n".join(parts)

Expand Down
4 changes: 2 additions & 2 deletions formatters/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ def conv_to_html_body(conv: dict) -> str:
"</div>"
)
elif btype == "text":
html = markdown_to_html(block.get("text", ""))
inner_parts.append(f'<div class="content">{html}</div>')
rendered_html = markdown_to_html(block.get("text", ""))
inner_parts.append(f'<div class="content">{rendered_html}</div>')
# tool_use / tool_result blocks silently skipped
inner = "\n".join(inner_parts) or "<em>(empty)</em>"
parts.append(
Expand Down
61 changes: 52 additions & 9 deletions tests/test_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
sys.path.insert(0, str(ROOT))

import format_conversations
from formatters import deepseek
from formatters import chatgpt, claude, deepseek


def test_load_conversations_accepts_zip_input(tmp_path: Path):
Expand All @@ -33,16 +33,59 @@ def test_deepseek_fragment_search_no_unboundlocalerror():
assert "https://example.com" in rendered


def test_main_zero_args_non_interactive_exits_cleanly(monkeypatch: pytest.MonkeyPatch, capsys):
monkeypatch.setattr(sys, "argv", ["format_conversations.py"])
monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
def test_claude_html_body_renders_without_unboundlocalerror():
conv = {
"name": "Claude Test",
"created_at": "2026-03-01T00:00:00",
"updated_at": "2026-03-01T00:01:00",
"chat_messages": [
{
"sender": "assistant",
"created_at": "2026-03-01T00:01:00",
"content": [{"type": "text", "text": "hello"}],
}
],
}
body = claude.conv_to_html_body(conv)
assert "Claude" in body
assert "hello" in body


def test_chatgpt_html_body_renders_without_unboundlocalerror():
conv = {
"id": "id-1",
"title": "ChatGPT Test",
"create_time": 1700000000.0,
"update_time": 1700000060.0,
"current_node": "node-1",
"mapping": {
"node-1": {
"parent": None,
"message": {
"author": {"role": "assistant"},
"weight": 1,
"create_time": 1700000060.0,
"content": {"content_type": "text", "parts": ["hello"]},
},
}
},
}
body = chatgpt.conv_to_html_body(conv)
assert "ChatGPT" in body
assert "hello" in body

with pytest.raises(SystemExit) as exc:
format_conversations.main()

assert exc.value.code == 2
stderr = capsys.readouterr().err
assert "No arguments provided and no interactive input is available." in stderr
def test_main_zero_args_non_interactive_dispatches_auto_mode(monkeypatch: pytest.MonkeyPatch):
called = {"auto": False}

def _auto():
called["auto"] = True

monkeypatch.setattr(format_conversations, "non_interactive_mode", _auto)
monkeypatch.setattr(sys, "argv", ["format_conversations.py"])
monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
format_conversations.main()
assert called["auto"] is True


def test_main_provider_prompt_skipped_non_interactive(monkeypatch: pytest.MonkeyPatch):
Expand Down