Skip to main content

Swiss-army knife for migrating coding-agent harness sessions (and their MCP/agent config) between OpenCode, Claude Code, and VS Code

Project description

harness2go

Tests PyPI

A swiss-army knife for migrating coding-agent harness sessions — and their MCP server / subagent config — between OpenCode, Claude Code, and VS Code (GitHub Copilot Chat).

h2go opencode2claude <list|convert|import-global> ...
h2go claude2opencode <list|convert|import-global> ...
h2go vscode2opencode <list|convert> ...
h2go vscode2claude   <list|convert> ...
h2go opencode2vscode <list|convert> ...
h2go claude2vscode   <list|convert> ...

h2go is a thin dispatcher over the six directions, each of which is also installed as its own standalone command (opencode2claude, claude2opencode, vscode2opencode, vscode2claude, opencode2vscode, claude2vscode) for anyone who only ever migrates one way. They share small harness_common.py / vscode_common.py modules (JSONC parsing, frontmatter parsing, secret masking, interactive prompts, VS Code session decoding/encoding) so behavior stays consistent across all of them.

Installation

Zero runtime dependencies (plain standard library) — a regular Python package, installable with either pip or uv.

From PyPI (once published — see Publishing below):

uv tool install harness2go
# or
pip install harness2go
# or, without installing anything persistently:
uvx --from harness2go h2go opencode2claude list

From source, without waiting on a release — as a global CLI tool (puts h2go and the six standalone commands on your PATH):

uv tool install .          # from a clone of this repo
# or, without cloning:
uv tool install git+https://github.com/thiagojesus/harness2go

pip-only equivalent (installs into whichever environment is currently active — a fresh pipx install . also works if you have pipx):

pip install .

For development (editable install, so local edits take effect immediately without reinstalling):

uv venv && source .venv/bin/activate && uv pip install -e .
# or: python3 -m venv .venv && source .venv/bin/activate && pip install -e .

python -m unittest discover -s tests -p "test_*.py"

Then from anywhere:

h2go opencode2claude list
h2go claude2vscode convert <session-uuid>

opencode2claude — OpenCode → Claude Code

Converts an OpenCode session into a Claude Code session transcript (.jsonl) that Claude Code can list and --resume.

OpenCode's real conversation data lives in a SQLite DB at ~/.local/share/opencode/opencode.db (the session, message, and part tables) — not in the small per-session status stub files some OpenCode wrappers leave lying around. This script reads directly from that DB (read-only — it never writes to opencode.db).

# List every OpenCode session available to convert
opencode2claude list

# Convert one session (writes into ~/.claude/projects/<slug>/<new-uuid>.jsonl)
opencode2claude convert ses_XXXXXXXXXXXX

# Convert everything
opencode2claude convert --all

# Preview without writing anything
opencode2claude convert ses_XXXXXXXXXXXX --dry-run

After converting, resume the session from the project directory it belongs to:

cd /path/to/project && claude --resume <printed-uuid>

