Branded, print-ready art QR codes that always scan. Describe a picture and a link; QRX paints the code, verifies it decodes, and hosts it on a qrx.to short…

Art QR codes that always scan.
QRX makes branded, print-ready QR codes for posters, menus, packaging and signage. Describe the look you want and give it a link: QRX paints the code into artwork, checks that it decodes before handing it over, and points it at a hosted short link, https://qrx.to/<id> on qrx.codes, that you can re-point later on a paid plan. This repository connects QRX to Claude, ChatGPT, Cursor, VS Code and other MCP clients through the remote server at https://qrx.codes/mcp.
Listed in the official MCP Registry as codes.qrx/mcp.
| Server URL | https://qrx.codes/mcp |
| Transport | Streamable HTTP (stateless) |
| Auth | Required to connect. Sign in with your QRX account (MCP authorization, OAuth 2.1); clients that support it need only the URL. The authorization server is https://clerk.qrx.codes (dynamic client registration or client ID metadata documents, PKCE S256, refresh tokens); scopes qrx:read qrx:generate |
| Auth with an API key | Authorization: Bearer qrx_… (preferred), or X-API-Key: qrx_… for clients that can't add the Bearer prefix. If both are sent, Authorization wins. For clients without sign-in, scripts and CI |
| Without auth | Every request, including initialize, answers 401 with a WWW-Authenticate header pointing at https://qrx.codes/.well-known/oauth-protected-resource/mcp, so the client asks you to sign in as soon as it connects |
| Public tool list | https://qrx.codes/.well-known/mcp/server-card.json |
| Docs | qrx.codes/developers/mcp (public: server URL, client set-up, tools, limits, privacy) |
Every client below that takes a URL signs in with your QRX account: add the URL, then sign in (free) when the client asks as it connects. There is nothing to copy or keep secret.
For clients that only run local servers, and for scripts and CI, use an API key. Any client below can use one instead of signing in:
qrx_.QRX_API_KEY wherever the client allows that: # macOS / Linux
export QRX_API_KEY="qrx_live_…"
# Windows PowerShell (current user, persistent)
[Environment]::SetEnvironmentVariable("QRX_API_KEY", "qrx_live_…", "User")
Revoke a key at any time from the same page.
Option A: plugin (MCP server, the QRX skill and the code card)
/plugin marketplace add qrxcodes/qrx-mcp
/plugin install qrx@qrx
Then run /mcp, select qrx and sign in to QRX. The plugin also adds the qrx skill, which teaches Claude how to write good art prompts, wait for the code and prepare it for print.
The plugin also shows each finished code in the transcript, under the QRX tool call: its picture, the short link it scans to and a link to its QRX page, with a line while the code is still being painted. It is a mod, so it draws in the terminal (Claude Code 2.1.287 or later) and the Desktop app's Code tab; it fetches the picture with curl. Terminals with kitty graphics (kitty, Ghostty) show the full picture; other terminals show it in coloured character cells, with a link to the full-quality image.
Option B: MCP server only
claude mcp add --transport http qrx https://qrx.codes/mcp --scope user
Then run /mcp, select qrx and sign in. Or commit this to a project's .mcp.json so the whole team gets it (each person signs in to their own account):
{
"mcpServers": {
"qrx": {
"type": "http",
"url": "https://qrx.codes/mcp"
}
}
}
With an API key instead (for example with claude -p or in CI):
claude mcp add --transport http qrx https://qrx.codes/mcp \
--header "Authorization: Bearer $QRX_API_KEY" --scope user
A configured Authorization header replaces sign-in; remove it to sign in instead.
Claude Desktop uses the same connector settings as claude.ai; you don't edit claude_desktop_config.json for a remote server.
QRX. URL: https://qrx.codes/mcp.With an API key instead, choose No sign-in and add Authorization with the value Bearer qrx_live_… under Request headers (where your organisation has request headers). Authentication can't be changed after saving; remove the connector and add it again.
Free Claude plans can add one custom connector.
QRX. Server URL: https://qrx.codes/mcp.ChatGPT's custom MCP servers don't take API keys. Workspace admins can restrict custom MCP servers on Business and Enterprise plans.
Use the Install in Cursor button above, or add this to ~/.cursor/mcp.json (all projects) or .cursor/mcp.json (one project):
{
"mcpServers": {
"qrx": {
"url": "https://qrx.codes/mcp"
}
}
}
Cursor opens a browser to sign in to QRX when it connects.
With an API key instead:
{
"mcpServers": {
"qrx": {
"url": "https://qrx.codes/mcp",
"headers": { "Authorization": "Bearer ${env:QRX_API_KEY}" }
}
}
}
Use the Install in VS Code button above, or add this to .vscode/mcp.json:
{
"servers": {
"qrx": {
"type": "http",
"url": "https://qrx.codes/mcp"
}
}
}
From a terminal:
code --add-mcp '{"name":"qrx","type":"http","url":"https://qrx.codes/mcp"}'
VS Code opens a browser to sign in to QRX when it connects.
With an API key instead (VS Code asks for it and stores it securely):
{
"inputs": [
{
"type": "promptString",
"id": "qrx-api-key",
"description": "QRX API key (starts with qrx_)",
"password": true
}
],
"servers": {
"qrx": {
"type": "http",
"url": "https://qrx.codes/mcp",
"headers": { "Authorization": "Bearer ${input:qrx-api-key}" }
}
}
}
Edit mcp_config.json (macOS/Linux ~/.config/devin/mcp_config.json, Windows %APPDATA%\devin\mcp_config.json; older Windsurf installs use ~/.codeium/windsurf/mcp_config.json):
{
"mcpServers": {
"qrx": {
"serverUrl": "https://qrx.codes/mcp"
}
}
}
Note the key is serverUrl, not url. Windsurf asks you to sign in to QRX when it connects. With an API key instead, add "headers": { "Authorization": "Bearer ${env:QRX_API_KEY}" }.
Open MCP Servers → Configure MCP Servers and add to cline_mcp_settings.json:
{
"mcpServers": {
"qrx": {
"type": "streamableHttp",
"url": "https://qrx.codes/mcp",
"disabled": false,
"autoApprove": ["list_styles", "get_qr_code", "list_qr_codes", "get_account", "get_profile"]
}
}
}
Cline asks you to sign in to QRX when it connects. In the Cline CLI, run cline mcp, choose qrx and Authorize OAuth. With an API key instead, add "headers": { "Authorization": "Bearer qrx_live_…" }.
Add to settings.json:
{
"context_servers": {
"qrx": {
"url": "https://qrx.codes/mcp"
}
}
}
Zed asks you to sign in to QRX when it connects. With an API key instead, add "headers": { "Authorization": "Bearer qrx_live_…" }; Zed then skips sign-in.
Run goose configure → Add Extension → Remote Extension (Streamable HTTP) and enter https://qrx.codes/mcp. Or edit config.yaml (macOS/Linux ~/.config/goose/config.yaml, Windows %APPDATA%\Block\goose\config\config.yaml):
extensions:
qrx:
name: qrx
type: streamable_http
enabled: true
uri: https://qrx.codes/mcp
timeout: 300
Goose opens your browser to sign in to QRX when it connects. With an API key instead, add the header Authorization: Bearer qrx_live_… (headers: in config.yaml).
codex mcp add qrx --url https://qrx.codes/mcp
Codex finds QRX's sign-in and opens your browser. Run codex mcp login qrx to sign in again later. The entry in ~/.codex/config.toml is just:
[mcp_servers.qrx]
url = "https://qrx.codes/mcp"
With an API key instead, add bearer_token_env_var = "QRX_API_KEY" to that entry.
gemini mcp add --transport http -s user qrx https://qrx.codes/mcp
Gemini CLI opens your browser to sign in to QRX when it connects; /mcp auth qrx signs in again later. Or in ~/.gemini/settings.json (note httpUrl, which selects Streamable HTTP):
{
"mcpServers": {
"qrx": {
"httpUrl": "https://qrx.codes/mcp"
}
}
}
With an API key instead:
gemini mcp add --transport http -s user --header "Authorization: Bearer $QRX_API_KEY" qrx https://qrx.codes/mcp
In the Program tab, choose Install → Edit mcp.json and add:
{
"mcpServers": {
"qrx": {
"url": "https://qrx.codes/mcp"
}
}
}
LM Studio asks you to sign in to QRX when it connects. With an API key instead, add "headers": { "Authorization": "Bearer qrx_live_…" }.
The skills/qrx folder follows the Agent Skills standard, so the same skill works in Codex, Gemini CLI, GitHub Copilot, Cursor, Goose and others. Install it with the cross-agent installer:
npx skills add qrxcodes/qrx-mcp
The skill expects the QRX MCP server to be connected (see your client above), or a QRX_API_KEY for the REST API.
gemini extensions install https://github.com/qrxcodes/qrx-mcp
Gemini CLI opens your browser to sign in to QRX when it connects; /mcp auth qrx signs in again later.
Clients that support MCP authorization need only https://qrx.codes/mcp: the server answers 401 until you sign in, so they ask when they connect. Otherwise point a Streamable HTTP client at the URL and send Authorization: Bearer qrx_… (or X-API-Key: qrx_…). Clients that only run local stdio servers can use @qrxcodes/mcp, which forwards to the same server with your API key:
{
"mcpServers": {
"qrx": {
"command": "npx",
"args": ["-y", "@qrxcodes/mcp"],
"env": { "QRX_API_KEY": "qrx_live_…" }
}
}
}
Connecting needs sign-in or an API key. The tool list is public at https://qrx.codes/.well-known/mcp/server-card.json.
| Tool | What it does | Read-only |
|---|---|---|
generate_qr_code | Starts a new art QR code from a prompt and a link. Returns the code's id straight away. | No |
vary_qr_code | Makes a new code from one of your codes with a change to its picture, such as "more trees". The new code has its own short link to the same destination; the original doesn't change. | No |
get_qr_code | Gets a code's status, image and short link. Waits up to wait seconds (0 to 25, default 25) for a code that's still being painted. | Yes |
list_qr_codes | Lists your codes, newest first, a page at a time (limit 1 to 100, default 20; pass nextCursor as cursor). | Yes |
change_qr_code_destination | Points an existing link code at a new absolute http or https URL (on a paid plan). The printed code keeps working. Marked destructive, because it replaces the old destination. | No |
get_account | Shows your plan, how many codes are left today, when the count resets and what the account can do. | Yes |
get_profile | Shows which QRX account is connected. | Yes |
list_styles | Lists the looks you can pass to generate_qr_code. | Yes |
In clients that show interactive views (MCP Apps), such as Claude, the code also appears in a card that fills in when it is ready.
generate_qr_code returns within a few seconds with an id and status: processing. The artwork usually takes under a minute on a GPU.get_qr_code then waits for up to 25 seconds per call (wait, 0–25). Agents call it again until the status is succeeded (or failed). In MCP Apps clients the card fills in with the image when it's ready.shortUrl: https://qrx.to/<id> on qrx.codes) and the destination it redirects to. Every code is decoded before it's marked succeeded. A code's id and short link never change.A free account has a daily allowance of codes; a paid plan has more, and lets you re-point links. Current plans are at https://qrx.codes/upgrade.
The API, the MCP server and the website share one allowance. Each key is also rate limited (30 requests a minute and 6 new codes per 10 seconds). If you hit a limit the tool returns an error; get_account shows what is left and when the daily count resets. Limits: qrx.codes/developers.
| Path | For |
|---|---|
.claude-plugin/plugin.json, .claude-plugin/marketplace.json, .mcp.json | Claude Code plugin and its marketplace (/plugin marketplace add qrxcodes/qrx-mcp) |
skills/qrx/ | The qrx skill (Agent Skills standard): prompting, waiting for the code, print checks |
gemini-extension.json, GEMINI.md | Gemini CLI extension |
server.json | The entry for the official MCP Registry |
glama.json | Glama maintainer claim |
llms-install.md | Step-by-step set-up written for AI agents (Cline and others) |
None of these files run code on your machine. They only point your client at https://qrx.codes/mcp.
Connecting to https://qrx.codes/mcp needs sign-in or an API key; without one the server answers 401. Clients send requests only to https://qrx.codes/mcp (and, when the skill falls back to the REST API, to https://qrx.codes/v1). Each request carries your sign-in token or API key and the prompt, destination link and style you asked for. Signing in goes through QRX's sign-in service at https://clerk.qrx.codes. A finished code's imageUrl is on https://qrx.codes/media/, which redirects to QRX's image CDN.
QRX stores the prompts, links and images you create so they appear in your account. It doesn't read your chat history; it only sees the arguments the client sends to its tools.
The configuration files, skill and documentation in this repository are released under the MIT licence. The QRX service itself is a hosted product, governed by the QRX terms.
<!-- Docs sources (re-check these when clients change; all read 9 Oct 2026):
-->
hooks/qrx-card.tsx 184 lines1// The QRX code card: shows a finished code's picture under the QRX tool call in Claude Code's transcript, with its
2// short link and page, and a line while the code is still being painted. The tool's own text is unchanged for Claude
3// and for every client that draws nothing.
4import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
5
6import { codeOf, isCodeTool, isQrxUrl, type Code } from './lib/code.ts'
7import { fullQualityUrl, pictureKind } from './lib/picture.ts'
8import { decodePng, encodePng, fromBase64, shrink, toBase64, type Rgb } from './lib/png.ts'
9
10type Picture =
11 | { state: 'loading' }
12 | { state: 'failed'; reason: string }
13 | { state: 'ready'; png: string; thumb: string; preview: Rgb }
14
15/** Pictures by code id: a code's image never changes once it is made. */
16const pictures = new Map<string, Picture>()
17
18/** The thumbnail the desktop and editor draw: small enough that the SVG holding it stays under 131,072 characters. */
19const THUMB = 160
20/** The pixels the terminal's cell grid is drawn from. */
21const PREVIEW = 96
22/** The widest picture the terminal draws, in columns; two pixels go in each cell, one above the other. */
23const MAX_COLUMNS = 48
24const UPPER_HALF = 0x2580
25
26const slash = (path: string) => path.split(String.fromCharCode(92)).join('/')
27
28let tempDir = '/tmp'
29let terminalImages = false
30
31export const register: Register = on => {
32 on('session.start', async ($, e, next) => {
33 const temp = slash((await $.env.get('TEMP')) || (await $.env.get('TMPDIR')) || '/tmp')
34 tempDir = temp.endsWith('/') ? temp.slice(0, -1) : temp
35 const term = `${(await $.env.get('TERM')) ?? ''} ${(await $.env.get('TERM_PROGRAM')) ?? ''}`.toLowerCase()
36 const multiplexed = Boolean((await $.env.get('TMUX')) || (await $.env.get('STY')))
37 // Claude Code draws an Image as pixels only where the terminal has kitty's placeholder images (kitty, Ghostty)
38 terminalImages =
39 (await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES')) === '1' ||
40 (!multiplexed && (term.includes('kitty') || term.includes('ghostty') || Boolean(await $.env.get('KITTY_WINDOW_ID'))))
41 return next(e)
42 })
43
44 // a call drawn on its own row: the card takes the place of the result's text
45 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
46 if (!isCodeTool(e.props.tool) || e.props.isErrored) return next(e)
47 const code = codeOf(e.props.output)
48 if (!code || code.status === 'failed') return next(e)
49 return card($, e, code)
50 })
51
52 // calls folded into a group's count line (the terminal folds MCP calls): the cards go under that line, one per code,
53 // from its latest call, so a code that was started and then fetched shows once
54 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
55 const latest = new Map<string, Code>()
56 for (const call of e.props.calls) {
57 if (!isCodeTool(call.tool) || call.isRunning || call.isErrored || call.isInterrupted) continue
58 const code = codeOf(call.output)
59 if (!code) continue
60 latest.delete(code.id)
61 latest.set(code.id, code)
62 }
63 const codes = [...latest.values()].filter(code => code.status !== 'failed')
64 if (!codes.length || e.props.isExpanded) return next(e)
65 const { Box } = $.ui.resolve(e)
66 const line = await next(e)
67 const cards: RenderElement[] = []
68 for (const code of codes) cards.push(await card($, e, code))
69 return (
70 <Box flexDirection="column">
71 {line}
72 {cards}
73 </Box>
74 )
75 })
76}
77
78/** The card for one code: its picture as the surface can draw it, the link it scans to and its QRX page. */
79async function card($: EngineInterface, e: RenderInput, code: Code): Promise<RenderElement> {
80 const { Box, Text, Link } = $.ui.resolve(e)
81
82 if (code.status === 'processing') {
83 return (
84 <Box key={`qrx-${code.id}`} flexDirection="column" paddingLeft={2}>
85 <Text dimColor>QRX is painting code {code.id}. It usually takes under a minute.</Text>
86 </Box>
87 )
88 }
89
90 const imageUrl = isQrxUrl(code.imageUrl) ? code.imageUrl : undefined
91 if (imageUrl && !pictures.has(code.id)) void load($, { ...code, imageUrl })
92 const picture = pictures.get(code.id)
93
94 let view: RenderElement | null = null
95 const alt = `QRX code ${code.id}${code.shortUrl ? `, scans to ${code.shortUrl}` : ''}`
96 if (!picture || picture.state === 'loading') view = <Text dimColor>Loading the code’s picture…</Text>
97 else if (picture.state === 'failed') view = imageUrl ? <Link href={imageUrl} label="Open the code’s picture (PNG)" /> : null
98 else if (e.surface === 'terminal') {
99 const { Image, Raster } = $.ui.resolve(e)
100 const width = e.viewport?.columns ?? 80
101 const columns = Math.max(16, Math.min(MAX_COLUMNS, width - 6)) & ~1
102 const rows = Math.round((columns * picture.preview.height) / picture.preview.width / 2)
103 const kind = pictureKind(e.surface, terminalImages, picture.png.length)
104 const fullQuality = fullQualityUrl(kind, code)
105 view =
106 kind === 'pixels' ? (
107 <Image key={`qrx-image-${code.id}`} source={{ png: picture.png }} columns={columns} rows={rows} alt={alt} />
108 ) : (
109 <Box flexDirection="column">
110 <Raster key={`qrx-image-${code.id}`} columns={columns} rows={rows} cells={halfBlocks(picture.preview, columns, rows)} />
111 {fullQuality ? (
112 <Box key={`qrx-full-quality-${code.id}`} flexDirection="row">
113 <Text dimColor>Open </Text>
114 <Link href={fullQuality} />
115 <Text dimColor> for the full-quality image</Text>
116 </Box>
117 ) : null}
118 </Box>
119 )
120 } else {
121 const { Svg } = $.ui.resolve(e)
122 const w = THUMB
123 const h = Math.round((THUMB * picture.preview.height) / picture.preview.width)
124 const source =
125 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
126 `<image width="${w}" height="${h}" href="data:image/png;base64,${picture.thumb}"/></svg>`
127 view = <Svg alt={alt} width={240} height={Math.round((240 * h) / w)} source={source} />
128 }
129
130 return (
131 <Box key={`qrx-${code.id}`} flexDirection="column" paddingLeft={2}>
132 <Text bold>QR code {code.id} is ready</Text>
133 {view}
134 {code.shortUrl ? (
135 <Text>
136 Scans open {code.destination ?? 'its link'} through {code.shortUrl}
137 </Text>
138 ) : null}
139 {code.pageUrl ? <Link href={code.pageUrl} label="View or download it in QRX" /> : null}
140 </Box>
141 )
142}
143
144/** Fetches a finished code's PNG and keeps it, decoded and shrunk, then redraws. */
145async function load($: EngineInterface, code: Code & { imageUrl: string }) {
146 pictures.set(code.id, { state: 'loading' })
147 try {
148 // $.http.fetch hands back text, so the PNG comes down to a file and is read back as bytes
149 const file = `${tempDir}/qrx-card-${code.id}.png`
150 const run = await $.process.run(['curl', '-sSfL', '--max-time', '30', '-o', file, code.imageUrl], { timeoutMs: 40_000 })
151 if (run.exitCode !== 0) throw new Error(run.stderr.trim() || `curl exited ${run.exitCode}`)
152 const { base64 } = (await $.fs.read(file, { as: 'bytes' })) as { base64: string }
153 const image = decodePng(fromBase64(base64))
154 pictures.set(code.id, {
155 state: 'ready',
156 png: base64,
157 thumb: toBase64(encodePng(shrink(image, THUMB, Math.round((THUMB * image.height) / image.width)))),
158 preview: shrink(image, PREVIEW, Math.round((PREVIEW * image.height) / image.width)),
159 })
160 } catch (error) {
161 pictures.set(code.id, { state: 'failed', reason: error instanceof Error ? error.message : String(error) })
162 }
163 $.ui.invalidate('ui.render')
164}
165
166/** Packs a picture into a Raster's cells: an upper half block per cell, its top pixel the ink and its bottom the paper. */
167function halfBlocks(preview: Rgb, columns: number, rows: number) {
168 const pixels = shrink(preview, columns, rows * 2)
169 const words = new Uint32Array(columns * rows * 3)
170 const color = (x: number, y: number) => {
171 const i = (y * columns + x) * 3
172 return (pixels.data[i] << 16) | (pixels.data[i + 1] << 8) | pixels.data[i + 2]
173 }
174 for (let y = 0; y < rows; y++) {
175 for (let x = 0; x < columns; x++) {
176 const w = (y * columns + x) * 3
177 words[w] = UPPER_HALF
178 words[w + 1] = color(x, y * 2)
179 words[w + 2] = color(x, y * 2 + 1)
180 }
181 }
182 return toBase64(new Uint8Array(words.buffer))
183}
184hooks/lib/code.ts 113 lines1// Reads a QRX code out of a QRX MCP tool's result, as Claude Code stores it for the transcript.
2
3export type CodeStatus = 'processing' | 'succeeded' | 'failed'
4
5export type Code = {
6 id: string
7 status: CodeStatus
8 imageUrl?: string
9 shortUrl?: string
10 pageUrl?: string
11 destination?: string
12}
13
14/** The QRX tools whose result is one code, under any server name that holds `qrx` (plugin or `claude mcp add`). */
15const CODE_TOOL = /^mcp__[\w-]*qrx[\w-]*__(generate_qr_code|vary_qr_code|get_qr_code)$/
16
17export const isCodeTool = (tool: string) => CODE_TOOL.test(tool)
18
19/** Only images QRX serves are fetched: qrx.codes and its subdomains (dev), and the qrx.to short links. */
20export function isQrxUrl(value: unknown): value is string {
21 if (typeof value !== 'string') return false
22 try {
23 const url = new URL(value)
24 const host = url.hostname
25 return url.protocol === 'https:' && (host === 'qrx.codes' || host.endsWith('.qrx.codes') || host === 'qrx.to')
26 } catch {
27 return false
28 }
29}
30
31const ID = /^[a-z0-9]{1,64}$/i
32const STATUSES: readonly string[] = ['processing', 'succeeded', 'failed']
33
34function fromObject(value: Record<string, unknown>): Code | undefined {
35 if (typeof value.id !== 'string' || !ID.test(value.id) || typeof value.status !== 'string') return undefined
36 if (!STATUSES.includes(value.status)) return undefined
37 return {
38 id: value.id,
39 status: value.status as CodeStatus,
40 imageUrl: isQrxUrl(value.imageUrl) ? value.imageUrl : undefined,
41 shortUrl: isQrxUrl(value.shortUrl) ? value.shortUrl : undefined,
42 pageUrl: isQrxUrl(value.pageUrl) ? value.pageUrl : undefined,
43 destination: typeof value.destination === 'string' ? value.destination : undefined,
44 }
45}
46
47const READY = /QR code ([a-z0-9]+) is ready\./i
48const PAINTING = /QR code ([a-z0-9]+) is still being painted/i
49const FAILED = /QR code ([a-z0-9]+) could not be made/i
50const IMAGE = /Image \(PNG\): (\S+)/
51const SCANS = /Scans open (\S+) through (\S+?)\.?$/m
52const PAGE = /View or download it in QRX: (\S+)/
53
54/** The text the server writes for a code (see codeLine in the QRX API): used when no structured result is kept. */
55function fromText(text: string): Code | undefined {
56 const ready = READY.exec(text)
57 if (ready) {
58 const image = IMAGE.exec(text)?.[1]
59 const scans = SCANS.exec(text)
60 const page = PAGE.exec(text)?.[1]
61 return {
62 id: ready[1],
63 status: 'succeeded',
64 imageUrl: isQrxUrl(image) ? image : undefined,
65 shortUrl: isQrxUrl(scans?.[2]) ? scans?.[2] : undefined,
66 destination: scans?.[1],
67 pageUrl: isQrxUrl(page) ? page : undefined,
68 }
69 }
70 const painting = PAINTING.exec(text)
71 if (painting) return { id: painting[1], status: 'processing' }
72 const failed = FAILED.exec(text)
73 if (failed) return { id: failed[1], status: 'failed' }
74 return undefined
75}
76
77/**
78 * Finds the code in a tool result of any shape Claude Code keeps: the structured result, a list of content blocks,
79 * an object holding either, or the text alone. The structured result wins over the text.
80 */
81export function codeOf(output: unknown): Code | undefined {
82 const texts: string[] = []
83 let found: Code | undefined
84 const visit = (value: unknown, depth: number) => {
85 if (found || depth > 6 || value === null || value === undefined) return
86 if (typeof value === 'string') {
87 const trimmed = value.trim()
88 if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
89 try {
90 visit(JSON.parse(trimmed), depth + 1)
91 return
92 } catch {
93 // not JSON: read it as text
94 }
95 }
96 texts.push(value)
97 return
98 }
99 if (Array.isArray(value)) {
100 for (const item of value) visit(item, depth + 1)
101 return
102 }
103 if (typeof value === 'object') {
104 const record = value as Record<string, unknown>
105 found = fromObject(record)
106 if (found) return
107 for (const key of Object.keys(record)) visit(record[key], depth + 1)
108 }
109 }
110 visit(output, 0)
111 return found ?? fromText(texts.join('\n'))
112}
113hooks/lib/picture.ts 19 lines1// How the card draws a code's picture on each surface, and when it points to the full-quality image.
2import type { Code } from './code.ts'
3
4/** `pixels`: an Image (kitty, Ghostty); `cells`: a Raster of half blocks (other terminals); `svg`: the Desktop app and the editor. */
5export type PictureKind = 'pixels' | 'cells' | 'svg'
6
7/** The largest PNG an Image takes (2 MiB decoded), as base64. */
8export const MAX_IMAGE_BASE64 = Math.floor(((2 * 1024 * 1024) / 3) * 4)
9
10export function pictureKind(surface: string, terminalImages: boolean, pngBase64Length: number): PictureKind {
11 if (surface !== 'terminal') return 'svg'
12 return terminalImages && pngBase64Length <= MAX_IMAGE_BASE64 ? 'pixels' : 'cells'
13}
14
15/** The link under a cell-drawn picture, which loses detail: the code's PNG, or its QRX page when there is no PNG link. */
16export function fullQualityUrl(kind: PictureKind, code: Code): string | undefined {
17 return kind === 'cells' ? (code.imageUrl ?? code.pageUrl) : undefined
18}
19hooks/lib/png.ts 218 lines1// Just enough PNG for the code card: read a code's PNG into pixels, shrink it, and write a small PNG back.
2// Mods run with no DOM, no Node and no DecompressionStream, so the inflate is vendored and the writer uses stored
3// (uncompressed) deflate blocks, which every PNG reader accepts.
4import inflate from './inflate.js'
5
6// the environment has the ES2026 base64 methods on Uint8Array; TypeScript's lib does not declare them yet
7type Base64Bytes = { toBase64(this: Uint8Array): string }
8export const toBase64 = (bytes: Uint8Array): string => (bytes as Uint8Array & Base64Bytes).toBase64()
9export const fromBase64 = (text: string): Uint8Array =>
10 (Uint8Array as unknown as { fromBase64(text: string): Uint8Array }).fromBase64(text)
11
12/** Pixels as RGB, 3 bytes each, row-major. */
13export type Rgb = { width: number; height: number; data: Uint8Array }
14
15const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]
16const CHANNELS: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }
17
18const u32 = (b: Uint8Array, i: number) => ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0
19const typeOf = (b: Uint8Array, i: number) => String.fromCharCode(b[i], b[i + 1], b[i + 2], b[i + 3])
20
21/** Decodes an 8-bit, non-interlaced PNG (the kind QRX serves) into RGB, flattening alpha onto white. */
22export function decodePng(bytes: Uint8Array): Rgb {
23 for (let i = 0; i < 8; i++) if (bytes[i] !== SIGNATURE[i]) throw new Error('not a PNG')
24 let width = 0
25 let height = 0
26 let colorType = 0
27 let palette: Uint8Array | undefined
28 let alphas: Uint8Array | undefined
29 const parts: Uint8Array[] = []
30 let total = 0
31 for (let at = 8; at + 8 <= bytes.length; ) {
32 const length = u32(bytes, at)
33 const type = typeOf(bytes, at + 4)
34 const body = bytes.subarray(at + 8, at + 8 + length)
35 if (type === 'IHDR') {
36 width = u32(body, 0)
37 height = u32(body, 4)
38 colorType = body[9]
39 if (body[8] !== 8) throw new Error(`unsupported PNG bit depth ${body[8]}`)
40 if (body[12] !== 0) throw new Error('interlaced PNGs are not supported')
41 if (!(colorType in CHANNELS)) throw new Error(`unsupported PNG color type ${colorType}`)
42 } else if (type === 'PLTE') palette = body
43 else if (type === 'tRNS') alphas = body
44 else if (type === 'IDAT') {
45 parts.push(body)
46 total += body.length
47 } else if (type === 'IEND') break
48 at += 12 + length
49 }
50 if (!width || !height || !parts.length) throw new Error('PNG has no image data')
51
52 const zlib = new Uint8Array(total)
53 let offset = 0
54 for (const part of parts) {
55 zlib.set(part, offset)
56 offset += part.length
57 }
58 const bpp = CHANNELS[colorType]
59 const stride = width * bpp
60 // the zlib header is 2 bytes; the Adler-32 at the end is not checked
61 const raw = inflate(zlib.subarray(2), new Uint8Array(height * (stride + 1))) as Uint8Array
62 if (raw.length !== height * (stride + 1)) throw new Error('PNG image data is truncated')
63
64 const pixels = new Uint8Array(height * stride)
65 for (let y = 0; y < height; y++) {
66 const filter = raw[y * (stride + 1)]
67 const src = y * (stride + 1) + 1
68 const row = y * stride
69 const up = row - stride
70 for (let x = 0; x < stride; x++) {
71 const a = x >= bpp ? pixels[row + x - bpp] : 0
72 const b = y > 0 ? pixels[up + x] : 0
73 const c = x >= bpp && y > 0 ? pixels[up + x - bpp] : 0
74 let value = raw[src + x]
75 if (filter === 1) value += a
76 else if (filter === 2) value += b
77 else if (filter === 3) value += (a + b) >> 1
78 else if (filter === 4) {
79 const p = a + b - c
80 const pa = Math.abs(p - a)
81 const pb = Math.abs(p - b)
82 const pc = Math.abs(p - c)
83 value += pa <= pb && pa <= pc ? a : pb <= pc ? b : c
84 }
85 pixels[row + x] = value & 255
86 }
87 }
88
89 const data = new Uint8Array(width * height * 3)
90 for (let i = 0, o = 0; i < width * height; i++, o += 3) {
91 let r: number
92 let g: number
93 let b: number
94 let alpha = 255
95 if (colorType === 2 || colorType === 6) {
96 r = pixels[i * bpp]
97 g = pixels[i * bpp + 1]
98 b = pixels[i * bpp + 2]
99 if (colorType === 6) alpha = pixels[i * bpp + 3]
100 } else if (colorType === 3) {
101 const index = pixels[i]
102 r = palette ? palette[index * 3] : 0
103 g = palette ? palette[index * 3 + 1] : 0
104 b = palette ? palette[index * 3 + 2] : 0
105 if (alphas && index < alphas.length) alpha = alphas[index]
106 } else {
107 r = g = b = pixels[i * bpp]
108 if (colorType === 4) alpha = pixels[i * bpp + 1]
109 }
110 data[o] = (r * alpha + 255 * (255 - alpha)) / 255
111 data[o + 1] = (g * alpha + 255 * (255 - alpha)) / 255
112 data[o + 2] = (b * alpha + 255 * (255 - alpha)) / 255
113 }
114 return { width, height, data }
115}
116
117/** Shrinks by averaging each target pixel's box of source pixels. */
118export function shrink(image: Rgb, width: number, height: number): Rgb {
119 const data = new Uint8Array(width * height * 3)
120 for (let y = 0; y < height; y++) {
121 const y0 = Math.floor((y * image.height) / height)
122 const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * image.height) / height))
123 for (let x = 0; x < width; x++) {
124 const x0 = Math.floor((x * image.width) / width)
125 const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * image.width) / width))
126 let r = 0
127 let g = 0
128 let b = 0
129 for (let sy = y0; sy < y1; sy++) {
130 for (let sx = x0; sx < x1; sx++) {
131 const i = (sy * image.width + sx) * 3
132 r += image.data[i]
133 g += image.data[i + 1]
134 b += image.data[i + 2]
135 }
136 }
137 const n = (y1 - y0) * (x1 - x0)
138 const o = (y * width + x) * 3
139 data[o] = r / n
140 data[o + 1] = g / n
141 data[o + 2] = b / n
142 }
143 }
144 return { width, height, data }
145}
146
147const CRC_TABLE = (() => {
148 const table = new Uint32Array(256)
149 for (let n = 0; n < 256; n++) {
150 let c = n
151 for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
152 table[n] = c >>> 0
153 }
154 return table
155})()
156
157function crc32(bytes: Uint8Array) {
158 let c = 0xffffffff
159 for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 255] ^ (c >>> 8)
160 return (c ^ 0xffffffff) >>> 0
161}
162
163function chunk(type: string, body: Uint8Array) {
164 const out = new Uint8Array(12 + body.length)
165 const view = new DataView(out.buffer)
166 view.setUint32(0, body.length)
167 for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i)
168 out.set(body, 8)
169 view.setUint32(8 + body.length, crc32(out.subarray(4, 8 + body.length)))
170 return out
171}
172
173/** Writes RGB pixels as a PNG whose image data is stored, not compressed. */
174export function encodePng(image: Rgb): Uint8Array {
175 const stride = image.width * 3
176 const raw = new Uint8Array(image.height * (stride + 1))
177 for (let y = 0; y < image.height; y++) raw.set(image.data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1)
178
179 const blocks = Math.max(1, Math.ceil(raw.length / 65535))
180 const zlib = new Uint8Array(2 + raw.length + blocks * 5 + 4)
181 zlib[0] = 0x78
182 zlib[1] = 0x01
183 let at = 2
184 for (let i = 0; i < blocks; i++) {
185 const start = i * 65535
186 const length = Math.min(65535, raw.length - start)
187 zlib[at] = i === blocks - 1 ? 1 : 0
188 zlib[at + 1] = length & 255
189 zlib[at + 2] = length >> 8
190 zlib[at + 3] = ~length & 255
191 zlib[at + 4] = (~length >> 8) & 255
192 zlib.set(raw.subarray(start, start + length), at + 5)
193 at += 5 + length
194 }
195 let s1 = 1
196 let s2 = 0
197 for (let i = 0; i < raw.length; i++) {
198 s1 = (s1 + raw[i]) % 65521
199 s2 = (s2 + s1) % 65521
200 }
201 new DataView(zlib.buffer).setUint32(at, ((s2 << 16) | s1) >>> 0)
202
203 const header = new Uint8Array(13)
204 const view = new DataView(header.buffer)
205 view.setUint32(0, image.width)
206 view.setUint32(4, image.height)
207 header[8] = 8
208 header[9] = 2
209 const parts = [new Uint8Array(SIGNATURE), chunk('IHDR', header), chunk('IDAT', zlib), chunk('IEND', new Uint8Array(0))]
210 const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
211 let offset = 0
212 for (const part of parts) {
213 out.set(part, offset)
214 offset += part.length
215 }
216 return out
217}
218hooks/lib/inflate.js 379 lines1// tiny-inflate 1.0.3 (https://github.com/foliojs/tiny-inflate), an ES module copy.
2// MIT License, Copyright (c) 2015-present Devon Govett: see LICENSE-tiny-inflate beside this file.
3
4var TINF_OK = 0;
5var TINF_DATA_ERROR = -3;
6
7function Tree() {
8 this.table = new Uint16Array(16); /* table of code length counts */
9 this.trans = new Uint16Array(288); /* code -> symbol translation table */
10}
11
12function Data(source, dest) {
13 this.source = source;
14 this.sourceIndex = 0;
15 this.tag = 0;
16 this.bitcount = 0;
17
18 this.dest = dest;
19 this.destLen = 0;
20
21 this.ltree = new Tree(); /* dynamic length/symbol tree */
22 this.dtree = new Tree(); /* dynamic distance tree */
23}
24
25/* --------------------------------------------------- *
26 * -- uninitialized global data (static structures) -- *
27 * --------------------------------------------------- */
28
29var sltree = new Tree();
30var sdtree = new Tree();
31
32/* extra bits and base tables for length codes */
33var length_bits = new Uint8Array(30);
34var length_base = new Uint16Array(30);
35
36/* extra bits and base tables for distance codes */
37var dist_bits = new Uint8Array(30);
38var dist_base = new Uint16Array(30);
39
40/* special ordering of code length codes */
41var clcidx = new Uint8Array([
42 16, 17, 18, 0, 8, 7, 9, 6,
43 10, 5, 11, 4, 12, 3, 13, 2,
44 14, 1, 15
45]);
46
47/* used by tinf_decode_trees, avoids allocations every call */
48var code_tree = new Tree();
49var lengths = new Uint8Array(288 + 32);
50
51/* ----------------------- *
52 * -- utility functions -- *
53 * ----------------------- */
54
55/* build extra bits and base tables */
56function tinf_build_bits_base(bits, base, delta, first) {
57 var i, sum;
58
59 /* build bits table */
60 for (i = 0; i < delta; ++i) bits[i] = 0;
61 for (i = 0; i < 30 - delta; ++i) bits[i + delta] = i / delta | 0;
62
63 /* build base table */
64 for (sum = first, i = 0; i < 30; ++i) {
65 base[i] = sum;
66 sum += 1 << bits[i];
67 }
68}
69
70/* build the fixed huffman trees */
71function tinf_build_fixed_trees(lt, dt) {
72 var i;
73
74 /* build fixed length tree */
75 for (i = 0; i < 7; ++i) lt.table[i] = 0;
76
77 lt.table[7] = 24;
78 lt.table[8] = 152;
79 lt.table[9] = 112;
80
81 for (i = 0; i < 24; ++i) lt.trans[i] = 256 + i;
82 for (i = 0; i < 144; ++i) lt.trans[24 + i] = i;
83 for (i = 0; i < 8; ++i) lt.trans[24 + 144 + i] = 280 + i;
84 for (i = 0; i < 112; ++i) lt.trans[24 + 144 + 8 + i] = 144 + i;
85
86 /* build fixed distance tree */
87 for (i = 0; i < 5; ++i) dt.table[i] = 0;
88
89 dt.table[5] = 32;
90
91 for (i = 0; i < 32; ++i) dt.trans[i] = i;
92}
93
94/* given an array of code lengths, build a tree */
95var offs = new Uint16Array(16);
96
97function tinf_build_tree(t, lengths, off, num) {
98 var i, sum;
99
100 /* clear code length count table */
101 for (i = 0; i < 16; ++i) t.table[i] = 0;
102
103 /* scan symbol lengths, and sum code length counts */
104 for (i = 0; i < num; ++i) t.table[lengths[off + i]]++;
105
106 t.table[0] = 0;
107
108 /* compute offset table for distribution sort */
109 for (sum = 0, i = 0; i < 16; ++i) {
110 offs[i] = sum;
111 sum += t.table[i];
112 }
113
114 /* create code->symbol translation table (symbols sorted by code) */
115 for (i = 0; i < num; ++i) {
116 if (lengths[off + i]) t.trans[offs[lengths[off + i]]++] = i;
117 }
118}
119
120/* ---------------------- *
121 * -- decode functions -- *
122 * ---------------------- */
123
124/* get one bit from source stream */
125function tinf_getbit(d) {
126 /* check if tag is empty */
127 if (!d.bitcount--) {
128 /* load next tag */
129 d.tag = d.source[d.sourceIndex++];
130 d.bitcount = 7;
131 }
132
133 /* shift bit out of tag */
134 var bit = d.tag & 1;
135 d.tag >>>= 1;
136
137 return bit;
138}
139
140/* read a num bit value from a stream and add base */
141function tinf_read_bits(d, num, base) {
142 if (!num)
143 return base;
144
145 while (d.bitcount < 24) {
146 d.tag |= d.source[d.sourceIndex++] << d.bitcount;
147 d.bitcount += 8;
148 }
149
150 var val = d.tag & (0xffff >>> (16 - num));
151 d.tag >>>= num;
152 d.bitcount -= num;
153 return val + base;
154}
155
156/* given a data stream and a tree, decode a symbol */
157function tinf_decode_symbol(d, t) {
158 while (d.bitcount < 24) {
159 d.tag |= d.source[d.sourceIndex++] << d.bitcount;
160 d.bitcount += 8;
161 }
162
163 var sum = 0, cur = 0, len = 0;
164 var tag = d.tag;
165
166 /* get more bits while code value is above sum */
167 do {
168 cur = 2 * cur + (tag & 1);
169 tag >>>= 1;
170 ++len;
171
172 sum += t.table[len];
173 cur -= t.table[len];
174 } while (cur >= 0);
175
176 d.tag = tag;
177 d.bitcount -= len;
178
179 return t.trans[sum + cur];
180}
181
182/* given a data stream, decode dynamic trees from it */
183function tinf_decode_trees(d, lt, dt) {
184 var hlit, hdist, hclen;
185 var i, num, length;
186
187 /* get 5 bits HLIT (257-286) */
188 hlit = tinf_read_bits(d, 5, 257);
189
190 /* get 5 bits HDIST (1-32) */
191 hdist = tinf_read_bits(d, 5, 1);
192
193 /* get 4 bits HCLEN (4-19) */
194 hclen = tinf_read_bits(d, 4, 4);
195
196 for (i = 0; i < 19; ++i) lengths[i] = 0;
197
198 /* read code lengths for code length alphabet */
199 for (i = 0; i < hclen; ++i) {
200 /* get 3 bits code length (0-7) */
201 var clen = tinf_read_bits(d, 3, 0);
202 lengths[clcidx[i]] = clen;
203 }
204
205 /* build code length tree */
206 tinf_build_tree(code_tree, lengths, 0, 19);
207
208 /* decode code lengths for the dynamic trees */
209 for (num = 0; num < hlit + hdist;) {
210 var sym = tinf_decode_symbol(d, code_tree);
211
212 switch (sym) {
213 case 16:
214 /* copy previous code length 3-6 times (read 2 bits) */
215 var prev = lengths[num - 1];
216 for (length = tinf_read_bits(d, 2, 3); length; --length) {
217 lengths[num++] = prev;
218 }
219 break;
220 case 17:
221 /* repeat code length 0 for 3-10 times (read 3 bits) */
222 for (length = tinf_read_bits(d, 3, 3); length; --length) {
223 lengths[num++] = 0;
224 }
225 break;
226 case 18:
227 /* repeat code length 0 for 11-138 times (read 7 bits) */
228 for (length = tinf_read_bits(d, 7, 11); length; --length) {
229 lengths[num++] = 0;
230 }
231 break;
232 default:
233 /* values 0-15 represent the actual code lengths */
234 lengths[num++] = sym;
235 break;
236 }
237 }
238
239 /* build dynamic trees */
240 tinf_build_tree(lt, lengths, 0, hlit);
241 tinf_build_tree(dt, lengths, hlit, hdist);
242}
243
244/* ----------------------------- *
245 * -- block inflate functions -- *
246 * ----------------------------- */
247
248/* given a stream and two trees, inflate a block of data */
249function tinf_inflate_block_data(d, lt, dt) {
250 while (1) {
251 var sym = tinf_decode_symbol(d, lt);
252
253 /* check for end of block */
254 if (sym === 256) {
255 return TINF_OK;
256 }
257
258 if (sym < 256) {
259 d.dest[d.destLen++] = sym;
260 } else {
261 var length, dist, offs;
262 var i;
263
264 sym -= 257;
265
266 /* possibly get more bits from length code */
267 length = tinf_read_bits(d, length_bits[sym], length_base[sym]);
268
269 dist = tinf_decode_symbol(d, dt);
270
271 /* possibly get more bits from distance code */
272 offs = d.destLen - tinf_read_bits(d, dist_bits[dist], dist_base[dist]);
273
274 /* copy match */
275 for (i = offs; i < offs + length; ++i) {
276 d.dest[d.destLen++] = d.dest[i];
277 }
278 }
279 }
280}
281
282/* inflate an uncompressed block of data */
283function tinf_inflate_uncompressed_block(d) {
284 var length, invlength;
285 var i;
286
287 /* unread from bitbuffer */
288 while (d.bitcount > 8) {
289 d.sourceIndex--;
290 d.bitcount -= 8;
291 }
292
293 /* get length */
294 length = d.source[d.sourceIndex + 1];
295 length = 256 * length + d.source[d.sourceIndex];
296
297 /* get one's complement of length */
298 invlength = d.source[d.sourceIndex + 3];
299 invlength = 256 * invlength + d.source[d.sourceIndex + 2];
300
301 /* check length */
302 if (length !== (~invlength & 0x0000ffff))
303 return TINF_DATA_ERROR;
304
305 d.sourceIndex += 4;
306
307 /* copy block */
308 for (i = length; i; --i)
309 d.dest[d.destLen++] = d.source[d.sourceIndex++];
310
311 /* make sure we start next block on a byte boundary */
312 d.bitcount = 0;
313
314 return TINF_OK;
315}
316
317/* inflate stream from source to dest */
318function tinf_uncompress(source, dest) {
319 var d = new Data(source, dest);
320 var bfinal, btype, res;
321
322 do {
323 /* read final block flag */
324 bfinal = tinf_getbit(d);
325
326 /* read block type (2 bits) */
327 btype = tinf_read_bits(d, 2, 0);
328
329 /* decompress block */
330 switch (btype) {
331 case 0:
332 /* decompress uncompressed block */
333 res = tinf_inflate_uncompressed_block(d);
334 break;
335 case 1:
336 /* decompress block with fixed huffman trees */
337 res = tinf_inflate_block_data(d, sltree, sdtree);
338 break;
339 case 2:
340 /* decompress block with dynamic huffman trees */
341 tinf_decode_trees(d, d.ltree, d.dtree);
342 res = tinf_inflate_block_data(d, d.ltree, d.dtree);
343 break;
344 default:
345 res = TINF_DATA_ERROR;
346 }
347
348 if (res !== TINF_OK)
349 throw new Error('Data error');
350
351 } while (!bfinal);
352
353 if (d.destLen < d.dest.length) {
354 if (typeof d.dest.slice === 'function')
355 return d.dest.slice(0, d.destLen);
356 else
357 return d.dest.subarray(0, d.destLen);
358 }
359
360 return d.dest;
361}
362
363/* -------------------- *
364 * -- initialization -- *
365 * -------------------- */
366
367/* build fixed huffman trees */
368tinf_build_fixed_trees(sltree, sdtree);
369
370/* build extra bits and base tables */
371tinf_build_bits_base(length_bits, length_base, 4, 3);
372tinf_build_bits_base(dist_bits, dist_base, 2, 1);
373
374/* fix a special case */
375length_bits[28] = 0;
376length_base[28] = 258;
377
378export default tinf_uncompress;
379