Swiss-army knife for migrating coding-agent harness sessions (and their MCP/agent config) between OpenCode, Claude Code, and VS Code
Project description
harness2go
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 realclaude mcp addCLI, atlocal/project/userscope, one server at a time. - Agents referenced in the session (assistant turns +
tasktoolsubagent_typecalls) as Claude Code subagent stubs (.claude/agents/*.md,localorglobalscope). 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 fromoh-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.claude2opencoderegroups consecutive assistant lines sharing the samemessage.idback into one OpenCodemessage(one step, bounded by synthesizedstep-start/step-finishparts), and reunites eachtool_useblock with its latertool_resultline into a single OpenCodetoolpart — the exact inverse of howopencode2claudesplit things apart. thinkingblocks become OpenCodereasoningparts (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 (Bash→bash,Read→read,Edit→edit,Task→task,AskUserQuestion→question,TodoWrite→todowrite,TaskOutput→background_output); anything else passes through lowercased by name. - The session row reuses OpenCode's existing
projectrow (OpenCode appears to key everything off a single project per install rather than one per directory — a freshprojectrow 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'sworkspace.jsonmaps 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. thinkingblocks become plaintext(Claude Code target) orreasoning(OpenCode target) — never a real Anthropicthinkingblock — 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 ownrequest_<uuid>— Claude Code sends the last assistant message'sidback to the API as aprevious_message_iddiagnostic 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'schat.ChatSessionStore.indexkey (anItemTablerow, standard VS Code storage-service SQLite), always taking a timestamped backup ofstate.vscdbfirst. - 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.jsonlookupvscode2opencode.py/vscode2claude.pyuse 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):
- Create a PyPI account at pypi.org if you don't have one (2FA is required).
- 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
- PyPI project name:
- On GitHub: Settings → Environments → New environment, name it
pypi(matching step 2; optionally add protection rules like required reviewers). - Cut a GitHub Release
(tag it e.g.
v0.1.0, matching the version inpyproject.toml) — thepublish.ymlworkflow 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d8b245858ba638be22d07127c1a20aa68bf9cea245249dbaf3344cac46599dae
|
|
| MD5 |
4195f9a4de5f9195e712b8789bbe4e3a
|
|
| BLAKE2b-256 |
37ac036271bf4cdf3004cd64ea74eb81a74b59736275bc89832f75aa3d69d0bc
|
Provenance
The following attestation bundles were made for harness2go-0.1.0.tar.gz:
Publisher:
publish.yml on thiagojesus/harness2go
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
harness2go-0.1.0.tar.gz -
Subject digest:
d8b245858ba638be22d07127c1a20aa68bf9cea245249dbaf3344cac46599dae - Sigstore transparency entry: 2256793718
- Sigstore integration time:
-
Permalink:
thiagojesus/harness2go@2510f1a5c1032776c2158f3214b512f7f3884bf3 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/thiagojesus
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2510f1a5c1032776c2158f3214b512f7f3884bf3 -
Trigger Event:
release
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
427f9fed5d0605975f0e873c333e5282a1e516a2a71fb6b5f62a78f632d37cdf
|
|
| MD5 |
7b3e085f5e7d3fed77cb13e199d1abac
|
|
| BLAKE2b-256 |
f5e7de31aca19a183a428502cfd101eed44ca2ad77a05de6af94d6b992cc25dc
|
Provenance
The following attestation bundles were made for harness2go-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on thiagojesus/harness2go
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
harness2go-0.1.0-py3-none-any.whl -
Subject digest:
427f9fed5d0605975f0e873c333e5282a1e516a2a71fb6b5f62a78f632d37cdf - Sigstore transparency entry: 2256793723
- Sigstore integration time:
-
Permalink:
thiagojesus/harness2go@2510f1a5c1032776c2158f3214b512f7f3884bf3 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/thiagojesus
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2510f1a5c1032776c2158f3214b512f7f3884bf3 -
Trigger Event:
release
-
Statement type: