diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9e882d..1685652 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,12 +32,27 @@ jobs: - name: Node syntax check (node --check) run: | - for f in launcher/claudemax.win.js fixes/thinking-summaries/proxy.js; do + for f in launcher/claudemax.win.js fixes/thinking-summaries/proxy.js fixes/markdown-copy-export/webview-inject.js; do node --check "$f" done - name: Python compile - run: python3 -m py_compile fixes/context-icon/fix-context-icon.py tests/test_regressions.py tests/test_reconcile.py + run: > + python3 -m py_compile + fixes/context-icon/fix-context-icon.py + fixes/markdown-copy-export/cc-export.py + fixes/markdown-copy-export/add-md-copy.py + tools/gen-embeds + tests/test_regressions.py tests/test_reconcile.py + tests/test_md_export.py tests/test_md_converter.py tests/test_md_inject.py + tests/test_md_patcher.py tests/test_gen_embeds.py + + - name: Embed drift check + # The launcher + standalone patcher carry a generated copy of the md-copy + # webview payload. --strict fails the build if any of the three consumers is + # missing its embed region (so a vanished region can't pass silently); --check + # fails if a present region drifts from the single source. + run: python3 tools/gen-embeds --check --strict - name: Regression tests run: python3 -m unittest discover -s tests -v diff --git a/README.md b/README.md index 00aac21..590c7fc 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ Not affiliated with or endorsed by Anthropic. A future Claude Code update could The context-usage pie in the chat input is hidden until you have used more than 50% of the context window. With the 1M window that is about 500,000 tokens, so it is effectively never shown. Fix via the launcher (re-patches the webview on each launch), or a standalone patcher script. -> [details](#workaround-2-context-usage-icon) +3. **No markdown copy / export of chat** [added 2026-06-09]. + The chat cannot copy a whole message or the whole conversation as Markdown, and + has no transcript export. Fix via the launcher (adds copy controls, re-applied + each launch), a standalone patcher, or a standalone session exporter CLI. + -> [details](fixes/markdown-copy-export/README.md) + ## The launcher The recommended fix for everything is one small launcher that wraps the real `claude` binary. It is a drop-in process wrapper carrying every fix in this repo; each fix is on by default and independently switchable with an environment variable, so the same artifact serves "I want everything" and "I want only X" without editing code and without recompiling. @@ -29,6 +35,7 @@ Toggles (set in the environment where Claude Code launches, then reload): | `CC_RECONCILE` | `1` | `0` = do not read or write the webview bundle this launch (emergency bypass). Argument injection still runs. | | `CC_THINKING_DISPLAY` | `summarized` | `summarized` shows extended-thinking summaries; `omitted` hides them (no injection). | | `CC_PATCH_CONTEXT_ICON` | `1` | `0` leaves the context-usage icon unpatched (and reverts ours on the next launch). | +| `CC_PATCH_MD_COPY` | `1` | `0` leaves the webview without the markdown copy/export controls (and reverts ours on the next launch). | See [`launcher/README.md`](launcher/README.md) for wiring details, the VS Code env-setting how-to, and the build command. @@ -45,10 +52,15 @@ The three bash launchers and three Windows launchers are gone. There is now one | Old launcher | New equivalent | | --- | --- | | `claudemax` (both fixes) | `launcher/claudemax` - all fixes on (same behavior) | -| `claude-think` (thinking only) | `launcher/claudemax` with `CC_PATCH_CONTEXT_ICON=0` | -| `claude-context` (context icon only) | `launcher/claudemax` with `CC_THINKING_DISPLAY=omitted` | +| `claude-think` (thinking only) | `launcher/claudemax` with `CC_PATCH_CONTEXT_ICON=0` (and `CC_PATCH_MD_COPY=0` if you do not want the copy UI) | +| `claude-context` (context icon only) | `launcher/claudemax` with `CC_THINKING_DISPLAY=omitted` (and `CC_PATCH_MD_COPY=0` if you do not want the copy UI) | | any `.exe` | the single `claudemax.exe`; scope features via `CC_*` (VS Code `claudeCode.environmentVariables`) | +> The unified launcher enables **every** fix by default, including the new +> markdown copy/export controls in the chat UI. If you do not want those controls, +> set `CC_PATCH_MD_COPY=0` (the webview is left untouched and any prior install is +> reverted on the next launch). + Old release assets remain available for anyone pinned to a previous version. --- @@ -309,6 +321,10 @@ Setup is otherwise identical to Option 1. This is unrelated to the fixes above. | [`fixes/thinking-summaries/proxy.js`](fixes/thinking-summaries/proxy.js) | thinking | Option 3 localhost proxy. Advanced and untested. | | [`fixes/thinking-summaries/test-thinking-display.sh`](fixes/thinking-summaries/test-thinking-display.sh) | thinking | Live A/B test showing that the flag is the relevant lever. | | [`fixes/context-icon/fix-context-icon.py`](fixes/context-icon/fix-context-icon.py) | context icon | Option 2 standalone webview patcher with `--revert`. | +| [`fixes/markdown-copy-export/add-md-copy.py`](fixes/markdown-copy-export/add-md-copy.py) | markdown copy | Standalone webview patcher (sentinel block, reverse-transform `--revert`). | +| [`fixes/markdown-copy-export/cc-export.py`](fixes/markdown-copy-export/cc-export.py) | markdown copy | Standalone session exporter (markdown/text, `--open`). | +| [`fixes/markdown-copy-export/webview-inject.js`](fixes/markdown-copy-export/webview-inject.js) | markdown copy | Single source of the appended copy-controls IIFE. | +| [`tools/gen-embeds`](tools/gen-embeds) | markdown copy | Generates the embedded payload into the launcher + patcher; `--check` drift gate. | | [`TECHNICAL.md`](TECHNICAL.md) | both | Full root-cause analysis, the reconcile model, and design notes. | ## Releases diff --git a/TECHNICAL.md b/TECHNICAL.md index 34bd2fd..2be784d 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -217,6 +217,25 @@ Every launch, for each webview file a bundle-patch feature targets, the launcher Timing note: the wrapper patches `index.js` on disk when the CLI is spawned, which can be *after* the webview already loaded the old bundle. So the first time you enable it you may need two reloads (the spawn patches the file, then the webview loads the patched bundle). Later windows and post-update launches are already patched on disk. +**Append features and multiple files.** A feature is either an *in-place* edit +(context-icon: a marked swap deep in `index.js`) or an *append* (md-copy: a +sentinel-delimited block at end-of-file). Each append feature's `undo` is +marker-scoped block removal (it deletes exactly its own OPEN..CLOSE block and +keeps any bytes after CLOSE), so append features compose with in-place features +and with each other without overlap, regardless of registration order. The reconcile runs per +file with that file's own feature list: `index.js` carries `[context-icon, +md-copy]`; `index.css` carries `[md-copy]`. md-copy's block is byte-identical +across the bash launcher (quoted heredocs), the node launcher (JSON string +literals), and the standalone `add-md-copy.py` (base64): `"\n" + OPEN + "\n" + +PAYLOAD + "\n" + CLOSE + "\n"`, where `OPEN`/`CLOSE` are `/* cc-md-copy v1 */` +and `/* /cc-md-copy v1 */` and `PAYLOAD` is the source with trailing newlines +stripped. The payload's single source is `fixes/markdown-copy-export/webview-inject.{js,css}`; +`tools/gen-embeds` writes the embedded copies and `tools/gen-embeds --check` +fails the build on drift. The standalone `add-md-copy.py` keeps its own +`.bak-md-copy` emergency snapshot; its `--revert` is the same reverse transform +(not a backup restore), so removing md-copy never disturbs a co-applied +context-icon. + ## How the icon works (context for future changes) ### Data source resets on reload diff --git a/fixes/markdown-copy-export/README.md b/fixes/markdown-copy-export/README.md new file mode 100644 index 0000000..98ae7f0 --- /dev/null +++ b/fixes/markdown-copy-export/README.md @@ -0,0 +1,64 @@ +# markdown-copy-export + +## What it fixes + +The Claude Code VS Code chat has no way to copy a whole message or the whole +conversation as Markdown (only code blocks have a copy button). This adds a +per-message copy control (Markdown primary, plain text secondary) on every user +and assistant message, a "copy entire conversation" control, and a standalone +CLI that exports a session transcript to Markdown/plain text or opens the raw +`.jsonl`. Affects the VS Code extension webview; the CLI is independent of it. + +## Standalone usage + +Webview controls (one-shot patch, re-applied automatically by the launcher): + + python3 fixes/markdown-copy-export/add-md-copy.py # patch all installs + python3 fixes/markdown-copy-export/add-md-copy.py --revert # remove (reverse transform) + python3 fixes/markdown-copy-export/add-md-copy.py /path/to/webview/index.js + +Reload the window after patching (first enable may need two reloads, like +context-icon). `--revert` removes only our sentinel block, so it composes with +the context-icon patcher on the same file. + +Session exporter (independent of the webview): + + python3 fixes/markdown-copy-export/cc-export.py # latest session -> markdown + python3 fixes/markdown-copy-export/cc-export.py --format text + python3 fixes/markdown-copy-export/cc-export.py --include-thinking --include-tools + python3 fixes/markdown-copy-export/cc-export.py --session ID -o out.md + python3 fixes/markdown-copy-export/cc-export.py --open # open raw .jsonl in VS Code + +## Launcher toggle + +`CC_PATCH_MD_COPY` (default `1`). `0` leaves the webview without the copy +controls and reverts ours on the next launch. `CC_WORKAROUNDS=0` reverts it too. + +## Maintenance Contract + +- Anchors / selectors: user bubble `[class*="userMessageContainer_"]`; assistant + bubble `[data-testid="assistant-message"]` (NOT `[data-message-rating]` — that is + the nested, experiment+analytics-gated rating widget, which the sanitizer strips); + chrome strip-prefixes `toolUse_`/`toolResult_`/`toolReference_`/`thinking_`/ + `unknownContent_` plus `[data-message-rating]` and `button`; + `navigator.clipboard.writeText` (proven to work via the built-in copy-code + button). Optional refinements pinned at install: the messages container and the + single all-content wrapper, if any (see the inject source constants + `MESSAGES_CONTAINER` / `ASSISTANT_CONTENT`). When a bundle update renames the + bubble anchor or a chrome hook, the Phase-9 selector guard fails loudly; re-pin + the constant and the Phase-2 fixtures together. +- Ownership marker: the sentinel block `/* cc-md-copy v1 */ ... /* /cc-md-copy v1 */` + appended to `webview/index.js` (the IIFE) and `webview/index.css` (its styles). +- Failure mode if an anchor moves: fails safe - the controls simply do not appear + (the IIFE no-ops; `boot()` is fully guarded). The conversation walk degrades to + "no bubbles matched", never wrong output. The launcher patch still installs. +- Launcher registry entry: feature id `md-copy`, files `webview/index.js` + + `webview/index.css`, apply = append the sentinel block (registered LAST, after + context-icon), undo = marker-scoped block removal (deletes exactly its own + OPEN..CLOSE block, keeps any bytes after CLOSE; composes regardless of ordering). + The payload is the single source `webview-inject.{js,css}`, + embedded into the launcher and `add-md-copy.py` by `tools/gen-embeds` (CI drift + check: `tools/gen-embeds --check`). +- Test fixture: `tests/test_md_converter.py`, `tests/test_md_inject.py`, + `tests/test_md_patcher.py`, `tests/test_md_export.py`, `tests/test_gen_embeds.py`, + and the `md-copy` cases in `tests/test_reconcile.py`. diff --git a/fixes/markdown-copy-export/add-md-copy.py b/fixes/markdown-copy-export/add-md-copy.py new file mode 100644 index 0000000..2958e3e --- /dev/null +++ b/fixes/markdown-copy-export/add-md-copy.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""add-md-copy.py - install per-message and whole-conversation markdown/plain copy +controls into the Claude Code VS Code webview, without a launcher. + +Appends a sentinel-delimited block to webview/index.js (the inject IIFE) and to +webview/index.css (its styles). Idempotent (detects the open sentinel), atomic +(temp file + verified rename, metadata preserved). `--revert` removes the block +by REVERSE TRANSFORM (not a backup restore), so it composes with the context-icon +patcher on the same file - removing md-copy never disturbs context-icon. A one-time +whole-file .bak-md-copy snapshot is kept for emergency manual restore only and is +NOT used by --revert (restoring it is not composition-safe). + + python3 add-md-copy.py # auto-discover & patch all installs + python3 add-md-copy.py --revert # remove our block (reverse transform) + python3 add-md-copy.py /path/to/webview/index.js # explicit target(s) + +The payload below is GENERATED from fixes/markdown-copy-export/webview-inject.{js,css} +by tools/gen-embeds; do not edit it by hand. Run `tools/gen-embeds` after changing +the source, and `tools/gen-embeds --check` verifies it is in sync (CI drift check). +""" +import base64 +import glob +import os +import re +import shutil +import sys +import tempfile + +OPEN = "/* cc-md-copy v1 */" +CLOSE = "/* /cc-md-copy v1 */" +BACKUP_SUFFIX = ".bak-md-copy" + +# >>>CCWA-MD-COPY-EMBED>>> (generated by tools/gen-embeds; do not edit) +INJECT_JS = base64.b64decode("LyogY2MtbWQtY29weTogcGVyLW1lc3NhZ2UgYW5kIHdob2xlLWNvbnZlcnNhdGlvbiBjb3B5IChtYXJrZG93bi9wbGFpbikgZm9yIHRoZQogKiBDbGF1ZGUgQ29kZSBWUyBDb2RlIHdlYnZpZXcuIFNlbGYtY29udGFpbmVkIElJRkUgYXBwZW5kZWQgdG8gd2Vidmlldy9pbmRleC5qcy4KICogQWRkaXRpdmUgYW5kIHJlYWQtb25seSB3LnIudC4gYXBwIHN0YXRlOyBrZXllZCBvbiBzdGFibGUgQ1NTLW1vZHVsZSBjbGFzcwogKiBwcmVmaXhlcywgc28gaXQgZmFpbHMgc2FmZSAoY29udHJvbHMgc2ltcGx5IGRvIG5vdCBhcHBlYXIpIGlmIGEgcHJlZml4IG1vdmVzLgogKiBFeHBvc2VzIGl0cyBwdXJlIGZ1bmN0aW9ucyBmb3Igbm9kZSB1bml0IHRlc3RzOyBib290KClzIG9ubHkgaW4gYSByZWFsIHdlYnZpZXcuICovCihmdW5jdGlvbiAoKSB7CiAgInVzZSBzdHJpY3QiOwoKICB2YXIgQ09OVFJPTF9QUkVGSVggPSAiY2MtbWQtY29weSI7IC8vIGV2ZXJ5IGluamVjdGVkIG5vZGUncyBjbGFzcyBzdGFydHMgd2l0aCB0aGlzCiAgdmFyIFVTRVJfQlVCQkxFID0gJ1tjbGFzcyo9InVzZXJNZXNzYWdlQ29udGFpbmVyXyJdJzsKICAvLyBBc3Npc3RhbnQgbWVzc2FnZSB3cmFwcGVyLiBWZXJpZmllZCBvbiAyLjEuMTcwOiB0aGUgcmVuZGVyIGVtaXRzIGV4YWN0bHkgb25lCiAgLy8gYGRhdGEtdGVzdGlkPSJhc3Npc3RhbnQtbWVzc2FnZSJgIGRpdiBwZXIgYXNzaXN0YW50IHR1cm4sIHdpdGggdGhlIHJhdGluZwogIC8vIHdpZGdldCBhbmQgY29udGVudCBibG9ja3MgYXMgaXRzIGNoaWxkcmVuLiAoVGhlIGVhcmxpZXIgYFtkYXRhLW1lc3NhZ2UtcmF0aW5nXWAKICAvLyB3YXMgV1JPTkc6IHRoYXQgYXR0cmlidXRlIHNpdHMgb24gdGhlIG5lc3RlZCByYXRpbmcgY29udHJvbCwgd2hpY2ggaXMgYWxzbyBvbmx5CiAgLy8gcmVuZGVyZWQgYmVoaW5kIGFuIGV4cGVyaW1lbnQrYW5hbHl0aWNzIGdhdGUuKSBSZS1waW5uZWQgaW4gVGFzayA2LgogIHZhciBBU1NJU1RBTlRfQlVCQkxFID0gJ1tkYXRhLXRlc3RpZD0iYXNzaXN0YW50LW1lc3NhZ2UiXSc7CiAgdmFyIE1FU1NBR0VTX0NPTlRBSU5FUiA9ICdbY2xhc3MqPSJtZXNzYWdlc0NvbnRhaW5lcl8iXSc7IC8vIGUuZy4gJ1tjbGFzcyo9InRpbWVsaW5lXyJdJzsgIiIgLT4gb2JzZXJ2ZSBkb2N1bWVudC5ib2R5CiAgLy8gT3B0aW9uYWwgbmFycm93aW5nIG9ubHkuIE1VU1QgYmUgYSBzaW5nbGUgd3JhcHBlciBhcm91bmQgQUxMIGNvbnRlbnQgYmxvY2tzLAogIC8vIG5vdCBhIHBlci1ibG9jayBjbGFzcyAoYSB0dXJuIGhhcyBtdWx0aXBsZSBibG9ja3MpLiAiIiAtPiB1c2UgdGhlIGJ1YmJsZSBpdHNlbGYKICAvLyAoYWxyZWFkeSBhZ2dyZWdhdGVzIGFsbCBibG9ja3M7IHNhbml0aXplQ2xvbmUgaXMgdGhlIGNvcnJlY3RuZXNzIGdhdGUpLgogIHZhciBBU1NJU1RBTlRfQ09OVEVOVCA9ICIiOwogIHZhciBGRUVEQkFDS19NUyA9IDE4MDA7CgogIC8vIC0tLS0gSFRNTCAtPiBNYXJrZG93biAoRE9NIHdhbGspIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KICAvLyBVc2VzIG9ubHk6IG5vZGVUeXBlLCB0YWdOYW1lLCBjaGlsZE5vZGVzLCB0ZXh0Q29udGVudCwgZ2V0QXR0cmlidXRlLCBjbGFzc05hbWUuCiAgZnVuY3Rpb24gaHRtbFRvTWFya2Rvd24ocm9vdCkgewogICAgLy8gTG9uZ2VzdCBydW4gb2YgY29uc2VjdXRpdmUgYmFja3RpY2tzIGluIHMsIHNvIGEgY29kZSBkZWxpbWl0ZXIvZmVuY2UgY2FuIGJlCiAgICAvLyBjaG9zZW4gbG9uZ2VyIHRoYW4gYW55dGhpbmcgaW5zaWRlIGl0IChlbHNlIGBgYCBpbiB0aGUgY29udGVudCBjbG9zZXMgZWFybHkpLgogICAgZnVuY3Rpb24gYmFja3RpY2tSdW4ocykgewogICAgICB2YXIgbWF4ID0gMCwgY3VyID0gMDsKICAgICAgZm9yICh2YXIgaSA9IDA7IGkgPCBzLmxlbmd0aDsgaSsrKSB7CiAgICAgICAgaWYgKHMuY2hhckF0KGkpID09PSAiYCIpIHsgY3VyKys7IGlmIChjdXIgPiBtYXgpIG1heCA9IGN1cjsgfSBlbHNlIGN1ciA9IDA7CiAgICAgIH0KICAgICAgcmV0dXJuIG1heDsKICAgIH0KICAgIGZ1bmN0aW9uIGZlbmNlKHMsIG1pbikgeyB2YXIgbiA9IGJhY2t0aWNrUnVuKHMpICsgMTsgaWYgKG4gPCBtaW4pIG4gPSBtaW47IHJldHVybiBuZXcgQXJyYXkobiArIDEpLmpvaW4oImAiKTsgfQogICAgZnVuY3Rpb24gaW5saW5lKG5vZGUpIHsKICAgICAgdmFyIG91dCA9ICIiOwogICAgICB2YXIga2lkcyA9IG5vZGUuY2hpbGROb2RlcyB8fCBbXTsKICAgICAgZm9yICh2YXIgaSA9IDA7IGkgPCBraWRzLmxlbmd0aDsgaSsrKSB7CiAgICAgICAgdmFyIGMgPSBraWRzW2ldOwogICAgICAgIGlmIChjLm5vZGVUeXBlID09PSAzKSB7IG91dCArPSBjLnRleHRDb250ZW50IHx8ICIiOyBjb250aW51ZTsgfQogICAgICAgIGlmIChjLm5vZGVUeXBlICE9PSAxKSBjb250aW51ZTsKICAgICAgICB2YXIgdGFnID0gKGMudGFnTmFtZSB8fCAiIikudG9VcHBlckNhc2UoKTsKICAgICAgICBpZiAodGFnID09PSAiQlIiKSBvdXQgKz0gIlxuIjsKICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJTVFJPTkciIHx8IHRhZyA9PT0gIkIiKSBvdXQgKz0gIioqIiArIGlubGluZShjKSArICIqKiI7CiAgICAgICAgZWxzZSBpZiAodGFnID09PSAiRU0iIHx8IHRhZyA9PT0gIkkiKSBvdXQgKz0gIioiICsgaW5saW5lKGMpICsgIioiOwogICAgICAgIGVsc2UgaWYgKHRhZyA9PT0gIkRFTCIgfHwgdGFnID09PSAiUyIpIG91dCArPSAifn4iICsgaW5saW5lKGMpICsgIn5+IjsKICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJDT0RFIikgewogICAgICAgICAgdmFyIGN0ID0gYy50ZXh0Q29udGVudCB8fCAiIjsKICAgICAgICAgIHZhciBkID0gZmVuY2UoY3QsIDEpOwogICAgICAgICAgLy8gQ29tbW9uTWFyayBzdHJpcHMgb25lIGxlYWRpbmcrdHJhaWxpbmcgc3BhY2UsIHNvIHBhZCB3aGVuIGFuIGVkZ2UgaXMgYQogICAgICAgICAgLy8gYmFja3RpY2sgdG8ga2VlcCBpdCBmcm9tIG1lcmdpbmcgd2l0aCB0aGUgZGVsaW1pdGVyLgogICAgICAgICAgdmFyIHAgPSAoY3QuY2hhckF0KDApID09PSAiYCIgfHwgY3QuY2hhckF0KGN0Lmxlbmd0aCAtIDEpID09PSAiYCIpID8gIiAiIDogIiI7CiAgICAgICAgICBvdXQgKz0gZCArIHAgKyBjdCArIHAgKyBkOwogICAgICAgIH0KICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJBIikgewogICAgICAgICAgdmFyIGhyZWYgPSBjLmdldEF0dHJpYnV0ZSA/IGMuZ2V0QXR0cmlidXRlKCJocmVmIikgOiBudWxsOwogICAgICAgICAgdmFyIHQgPSBpbmxpbmUoYyk7CiAgICAgICAgICBvdXQgKz0gaHJlZiA/ICJbIiArIHQgKyAiXSgiICsgaHJlZiArICIpIiA6IHQ7CiAgICAgICAgfSBlbHNlIG91dCArPSBpbmxpbmUoYyk7IC8vIHVua25vd24gaW5saW5lIHdyYXBwZXI6IGtlZXAgdGV4dCwgZHJvcCB0YWcKICAgICAgfQogICAgICByZXR1cm4gb3V0OwogICAgfQogICAgZnVuY3Rpb24gbGFuZ09mKGNvZGVFbCkgewogICAgICB2YXIgY2xzID0gIiI7CiAgICAgIGlmIChjb2RlRWwpIGNscyA9IChjb2RlRWwuZ2V0QXR0cmlidXRlICYmIGNvZGVFbC5nZXRBdHRyaWJ1dGUoImNsYXNzIikpIHx8IGNvZGVFbC5jbGFzc05hbWUgfHwgIiI7CiAgICAgIHZhciBtID0gL2xhbmd1YWdlLShbQS1aYS16MC05KyMuXC1dKykvLmV4ZWMoY2xzIHx8ICIiKTsKICAgICAgcmV0dXJuIG0gPyBtWzFdIDogIiI7CiAgICB9CiAgICBmdW5jdGlvbiBmaW5kQ2hpbGRUYWcobm9kZSwgdGFnKSB7CiAgICAgIHZhciBraWRzID0gbm9kZS5jaGlsZE5vZGVzIHx8IFtdOwogICAgICBmb3IgKHZhciBpID0gMDsgaSA8IGtpZHMubGVuZ3RoOyBpKyspIHsKICAgICAgICBpZiAoa2lkc1tpXS5ub2RlVHlwZSA9PT0gMSAmJiAoa2lkc1tpXS50YWdOYW1lIHx8ICIiKS50b1VwcGVyQ2FzZSgpID09PSB0YWcpIHJldHVybiBraWRzW2ldOwogICAgICB9CiAgICAgIHJldHVybiBudWxsOwogICAgfQogICAgZnVuY3Rpb24gbGlzdChub2RlLCBvcmRlcmVkLCBkZXB0aCkgewogICAgICB2YXIgb3V0ID0gIiIsIG4gPSAxOwogICAgICB2YXIga2lkcyA9IG5vZGUuY2hpbGROb2RlcyB8fCBbXTsKICAgICAgZm9yICh2YXIgaSA9IDA7IGkgPCBraWRzLmxlbmd0aDsgaSsrKSB7CiAgICAgICAgdmFyIGxpID0ga2lkc1tpXTsKICAgICAgICBpZiAobGkubm9kZVR5cGUgIT09IDEgfHwgKGxpLnRhZ05hbWUgfHwgIiIpLnRvVXBwZXJDYXNlKCkgIT09ICJMSSIpIGNvbnRpbnVlOwogICAgICAgIHZhciBtYXJrZXIgPSBvcmRlcmVkID8gbisrICsgIi4gIiA6ICItICI7CiAgICAgICAgdmFyIGluZGVudCA9IG5ldyBBcnJheShkZXB0aCArIDEpLmpvaW4oIiAgIik7CiAgICAgICAgdmFyIGxlYWQgPSAiIiwgbmVzdGVkID0gIiI7CiAgICAgICAgdmFyIGxrID0gbGkuY2hpbGROb2RlcyB8fCBbXTsKICAgICAgICBmb3IgKHZhciBqID0gMDsgaiA8IGxrLmxlbmd0aDsgaisrKSB7CiAgICAgICAgICB2YXIgY2ggPSBsa1tqXTsKICAgICAgICAgIHZhciBjdCA9IGNoLm5vZGVUeXBlID09PSAxID8gKGNoLnRhZ05hbWUgfHwgIiIpLnRvVXBwZXJDYXNlKCkgOiAiIjsKICAgICAgICAgIGlmIChjdCA9PT0gIlVMIikgbmVzdGVkICs9IGxpc3QoY2gsIGZhbHNlLCBkZXB0aCArIDEpOwogICAgICAgICAgZWxzZSBpZiAoY3QgPT09ICJPTCIpIG5lc3RlZCArPSBsaXN0KGNoLCB0cnVlLCBkZXB0aCArIDEpOwogICAgICAgICAgZWxzZSBpZiAoY2gubm9kZVR5cGUgPT09IDMpIGxlYWQgKz0gY2gudGV4dENvbnRlbnQgfHwgIiI7CiAgICAgICAgICBlbHNlIGxlYWQgKz0gaW5saW5lKGNoKTsKICAgICAgICB9CiAgICAgICAgb3V0ICs9IGluZGVudCArIG1hcmtlciArIGxlYWQudHJpbSgpICsgIlxuIiArIG5lc3RlZDsKICAgICAgfQogICAgICByZXR1cm4gb3V0OwogICAgfQogICAgZnVuY3Rpb24gdGFibGUobm9kZSkgewogICAgICB2YXIgcm93cyA9IFtdOwogICAgICAoZnVuY3Rpb24gY29sbGVjdChjb250YWluZXIpIHsKICAgICAgICB2YXIga2lkcyA9IGNvbnRhaW5lci5jaGlsZE5vZGVzIHx8IFtdOwogICAgICAgIGZvciAodmFyIGkgPSAwOyBpIDwga2lkcy5sZW5ndGg7IGkrKykgewogICAgICAgICAgdmFyIGMgPSBraWRzW2ldOwogICAgICAgICAgaWYgKGMubm9kZVR5cGUgIT09IDEpIGNvbnRpbnVlOwogICAgICAgICAgdmFyIHQgPSAoYy50YWdOYW1lIHx8ICIiKS50b1VwcGVyQ2FzZSgpOwogICAgICAgICAgaWYgKHQgPT09ICJUSEVBRCIgfHwgdCA9PT0gIlRCT0RZIiB8fCB0ID09PSAiVEZPT1QiKSBjb2xsZWN0KGMpOwogICAgICAgICAgZWxzZSBpZiAodCA9PT0gIlRSIikgewogICAgICAgICAgICB2YXIgY2VsbHMgPSBbXSwgY2MgPSBjLmNoaWxkTm9kZXMgfHwgW107CiAgICAgICAgICAgIGZvciAodmFyIGogPSAwOyBqIDwgY2MubGVuZ3RoOyBqKyspIHsKICAgICAgICAgICAgICB2YXIgZCA9IGNjW2pdOwogICAgICAgICAgICAgIGlmIChkLm5vZGVUeXBlICE9PSAxKSBjb250aW51ZTsKICAgICAgICAgICAgICB2YXIgZHQgPSAoZC50YWdOYW1lIHx8ICIiKS50b1VwcGVyQ2FzZSgpOwogICAgICAgICAgICAgIGlmIChkdCA9PT0gIlRIIiB8fCBkdCA9PT0gIlREIikgY2VsbHMucHVzaChpbmxpbmUoZCkudHJpbSgpKTsKICAgICAgICAgICAgfQogICAgICAgICAgICByb3dzLnB1c2goY2VsbHMpOwogICAgICAgICAgfQogICAgICAgIH0KICAgICAgfSkobm9kZSk7CiAgICAgIGlmICghcm93cy5sZW5ndGgpIHJldHVybiAiIjsKICAgICAgdmFyIGhlYWQgPSByb3dzWzBdLCBib2R5ID0gcm93cy5zbGljZSgxKTsKICAgICAgdmFyIHNlcCA9IGhlYWQubWFwKGZ1bmN0aW9uICgpIHsgcmV0dXJuICItLS0iOyB9KTsKICAgICAgdmFyIG91dCA9ICJ8ICIgKyBoZWFkLmpvaW4oIiB8ICIpICsgIiB8XG58ICIgKyBzZXAuam9pbigiIHwgIikgKyAiIHxcbiI7CiAgICAgIGZvciAodmFyIGsgPSAwOyBrIDwgYm9keS5sZW5ndGg7IGsrKykgb3V0ICs9ICJ8ICIgKyBib2R5W2tdLmpvaW4oIiB8ICIpICsgIiB8XG4iOwogICAgICByZXR1cm4gb3V0OwogICAgfQogICAgZnVuY3Rpb24gYmxvY2sobm9kZSkgewogICAgICB2YXIgb3V0ID0gIiI7CiAgICAgIHZhciBraWRzID0gbm9kZS5jaGlsZE5vZGVzIHx8IFtdOwogICAgICBmb3IgKHZhciBpID0gMDsgaSA8IGtpZHMubGVuZ3RoOyBpKyspIHsKICAgICAgICB2YXIgYyA9IGtpZHNbaV07CiAgICAgICAgaWYgKGMubm9kZVR5cGUgPT09IDMpIHsgaWYgKChjLnRleHRDb250ZW50IHx8ICIiKS50cmltKCkpIG91dCArPSBjLnRleHRDb250ZW50OyBjb250aW51ZTsgfQogICAgICAgIGlmIChjLm5vZGVUeXBlICE9PSAxKSBjb250aW51ZTsKICAgICAgICB2YXIgdGFnID0gKGMudGFnTmFtZSB8fCAiIikudG9VcHBlckNhc2UoKTsKICAgICAgICBpZiAoL15IWzEtNl0kLy50ZXN0KHRhZykpIG91dCArPSBuZXcgQXJyYXkoK3RhZ1sxXSArIDEpLmpvaW4oIiMiKSArICIgIiArIGlubGluZShjKS50cmltKCkgKyAiXG5cbiI7CiAgICAgICAgZWxzZSBpZiAodGFnID09PSAiUCIpIG91dCArPSBpbmxpbmUoYykudHJpbSgpICsgIlxuXG4iOwogICAgICAgIGVsc2UgaWYgKHRhZyA9PT0gIlVMIikgb3V0ICs9IGxpc3QoYywgZmFsc2UsIDApICsgIlxuIjsKICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJPTCIpIG91dCArPSBsaXN0KGMsIHRydWUsIDApICsgIlxuIjsKICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJQUkUiKSB7CiAgICAgICAgICB2YXIgY29kZSA9IGZpbmRDaGlsZFRhZyhjLCAiQ09ERSIpOwogICAgICAgICAgdmFyIGxhbmcgPSBsYW5nT2YoY29kZSB8fCBjKTsKICAgICAgICAgIHZhciBib2R5ID0gKGNvZGUgfHwgYykudGV4dENvbnRlbnQgfHwgIiI7CiAgICAgICAgICB2YXIgZiA9IGZlbmNlKGJvZHksIDMpOwogICAgICAgICAgb3V0ICs9IGYgKyBsYW5nICsgIlxuIiArIGJvZHkucmVwbGFjZSgvXG4kLywgIiIpICsgIlxuIiArIGYgKyAiXG5cbiI7CiAgICAgICAgfSBlbHNlIGlmICh0YWcgPT09ICJCTE9DS1FVT1RFIikgewogICAgICAgICAgdmFyIGlubmVyID0gYmxvY2soYykudHJpbSgpLnNwbGl0KCJcbiIpLm1hcChmdW5jdGlvbiAobCkgeyByZXR1cm4gIj4gIiArIGw7IH0pLmpvaW4oIlxuIik7CiAgICAgICAgICBvdXQgKz0gaW5uZXIgKyAiXG5cbiI7CiAgICAgICAgfSBlbHNlIGlmICh0YWcgPT09ICJIUiIpIG91dCArPSAiLS0tXG5cbiI7CiAgICAgICAgZWxzZSBpZiAodGFnID09PSAiVEFCTEUiKSBvdXQgKz0gdGFibGUoYykgKyAiXG4iOwogICAgICAgIGVsc2UgaWYgKHRhZyA9PT0gIkJSIikgb3V0ICs9ICJcbiI7CiAgICAgICAgZWxzZSBpZiAodGFnID09PSAiU1RST05HIiB8fCB0YWcgPT09ICJCIiB8fCB0YWcgPT09ICJFTSIgfHwgdGFnID09PSAiSSIgfHwKICAgICAgICAgICAgICAgICB0YWcgPT09ICJBIiB8fCB0YWcgPT09ICJDT0RFIiB8fCB0YWcgPT09ICJERUwiIHx8IHRhZyA9PT0gIlMiKQogICAgICAgICAgb3V0ICs9IGlubGluZShjKSArICJcblxuIjsKICAgICAgICBlbHNlIG91dCArPSBibG9jayhjKTsgLy8gdW5rbm93biB3cmFwcGVyOiByZWN1cnNlIChkcm9wIHRhZywga2VlcCBjb250ZW50KQogICAgICB9CiAgICAgIHJldHVybiBvdXQ7CiAgICB9CiAgICAvLyBibG9jaygpIGRpc3BhdGNoZXMgb24gZWFjaCBDSElMRCdzIHRhZywgdHJlYXRpbmcgdGhlIHBhc3NlZCBub2RlIGFzIGEgcGxhaW4KICAgIC8vIGNvbnRhaW5lci4gV3JhcCByb290IGluIGEgb25lLW9mZiBjb250YWluZXIgc28gcm9vdCdzIE9XTiB0YWcgaXMgZGlzcGF0Y2hlZAogICAgLy8gdG9vOiBjYWxsZXJzIHBhc3MgZWl0aGVyIHRoZSBidWJibGUgY29udGFpbmVyIChpdHMgYmxvY2sgY2hpbGRyZW4gcmVuZGVyKSBvcgogICAgLy8gYSBzaW5nbGUgYmxvY2sgZWxlbWVudCBsaWtlIDxwcmU+Lzx1bD4vPHRhYmxlPiAobm93IGhhbmRsZWQsIG5vdCBmbGF0dGVuZWQpLgogICAgcmV0dXJuIGJsb2NrKHsgY2hpbGROb2RlczogW3Jvb3RdIH0pLnJlcGxhY2UoL1xuezMsfS9nLCAiXG5cbiIpLnRyaW0oKTsKICB9CgogIC8vIC0tLS0gcHVyZSBoZWxwZXJzIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KICBmdW5jdGlvbiBoYXNQcmVmaXgobm9kZSwgcHJlZml4KSB7CiAgICBpZiAobm9kZS5ub2RlVHlwZSAhPT0gMSB8fCB0eXBlb2Ygbm9kZS5jbGFzc05hbWUgIT09ICJzdHJpbmciKSByZXR1cm4gZmFsc2U7CiAgICB2YXIgcGFydHMgPSBub2RlLmNsYXNzTmFtZS5zcGxpdCgvXHMrLyk7CiAgICBmb3IgKHZhciBpID0gMDsgaSA8IHBhcnRzLmxlbmd0aDsgaSsrKSBpZiAocGFydHNbaV0uaW5kZXhPZihwcmVmaXgpID09PSAwKSByZXR1cm4gdHJ1ZTsKICAgIHJldHVybiBmYWxzZTsKICB9CgogIC8vIENsYXNzLXByZWZpeCBob29rcyBmb3Igbm9uLWNvbnRlbnQgY2hyb21lIHRoYXQgcmVuZGVycyAqaW5zaWRlKiBhbiBhc3Npc3RhbnQKICAvLyBidWJibGUgKHZlcmlmaWVkIG9uIDIuMS4xNzA7IFRhc2sgNiByZS1waW5zIHRoZXNlKS4gdG9vbCovdGhpbmtpbmdfIGFyZSB0aGUgdjEKICAvLyBleGNsdXNpb25zOyB1bmtub3duQ29udGVudF8gaXMgdGhlIHJlbmRlcmVyJ3MgZmFsbGJhY2sgZm9yIHVucmVjb2duaXplZCBibG9jawogIC8vIHR5cGVzLCBzbyBzdHJpcHBpbmcgaXQgbWFrZXMgYSAqZnV0dXJlKiBibG9jayB0eXBlIGZhaWwgc2FmZSB0byBleGNsdWRlZCByYXRoZXIKICAvLyB0aGFuIGxlYWtpbmcgIlVuc3VwcG9ydGVkIGNvbnRlbnQiIGludG8gdGhlIGNvcHkuIFJlLXBpbiBpZiBhIHByZWZpeCBtb3Zlcy4KICB2YXIgQ0hST01FX1BSRUZJWEVTID0gWyJ0b29sVXNlXyIsICJ0b29sUmVzdWx0XyIsICJ0b29sUmVmZXJlbmNlXyIsICJ0aGlua2luZ18iLCAidW5rbm93bkNvbnRlbnRfIl07CgogIC8vIFRydWUgZm9yIGFueSBub2RlIHRoYXQgbXVzdCBuZXZlciBhcHBlYXIgaW4gY29waWVkIG91dHB1dDogb3VyIG93biBjb250cm9scywKICAvLyB0aGUgcmF0aW5nIHdpZGdldCAoYGRhdGEtbWVzc2FnZS1yYXRpbmdgICsgaXRzICJUaGFua3MgZm9yIHlvdXIgZmVlZGJhY2siCiAgLy8gdGV4dCksIGFueSBidXR0b24gKGNvcHktY29kZSBjaHJvbWUpLCBhbmQgdGhlIGV4Y2x1ZGVkIGNvbnRlbnQgYmxvY2tzIGFib3ZlLgogIGZ1bmN0aW9uIGlzQ2hyb21lKG5vZGUpIHsKICAgIGlmIChub2RlLm5vZGVUeXBlICE9PSAxKSByZXR1cm4gZmFsc2U7CiAgICBpZiAoKG5vZGUudGFnTmFtZSB8fCAiIikudG9VcHBlckNhc2UoKSA9PT0gIkJVVFRPTiIpIHJldHVybiB0cnVlOwogICAgaWYgKG5vZGUuZ2V0QXR0cmlidXRlICYmIG5vZGUuZ2V0QXR0cmlidXRlKCJkYXRhLW1lc3NhZ2UtcmF0aW5nIikgIT09IG51bGwpIHJldHVybiB0cnVlOwogICAgaWYgKGhhc1ByZWZpeChub2RlLCBDT05UUk9MX1BSRUZJWCkpIHJldHVybiB0cnVlOwogICAgZm9yICh2YXIgaSA9IDA7IGkgPCBDSFJPTUVfUFJFRklYRVMubGVuZ3RoOyBpKyspIGlmIChoYXNQcmVmaXgobm9kZSwgQ0hST01FX1BSRUZJWEVTW2ldKSkgcmV0dXJuIHRydWU7CiAgICByZXR1cm4gZmFsc2U7CiAgfQoKICAvLyBEZWVwLWNsb25lIGBjb250ZW50Tm9kZWAsIHRoZW4gc3RyaXAgZXZlcnkgY2hyb21lIG5vZGUgc28gY29waWVkIG91dHB1dCBpcyB0aGUKICAvLyBtZXNzYWdlJ3MgdGV4dCBjb250ZW50IG9ubHkuIFRoaXMgaXMgYSBDT1JSRUNUTkVTUyBHQVRFLCBub3QgY29zbWV0aWM6IHRoZQogIC8vIGRlZmF1bHQgY29udGVudCBub2RlIGlzIHRoZSB3aG9sZSBidWJibGUgKGFsbCBjb250ZW50LWJsb2NrIHNpYmxpbmdzLCBzbyBtdWx0aS0KICAvLyBibG9jayBhc3Npc3RhbnQgdHVybnMgYXJlIGNhcHR1cmVkKSwgYW5kIHRoaXMgc3RyaXAtbGlzdCBpcyB0aGUgb25seSB0aGluZwogIC8vIGtlZXBpbmcgdGhlIHJhdGluZyB3aWRnZXQgYW5kIHYxLWV4Y2x1ZGVkIGJsb2NrcyBvdXQgb2YgdGhlIGNvcHkuCiAgZnVuY3Rpb24gc2FuaXRpemVDbG9uZShjb250ZW50Tm9kZSkgewogICAgdmFyIGNsb25lID0gY29udGVudE5vZGUuY2xvbmVOb2RlKHRydWUpOwogICAgKGZ1bmN0aW9uIHN0cmlwKG5vZGUpIHsKICAgICAgdmFyIGtpZHMgPSAobm9kZS5jaGlsZE5vZGVzIHx8IFtdKS5zbGljZSgpOwogICAgICBmb3IgKHZhciBpID0gMDsgaSA8IGtpZHMubGVuZ3RoOyBpKyspIHsKICAgICAgICB2YXIgYyA9IGtpZHNbaV07CiAgICAgICAgaWYgKGMubm9kZVR5cGUgPT09IDEgJiYgaXNDaHJvbWUoYykpIHsgbm9kZS5yZW1vdmVDaGlsZChjKTsgY29udGludWU7IH0KICAgICAgICBpZiAoYy5ub2RlVHlwZSA9PT0gMSkgc3RyaXAoYyk7CiAgICAgIH0KICAgIH0pKGNsb25lKTsKICAgIHJldHVybiBjbG9uZTsKICB9CgogIGZ1bmN0aW9uIGNsYXNzaWZ5QnViYmxlKG5vZGUpIHsKICAgIGlmIChub2RlLm5vZGVUeXBlICE9PSAxKSByZXR1cm4gbnVsbDsKICAgIGlmIChoYXNQcmVmaXgobm9kZSwgInVzZXJNZXNzYWdlQ29udGFpbmVyXyIpKSByZXR1cm4gInVzZXIiOwogICAgaWYgKG5vZGUuZ2V0QXR0cmlidXRlICYmIG5vZGUuZ2V0QXR0cmlidXRlKCJkYXRhLXRlc3RpZCIpID09PSAiYXNzaXN0YW50LW1lc3NhZ2UiKSByZXR1cm4gImFzc2lzdGFudCI7CiAgICByZXR1cm4gbnVsbDsKICB9CgogIC8vIEJ1aWxkIHRoZSB3aG9sZS1jb252ZXJzYXRpb24gbWFya2Rvd24gZnJvbSBhbiBvcmRlcmVkIGxpc3Qgb2YgYnViYmxlcy4KICAvLyBgY29udGVudE9mKGJ1YmJsZSlgIHJlc29sdmVzIHRoZSBjb250ZW50IG5vZGUgKGRlZmF1bHQ6IHRoZSBidWJibGUgaXRzZWxmLCBzbwogIC8vIGV2ZXJ5IGNvbnRlbnQgYmxvY2sgaXMgaW5jbHVkZWQ7IHNhbml0aXplQ2xvbmUgZHJvcHMgY2hyb21lKTsgYSBkZWZhdWx0IGlzCiAgLy8gcHJvdmlkZWQgZm9yIHRlc3RzLgogIGZ1bmN0aW9uIGNvbnZlcnNhdGlvblRvTWFya2Rvd24oYnViYmxlcywgY29udGVudE9mKSB7CiAgICBjb250ZW50T2YgPSBjb250ZW50T2YgfHwgZnVuY3Rpb24gKGIpIHsgcmV0dXJuIGI7IH07CiAgICB2YXIgcGFydHMgPSBbXTsKICAgIGZvciAodmFyIGkgPSAwOyBpIDwgYnViYmxlcy5sZW5ndGg7IGkrKykgewogICAgICB2YXIgcm9sZSA9IGNsYXNzaWZ5QnViYmxlKGJ1YmJsZXNbaV0pOwogICAgICBpZiAoIXJvbGUpIGNvbnRpbnVlOwogICAgICB2YXIgY2xlYW4gPSBzYW5pdGl6ZUNsb25lKGNvbnRlbnRPZihidWJibGVzW2ldKSk7CiAgICAgIHZhciBib2R5ID0gcm9sZSA9PT0gImFzc2lzdGFudCIgPyBodG1sVG9NYXJrZG93bihjbGVhbikgOiAoY2xlYW4udGV4dENvbnRlbnQgfHwgIiIpLnRyaW0oKTsKICAgICAgaWYgKCFib2R5KSBjb250aW51ZTsKICAgICAgcGFydHMucHVzaCgocm9sZSA9PT0gInVzZXIiID8gIiMjIFVzZXIiIDogIiMjIEFzc2lzdGFudCIpICsgIlxuXG4iICsgYm9keSk7CiAgICB9CiAgICByZXR1cm4gcGFydHMuam9pbigiXG5cbiIpICsgKHBhcnRzLmxlbmd0aCA/ICJcbiIgOiAiIik7CiAgfQoKICAvLyAtLS0tIGV4cG9ydHMgKG5vZGUgdGVzdHMpIC8gYm9vdCAocmVhbCB3ZWJ2aWV3KSAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tCiAgaWYgKHR5cGVvZiBkb2N1bWVudCAhPT0gInVuZGVmaW5lZCIpIHsKICAgIGJvb3QoKTsKICB9IGVsc2UgaWYgKHR5cGVvZiBtb2R1bGUgIT09ICJ1bmRlZmluZWQiICYmIG1vZHVsZS5leHBvcnRzKSB7CiAgICBtb2R1bGUuZXhwb3J0cyA9IHsgaHRtbFRvTWFya2Rvd246IGh0bWxUb01hcmtkb3duLCBzYW5pdGl6ZUNsb25lOiBzYW5pdGl6ZUNsb25lLAogICAgICAgICAgICAgICAgICAgICAgIGNsYXNzaWZ5QnViYmxlOiBjbGFzc2lmeUJ1YmJsZSwgY29udmVyc2F0aW9uVG9NYXJrZG93bjogY29udmVyc2F0aW9uVG9NYXJrZG93biB9OwogIH0KCiAgLy8gLS0tLSBsaXZlLXdlYnZpZXcgd2lyaW5nIChydW5zIG9ubHkgd2hlbiBhIGRvY3VtZW50IGV4aXN0cykgLS0tLS0tLS0tLS0tLS0tLQogIGZ1bmN0aW9uIHFzKG5vZGUsIHNlbCkgeyB0cnkgeyByZXR1cm4gc2VsICYmIG5vZGUucXVlcnlTZWxlY3RvciA/IG5vZGUucXVlcnlTZWxlY3RvcihzZWwpIDogbnVsbDsgfSBjYXRjaCAoXykgeyByZXR1cm4gbnVsbDsgfSB9CiAgZnVuY3Rpb24gcXNhKHNlbCkgeyB0cnkgeyByZXR1cm4gQXJyYXkucHJvdG90eXBlLnNsaWNlLmNhbGwoZG9jdW1lbnQucXVlcnlTZWxlY3RvckFsbChzZWwpKTsgfSBjYXRjaCAoXykgeyByZXR1cm4gW107IH0gfQoKICAvLyBUaGUgY29udGVudCBub2RlIHRvIGNvbnZlcnQvY29weTogdGhlIG9wdGlvbmFsIEFTU0lTVEFOVF9DT05URU5UIHdyYXBwZXIgaWYKICAvLyBwaW5uZWQgYW5kIHByZXNlbnQsIGVsc2UgdGhlIGJ1YmJsZSBpdHNlbGYuIFRoZSBidWJibGUgYWxyZWFkeSBjb250YWlucyBldmVyeQogIC8vIGNvbnRlbnQtYmxvY2sgc2libGluZyBvZiBhIG11bHRpLWJsb2NrIHR1cm4sIGFuZCBzYW5pdGl6ZUNsb25lIHN0cmlwcyB0aGUKICAvLyBjaHJvbWUgKHJhdGluZyB3aWRnZXQsIHRvb2wvdGhpbmtpbmcvdW5rbm93biBibG9ja3MsIGJ1dHRvbnMsIG91ciBjb250cm9scykKICAvLyBlaXRoZXIgd2F5IC0tIHNvIHRoaXMgaXMgYSBuYXJyb3dpbmcsIG5ldmVyIHRoZSB0aGluZyB0aGF0IGd1YXJhbnRlZXMKICAvLyBjb3JyZWN0bmVzcy4KICBmdW5jdGlvbiBjb250ZW50Tm9kZU9mKGJ1YmJsZSwgcm9sZSkgewogICAgaWYgKHJvbGUgPT09ICJhc3Npc3RhbnQiICYmIEFTU0lTVEFOVF9DT05URU5UKSB7CiAgICAgIHZhciBuID0gcXMoYnViYmxlLCBBU1NJU1RBTlRfQ09OVEVOVCk7CiAgICAgIGlmIChuKSByZXR1cm4gbjsKICAgIH0KICAgIHJldHVybiBidWJibGU7CiAgfQoKICBmdW5jdGlvbiBjb3B5VGV4dCh0ZXh0KSB7CiAgICB0cnkgewogICAgICBpZiAobmF2aWdhdG9yLmNsaXBib2FyZCAmJiBuYXZpZ2F0b3IuY2xpcGJvYXJkLndyaXRlVGV4dCkgcmV0dXJuIG5hdmlnYXRvci5jbGlwYm9hcmQud3JpdGVUZXh0KHRleHQpOwogICAgfSBjYXRjaCAoXykge30KICAgIHJldHVybiBQcm9taXNlLnJlc29sdmUoKTsgLy8gYmVzdC1lZmZvcnQ7IG5ldmVyIHRocm93IGludG8gdGhlIGFwcAogIH0KCiAgZnVuY3Rpb24gZmxhc2hGZWVkYmFjayhob3N0KSB7CiAgICB0cnkgewogICAgICB2YXIgZmIgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCJzcGFuIik7CiAgICAgIGZiLmNsYXNzTmFtZSA9IENPTlRST0xfUFJFRklYICsgIi1mZWVkYmFjayI7CiAgICAgIGZiLnRleHRDb250ZW50ID0gIkNvcGllZCI7CiAgICAgIGhvc3QuYXBwZW5kQ2hpbGQoZmIpOwogICAgICBzZXRUaW1lb3V0KGZ1bmN0aW9uICgpIHsgaWYgKGZiICYmIGZiLnBhcmVudE5vZGUpIGZiLnBhcmVudE5vZGUucmVtb3ZlQ2hpbGQoZmIpOyB9LCBGRUVEQkFDS19NUyk7CiAgICB9IGNhdGNoIChfKSB7fQogIH0KCiAgZnVuY3Rpb24gYnViYmxlTWFya2Rvd24oYnViYmxlLCByb2xlKSB7CiAgICB2YXIgY2xlYW4gPSBzYW5pdGl6ZUNsb25lKGNvbnRlbnROb2RlT2YoYnViYmxlLCByb2xlKSk7CiAgICByZXR1cm4gcm9sZSA9PT0gImFzc2lzdGFudCIgPyBodG1sVG9NYXJrZG93bihjbGVhbikgOiAoY2xlYW4udGV4dENvbnRlbnQgfHwgIiIpLnRyaW0oKTsKICB9CiAgZnVuY3Rpb24gYnViYmxlUGxhaW4oYnViYmxlLCByb2xlKSB7CiAgICByZXR1cm4gKHNhbml0aXplQ2xvbmUoY29udGVudE5vZGVPZihidWJibGUsIHJvbGUpKS50ZXh0Q29udGVudCB8fCAiIikudHJpbSgpOwogIH0KCiAgLy8gQnVpbGQgYSBzaW5nbGUgY29udHJvbDogYSBwcmltYXJ5ICJDb3B5IiAobWFya2Rvd24pIHBsdXMgYSBzbWFsbCBjYXJldCB0aGF0CiAgLy8gdG9nZ2xlcyBhIG1lbnUgd2l0aCAiQ29weSBhcyBwbGFpbiB0ZXh0Ii4gQWxsIG5vZGVzIGNhcnJ5IHRoZSBDT05UUk9MX1BSRUZJWAogIC8vIGNsYXNzIHNvIHNhbml0aXplQ2xvbmUgcmVtb3ZlcyB0aGVtIGZyb20gYW55IGNvcGllZCBjb250ZW50LgogIGZ1bmN0aW9uIGJ1aWxkQ29udHJvbChvbk1hcmtkb3duLCBvblBsYWluKSB7CiAgICB2YXIgd3JhcCA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoInNwYW4iKTsKICAgIHdyYXAuY2xhc3NOYW1lID0gQ09OVFJPTF9QUkVGSVg7CiAgICB2YXIgcHJpbWFyeSA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoImJ1dHRvbiIpOwogICAgcHJpbWFyeS50eXBlID0gImJ1dHRvbiI7CiAgICBwcmltYXJ5LmNsYXNzTmFtZSA9IENPTlRST0xfUFJFRklYICsgIi1idG4iOwogICAgcHJpbWFyeS50aXRsZSA9ICJDb3B5IGFzIE1hcmtkb3duIjsKICAgIHByaW1hcnkudGV4dENvbnRlbnQgPSAiQ29weSI7CiAgICBwcmltYXJ5LmFkZEV2ZW50TGlzdGVuZXIoImNsaWNrIiwgZnVuY3Rpb24gKGUpIHsgZS5zdG9wUHJvcGFnYXRpb24oKTsgb25NYXJrZG93bihwcmltYXJ5KTsgfSk7CiAgICB2YXIgY2FyZXQgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCJidXR0b24iKTsKICAgIGNhcmV0LnR5cGUgPSAiYnV0dG9uIjsKICAgIGNhcmV0LmNsYXNzTmFtZSA9IENPTlRST0xfUFJFRklYICsgIi1jYXJldCI7CiAgICBjYXJldC50aXRsZSA9ICJDb3B5IG9wdGlvbnMiOwogICAgY2FyZXQudGV4dENvbnRlbnQgPSAi4pa+IjsgLy8gYmxhY2sgZG93bi1wb2ludGluZyBzbWFsbCB0cmlhbmdsZQogICAgdmFyIG1lbnUgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCJzcGFuIik7CiAgICBtZW51LmNsYXNzTmFtZSA9IENPTlRST0xfUFJFRklYICsgIi1tZW51IjsKICAgIG1lbnUuc3R5bGUuZGlzcGxheSA9ICJub25lIjsKICAgIHZhciBwbGFpbiA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoImJ1dHRvbiIpOwogICAgcGxhaW4udHlwZSA9ICJidXR0b24iOwogICAgcGxhaW4uY2xhc3NOYW1lID0gQ09OVFJPTF9QUkVGSVggKyAiLWJ0biI7CiAgICBwbGFpbi50ZXh0Q29udGVudCA9ICJDb3B5IGFzIHBsYWluIHRleHQiOwogICAgcGxhaW4uYWRkRXZlbnRMaXN0ZW5lcigiY2xpY2siLCBmdW5jdGlvbiAoZSkgeyBlLnN0b3BQcm9wYWdhdGlvbigpOyBtZW51LnN0eWxlLmRpc3BsYXkgPSAibm9uZSI7IG9uUGxhaW4ocGxhaW4pOyB9KTsKICAgIG1lbnUuYXBwZW5kQ2hpbGQocGxhaW4pOwogICAgY2FyZXQuYWRkRXZlbnRMaXN0ZW5lcigiY2xpY2siLCBmdW5jdGlvbiAoZSkgewogICAgICBlLnN0b3BQcm9wYWdhdGlvbigpOwogICAgICBtZW51LnN0eWxlLmRpc3BsYXkgPSBtZW51LnN0eWxlLmRpc3BsYXkgPT09ICJub25lIiA/ICJpbmxpbmUtYmxvY2siIDogIm5vbmUiOwogICAgfSk7CiAgICB3cmFwLmFwcGVuZENoaWxkKHByaW1hcnkpOwogICAgd3JhcC5hcHBlbmRDaGlsZChjYXJldCk7CiAgICB3cmFwLmFwcGVuZENoaWxkKG1lbnUpOwogICAgcmV0dXJuIHdyYXA7CiAgfQoKICBmdW5jdGlvbiBkZWNvcmF0ZShidWJibGUpIHsKICAgIHRyeSB7CiAgICAgIHZhciByb2xlID0gY2xhc3NpZnlCdWJibGUoYnViYmxlKTsKICAgICAgaWYgKCFyb2xlKSByZXR1cm47CiAgICAgIGlmIChxcyhidWJibGUsICIuIiArIENPTlRST0xfUFJFRklYKSkgcmV0dXJuOyAvLyBhbHJlYWR5IGRlY29yYXRlZAogICAgICB2YXIgY29udHJvbCA9IGJ1aWxkQ29udHJvbCgKICAgICAgICBmdW5jdGlvbiAoaG9zdCkgeyBjb3B5VGV4dChidWJibGVNYXJrZG93bihidWJibGUsIHJvbGUpKS50aGVuKGZ1bmN0aW9uICgpIHsgZmxhc2hGZWVkYmFjayhjb250cm9sKTsgfSk7IH0sCiAgICAgICAgZnVuY3Rpb24gKGhvc3QpIHsgY29weVRleHQoYnViYmxlUGxhaW4oYnViYmxlLCByb2xlKSkudGhlbihmdW5jdGlvbiAoKSB7IGZsYXNoRmVlZGJhY2soY29udHJvbCk7IH0pOyB9CiAgICAgICk7CiAgICAgIGJ1YmJsZS5hcHBlbmRDaGlsZChjb250cm9sKTsKICAgIH0gY2F0Y2ggKF8pIHt9CiAgfQoKICBmdW5jdGlvbiBjb3B5Q29udmVyc2F0aW9uKGZvcm1hdCkgewogICAgdmFyIGJ1YmJsZXMgPSBxc2EoVVNFUl9CVUJCTEUgKyAiLCIgKyBBU1NJU1RBTlRfQlVCQkxFKTsKICAgIGlmIChmb3JtYXQgPT09ICJ0ZXh0IikgewogICAgICB2YXIgbGluZXMgPSBbXTsKICAgICAgZm9yICh2YXIgaSA9IDA7IGkgPCBidWJibGVzLmxlbmd0aDsgaSsrKSB7CiAgICAgICAgdmFyIHJvbGUgPSBjbGFzc2lmeUJ1YmJsZShidWJibGVzW2ldKTsKICAgICAgICBpZiAoIXJvbGUpIGNvbnRpbnVlOwogICAgICAgIHZhciBib2R5ID0gYnViYmxlUGxhaW4oYnViYmxlc1tpXSwgcm9sZSk7CiAgICAgICAgaWYgKGJvZHkpIGxpbmVzLnB1c2goYm9keSk7CiAgICAgIH0KICAgICAgcmV0dXJuIGNvcHlUZXh0KGxpbmVzLmpvaW4oIlxuXG4iKSArIChsaW5lcy5sZW5ndGggPyAiXG4iIDogIiIpKTsKICAgIH0KICAgIHJldHVybiBjb3B5VGV4dChjb252ZXJzYXRpb25Ub01hcmtkb3duKGJ1YmJsZXMsIGZ1bmN0aW9uIChiKSB7CiAgICAgIHJldHVybiBjb250ZW50Tm9kZU9mKGIsIGNsYXNzaWZ5QnViYmxlKGIpKTsKICAgIH0pKTsKICB9CgogIGZ1bmN0aW9uIGluc3RhbGxDb252ZXJzYXRpb25Db250cm9sKCkgewogICAgdHJ5IHsKICAgICAgaWYgKHFzKGRvY3VtZW50LCAiLiIgKyBDT05UUk9MX1BSRUZJWCArICItY29udmVyc2F0aW9uIikpIHJldHVybjsKICAgICAgdmFyIGJhciA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoImRpdiIpOwogICAgICBiYXIuY2xhc3NOYW1lID0gQ09OVFJPTF9QUkVGSVggKyAiLWNvbnZlcnNhdGlvbiI7CiAgICAgIHZhciBjb250cm9sID0gYnVpbGRDb250cm9sKAogICAgICAgIGZ1bmN0aW9uICgpIHsgY29weUNvbnZlcnNhdGlvbigibWFya2Rvd24iKS50aGVuKGZ1bmN0aW9uICgpIHsgZmxhc2hGZWVkYmFjayhiYXIpOyB9KTsgfSwKICAgICAgICBmdW5jdGlvbiAoKSB7IGNvcHlDb252ZXJzYXRpb24oInRleHQiKS50aGVuKGZ1bmN0aW9uICgpIHsgZmxhc2hGZWVkYmFjayhiYXIpOyB9KTsgfQogICAgICApOwogICAgICBjb250cm9sLnRpdGxlID0gIkNvcHkgZW50aXJlIGNvbnZlcnNhdGlvbiI7CiAgICAgIGJhci5hcHBlbmRDaGlsZChjb250cm9sKTsKICAgICAgZG9jdW1lbnQuYm9keS5hcHBlbmRDaGlsZChiYXIpOyAvLyBmaXhlZC1wb3NpdGlvbiB2aWEgQ1NTOyBwbGFjZW1lbnQgcmVmaW5lZCBpbiBUYXNrIDYKICAgIH0gY2F0Y2ggKF8pIHt9CiAgfQoKICBmdW5jdGlvbiBzd2VlcCgpIHsgdmFyIGIgPSBxc2EoVVNFUl9CVUJCTEUgKyAiLCIgKyBBU1NJU1RBTlRfQlVCQkxFKTsgZm9yICh2YXIgaSA9IDA7IGkgPCBiLmxlbmd0aDsgaSsrKSBkZWNvcmF0ZShiW2ldKTsgfQoKICBmdW5jdGlvbiBib290KCkgewogICAgdHJ5IHsKICAgICAgdmFyIHRhcmdldCA9IChNRVNTQUdFU19DT05UQUlORVIgJiYgcXMoZG9jdW1lbnQsIE1FU1NBR0VTX0NPTlRBSU5FUikpIHx8IGRvY3VtZW50LmJvZHk7CiAgICAgIHN3ZWVwKCk7CiAgICAgIGluc3RhbGxDb252ZXJzYXRpb25Db250cm9sKCk7CiAgICAgIGlmICh0eXBlb2YgTXV0YXRpb25PYnNlcnZlciA9PT0gInVuZGVmaW5lZCIpIHJldHVybjsKICAgICAgdmFyIG9icyA9IG5ldyBNdXRhdGlvbk9ic2VydmVyKGZ1bmN0aW9uICgpIHsgc3dlZXAoKTsgfSk7CiAgICAgIG9icy5vYnNlcnZlKHRhcmdldCwgeyBjaGlsZExpc3Q6IHRydWUsIHN1YnRyZWU6IHRydWUgfSk7CiAgICB9IGNhdGNoIChfKSB7fQogIH0KfSkoKTsK").decode("utf-8") +INJECT_CSS = base64.b64decode("LmNjLW1kLWNvcHkgewogIGRpc3BsYXk6IGlubGluZS1mbGV4OwogIGFsaWduLWl0ZW1zOiBjZW50ZXI7CiAgZ2FwOiAycHg7CiAgdmVydGljYWwtYWxpZ246IG1pZGRsZTsKICBtYXJnaW4tbGVmdDogNnB4Owp9Ci5jYy1tZC1jb3B5LWJ0biwKLmNjLW1kLWNvcHktY2FyZXQgewogIGZvbnQ6IGluaGVyaXQ7CiAgZm9udC1zaXplOiAxMXB4OwogIGxpbmUtaGVpZ2h0OiAxLjQ7CiAgcGFkZGluZzogMXB4IDZweDsKICBjb2xvcjogdmFyKC0tdnNjb2RlLWZvcmVncm91bmQpOwogIGJhY2tncm91bmQ6IHRyYW5zcGFyZW50OwogIGJvcmRlcjogMXB4IHNvbGlkIHZhcigtLXZzY29kZS13aWRnZXQtYm9yZGVyLCB0cmFuc3BhcmVudCk7CiAgYm9yZGVyLXJhZGl1czogNHB4OwogIGN1cnNvcjogcG9pbnRlcjsKICBvcGFjaXR5OiAwLjY1Owp9Ci5jYy1tZC1jb3B5LWJ0bjpob3ZlciwKLmNjLW1kLWNvcHktY2FyZXQ6aG92ZXIgewogIG9wYWNpdHk6IDE7CiAgYmFja2dyb3VuZDogdmFyKC0tdnNjb2RlLXRvb2xiYXItaG92ZXJCYWNrZ3JvdW5kLCByZ2JhKDEyOCwgMTI4LCAxMjgsIDAuMTUpKTsKfQouY2MtbWQtY29weS1tZW51IHsKICBwb3NpdGlvbjogcmVsYXRpdmU7CiAgbWFyZ2luLWxlZnQ6IDRweDsKICBwYWRkaW5nOiAycHg7CiAgYmFja2dyb3VuZDogdmFyKC0tdnNjb2RlLW1lbnUtYmFja2dyb3VuZCwgdmFyKC0tdnNjb2RlLWVkaXRvcldpZGdldC1iYWNrZ3JvdW5kKSk7CiAgYm9yZGVyOiAxcHggc29saWQgdmFyKC0tdnNjb2RlLW1lbnUtYm9yZGVyLCB2YXIoLS12c2NvZGUtd2lkZ2V0LWJvcmRlciwgdHJhbnNwYXJlbnQpKTsKICBib3JkZXItcmFkaXVzOiA0cHg7CiAgei1pbmRleDogNTsKfQouY2MtbWQtY29weS1mZWVkYmFjayB7CiAgbWFyZ2luLWxlZnQ6IDZweDsKICBmb250LXNpemU6IDExcHg7CiAgb3BhY2l0eTogMC44NTsKICBjb2xvcjogdmFyKC0tdnNjb2RlLWZvcmVncm91bmQpOwp9Ci5jYy1tZC1jb3B5LWNvbnZlcnNhdGlvbiB7CiAgcG9zaXRpb246IGZpeGVkOwogIHJpZ2h0OiAxNnB4OwogIGJvdHRvbTogNTZweDsKICB6LWluZGV4OiAxMDsKICBwYWRkaW5nOiAycHg7CiAgYmFja2dyb3VuZDogdmFyKC0tdnNjb2RlLWVkaXRvcldpZGdldC1iYWNrZ3JvdW5kKTsKICBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS12c2NvZGUtd2lkZ2V0LWJvcmRlciwgdHJhbnNwYXJlbnQpOwogIGJvcmRlci1yYWRpdXM6IDZweDsKICBvcGFjaXR5OiAwLjg1Owp9Ci5jYy1tZC1jb3B5LWNvbnZlcnNhdGlvbjpob3ZlciB7CiAgb3BhY2l0eTogMTsKfQo=").decode("utf-8") +# << the CSS block; everything else (index.js) -> the JS block.""" + return INJECT_CSS if path.endswith(".css") else INJECT_JS + + +def write_atomic_preserving_metadata(path, text): + st = os.stat(path) + directory = os.path.dirname(path) or "." + basename = os.path.basename(path) + fd, tmp = tempfile.mkstemp(prefix="." + basename + ".", suffix=".tmp", dir=directory) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="") as f: + f.write(text) + f.flush() + os.fsync(f.fileno()) + try: + os.chown(tmp, st.st_uid, st.st_gid) + except (AttributeError, PermissionError, OSError): + pass + shutil.copystat(path, tmp) + os.replace(tmp, path) + tmp = None + finally: + if tmp is not None: + try: + os.unlink(tmp) + except FileNotFoundError: + pass + + +def patch_file(path, payload): + with open(path, "r", encoding="utf-8", newline="") as f: + data = f.read() + if OPEN in data: + return "already-patched" + if not payload.strip(): + return "no-payload (run tools/gen-embeds)" + backup = path + BACKUP_SUFFIX + if not os.path.exists(backup): + with open(backup, "w", encoding="utf-8", newline="") as b: + b.write(data) + block = OPEN + "\n" + payload.rstrip("\n") + "\n" + CLOSE + write_atomic_preserving_metadata(path, data + "\n" + block + "\n") + return "PATCHED" + + +def revert_file(path): + with open(path, "r", encoding="utf-8", newline="") as f: + data = f.read() + if OPEN not in data: + return "not-patched" + write_atomic_preserving_metadata(path, _BLOCK_RE.sub("", data, count=1)) + return "REVERTED" + + +def siblings(js_path): + """Given a .../webview/index.js, also target the sibling index.css.""" + targets = [js_path] + css = os.path.join(os.path.dirname(js_path), "index.css") + if os.path.isfile(css): + targets.append(css) + return targets + + +def main(argv): + revert = "--revert" in argv + explicit = [a for a in argv if not a.startswith("--")] + if explicit: + # An explicit .../webview/index.js expands to its index.css sibling too, so + # `add-md-copy.py .../index.js` patches BOTH files (matching auto-discovery + # and the index.js+index.css architecture). An explicit index.css (or a + # webview dir, resolved by siblings() to js+css) stays as given. + targets = [] + for p in explicit: + rp = os.path.realpath(p) + if os.path.basename(rp) == "index.js": + targets.extend(siblings(rp)) + elif os.path.isdir(rp): + targets.extend(siblings(os.path.join(rp, "index.js"))) + else: + targets.append(rp) + seen = set() + targets = [t for t in targets if not (t in seen or seen.add(t))] + else: + targets = [] + for js in discover(): + targets.extend(siblings(js)) + if not targets: + print("No Claude Code extension webview found.") + return 1 + action = (lambda p: revert_file(p)) if revert else (lambda p: patch_file(p, payload_for(p))) + print(("Reverting" if revert else "Patching") + " %d file(s):\n" % len(targets)) + changed = 0 + for t in targets: + try: + status = action(t) + except OSError as e: + status = "ERROR: %s" % e + if status in ("PATCHED", "REVERTED"): + changed += 1 + print(" [%s] %s" % (status, t)) + print("\n%d file(s) changed." % changed) + if changed: + print('Reload the webview to apply: Command Palette -> "Developer: Reload Window".') + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/fixes/markdown-copy-export/cc-export.py b/fixes/markdown-copy-export/cc-export.py new file mode 100644 index 0000000..be0979e --- /dev/null +++ b/fixes/markdown-copy-export/cc-export.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""cc-export - export a Claude Code session transcript to Markdown or plain text. + +Reads the session JSONL directly (the high-fidelity source: exact text, no +re-conversion), so it is independent of the VS Code webview. Default output is +the readable conversation only (user text + assistant text); thinking and tool +calls are opt-in. `--open` opens the raw .jsonl in the editor instead. + + python3 cc-export.py # latest session in this cwd -> stdout (markdown) + python3 cc-export.py --session ID # a specific session id + python3 cc-export.py --format text # plain text + python3 cc-export.py --include-thinking --include-tools + python3 cc-export.py -o out.md # write to a file + python3 cc-export.py --open # open the raw transcript in VS Code + python3 cc-export.py --cwd /path/to/proj # resolve as if launched from there + +The JSONL is an internal format that can change, so each row's shape is validated +and unknown shapes are skipped rather than assuming a fixed schema. +""" +import argparse +import glob +import json +import os +import subprocess +import sys +import unicodedata + + +def config_dir(env=None): + env = env if env is not None else os.environ + return env.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude") + + +def project_key_for_cwd(cwd): + """The on-disk project dir name: realpath, NFC-normalized, slashes -> dashes.""" + real = os.path.realpath(cwd) + norm = unicodedata.normalize("NFC", real) + return norm.replace(os.sep, "-").replace("/", "-") + + +def resolve_session(config, cwd, session_id): + """Return the path to the session JSONL, or None. + + With an explicit id: look in this cwd's project dir, then scan all project + dirs. With no id: the most recently modified .jsonl in this cwd's project dir. + """ + projects = os.path.join(config, "projects") + key = project_key_for_cwd(cwd) + proj_dir = os.path.join(projects, key) + if session_id: + local = os.path.join(proj_dir, f"{session_id}.jsonl") + if os.path.isfile(local): + return local + for hit in glob.glob(os.path.join(projects, "*", f"{session_id}.jsonl")): + if os.path.isfile(hit): + return hit + return None + candidates = glob.glob(os.path.join(proj_dir, "*.jsonl")) + if not candidates: + return None + return max(candidates, key=lambda p: os.path.getmtime(p)) + + +def read_rows(path): + rows = [] + with open(path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + rows.append(json.loads(line)) + except json.JSONDecodeError: + continue # skip a corrupt line rather than abort + return rows + + +def _blocks(content): + """Normalize a message's content to a list of block dicts. + + Content may be a plain string (treated as one text block) or a list. + """ + if isinstance(content, str): + return [{"type": "text", "text": content}] + if isinstance(content, list): + return [b for b in content if isinstance(b, dict)] + return [] + + +def render(rows, fmt="markdown", include_thinking=False, include_tools=False): + """Render rows to markdown (with role headers) or plain text.""" + md = fmt != "text" + parts = [] + for row in rows: + if not isinstance(row, dict): + continue + rtype = row.get("type") + if rtype not in ("user", "assistant"): + continue # skip summary / file-history-snapshot / etc. + message = row.get("message") + if not isinstance(message, dict): + continue + blocks = _blocks(message.get("content")) + chunks = [] + for b in blocks: + bt = b.get("type") + if bt == "text": + t = b.get("text") + if isinstance(t, str) and t.strip(): + chunks.append(t.rstrip()) + elif bt == "thinking" and include_thinking: + t = b.get("thinking") + if isinstance(t, str) and t.strip(): + chunks.append(_fence("thinking", t) if md else t.rstrip()) + elif bt == "tool_use" and include_tools: + name = b.get("name", "tool") + payload = json.dumps(b.get("input", {}), indent=2, ensure_ascii=False) + chunks.append(_fence(f"tool_use {name}", payload) if md else f"[tool_use {name}] {payload}") + elif bt == "tool_result" and include_tools: + payload = b.get("content") + if isinstance(payload, (dict, list)): + payload = json.dumps(payload, indent=2, ensure_ascii=False) + payload = "" if payload is None else str(payload) + chunks.append(_fence("tool_result", payload) if md else f"[tool_result] {payload}") + if not chunks: + continue + body = "\n\n".join(chunks) + if md: + header = "## User" if rtype == "user" else "## Assistant" + parts.append(f"{header}\n\n{body}") + else: + parts.append(body) + return ("\n\n".join(parts) + "\n") if parts else "" + + +def _fence(label, text): + # Pick a fence longer than the longest backtick run in the body, so content + # that itself contains ``` cannot close the fence early. + text = text.rstrip() + longest = run = 0 + for ch in text: + if ch == "`": + run += 1 + longest = max(longest, run) + else: + run = 0 + fence = "`" * max(3, longest + 1) + return f"{fence}{label}\n{text}\n{fence}" + + +def main(argv=None, config=None, env=None): + env = env if env is not None else os.environ + argv = list(sys.argv[1:] if argv is None else argv) + ap = argparse.ArgumentParser(prog="cc-export", description="Export a Claude Code session.") + ap.add_argument("--session", default=None, help="session id (default: latest in cwd)") + ap.add_argument("--cwd", default=None, help="resolve as if launched from this dir") + ap.add_argument("--format", choices=("markdown", "text"), default="markdown") + ap.add_argument("--include-thinking", action="store_true") + ap.add_argument("--include-tools", action="store_true") + ap.add_argument("--open", action="store_true", help="open the raw .jsonl in VS Code") + ap.add_argument("-o", "--output", default=None, help="write to FILE (default: stdout)") + args = ap.parse_args(argv) + + conf = config if config is not None else config_dir(env) + cwd = args.cwd or os.getcwd() + path = resolve_session(conf, cwd, args.session) + if not path: + sys.stderr.write("cc-export: no matching session transcript found\n") + return 1 + + if args.open: + editor = env.get("CC_EDITOR", "code") + try: + subprocess.run([editor, path], check=False, env=env) + except FileNotFoundError: + sys.stderr.write(f"cc-export: editor '{editor}' not found on PATH\n") + return 1 + return 0 + + out = render( + read_rows(path), + fmt=args.format, + include_thinking=args.include_thinking, + include_tools=args.include_tools, + ) + if args.output: + with open(args.output, "w", encoding="utf-8") as f: + f.write(out) + else: + sys.stdout.write(out) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/fixes/markdown-copy-export/webview-inject.css b/fixes/markdown-copy-export/webview-inject.css new file mode 100644 index 0000000..21ce251 --- /dev/null +++ b/fixes/markdown-copy-export/webview-inject.css @@ -0,0 +1,54 @@ +.cc-md-copy { + display: inline-flex; + align-items: center; + gap: 2px; + vertical-align: middle; + margin-left: 6px; +} +.cc-md-copy-btn, +.cc-md-copy-caret { + font: inherit; + font-size: 11px; + line-height: 1.4; + padding: 1px 6px; + color: var(--vscode-foreground); + background: transparent; + border: 1px solid var(--vscode-widget-border, transparent); + border-radius: 4px; + cursor: pointer; + opacity: 0.65; +} +.cc-md-copy-btn:hover, +.cc-md-copy-caret:hover { + opacity: 1; + background: var(--vscode-toolbar-hoverBackground, rgba(128, 128, 128, 0.15)); +} +.cc-md-copy-menu { + position: relative; + margin-left: 4px; + padding: 2px; + background: var(--vscode-menu-background, var(--vscode-editorWidget-background)); + border: 1px solid var(--vscode-menu-border, var(--vscode-widget-border, transparent)); + border-radius: 4px; + z-index: 5; +} +.cc-md-copy-feedback { + margin-left: 6px; + font-size: 11px; + opacity: 0.85; + color: var(--vscode-foreground); +} +.cc-md-copy-conversation { + position: fixed; + right: 16px; + bottom: 56px; + z-index: 10; + padding: 2px; + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-widget-border, transparent); + border-radius: 6px; + opacity: 0.85; +} +.cc-md-copy-conversation:hover { + opacity: 1; +} diff --git a/fixes/markdown-copy-export/webview-inject.js b/fixes/markdown-copy-export/webview-inject.js new file mode 100644 index 0000000..947d691 --- /dev/null +++ b/fixes/markdown-copy-export/webview-inject.js @@ -0,0 +1,380 @@ +/* cc-md-copy: per-message and whole-conversation copy (markdown/plain) for the + * Claude Code VS Code webview. Self-contained IIFE appended to webview/index.js. + * Additive and read-only w.r.t. app state; keyed on stable CSS-module class + * prefixes, so it fails safe (controls simply do not appear) if a prefix moves. + * Exposes its pure functions for node unit tests; boot()s only in a real webview. */ +(function () { + "use strict"; + + var CONTROL_PREFIX = "cc-md-copy"; // every injected node's class starts with this + var USER_BUBBLE = '[class*="userMessageContainer_"]'; + // Assistant message wrapper. Verified on 2.1.170: the render emits exactly one + // `data-testid="assistant-message"` div per assistant turn, with the rating + // widget and content blocks as its children. (The earlier `[data-message-rating]` + // was WRONG: that attribute sits on the nested rating control, which is also only + // rendered behind an experiment+analytics gate.) Re-pinned in Task 6. + var ASSISTANT_BUBBLE = '[data-testid="assistant-message"]'; + var MESSAGES_CONTAINER = '[class*="messagesContainer_"]'; // e.g. '[class*="timeline_"]'; "" -> observe document.body + // Optional narrowing only. MUST be a single wrapper around ALL content blocks, + // not a per-block class (a turn has multiple blocks). "" -> use the bubble itself + // (already aggregates all blocks; sanitizeClone is the correctness gate). + var ASSISTANT_CONTENT = ""; + var FEEDBACK_MS = 1800; + + // ---- HTML -> Markdown (DOM walk) ------------------------------------------- + // Uses only: nodeType, tagName, childNodes, textContent, getAttribute, className. + function htmlToMarkdown(root) { + // Longest run of consecutive backticks in s, so a code delimiter/fence can be + // chosen longer than anything inside it (else ``` in the content closes early). + function backtickRun(s) { + var max = 0, cur = 0; + for (var i = 0; i < s.length; i++) { + if (s.charAt(i) === "`") { cur++; if (cur > max) max = cur; } else cur = 0; + } + return max; + } + function fence(s, min) { var n = backtickRun(s) + 1; if (n < min) n = min; return new Array(n + 1).join("`"); } + function inline(node) { + var out = ""; + var kids = node.childNodes || []; + for (var i = 0; i < kids.length; i++) { + var c = kids[i]; + if (c.nodeType === 3) { out += c.textContent || ""; continue; } + if (c.nodeType !== 1) continue; + var tag = (c.tagName || "").toUpperCase(); + if (tag === "BR") out += "\n"; + else if (tag === "STRONG" || tag === "B") out += "**" + inline(c) + "**"; + else if (tag === "EM" || tag === "I") out += "*" + inline(c) + "*"; + else if (tag === "DEL" || tag === "S") out += "~~" + inline(c) + "~~"; + else if (tag === "CODE") { + var ct = c.textContent || ""; + var d = fence(ct, 1); + // CommonMark strips one leading+trailing space, so pad when an edge is a + // backtick to keep it from merging with the delimiter. + var p = (ct.charAt(0) === "`" || ct.charAt(ct.length - 1) === "`") ? " " : ""; + out += d + p + ct + p + d; + } + else if (tag === "A") { + var href = c.getAttribute ? c.getAttribute("href") : null; + var t = inline(c); + out += href ? "[" + t + "](" + href + ")" : t; + } else out += inline(c); // unknown inline wrapper: keep text, drop tag + } + return out; + } + function langOf(codeEl) { + var cls = ""; + if (codeEl) cls = (codeEl.getAttribute && codeEl.getAttribute("class")) || codeEl.className || ""; + var m = /language-([A-Za-z0-9+#.\-]+)/.exec(cls || ""); + return m ? m[1] : ""; + } + function findChildTag(node, tag) { + var kids = node.childNodes || []; + for (var i = 0; i < kids.length; i++) { + if (kids[i].nodeType === 1 && (kids[i].tagName || "").toUpperCase() === tag) return kids[i]; + } + return null; + } + function list(node, ordered, depth) { + var out = "", n = 1; + var kids = node.childNodes || []; + for (var i = 0; i < kids.length; i++) { + var li = kids[i]; + if (li.nodeType !== 1 || (li.tagName || "").toUpperCase() !== "LI") continue; + var marker = ordered ? n++ + ". " : "- "; + var indent = new Array(depth + 1).join(" "); + var lead = "", nested = ""; + var lk = li.childNodes || []; + for (var j = 0; j < lk.length; j++) { + var ch = lk[j]; + var ct = ch.nodeType === 1 ? (ch.tagName || "").toUpperCase() : ""; + if (ct === "UL") nested += list(ch, false, depth + 1); + else if (ct === "OL") nested += list(ch, true, depth + 1); + else if (ch.nodeType === 3) lead += ch.textContent || ""; + else lead += inline(ch); + } + out += indent + marker + lead.trim() + "\n" + nested; + } + return out; + } + function table(node) { + var rows = []; + (function collect(container) { + var kids = container.childNodes || []; + for (var i = 0; i < kids.length; i++) { + var c = kids[i]; + if (c.nodeType !== 1) continue; + var t = (c.tagName || "").toUpperCase(); + if (t === "THEAD" || t === "TBODY" || t === "TFOOT") collect(c); + else if (t === "TR") { + var cells = [], cc = c.childNodes || []; + for (var j = 0; j < cc.length; j++) { + var d = cc[j]; + if (d.nodeType !== 1) continue; + var dt = (d.tagName || "").toUpperCase(); + if (dt === "TH" || dt === "TD") cells.push(inline(d).trim()); + } + rows.push(cells); + } + } + })(node); + if (!rows.length) return ""; + var head = rows[0], body = rows.slice(1); + var sep = head.map(function () { return "---"; }); + var out = "| " + head.join(" | ") + " |\n| " + sep.join(" | ") + " |\n"; + for (var k = 0; k < body.length; k++) out += "| " + body[k].join(" | ") + " |\n"; + return out; + } + function block(node) { + var out = ""; + var kids = node.childNodes || []; + for (var i = 0; i < kids.length; i++) { + var c = kids[i]; + if (c.nodeType === 3) { if ((c.textContent || "").trim()) out += c.textContent; continue; } + if (c.nodeType !== 1) continue; + var tag = (c.tagName || "").toUpperCase(); + if (/^H[1-6]$/.test(tag)) out += new Array(+tag[1] + 1).join("#") + " " + inline(c).trim() + "\n\n"; + else if (tag === "P") out += inline(c).trim() + "\n\n"; + else if (tag === "UL") out += list(c, false, 0) + "\n"; + else if (tag === "OL") out += list(c, true, 0) + "\n"; + else if (tag === "PRE") { + var code = findChildTag(c, "CODE"); + var lang = langOf(code || c); + var body = (code || c).textContent || ""; + var f = fence(body, 3); + out += f + lang + "\n" + body.replace(/\n$/, "") + "\n" + f + "\n\n"; + } else if (tag === "BLOCKQUOTE") { + var inner = block(c).trim().split("\n").map(function (l) { return "> " + l; }).join("\n"); + out += inner + "\n\n"; + } else if (tag === "HR") out += "---\n\n"; + else if (tag === "TABLE") out += table(c) + "\n"; + else if (tag === "BR") out += "\n"; + else if (tag === "STRONG" || tag === "B" || tag === "EM" || tag === "I" || + tag === "A" || tag === "CODE" || tag === "DEL" || tag === "S") + out += inline(c) + "\n\n"; + else out += block(c); // unknown wrapper: recurse (drop tag, keep content) + } + return out; + } + // block() dispatches on each CHILD's tag, treating the passed node as a plain + // container. Wrap root in a one-off container so root's OWN tag is dispatched + // too: callers pass either the bubble container (its block children render) or + // a single block element like
/