After converting a single session (interactively, not --all), a wizard offers to also import:

  • MCP servers configured for that OpenCode project (global + project-level opencode.json(c)) via the real claude mcp add CLI, at local/project/user scope, one server at a time.
  • Agents referenced in the session (assistant turns + task tool subagent_type calls) as Claude Code subagent stubs (.claude/agents/*.md, local or global scope). These are honest placeholders — OpenCode's real system-prompt text for built-in/plugin agents (Sisyphus, Oracle, Librarian, ...) is compiled into a third-party plugin bundle and isn't recoverable from the session, so the stub only carries what's genuinely observable (name, usage count, task descriptions, best-effort model from oh-my-openagent.json) and says so.

Control it with convert ... --wizard {auto,always,never} (default auto: runs only for a single, non-dry-run conversion in an interactive terminal).

opencode2claude import-global

Same MCP/agent import wizard, but independent of any session or opencode.db: MCP servers come from OpenCode's global config only (~/.config/opencode/opencode.jsonc); agents come from ~/.config/opencode/agent/*.md — real files the user actually authored, copied verbatim — plus any additional agent names known only via oh-my-openagent.json's model-routing config (stub fidelity; a real file always takes precedence over a stub for the same agent name).

claude2opencode — Claude Code → OpenCode

The mirror image. Converts a Claude Code session (.jsonl transcript) into a real OpenCode session — inserted into opencode.db — and imports Claude Code's MCP servers / subagent definitions into OpenCode's own config.

# List every Claude Code session available to convert
claude2opencode list

# Convert one session (by UUID, searched across ~/.claude/projects/*,
# or pass a direct .jsonl path)
claude2opencode convert <session-uuid>

# Preview without writing anything
claude2opencode convert <session-uuid> --dry-run

This one writes to a live application database, which is a materially riskier operation than writing a plain session file — so convert always takes a timestamped backup of opencode.db (opencode.db.bak-<epoch>) before inserting anything, and does the whole insert in a single transaction.

Like the other direction, converting a single session offers a wizard to import the MCP servers configured for that Claude Code project (Claude's local + project scope, from ~/.claude.json and .mcp.json) and any project-level Claude agents (.claude/agents/*.md) into OpenCode's project-level config. import-global does the session-independent version: Claude Code's user-scope MCP servers (~/.claude.json top-level mcpServers) and global agents (~/.claude/agents/*.md, copied verbatim — these are real files the user wrote, so unlike the Claude-Code-agent-stub direction there's no lesser-fidelity fallback needed here).

Writing into OpenCode's opencode.jsonc also takes a backup first, and warns if the existing file has ////* */ comments — comments aren't preserved across a rewrite (no dependency-free JSONC writer exists to keep them), so the backup is the safety net.

Design notes

  • Claude Code's transcript is per-content-block JSONL lines chained by parentUuid; OpenCode is structured SQLite rows. claude2opencode regroups consecutive assistant lines sharing the same message.id back into one OpenCode message (one step, bounded by synthesized step-start/step-finish parts), and reunites each tool_use block with its later tool_result line into a single OpenCode tool part — the exact inverse of how opencode2claude split things apart.
  • thinking blocks become OpenCode reasoning parts (the cryptographic signature is dropped — it isn't meaningful outside Anthropic's API and OpenCode has no equivalent concept).
  • Tool name mapping is the literal inverse of opencode2claude's (Bashbash, Readread, Editedit, Tasktask, AskUserQuestionquestion, TodoWritetodowrite, TaskOutputbackground_output); anything else passes through lowercased by name.
  • The session row reuses OpenCode's existing project row (OpenCode appears to key everything off a single project per install rather than one per directory — a fresh project row is only created if none exists at all).

vscode2opencode / vscode2claude — VS Code (Copilot Chat) → OpenCode/Claude Code

VS Code (via the GitHub Copilot Chat extension) stores chat sessions as an append-only "operation log" — not a simple flat file. The first line is a full JSON snapshot (kind: 0); later lines are Set/Push/Delete patches against explicit object paths (verified against VS Code's own source, chatSessionStore.ts/objectMutationLog.ts in microsoft/vscode). Sessions live in two places:

  • No-folder ("empty window") chats: ~/Library/Application Support/Code/User/globalStorage/emptyWindowChatSessions/<uuid>.json[l]
  • Per-project chats: ~/Library/Application Support/Code/User/workspaceStorage/<hash>/chatSessions/<uuid>.json[l] (the hash's workspace.json maps it to a folder)

vscode_common.py replays that log and normalizes it into the same canonical "turns" shape claude2opencode.py's transcript parser produces, so both converters build on one shared reader.

vscode2opencode list
vscode2opencode convert <session-uuid> [--directory DIR] [--dry-run]

vscode2claude list
vscode2claude convert <session-uuid> [--directory DIR] [--dry-run]

--directory is required (or falls back to the current working directory with a warning) for no-folder sessions, which have no natural project association.

Scope — core content only. A single VS Code response can contain any of ~35 distinct content-block kinds (progress messages, mcpServersStarting notices, confirmations, todoList widgets, terminal/ notebook/file-edit blocks, pull requests, ...). Only the substantive ones are mapped — text/markdown, thinking, and toolInvocationSerialized — the same content OpenCode/Claude Code already model; everything else is UI chrome, dropped the same way opencode2claude.py drops OpenCode's step-start/step-finish structural markers.

Two things worth knowing:

  • VS Code's own tool names (run_in_terminal, copilot_readFile, ...) don't match either target's tool vocabulary, so they pass through verbatim (lowercased on the OpenCode side, unchanged on the Claude Code side) rather than being misleadingly relabeled — an accepted limitation of "core content only," not a bug. Likewise, a tool's persisted "input" is often just VS Code's human-readable invocation message (e.g. "Reading file.py"), not the tool's real structured arguments — that's what's actually available in the serialized log.
  • thinking blocks become plain text (Claude Code target) or reasoning (OpenCode target) — never a real Anthropic thinking block — since these turns frequently aren't even from an Anthropic model (VS Code routes through GPT/Gemini/etc. too), so a fabricated signature would be actively wrong, not just unnecessary.
  • On the Claude Code side specifically: message ids are always freshly synthesized as msg_<hex>, never VS Code's own request_<uuid> — Claude Code sends the last assistant message's id back to the API as a previous_message_id diagnostic on resume, and the API rejects anything not shaped like a real response id. (Discovered by testing an actual resume against a converted session — the API returned a 400 until this was fixed.)

opencode2vscode / claude2vscode — OpenCode/Claude Code → VS Code (Copilot Chat)

The reverse of vscode2opencode.py/vscode2claude.py: writes a real, openable VS Code chat session.

opencode2vscode convert ses_XXXXXXXXXXXX [--dry-run]
claude2vscode convert <session-uuid> [--dry-run]

This one needs more than a session file. VS Code's Chat view discovers sessions purely through an index kept in state.vscdb (its general-purpose settings store) — confirmed by reading chatSessionStore.ts's actual load path, which has no fallback that scans the session folder. A session file dropped in without a matching index entry is invisible to the UI. So convert:

  • Writes the session file (.json) in the right location.
  • Merges a matching entry into state.vscdb's chat.ChatSessionStore.index key (an ItemTable row, standard VS Code storage-service SQLite), always taking a timestamped backup of state.vscdb first.
  • Refuses to run if VS Code is currently open. That index is cached in memory by a running window and only read fresh on cold start — writing it underneath a live VS Code process risks the change being silently discarded the next time that window saves any chat state at all. Close VS Code first.
  • Only targets an existing VS Code workspace, matched by directory (the same workspace.json lookup vscode2opencode.py/vscode2claude.py use for reading). If the target project has never been opened in VS Code, the session is written as a no-folder ("empty window") chat instead of inventing a new workspace — the same "never fabricate what isn't already there" discipline as the MCP/agent import directions.

Content mapping mirrors the read direction in reverse: text/reasoning (OpenCode) or text/thinking (Claude Code) become VS Code markdownContent/ thinking blocks, and tool calls become toolInvocationSerialized blocks — with the tool's original input rendered as a human-readable summary (VS Code's serialized format has no slot for raw structured arguments) and its output carried in resultDetails when there is one.

Validated with a real three-way round trip: a real VS Code session was converted to Claude Code (previous PR), then converted back to VS Code with this code (against a throwaway copy of state.vscdb, never the real one) — replaying the written file reproduced the original conversation content, and the index entry showed up correctly alongside the pre-existing real entries, untouched.

Requirements

Python 3.9+, standard library only at runtime (sqlite3, argparse) — pyproject.toml declares zero dependencies. Optionally uses git/claude --version/opencode --version if present on PATH, all with safe fallbacks.

Project layout

harness2go/
├── pyproject.toml          # build config + console_scripts entry points
├── src/harness2go/         # the actual package
│   ├── cli.py              # h2go dispatcher
│   ├── harness_common.py   # shared JSONC/frontmatter/prompt helpers
│   ├── vscode_common.py    # shared VS Code session decode/encode
│   ├── opencode2claude.py
│   ├── claude2opencode.py
│   ├── vscode2opencode.py
│   ├── vscode2claude.py
│   ├── opencode2vscode.py
│   └── claude2vscode.py
└── tests/                  # unittest suite, run against the installed package

Publishing

Releases publish to PyPI automatically via Trusted Publishing — no API token stored anywhere; GitHub's OIDC identity authenticates the publish (.github/workflows/publish.yml). One-time setup (do this once, on PyPI and GitHub — not something a CI job can do for you):

  1. Create a PyPI account at pypi.org if you don't have one (2FA is required).
  2. On PyPI: Your projects → Publishing → Add a new pending publisher, and fill in:
    • PyPI project name: harness2go
    • Owner: thiagojesus
    • Repository name: harness2go
    • Workflow filename: publish.yml
    • Environment name: pypi
  3. On GitHub: Settings → Environments → New environment, name it pypi (matching step 2; optionally add protection rules like required reviewers).
  4. Cut a GitHub Release (tag it e.g. v0.1.0, matching the version in pyproject.toml) — the publish.yml workflow triggers on release publish, builds the sdist/wheel, and uploads them to PyPI automatically.

For each subsequent release: bump version in pyproject.toml, commit, tag, and cut a new GitHub Release — no PyPI-side setup needed again after the first time.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

harness2go-0.1.0.tar.gz (47.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

harness2go-0.1.0-py3-none-any.whl (43.5 kB view details)

Uploaded Python 3

File details

Details for the file harness2go-0.1.0.tar.gz.

File metadata

  • Download URL: harness2go-0.1.0.tar.gz
  • Upload date:
  • Size: 47.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for harness2go-0.1.0.tar.gz
Algorithm Hash digest
SHA256 d8b245858ba638be22d07127c1a20aa68bf9cea245249dbaf3344cac46599dae
MD5 4195f9a4de5f9195e712b8789bbe4e3a
BLAKE2b-256 37ac036271bf4cdf3004cd64ea74eb81a74b59736275bc89832f75aa3d69d0bc

See more details on using hashes here.

Provenance

The following attestation bundles were made for harness2go-0.1.0.tar.gz:

Publisher: publish.yml on thiagojesus/harness2go

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file harness2go-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: harness2go-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 43.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for harness2go-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 427f9fed5d0605975f0e873c333e5282a1e516a2a71fb6b5f62a78f632d37cdf
MD5 7b3e085f5e7d3fed77cb13e199d1abac
BLAKE2b-256 f5e7de31aca19a183a428502cfd101eed44ca2ad77a05de6af94d6b992cc25dc

See more details on using hashes here.

Provenance

The following attestation bundles were made for harness2go-0.1.0-py3-none-any.whl:

Publisher: publish.yml on thiagojesus/harness2go

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page