diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1f42c03..abe99cc 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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, diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8de661f..3c5b144 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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" @@ -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 }} @@ -52,7 +52,7 @@ jobs: steps: - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: artifacts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d93cb1e..a60588a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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" diff --git a/.gitignore b/.gitignore index 78f01a8..83982cb 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/BINARY_USAGE.md b/BINARY_USAGE.md new file mode 100644 index 0000000..d86eadb --- /dev/null +++ b/BINARY_USAGE.md @@ -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//` | 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 + +# 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). diff --git a/README.md b/README.md index 786a0f5..70590cb 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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) diff --git a/format_conversations.py b/format_conversations.py index 61088b9..af4b8eb 100644 --- a/format_conversations.py +++ b/format_conversations.py @@ -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)") @@ -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(".") @@ -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 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( diff --git a/formatters/chatgpt.py b/formatters/chatgpt.py index 0c47ed0..16c42db 100644 --- a/formatters/chatgpt.py +++ b/formatters/chatgpt.py @@ -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) diff --git a/formatters/claude.py b/formatters/claude.py index 9b337f0..e2a30d1 100644 --- a/formatters/claude.py +++ b/formatters/claude.py @@ -91,8 +91,8 @@ def conv_to_html_body(conv: dict) -> str: "" ) elif btype == "text": - html = markdown_to_html(block.get("text", "")) - inner_parts.append(f'
{html}
') + rendered_html = markdown_to_html(block.get("text", "")) + inner_parts.append(f'
{rendered_html}
') # tool_use / tool_result blocks silently skipped inner = "\n".join(inner_parts) or "(empty)" parts.append( diff --git a/tests/test_regressions.py b/tests/test_regressions.py index c9b513b..bd43afd 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -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): @@ -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):