diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 226b447..9c5f8dc 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -16,8 +16,10 @@ jobs: run: | python3 -c "import json; json.load(open('.claude-plugin/marketplace.json')); print('marketplace.json OK')" python3 -c "import json; json.load(open('plugins/kitty-image/.claude-plugin/plugin.json')); print('plugin.json OK')" - - name: Compile show.py - run: python3 -m py_compile plugins/kitty-image/skills/kitty-image/show.py + - name: Compile Python scripts + run: | + python3 -m py_compile plugins/kitty-image/skills/kitty-image/show.py + python3 -m py_compile plugins/kitty-image/skills/kitty-image/mermaid.py - name: Check SKILL.md frontmatter run: | head -1 plugins/kitty-image/skills/kitty-image/SKILL.md | grep -qx -- '---' && echo "SKILL.md frontmatter OK" diff --git a/CHANGELOG.md b/CHANGELOG.md index f197176..6fb82f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,16 @@ All notable changes to this project are documented here. The format is based on [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + +## [0.2.0] - 2026-05-29 +### Added +- **Mermaid diagram rendering** (`mermaid.py`): turn a Mermaid definition (flowchart, sequence, state, ER, class, architecture) into an inline PNG. Renders the diagram and hands off to `show.py` for display in one step; reads from a `.mmd` file or stdin (`-`). +- Local-first rendering for privacy: uses `mmdc` if present, else `npx -p @mermaid-js/mermaid-cli mmdc` pointed at an already-installed Chrome (no Puppeteer Chromium download). The diagram text never leaves the machine unless `--remote` is passed, which renders via the public mermaid.ink service. +- Defaults (`--theme dark`, `--bg '#10121a'`, `--scale 2`) match the existing Pillow chart aesthetic. Dedicated exit codes 10–13 for renderer-missing, render-failure, remote-failure, and bad-input. + ### Changed - Relicensed from PolyForm Noncommercial 1.0.0 to MIT — free to use, modify, and distribute, including commercially. +- Documented `show.py` exit code 6 (non-PNG conversion failure) in the SKILL.md failure-modes table. ## [0.1.2] - 2026-05-23 ### Changed @@ -23,7 +31,8 @@ All notable changes to this project are documented here. The format is based on - Initial release: display PNG/JPEG images inline in a Claude Code session running in the Kitty terminal, working around the Bash tool's lack of a controlling TTY. - stdout-based row reservation so Claude's following text does not overlap the rendered image. -[Unreleased]: https://github.com/whallil/claude-kitty-image/compare/v0.1.2...HEAD +[Unreleased]: https://github.com/whallil/claude-kitty-image/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/whallil/claude-kitty-image/compare/v0.1.2...v0.2.0 [0.1.2]: https://github.com/whallil/claude-kitty-image/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/whallil/claude-kitty-image/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/whallil/claude-kitty-image/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 7569ace..ba9e131 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Show real PNG/JPEG images **inline** in a [Claude Code](https://claude.com/claud ![Claude Code rendering a generated image inline in the Kitty terminal via kitty-image](assets/demo.png) -It ships as a Claude Code **plugin** containing a single skill, `kitty-image`, that Claude invokes automatically whenever you ask for a chart, plot, diagram, or any visual that block-character ASCII can't do justice. +It ships as a Claude Code **plugin** containing a single skill, `kitty-image`, that Claude invokes automatically whenever you ask for a chart, plot, diagram, or any visual that block-character ASCII can't do justice. It also renders **[Mermaid](https://mermaid.js.org) diagrams** — flowcharts, sequence and state diagrams, workflows — straight to an inline image (see [Mermaid diagrams](#mermaid-diagrams)). ## The problem it solves @@ -25,6 +25,7 @@ The harder problem is **layout**: that graphics layer is invisible to Claude's t - **Kitty terminal** (`TERM=xterm-kitty`). The skill refuses to run elsewhere. Other Kitty-protocol terminals (Ghostty, WezTerm, Konsole, iTerm2) may work with a manual `--pts` override, but are untested. - **Python 3** (standard library only). [Pillow](https://python-pillow.org/) is optional — needed only to transcode non-PNG input (e.g. JPEG) to PNG. - Linux. PTY discovery reads `/proc`, so macOS is not currently supported. +- **Mermaid rendering (optional).** For local rendering you need either [`mermaid-cli`](https://github.com/mermaid-js/mermaid-cli) (`mmdc`) on `PATH`, or `npx` (Node) plus an installed Chrome/Chromium. No local renderer? Pass `--remote` to render via the public [mermaid.ink](https://mermaid.ink) service instead — that sends the diagram text off-box, so it's opt-in only. ## Install @@ -52,6 +53,29 @@ python3 "${CLAUDE_PLUGIN_ROOT}/skills/kitty-image/show.py" /path/to/image.png -- python3 "${CLAUDE_PLUGIN_ROOT}/skills/kitty-image/show.py" --clear ``` +### Mermaid diagrams + +Write a Mermaid definition to a `.mmd` file and render it inline in one step (`mermaid.py` renders the PNG, then hands off to `show.py`): + +```bash +cat > /tmp/flow.mmd <<'EOF' +graph TD + A[Start] --> B{Renderer available?} + B -->|local mmdc| C[Render offline] + B -->|--remote| D[mermaid.ink] + C --> E[Display inline] + D --> E +EOF + +# Local render (default): dark theme, #10121a background, 2x scale +python3 "${CLAUDE_PLUGIN_ROOT}/skills/kitty-image/mermaid.py" /tmp/flow.mmd + +# No local renderer? Opt in to the hosted mermaid.ink service +python3 "${CLAUDE_PLUGIN_ROOT}/skills/kitty-image/mermaid.py" /tmp/flow.mmd --remote +``` + +Rendering is **local-first**: the diagram text never leaves your machine unless you pass `--remote`. Flags: `--theme`, `--bg`, `--scale`, `--out`, `--no-show`, `--pts`. + ## Known limitations - **Drift margin is a heuristic.** Space reservation adds a small fixed margin to absorb the offset between where the image anchors and where the reserved rows land. For the skill's normal one-line invocation this is stable; a pathologically long, wrapping command could still clip a row or two. diff --git a/docs/superpowers/specs/2026-05-29-mermaid-diagrams-design.md b/docs/superpowers/specs/2026-05-29-mermaid-diagrams-design.md new file mode 100644 index 0000000..cb175d2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-mermaid-diagrams-design.md @@ -0,0 +1,177 @@ +# Design: Mermaid workflow diagrams for kitty-image + +**Date:** 2026-05-29 +**Status:** Approved (design); implementation in progress +**Branch:** worktree-mermaid-diagrams + +## Problem + +The `kitty-image` skill displays an existing PNG/JPEG inline in a Kitty terminal. To +show a *workflow* today, the caller must hand-produce a raster image (Pillow boxes and +arrows by hand, or matplotlib). There is no first-class way to go from a **mermaid** +diagram definition (`graph TD; A-->B`) to an inline image, even though mermaid is the +natural language for flowcharts, sequence, state, and workflow diagrams. + +## Goal + +Let Claude (proactively) and the user turn a mermaid diagram definition into an inline +terminal image, reusing the existing display path, while staying: + +- **Portable** — the plugin ships to all installers; must degrade gracefully where + no renderer exists. +- **Privacy-first** — default to local rendering; never send diagram text off-box + unless the user explicitly opts in. +- **Dependency-honest** — like the existing skill (Pillow "always available", + matplotlib optional), be explicit that mermaid needs node+Chrome locally, or `--remote`. + +## Non-goals + +- Editing/round-tripping mermaid. We render a definition to a PNG, full stop. +- Supplanting the Pillow/matplotlib chart pattern — that stays for data charts. +- Inline display from a background job (the existing PTY-detection limitation is + unchanged; render works anywhere, display needs the live `claude` TUI). + +## Empirically verified on this box (2026-05-29) + +Both render paths were tested for real before writing this spec, and the live tests +**overruled the docs in two places** (see "Research vs reality" below): + +- **Local (default):** + `PUPPETEER_SKIP_DOWNLOAD=1 npx --yes -p @mermaid-js/mermaid-cli mmdc -i in.mmd -o out.png -t dark -b '#10121a' -s 2 -p puppeteer.json` + where `puppeteer.json` = `{"executablePath":"/bin/google-chrome","args":["--no-sandbox","--disable-gpu"]}` + → exit 0, **746×1100 dark RGB PNG**, ~17s first run (incl. package download), + **~1.65s cached**, reuses system `google-chrome` (no Chromium download). + `executablePath` lives **in the config file** (officially documented, version-robust); + the env var `PUPPETEER_EXECUTABLE_PATH` *also* works but the config is primary. +- **Remote (`--remote` opt-in):** **pako envelope** — + `python3` builds `pako:` + base64url(zlib.deflate(JSON `{"code":,"mermaid":{"theme":"dark"}}`)), + then `curl 'https://mermaid.ink/img/pako:<...>?type=png&bgColor=10121a'` + → HTTP 200, `image/png`, dark-themed **RGB** PNG (theme + solid bg applied). + Plain base64url *also* still works today but yields a light, transparent (RGBA) + image and is not URL-safe for diagrams whose base64 contains `/`. + +### Research vs reality (resolved by live test) + +| Doc claim (research) | Live result | Decision | +|----------------------|-------------|----------| +| mermaid.ink: plain base64 dead, returns "invalid encoded code" | Plain base64url still returns a valid PNG | Use **pako** anyway — URL-safe, compressed (no 414), carries dark theme/bg | +| mmdc: `PUPPETEER_EXECUTABLE_PATH` "does NOT work" | It *did* reuse google-chrome | Put `executablePath` **in config** (robust) + keep env var as backup | + +## Architecture + +``` +diagram.mmd ──> mermaid.py ──(renders PNG to /tmp)──> show.py ──> Kitty inline + │ + ├─ backend resolution (local-first): + │ 1. mmdc on PATH + │ 2. else npx + detected Chrome + │ 3. else FAIL(10) suggesting --remote + └─ --remote: base64 → curl mermaid.ink → PNG +``` + +`mermaid.py` is **render-only**; display stays in `show.py`. They communicate through a +PNG file path — the same contract `show.py` already has with every other caller. + +## Component: `mermaid.py` + +Lives at `plugins/kitty-image/skills/kitty-image/mermaid.py`. + +### Interface + +``` +python3 mermaid.py # render → /tmp PNG → exec show.py +python3 mermaid.py - # read diagram from stdin +python3 mermaid.py --remote # explicit opt-in: hosted renderer +python3 mermaid.py --theme dark # mermaid theme (default: dark) +python3 mermaid.py --bg '#10121a' # background (default: skill bg) +python3 mermaid.py --scale 2 # render scale (default: 2) +python3 mermaid.py --out PATH # output PNG path (default: /tmp/.png) +python3 mermaid.py --no-show # render only, print path, skip display +python3 mermaid.py --pts /dev/pts/N # passthrough to show.py +``` + +Primary input is a **file path** (Claude writes the `.mmd` with the Write tool — avoids +fragile shell-escaping of multi-line diagrams). `-` reads stdin as a convenience. + +### Backend resolution (local-first; privacy default) + +1. **`mmdc` on `PATH`** → use it directly (fastest, fully offline, private). +2. **else `npx` present AND a Chrome binary detected** (search order: + `google-chrome`, `google-chrome-stable`, `chromium`, `chromium-browser`, `chrome`) + → `npx --yes -p @mermaid-js/mermaid-cli mmdc ...` with `PUPPETEER_SKIP_DOWNLOAD=1` + and a temp `puppeteer.json` carrying `{"executablePath":,"args":["--no-sandbox","--disable-gpu"]}`. + `PUPPETEER_EXECUTABLE_PATH=` is also exported as a backup. +3. **else** → exit **10** with a clear message: + *"No local mermaid renderer (need `mmdc`, or `node`+Chrome). Re-run with `--remote` + to use mermaid.ink — note this sends the diagram text to a third-party service."* + +`--remote` **skips 1–2 entirely** and does the hosted path: build the **pako envelope** +(`pako:` + base64url(zlib.deflate(JSON `{"code":,"mermaid":{"theme":}}`))), +`curl 'https://mermaid.ink/img/pako:<...>?type=png&bgColor='`, save PNG. +Nothing leaves the box unless `--remote` is passed. + +> **Why local-first, hosted opt-in:** the diagram text is the payload, and workflow +> diagrams can encode internal architecture. Defaulting to local keeps that on-box; +> `--remote` is a deliberate, logged choice. + +### Theming + +Defaults match the skill's chart aesthetic so diagrams look consistent with Pillow +charts: `--theme dark`, `--bg '#10121a'` (the skill's `(16,18,26)`), `--scale 2` +(crisp text). All overridable. For `--remote`, theme/bg are mapped to mermaid.ink's +query params where supported; otherwise the service default is used (documented). + +### Display handoff + +On successful render, `mermaid.py` invokes `show.py ` (via `subprocess`, +forwarding `--pts` if given) so reservation / anti-overlap / caption logic is reused +unchanged. `--no-show` prints the path and exits (useful for bg jobs / piping). + +## Error handling — new exit codes + +Distinct from `show.py`'s existing 2–5 so failures are unambiguous when chained: + +| Code | Meaning | Surfaced fix | +|------|---------|--------------| +| 10 | No local renderer found and `--remote` not given | message suggests `--remote` | +| 11 | Local render (mmdc/npx) failed — e.g. bad mermaid syntax | mermaid's stderr is forwarded | +| 12 | `--remote` failed — network / service / non-PNG response | show HTTP status + body head | +| 13 | Input diagram file not found / empty | verify path | + +`show.py`'s own codes (2 not-Kitty, 3 no-claude-ancestor, 4 PTY-not-writable, +5 image-not-found) still propagate when the display step runs. + +## SKILL.md updates + +- **Frontmatter `description`:** add mermaid/workflow-diagram triggering so the skill + fires when a user wants to *see a workflow/flowchart/sequence/state diagram*. +- **"When to invoke":** add a bullet for rendering a mermaid definition. +- **New section "Mermaid workflow diagrams":** the write-`.mmd`→`mermaid.py` flow, the + local-first/`--remote` privacy stance, the dependency reality (node+Chrome or + `--remote`), and the default dark theming. +- **Failure-modes table:** add codes 10–13. + +## Testing + +1. **Local happy path** — render `sample.mmd` via npx+google-chrome, assert exit 0 and + a valid PNG of expected dimensions. (✓ already verified manually.) +2. **Remote happy path** — `--remote` against mermaid.ink, assert PNG. (✓ verified.) +3. **Bad syntax** — malformed diagram → exit 11 with mermaid's error surfaced. +4. **No renderer** — invoke with `PATH` stripped of node/mmdc and no `--remote` + → exit 10 with the `--remote` hint. +5. **Missing input** — nonexistent `.mmd` → exit 13. +6. **stdin** — `cat sample.mmd | mermaid.py - --no-show` → prints a valid PNG path. +7. **Display chain** — `--no-show` omitted in a live Kitty TUI displays inline (manual, + cannot be exercised from a background job). + +## Open questions resolved during research + live test + +- mermaid.ink encoding: **pako envelope** chosen (verified working, applies dark theme + + solid bg, URL-safe, compressed). Plain base64url also works today but is light/RGBA and + not URL-safe for all diagrams. +- Reusing system Chrome under npx: **`executablePath` in the puppeteer config file** + (verified) avoids the ~150–200 MB Chromium download; `PUPPETEER_SKIP_DOWNLOAD=1` keeps + npx from fetching one during install. ~1.65s per render once cached. +- kroki.io retained as a *documented* alternative remote backend, not implemented in v1 + (kroki PNG is always transparent with no solid-bg option, so it can't match the dark + aesthetic, and its GET needs raw zlib + base64url). Revisit only if mermaid.ink is down. diff --git a/plugins/kitty-image/.claude-plugin/plugin.json b/plugins/kitty-image/.claude-plugin/plugin.json index 9b9567e..d6ee81c 100644 --- a/plugins/kitty-image/.claude-plugin/plugin.json +++ b/plugins/kitty-image/.claude-plugin/plugin.json @@ -1,12 +1,12 @@ { "name": "kitty-image", - "version": "0.1.2", - "description": "Display PNG/JPEG images inline in a Claude Code session running in the Kitty terminal, reserving layout space so following text never overlaps the image. Works around the Bash tool's lack of a controlling TTY.", + "version": "0.2.0", + "description": "Display PNG/JPEG images inline in a Claude Code session running in the Kitty terminal, reserving layout space so following text never overlaps the image. Works around the Bash tool's lack of a controlling TTY. Also renders Mermaid workflow/flowchart/sequence diagrams to PNG (local-first via mermaid-cli, optional mermaid.ink fallback).", "author": { "name": "Thistle Intelligence, LLC", "email": "licensing@thistleintelligence.com" }, "license": "MIT", "homepage": "https://github.com/whallil/claude-kitty-image", - "keywords": ["kitty", "terminal", "image", "graphics-protocol", "inline-image", "chart", "visualization"] + "keywords": ["kitty", "terminal", "image", "graphics-protocol", "inline-image", "chart", "visualization", "mermaid", "diagram", "flowchart", "workflow"] } diff --git a/plugins/kitty-image/skills/kitty-image/SKILL.md b/plugins/kitty-image/skills/kitty-image/SKILL.md index 20b8f51..e96e7bf 100644 --- a/plugins/kitty-image/skills/kitty-image/SKILL.md +++ b/plugins/kitty-image/skills/kitty-image/SKILL.md @@ -1,6 +1,6 @@ --- name: kitty-image -description: Use when you want to show the user an actual image inline in the terminal — a photo, an image you fetched or downloaded, an existing image file or screenshot on disk, or a chart/diagram/plot/figure you generated. Any time the user wants to SEE something, asks what something looks like, or a picture would communicate better than words, prefer obtaining the image and displaying it over describing it in prose. Renders real PNG/JPEG images via the Kitty graphics protocol; requires the Kitty terminal (TERM=xterm-kitty) and fails with exit 2 otherwise. Do NOT use for ASCII/Unicode sparklines (those go in chat) or for saving an image to disk without displaying it. +description: Use when you want to show the user an actual image inline in the terminal — a photo, an image you fetched or downloaded, an existing image file or screenshot on disk, a chart/diagram/plot/figure you generated, or a workflow/flowchart/sequence/state/architecture diagram authored as a Mermaid definition. Any time the user wants to SEE something, asks what something looks like, asks you to diagram or visualize a workflow/process/flow, or a picture would communicate better than words, prefer obtaining the image and displaying it over describing it in prose. Renders real PNG/JPEG images via the Kitty graphics protocol, and renders Mermaid text to PNG via mermaid.py; requires the Kitty terminal (TERM=xterm-kitty) and fails with exit 2 otherwise. Do NOT use for ASCII/Unicode sparklines (those go in chat) or for saving an image to disk without displaying it. --- # kitty-image @@ -13,6 +13,7 @@ This is the way to put **any image** in front of the user in a Kitty terminal - The user asks to **see** something, or **what something looks like** — a place, animal, object, person, product, artwork. Show a real image instead of describing it in prose. - The user points you at an **existing image file or screenshot** on disk and wants to view it. - You produced an image yourself — a **chart, plot, diagram, flowchart, or figure** — that should appear inline. +- The user wants to **see a workflow, process, flowchart, sequence, state machine, or architecture** — author it as a **Mermaid** definition and render it with `mermaid.py` (see "Mermaid workflow diagrams" below). This is usually the fastest way to a clean diagram; reach for it before hand-drawing boxes in Pillow. - A **picture would communicate better than words** (distributions, comparisons, architecture sketches, anything visual). ### You obtain the image; this skill only displays it @@ -22,7 +23,8 @@ kitty-image does **not** fetch or generate — it renders a PNG/JPEG you hand it - **Real-world photo/reference (puppy, landmark, product, etc.):** use a web **search** tool (e.g. firecrawl-search, WebSearch) to find a *real* image URL from the results, download it with `curl` to `/tmp`, then display it. - **Never invent, guess, or construct an image URL** — only ever use a URL that came back from an actual search/tool result. Searching for a real URL is exactly how you respect the "don't fabricate URLs" rule *and* still show the image. The rule forbids making URLs up; it does not forbid showing images. - If you genuinely have no web-search/fetch tool available, say so and ask the user for a file path or URL — don't fall back to a Pillow cartoon of a real thing. -- **Chart / diagram / figure:** render it with Pillow or matplotlib to `/tmp`, then display it. +- **Chart / data figure:** render it with Pillow or matplotlib to `/tmp`, then display it. +- **Workflow / flow / sequence / state / architecture diagram:** write a **Mermaid** definition and let `mermaid.py` render *and* display it in one step (it execs `show.py` for you). See "Mermaid workflow diagrams" below. Don't let *"I don't have an image file"* stop you — obtaining the image is part of the job, not a reason to refuse. @@ -93,6 +95,58 @@ Design defaults that produce decent-looking charts: - Always include: title, subtitle (data source + date range), axis labels, units, and a legend if categories are present. - Use `DejaVuSans` (always installed on Debian/Ubuntu) at sizes 26 (title), 14 (subtitle), 13 (axis), 12 (ticks/labels). +## Mermaid workflow diagrams + +For **workflows, flowcharts, sequence diagrams, state machines, ER/class diagrams, and architecture sketches**, don't hand-draw boxes in Pillow — write a [Mermaid](https://mermaid.js.org) definition and let `mermaid.py` render it to PNG *and* display it (it execs `show.py` for you, so all the text-overlap handling is reused). + +``` +1. Write the diagram to a .mmd file (use the Write tool — avoids shell-escaping + multi-line text). Example /tmp/flow.mmd: + + graph TD + A[Start] --> B{Renderer available?} + B -->|local mmdc| C[Render offline] + B -->|--remote| D[mermaid.ink] + C --> E[Display via show.py] + D --> E + +2. Render + display in one call: + python3 "${CLAUDE_PLUGIN_ROOT}/skills/kitty-image/mermaid.py" /tmp/flow.mmd + +3. Tell the user the diagram has been sent. +``` + +You can also pipe a diagram in: `... | mermaid.py -`. + +### Local-first, with an explicit remote opt-in (privacy) + +The diagram text is the payload — a workflow can encode internal architecture — so rendering is **local by default and never silently leaves the machine**: + +- **Local (default):** uses `mmdc` if it's on `PATH`; otherwise falls back to + `npx -p @mermaid-js/mermaid-cli mmdc` pointed at an already-installed Chrome + (`google-chrome`, `chromium`, …) via a generated Puppeteer config, with + `PUPPETEER_SKIP_DOWNLOAD=1` so Puppeteer never downloads its own ~150 MB Chromium. + First `npx` run fetches mermaid-cli (~15–20 s); cached runs are ~2 s. Fully offline thereafter. +- **Remote (`--remote`, opt-in only):** renders via the public **mermaid.ink** service + (pako-encoded URL, fetched as PNG). This **sends the diagram text to a third party**, + so it only happens when *you* pass `--remote` — typically the fallback you offer the + user when there's no local renderer (exit code 10). + +### Options + +```bash +mermaid.py # local render, dark theme, #10121a bg, 2x scale, then display +mermaid.py --remote # render via mermaid.ink instead (sends diagram offsite) +mermaid.py --theme dark|default|forest|neutral +mermaid.py --bg '#10121a' # or a color name, or 'transparent' +mermaid.py --scale 2 # local renderer only +mermaid.py --out /tmp/x.png # control output path +mermaid.py --no-show # render only; print the PNG path (e.g. for bg jobs) +mermaid.py --pts /dev/pts/N # passthrough to show.py +``` + +Defaults (`--theme dark`, `--bg '#10121a'`, `--scale 2`) match the Pillow chart aesthetic above, so mermaid diagrams sit consistently alongside generated charts. + ## Override and clear ```bash @@ -105,9 +159,14 @@ python3 "${CLAUDE_PLUGIN_ROOT}/skills/kitty-image/show.py" --clear ## Failure modes and what they mean -| Exit code | Meaning | Fix | -|-----------|---------|-----| -| 2 | Not running inside Kitty (`TERM != xterm-kitty`) | The skill cannot help; tell the user to open the file manually or use a Kitty-compatible terminal | -| 3 | No `claude` ancestor process found | The Bash environment is unusual; try `--pts` override | -| 4 | Detected PTY is not writable | Permission issue on `/dev/pts/`; check ownership | -| 5 | Image file not found | Verify the path before calling the skill | +| Exit code | Source | Meaning | Fix | +|-----------|--------|---------|-----| +| 2 | show.py | Not running inside Kitty (`TERM != xterm-kitty`) | The skill cannot help; tell the user to open the file manually or use a Kitty-compatible terminal | +| 3 | show.py | No `claude` ancestor process found | The Bash environment is unusual (e.g. a background job); try `--pts` override, or render with `--no-show` and view the file another way | +| 4 | show.py | Detected PTY is not writable | Permission issue on `/dev/pts/`; check ownership | +| 5 | show.py | Image file not found | Verify the path before calling the skill | +| 6 | show.py | Non-PNG input couldn't be converted (Pillow missing / unrecognized format) | Install `python3-pil`, or hand it a real PNG/JPEG | +| 10 | mermaid.py | No local mermaid renderer (`mmdc`, or `node`+Chrome) | Re-run with `--remote` to use mermaid.ink, or install mermaid-cli / a Chrome binary | +| 11 | mermaid.py | Local render failed (usually invalid Mermaid syntax) | mmdc's stderr is forwarded above; fix the diagram | +| 12 | mermaid.py | Remote render failed (network / service / non-PNG) | Check connectivity to mermaid.ink, or render locally | +| 13 | mermaid.py | Input diagram not found/empty, or output path not writable | Verify the `.mmd` path / that stdin had content / that `--out`'s directory is writable | diff --git a/plugins/kitty-image/skills/kitty-image/mermaid.py b/plugins/kitty-image/skills/kitty-image/mermaid.py new file mode 100644 index 0000000..9fe3998 --- /dev/null +++ b/plugins/kitty-image/skills/kitty-image/mermaid.py @@ -0,0 +1,347 @@ +#!/usr/bin/env python3 +"""Render a Mermaid diagram definition to a PNG and display it inline. + +This is the *render* half of the kitty-image skill; display is delegated to +show.py (which it execs on success), so all of show.py's PTY-detection and +text-overlap handling is reused unchanged. + +Two backends, local-first for privacy: + + 1. LOCAL (default) -- mermaid-cli (`mmdc`). If `mmdc` is on PATH it is used + directly; otherwise we fall back to `npx -p @mermaid-js/mermaid-cli mmdc` + pointed at an already-installed Chrome (so Puppeteer never downloads its + own ~150MB Chromium). Fully offline once cached; nothing leaves the box. + 2. REMOTE (--remote, explicit opt-in) -- the public mermaid.ink HTTP service. + The diagram is wrapped in a `pako:` envelope (zlib-deflate + base64url of + `{"code": , "mermaid": {"theme": }}`) and fetched as PNG. + This SENDS THE DIAGRAM TEXT to a third party, so it is never automatic. + +Usage: + python3 mermaid.py # render -> /tmp PNG -> show.py + python3 mermaid.py - # read diagram from stdin + python3 mermaid.py --remote # opt in to mermaid.ink + python3 mermaid.py --theme dark # mermaid theme (default dark) + python3 mermaid.py --bg '#10121a' # background (default skill bg) + python3 mermaid.py --scale 2 # render scale, local only (default 2) + python3 mermaid.py --out /tmp/x.png # output PNG path + python3 mermaid.py --no-show # render only, print path, don't display + python3 mermaid.py --pts /dev/pts/N # passthrough to show.py + +Exit codes: + 0 success + 2 usage error (bad arguments; raised by argparse) + 10 no local renderer available and --remote not given + 11 local render failed (invalid mermaid, or no usable Chrome) -- stderr forwarded + 12 remote render failed (network / service / non-PNG response) + 13 input diagram missing/empty, or output path not writable + (show.py's own codes 2-6 propagate when the display step runs) +""" +import argparse +import base64 +import json +import os +import shutil +import subprocess +import sys +import tempfile +import urllib.error +import urllib.parse +import urllib.request +import zlib +from pathlib import Path + +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" +SKILL_DIR = Path(__file__).resolve().parent +SHOW_PY = SKILL_DIR / "show.py" + +# Chrome/Chromium binaries to reuse, in preference order. Reusing a system +# browser keeps Puppeteer from downloading its own copy. +CHROME_CANDIDATES = ( + "google-chrome", + "google-chrome-stable", + "chromium", + "chromium-browser", + "chrome", +) + +# npm package spec for the npx fallback; override to pin a version if needed. +MERMAID_CLI_SPEC = os.environ.get("KITTY_MERMAID_CLI_SPEC", "@mermaid-js/mermaid-cli") +MERMAID_INK_BASE = os.environ.get("KITTY_MERMAID_INK_BASE", "https://mermaid.ink") + + +# --------------------------------------------------------------------------- # +# Input +# --------------------------------------------------------------------------- # +def read_diagram(source: str) -> str: + """Return the mermaid definition text from a file path or '-' (stdin). + + Exits 13 if a file path is missing, or if the resulting text is empty. + """ + if source == "-": + text = sys.stdin.read() + else: + p = Path(source) + if not p.is_file(): + print(f"error: diagram file {p} not found", file=sys.stderr) + sys.exit(13) + text = p.read_text(encoding="utf-8", errors="replace") + if not text.strip(): + print("error: diagram is empty", file=sys.stderr) + sys.exit(13) + return text + + +def default_out_path(source: str) -> Path: + """Choose an output PNG path: /tmp/.png, or /tmp/mermaid.png for stdin.""" + stem = "mermaid" if source == "-" else (Path(source).stem or "mermaid") + return Path(tempfile.gettempdir()) / f"{stem}.png" + + +# --------------------------------------------------------------------------- # +# Backend detection +# --------------------------------------------------------------------------- # +def find_chrome() -> str | None: + """Return the path to a usable Chrome/Chromium binary, or None.""" + for name in CHROME_CANDIDATES: + path = shutil.which(name) + if path: + return path + return None + + +# --------------------------------------------------------------------------- # +# Local rendering (mmdc / npx) +# --------------------------------------------------------------------------- # +def _write_puppeteer_config(chrome: str | None, tmpdir: str) -> str: + """Write a puppeteer config JSON and return its path. + + `executablePath` (when a system Chrome was found) is the officially + documented, version-robust way to reuse an installed browser. `--no-sandbox` + is required when running headless Chrome as root or in many containers; + `--disable-gpu` avoids GPU init noise in headless environments. + """ + cfg: dict = {"args": ["--no-sandbox", "--disable-gpu"]} + if chrome: + cfg["executablePath"] = chrome + path = os.path.join(tmpdir, "puppeteer.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(cfg, f) + return path + + +def render_local(diagram: str, out: Path, theme: str, bg: str, scale: int) -> None: + """Render via mermaid-cli. Exits 10 if no local renderer, 11 on render error. + + Resolution order: + 1. `mmdc` on PATH -> use it directly. + 2. else `npx` + a Chrome binary -> npx -p @mermaid-js/mermaid-cli mmdc. + 3. else -> exit 10 (suggest --remote). + """ + mmdc = shutil.which("mmdc") + npx = shutil.which("npx") + chrome = find_chrome() + + if mmdc: + base_cmd = [mmdc] + elif npx and chrome: + # --yes: don't interactively prompt to install on first run. + base_cmd = [npx, "--yes", "-p", MERMAID_CLI_SPEC, "mmdc"] + else: + print( + "error: no local mermaid renderer found.\n" + " Need `mmdc` on PATH, or `npx` (Node) plus a Chrome/Chromium binary " + f"({', '.join(CHROME_CANDIDATES)}).\n" + " Re-run with --remote to render via mermaid.ink instead " + "(note: this sends the diagram text to a third-party service).", + file=sys.stderr, + ) + sys.exit(10) + + with tempfile.TemporaryDirectory(prefix="kitty-mermaid-") as tmpdir: + mmd_path = os.path.join(tmpdir, "diagram.mmd") + with open(mmd_path, "w", encoding="utf-8") as f: + f.write(diagram) + cfg_path = _write_puppeteer_config(chrome, tmpdir) + + cmd = [ + *base_cmd, + "-i", mmd_path, + "-o", str(out), + "-t", theme, + "-b", bg, + "-s", str(scale), + "-p", cfg_path, + ] + + env = dict(os.environ) + # Keep Puppeteer from fetching its own Chromium during an npx install; + # also export the path as a runtime backup to the config's executablePath. + env["PUPPETEER_SKIP_DOWNLOAD"] = "1" + if chrome: + env["PUPPETEER_EXECUTABLE_PATH"] = chrome + + proc = subprocess.run(cmd, env=env, capture_output=True, text=True) + if proc.returncode != 0 or not _is_png(out): + sys.stderr.write(proc.stdout) + sys.stderr.write(proc.stderr) + if proc.returncode != 0: + # mmdc can fail for an invalid diagram OR because Puppeteer found + # no usable browser (no system Chrome and no bundled Chromium). + # Don't assume syntax — point at both, and offer the remote escape. + msg = ( + f"error: local mermaid render failed (renderer exited {proc.returncode}). " + "See the output above — likely an invalid Mermaid diagram, or no usable " + "Chrome/Chromium for the renderer. You can also try --remote." + ) + else: + msg = ( + "error: the renderer exited cleanly but did not produce a valid PNG. " + "Check the diagram and the output above." + ) + print(msg, file=sys.stderr) + sys.exit(11) + + +# --------------------------------------------------------------------------- # +# Remote rendering (mermaid.ink, pako envelope) +# --------------------------------------------------------------------------- # +def _pako_encode(diagram: str, theme: str) -> str: + """Build the `pako:`-prefixed token mermaid.ink expects. + + Envelope: {"code": , "mermaid": {"theme": }} -> JSON -> + zlib deflate (level 9) -> base64url. base64url (-, _ instead of +, /) is + required so the token is safe inside a URL path. + """ + envelope = {"code": diagram, "mermaid": {"theme": theme}} + raw = json.dumps(envelope).encode("utf-8") + compressed = zlib.compress(raw, 9) + token = base64.urlsafe_b64encode(compressed).decode("ascii") + return f"pako:{token}" + + +def _bg_query(bg: str) -> str | None: + """Map a --bg value to mermaid.ink's bgColor query param, or None. + + mermaid.ink wants a bare hex (no '#') or a named color prefixed with '!'. + 'transparent' (or empty) means: send no bgColor at all. + """ + bg = bg.strip() + if not bg or bg.lower() == "transparent": + return None + if bg.startswith("#"): + return bg[1:] + if all(c in "0123456789abcdefABCDEF" for c in bg) and len(bg) in (3, 6): + return bg + return "!" + bg # treat as a named color + + +def render_remote(diagram: str, out: Path, theme: str, bg: str) -> None: + """Render via mermaid.ink. Exits 12 on network/service/non-PNG failure.""" + token = _pako_encode(diagram, theme) # base64url -> already URL-path-safe + params = {"type": "png"} + bgq = _bg_query(bg) + if bgq: + params["bgColor"] = bgq + # urlencode so a bgColor with reserved/space chars can't produce an invalid + # URL (urllib would otherwise raise http.client.InvalidURL, which our except + # clauses below don't catch -> uncaught traceback instead of a clean exit 12). + url = f"{MERMAID_INK_BASE}/img/{token}?{urllib.parse.urlencode(params)}" + + try: + req = urllib.request.Request(url, headers={"User-Agent": "kitty-image/mermaid"}) + with urllib.request.urlopen(req, timeout=30) as resp: + data = resp.read() + ctype = resp.headers.get("Content-Type", "") + except urllib.error.HTTPError as e: + body = e.read(300).decode("utf-8", "replace") + print(f"error: mermaid.ink returned HTTP {e.code}: {body}", file=sys.stderr) + sys.exit(12) + except (urllib.error.URLError, OSError) as e: + print(f"error: could not reach mermaid.ink: {e}", file=sys.stderr) + sys.exit(12) + + if not data.startswith(PNG_MAGIC): + head = data[:300].decode("utf-8", "replace") + print( + f"error: mermaid.ink did not return a PNG (Content-Type: {ctype}). " + f"Response head: {head}", + file=sys.stderr, + ) + sys.exit(12) + + try: + out.write_bytes(data) + except OSError as e: + print(f"error: could not write output {out}: {e}", file=sys.stderr) + sys.exit(13) + + +# --------------------------------------------------------------------------- # +# Display handoff +# --------------------------------------------------------------------------- # +def _is_png(path: Path) -> bool: + try: + with open(path, "rb") as f: + return f.read(len(PNG_MAGIC)) == PNG_MAGIC + except OSError: + return False + + +def display(png: Path, pts: str | None) -> int: + """Exec show.py on the rendered PNG; return its exit code.""" + cmd = [sys.executable, str(SHOW_PY), str(png)] + if pts: + cmd += ["--pts", pts] + return subprocess.run(cmd).returncode + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # +def main() -> int: + ap = argparse.ArgumentParser( + description="Render a Mermaid diagram to PNG and display it inline (kitty-image)." + ) + ap.add_argument("diagram", help="path to a .mmd file, or '-' to read from stdin") + ap.add_argument("--remote", action="store_true", + help="render via mermaid.ink instead of locally (sends diagram offsite)") + ap.add_argument("--theme", default="dark", + help="mermaid theme: default, dark, forest, neutral (default: dark)") + ap.add_argument("--bg", default="#10121a", + help="background color; hex like '#10121a', a name, or 'transparent' " + "(default: #10121a, the skill's chart background)") + ap.add_argument("--scale", type=int, default=2, + help="render scale factor, local renderer only (default: 2)") + ap.add_argument("--out", help="output PNG path (default: /tmp/.png)") + ap.add_argument("--no-show", action="store_true", + help="render only; print the PNG path and do not display it") + ap.add_argument("--pts", help="override show.py's auto-detected PTY (e.g. /dev/pts/4)") + args = ap.parse_args() + + if args.scale <= 0: + ap.error("--scale must be a positive integer") + if args.remote and args.scale != 2: + print("warning: --scale has no effect with --remote (mermaid.ink has no scale knob)", + file=sys.stderr) + + diagram = read_diagram(args.diagram) + out = Path(args.out) if args.out else default_out_path(args.diagram) + try: + out.parent.mkdir(parents=True, exist_ok=True) + except OSError as e: + print(f"error: cannot create output directory {out.parent}: {e}", file=sys.stderr) + return 13 + + if args.remote: + render_remote(diagram, out, args.theme, args.bg) + else: + render_local(diagram, out, args.theme, args.bg, args.scale) + + if args.no_show: + print(out) + return 0 + return display(out, args.pts) + + +if __name__ == "__main__": + sys.exit(main())