License: First Million of Revenue from Building a Competing Service Stays Yours License. Free to use, fork, and modify. Even competing products built with this code are free up to $1 million in revenue a year, with only a small 3% fee beyond that $1 million.
Always free Competing offerings Becomes MIT Internal use and noncompeting offerings First $1M/year free; 3% of excess revenue attributable to this software 2028-04-27 (development snapshot) Plain answers: hyperclay.com/host-program · binding text in LICENSE · questions: license@hyperclay.com
A desktop app that makes self-saving HTML files a native OS feature.
- Website: htmlclay.com
- File extension:
.htmlclay - Format: a malleable document
- Parent platform: Hyperclay
Worth stating once, because the three get mixed up:
- The format is a malleable document, and its real extension is
.html. It is specified at malleabledocument.com and several programs can host one. - HTML Clay is this app. It is one of those programs, not the format.
.htmlclayis an operating system convention. It tells your OS to open the file with HTML Clay, the same way.psdnames Photoshop rather than naming the image format.
Rename any .htmlclay file to .html and it opens in any browser, unchanged. Nothing about the file
depends on this app.
HTML Clay lets you double-click an HTML file, edit it visually, and save your changes — just like you would with a Word document or a Photoshop file.
HTML is the most powerful document format ever created. It can render rich interfaces, run code, play media, and work offline. But unlike every other document format, an HTML file can't save changes back to itself. HTML Clay fixes that.
How it works:
- Install HTML Clay (one time)
- Double-click any
.htmlclayfile - It opens in a window — edit it however you like
- Hit save — your changes write back to the same file on disk
No cloud. No accounts. No build step. The file is just HTML.
- Self-saving HTML files — The file knows where it lives on disk. Edit and save without "Save As" dialogs or cloud storage.
- True portability — A
.htmlclayfile works offline, locally, with no infrastructure. Email it, Dropbox it, AirDrop it, git it. - Low barrier for developers — Write HTML + a small JS save call + rename to
.htmlclay. That's it. - One-click OS integration — File extension associates with the app. Double-click to open. No registration, no login.
- A project is one place — Trust a folder and every
.htmlclayfile in it opens editable at the folder's own hostname on the app's one port, an address that survives a restart, so links between them work and a bookmark keeps working. - Platform potential — The same
.htmlclayfile could eventually run both locally and on a web hosting platform.
It's just HTML. Here's a minimal example — a page with editable text and a save button:
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8">
<title>My Note</title>
</head>
<body>
<h1 contenteditable>Edit me</h1>
<button id="save">Save</button>
<script>
document.getElementById('save').addEventListener('click', async () => {
const html = '<!DOCTYPE html>\n' + document.documentElement.outerHTML;
const token = document.documentElement.getAttribute('savetoken');
const res = await fetch('/_/save/' + token, {
method: 'POST',
headers: { 'Content-Type': 'text/html' },
body: html
});
if (res.ok) document.title = 'Saved!';
});
</script>
</body></html>The key line is fetch('/_/save/' + token, ...) — that's the save call. HTML Clay injects a savetoken attribute into the <html> tag when serving the file, and the page reads it to know where to save. The token is a cryptographic session identifier that maps to the file on disk.
Before the save protocol settled on savetoken, HTML Clay called the attribute htmlclaytoken. It serves both names carrying the same token, and strips both on save, so a document written against the old name goes on saving forever. Read savetoken in anything new.
A .htmlclay file can be anything you'd build as a web page that benefits from being opened, edited, and saved like a document. Here are some ideas:
- Flashcard deck with spaced repetition
- Kanban board
- Weekly planner
- Habit tracker grid
- Recipe box
- Reading list with notes and ratings
- Markdown journal with daily entries
- Gratitude journal
- Vocabulary builder
- Meeting notes template
- Invoice generator
- Language phrase book
- Workout log
- Movie/TV watchlist
- Guitar tab editor
- Freelance time tracker
- Screenplay formatter
- Running log
- Blood pressure log
- Song lyrics organizer
- Poetry notebook with syllable counter
- Dream log
- Subscription tracker
- Envelope budgeting
- Bucket list
- SVG icon editor
- Packing list
- Snippet library
- Music practice log
- Podcast episode planner
- Setlist planner
- ASCII art canvas
- Changelog editor
- Gift ideas tracker
- Garage sale inventory
- Choose-your-own-adventure engine
- Moving checklist
- Interactive fiction editor
Select a sentence in any HTML file that loads ClayJS, press ⌘J (Ctrl+J on Windows and Linux), and type what you want. HTML Clay runs Claude Code on your computer with no tools, the rewrite appears in place, and Keep saves it. Start the request with @fable or @codex to pick another agent. The agent you use must be installed and signed in. Turn AI editing off with AI Editing in the tray menu. SECURITY.md says exactly what is sent and where.
A page open in HTML Clay can ask a program on your machine to change the file it is running from, and watch the change arrive. You point at a paragraph and say "make this shorter", and an AI agent, a script, or a formatter running in your own terminal does it.
htmlclay wire serve ~/notes/page.htmlclay -- ./my-agent.shNo HTML travels between the two: the page sends a small request, your program edits the file, and the edit reaches the page as an ordinary file change. The file is the only thing they share, so nothing needs an account, a service, or a protocol you have to trust.
See docs/wire.md for the page API, the handler contract, the CLI, and the security model.
Link a stylesheet named /tailwindcss/<name>.css and HTML Clay builds it from the classes the document uses, the same way Hyperclay and Hyperclay Local do:
<link rel="stylesheet" href="/tailwindcss/notes.css">The stylesheet comes from the page that asked for it, so a document keeps its styles after a rename or a copy, and HTML Clay never edits the link or anything else in your file. It uses Tailwind v4 with the typography and forms (class mode) plugins and an ephemeral-<word>: variant for ClayJS's per-tab ephemeral attribute. Tailwind's compiler ships inside HTML Clay and runs on your computer: nothing is downloaded, and nothing leaves the machine. The first stylesheet after a change takes about a second to build; after that it comes from a cache until the document changes.
Every other document format has self-saving figured out. Photoshop files open, edit, save. Word documents open, edit, save. Even macro-laden Excel spreadsheets — which execute arbitrary code — open, edit, save.
HTML can't do this because browsers run it in a sandbox designed for the web. But a file you downloaded and double-clicked isn't the web — you trust it the same way you trust a .docx. HTML Clay bridges this gap safely by running a tiny local server that handles file reads and writes.
For a deeper exploration of the problem and the landscape of existing solutions, see docs/malleable-html-file.md.
HTML Clay only saves files you chose: a file you opened yourself, or a file inside a folder you have trusted. A page can only read inside the folder you opened it from; anything outside that pauses and asks. SECURITY.md explains the model, what each protection covers, and the current known limitations.
HTML Clay is a Go application with a simple architecture: a localhost HTTP server that bridges the browser sandbox with the filesystem.
User double-clicks .htmlclay file
→ OS launches HTML Clay (registered handler for .htmlclay)
→ App picks the file's origin: the trusted folder containing it, else its own folder
→ App serves the folder at its own hostname on the app's one port, and mints a session token
→ App opens the default browser at http://htmlclay<n>.localhost:1212/
→ Browser loads file from the loopback server
→ User edits, hits save
→ JS reads savetoken, calls POST /_/save/{token}
→ Server writes changes back to disk
The app's one port is bound at startup, on both loopback addresses, before a file is opened, and
every folder answers at its own hostname on it. If another program holds the port at launch, a
dialog names it and offers to quit it, move HTML Clay to a new port, or quit. If trusting an
enclosing folder changes the active origin, an ordinary remembered 127.0.0.1:<port> address
redirects an eligible top-level document navigation to the folder's current hostname while its old
port is free. The old listener serves no files and grants no permissions. Missing trust, a failed
folder identity check, unsafe paths, and non-navigation requests keep the recovery page. Explicitly
revoked addresses remain on that page for the rest of the run.
Folder trust does not migrate browser storage between origins, and forgotten or evicted records are not permanent bookmark records.
Programs → Open Documents lists the programs a document declares for files already registered, including files opened from a bookmark. A row that needs setup opens the setup dialog; a row that is set up opens the Programs page. Helpers receive protocol requests on stdin; launchers that only open pages are not helpers. Cancel or deny leaves bindings unchanged. A stored valid binding is restored on reload or restart without another approval. This menu is generic; Search's specific contract belongs in its own README.
The Programs page (Programs → Manage Programs…) is served only on http://127.0.0.1:<port>/programs,
through a one-time link the tray opens, and has no JavaScript. It can only remove access directly.
Relinking a moved program and allowing one in trusted folders always go through a native dialog.
| Method | Route | Purpose |
|---|---|---|
GET |
/{path} |
Serve a .htmlclay file with session token injected |
GET |
/_/read/{token} |
Return raw file contents |
POST |
/_/save/{token} |
Write updated HTML back to disk (atomic write) |
GET |
/_/meta/{token} |
Return file metadata (path, size, modification time) |
GET |
/tailwindcss/{name}.css |
Tailwind stylesheet compiled from the requesting document (or {name}.html, then {name}.htmlclay) |
GET |
/{path}?data={…} |
Extract JSON from the file using rules you supply |
GET |
/_/api/{path} |
Extract JSON using caller data rules, or the embedded mapping |
POST |
/_/api/{path} |
Write JSON using caller data rules, or the embedded mapping |
POST |
/{path}?data={…} |
Write JSON using caller rules, content only |
Content is served at the top level; actions live under the /_/ marker, matching the Hyperclay platform convention. The save endpoint takes the document as a plain-text body. That is the one shape the format defines for it, so a JSON body is refused with 415 rather than guessed at, and anything a save needs to say beyond the document travels in a header.
Any .html or .htmlclay file HTML Clay will serve can also be read as JSON, using the same
extraction rules as the Hyperclay platform. Two ways in:
# You supply the rules
curl --get 'http://htmlclay.localhost:1212/notes.htmlclay' \
--data-urlencode 'data={title:"h1",items:".todo[]"}'
# The file supplies its own, from a tag inside it
curl 'http://htmlclay.localhost:1212/_/api/notes.htmlclay'Replace htmlclay with the hostname in your tab's address bar; the port is the same for every
folder. Browsers and curl resolve *.localhost to loopback by themselves. Other programs ask
the system resolver, which macOS answers, Linux answers with systemd-resolved, and Windows usually
does not (PowerShell's Invoke-RestMethod fails with "remote name could not be resolved"). A
program can skip the hostname entirely: 127.0.0.1:1212/_/api/<path> reaches whichever open folder
serves that file, for any caller that is not a browser (no Origin, no Sec-Fetch-Site):
Invoke-RestMethod http://127.0.0.1:1212/_/api/Documents/notes/notes.htmlclayThe path is relative to your home folder, the same path the address bar shows. If no open folder
contains the file, the answer is a 404 No open folder; open the file in HTML Clay first.
The second reads a tag the page publishes:
<script data-rules-name="api" data-rules-version="1">
{title: "h1", items: [".todo", {text: ".", done: "@data-done"}]}
</script>Rules are relaxed JSON: unquoted keys and bare selectors are fine. A rule is a CSS selector, with
sel[] for every match's text, sel@attr for an attribute, and [sel, {…}] for one object per
match.
A data request reads exactly what a normal request reads, and nothing more. It runs the same
permission checks in the same order, asks the same folder-access question, and returns the same
refusal. It never creates an editing session: no save token, no edit-mode cookie, and no version
snapshot. There is no CORS header on either route, so only a program you run yourself, such as
curl, or a page on this same site can read it.
Deliberate, and each one measured against the platform's own engine rather than assumed.
| Behavior | HTML Clay | Platform |
|---|---|---|
| A selector the two engines read differently | refused with a reason, e.g. :is, :empty, :gt, :matches, CSS comments, the [a=b s] flag |
answered, sometimes differently |
A positional that is not last, e.g. li:first span |
refused | answered |
| Any broken selector | 400 |
400 or 500, depending on the wording of the parser's message |
@type on an element |
the type attribute |
"tag", the internal node type |
@readOnly |
the real value | always false |
sel@href[] |
refused, naming the array form that works | silently [] |
A repeated ?data= parameter |
400 |
500 |
| Caching | none | five minutes |
| CORS | none | enabled |
The full ledger, with the measurement behind every row, is in
internal/dataapi/testdata/selector-parity.json: one list of constructs that must match the platform exactly,
and one of constructs HTML Clay refuses, each recorded with the answer the platform gives so the cost
of the refusal is written down rather than guessed at.
Caller supplied rules work on both URL forms, for GET and POST. No embedded tag is required:
curl --get 'http://htmlclay.localhost:1212/soup.htmlclay' \
--data-urlencode 'data={title:h1}'
curl --request POST \
'http://htmlclay.localhost:1212/soup.htmlclay?data=%7Btitle%3Ah1%7D' \
--header 'Content-Type: application/json' \
--data '{"title":"Lunch"}'Replace htmlclay with the hostname in your tab's address bar; the port is the same for every
folder. The same requests work with /_/api/soup.htmlclay?data=%7Btitle%3Ah1%7D, and with .html
files.
Supplied rules replace the embedded mapping for that request, even if the tag is malformed.
They are never merged with it or saved into the file. Without data, /_/api/ uses the
embedded api tag as before. An empty, malformed, or repeated data parameter returns
400; it never falls back to the tag. POST bodies remain strict JSON, with no rules envelope.
The response uses the same rules as the request. GET and POST return the source file's
ETag; send it as If-Match to refuse a stale write with 412.
POST JSON to the same /_/api/ address and HTML Clay writes it into the file through the page's own
api rules tag. The body has the same shape a GET returns:
curl -X POST 'http://htmlclay.localhost:1212/_/api/notes.htmlclay' \
-H 'Content-Type: application/json' \
-d '{"title":"Groceries","items":[{"text":"eggs","done":"no"}]}'- Content only. Text is written as text, never parsed as HTML. The write is refused (
400 Write refused, nothing written) when it would set anon*handler,style,srcdoc, a save or region marker, or ajavascript:,vbscript:ordata:URL, when it targets anything inside<script>,<style>,<template>,<iframe>or the head's metadata tags, and for@innerHTMLand@outerHTML. - Strict keys. A key the selected rules do not define, or a rule whose selector matches nothing,
is
400 Write rejectedwith the offending names. A key you leave out is left alone, andnullclears a text value. Lists grow by cloning their first row, or acms-templateseed when empty. - Only the changed elements are rewritten. Every other byte of the file, line endings and quoting included, stays exactly as it was. The splice is proven by reparsing, and when it cannot be proven the whole document is rendered instead.
- It lands like a save. Version history records it, open tabs update live, and a body that
changes nothing writes nothing. The answer is the fresh extraction with an
ETag; the GET faces send the sameETag, and sending it back asIf-Matchturns a write over a changed file into a412. - Who can write. A program you run yourself, such as
curlor an agent, needs nothing extra. A browser page must send the target file's save token in aSave-Tokenheader, so a page cannot write a sibling it has not been handed the token for. The file must be one HTML Clay has open, or an HTML Clay file inside a trusted folder; anything else is403 Not writable. Bodies areapplication/json(415otherwise), at most 1 MB.
cmd/htmlclay/ The executable. main.go starts and stops it, sites.go owns the site registry,
folders.go the trusted-folder list, open.go hands a file to the browser,
recovery.go the remembered-port page, wire_cli.go the `htmlclay wire` subcommand
internal/server/ HTTP server, request handlers, security middleware
internal/session/ Cryptographic token generation, file↔token mapping, held read roots
internal/trust/ The rules about which folders may be trusted, and on whose say-so
internal/dataapi/ Reading a document as JSON, with its vendored conformance corpus
internal/versions/ Save history: every write keeps a restorable previous version
internal/tailwind/ Builds a document's Tailwind stylesheet with the bundled Tailwind CLI
internal/browser/ Opening a URL in the system's default browser
internal/htmlutil/ Inject/strip savetoken and documentid attributes in <html> tag
internal/config/ Persist settings to OS config dir (~/Library/Application Support, ~/.config, %APPDATA%)
internal/platform/ Single-instance enforcement (Unix socket / TCP on Windows), Start on Login, file associations
internal/tray/ System tray icon and menu
internal/logging/ File-based logger with 10MB rotation
internal/update/ Version check against htmlclay.com
packaging/ Per-OS packaging. packaging_test.go sits here rather than at the root: it
pins the couplings between these assets and the code that reads them
packaging/macos/ macOS .app bundle build script, Info.plist, codesigning
packaging/linux/ Desktop entry, MIME type registration, icons, install and uninstall scripts
packaging/windows/ Icon and version resources (winres.json), zip README, fallback registration script
packaging/icons/ The icon source set and the generator that renders every platform's icons
website/ htmlclay.com, deployed from this repo
- Localhost only — The server binds its one port on
127.0.0.1and::1, serves each folder at its own*.localhosthostname, validates theHostheader, and rejects cross-site requests (Sec-Fetch-Site: cross-site). Mutating routes (save, restore, live sync, permission requests) additionally requireSec-Fetch-Site: same-originand a matchingOrigin. Two folders' hostnames are cross-site to each other, so a link between folders works under the default referrer policy and fails closed from a page that setsno-referrer. - Read-only by default, two ways to editable — A linked or typed
.htmlclayfile serves read-only with a banner offering to trust its folder (a single-use server-minted nonce plus a native dialog). A trusted folder auto-registers its.htmlclayfiles as editable on real navigations, with no prompts. Save tokens are injected only into document navigations, never into background fetches. - 256-bit session tokens — Each opened file gets a cryptographically random token. The read, save, and meta endpoints (under
/_/) require a valid token; the top-level file-serving route only resolves paths that match an already-open file. Tokens are redacted from the log and live for the lifetime of the process (there is no per-file expiry); on a single-user desktop this is fine, since they never leave loopback. - Path traversal prevention — All file paths are validated as relative and within the user's home directory. Symlinks are resolved before validation.
- Atomic writes — Files are written to a temp file first, then renamed into place, preventing corruption on crash.
- Single instance — A Unix socket (or TCP on Windows) ensures only one server runs at a time. Additional launches forward their file paths to the running instance.
- Per-HOME lock scope: Because the socket and pidfile live under
os.UserConfigDir()($HOME/Library/Application Support/htmlclayon macOS), the lock is scoped to$HOME(and breaks if a test wipes that config directory between launches), so sandboxed agent runs with customHOMEdirectories each spawn their own GUI instance and menu-bar icon. Identify the real instance by matching its PID against~/Library/Application Support/htmlclay/lock(inspectHOME=viaps eww -o command= <pid>), and checklsofon thatHOME'shtmlclay.logbefore killing anything.
- Per-HOME lock scope: Because the socket and pidfile live under
- Local trust boundary — The bridge listens on loopback, so any process running as your user can reach the app's port, at
127.0.0.1:<port>or at any*.localhosthostname it serves. Saving requires the per-file token, but the top-level serve route returns a currently-open file by path with no token, so a malicious local process (or a page you already have open) can read other open documents. This is inherent to the localhost-bridge model: htmlclay only serves files you have explicitly opened, and nothing is exposed off the machine.
Stored at <os-config-dir>/htmlclay/config.json (~/Library/Application Support on macOS, ~/.config on Linux, %APPDATA% on Windows):
{
"startOnLogin": false,
"port": 1212,
"siteSlugs": {
"/Users/you/projects/notes": "htmlclay1"
},
"retiredSlugs": [
"htmlclay"
],
"nextSiteNumber": 3,
"sitePorts": {
"/Users/you/projects/notes": 54321
},
"workspaceFolders": [
{ "path": "/Users/you/projects/notes", "identity": "16777229:224754543" }
]
}port is the one port every folder is served on, chosen on the first launch and kept. siteSlugs
remembers the hostname each folder answers at, keyed by its anchor folder, so an address survives a
restart: the label is chosen once and never changes. Labels are htmlclay, then htmlclay1,
htmlclay2, and so on, in the order folders are first served, and nextSiteNumber is the next one.
retiredSlugs are labels that were once issued and then given up, which are never issued again.
sitePorts is the legacy record from the per-folder-port model, kept only so an old
127.0.0.1:<port> bookmark redirects to the folder's hostname while that port is free. Nothing new
is ever written to it.
aiEdit holds the AI Editing switch and is absent until you change something. "enabled": false
turns AI editing off. "default" names the agent used when a request names none (claude, fable
or codex, or one of your own). "engines" adds your own agents, each a command as an argument
list, where an argument containing {prompt} receives the prompt; without one, the prompt arrives
on standard input. For example "aiEdit": { "engines": { "echo": ["sh", "-c", "cat"] } } makes
@echo send the prompt back unchanged.
The trusted-folder list keeps the on-disk key workspaceFolders from the version that introduced it.
Renaming the key would make older configs fail to parse, and the corrupt-config path would then reset
every other setting. identity is the folder's fingerprint at the moment you trusted it: volume UUID
and inode on macOS, device and inode on Linux, volume serial and file id on Windows. A folder replaced
since then stops granting anything rather than covering the newcomer. An entry recorded before Windows
could fingerprint a directory has no identity; it keeps working and gains one on the next launch.
The app lives in the system tray with controls for:
- Trusted Folders: trust one through a folder picker, or click a row to stop trusting it
- Opening the example file and the backups folder
- Toggling Start on Login (LaunchAgent on macOS, autostart desktop entry on Linux, registry key on Windows)
- Turning AI Editing on or off (on by default)
- A notification when a new version is available (click to open the download page)
Requires Go 1.26+. Linux and Windows build as pure Go (no system libraries needed); macOS builds use cgo for the system tray and Finder integration.
To install the binary straight from the module:
go install github.com/panphora/htmlclay/cmd/htmlclay@latestThat gives you the tray app, the local server, and the htmlclay wire CLI. On Windows it also gives you the .htmlclay double-click association, which the binary registers itself on launch. On macOS and Linux that association lives outside the binary, in the .app bundle and the freedesktop MIME entry the packaged builds install, so download a build from htmlclay.com if you want double-click to work.
To build from a checkout instead:
# Build the binary
make build
# Build the binary with the Tailwind compiler bundled (downloads the pinned
# Tailwind release once; `make build` leaves it out and /tailwindcss/ answers
# an empty stylesheet)
make build-tailwind
# Run tests
make test
# Build macOS .app bundle
make dist-macos
# Build Linux binary (prints install steps; CI assembles the tarball)
make dist-linux
# Build Windows executable
make dist-windows
# Clean build artifacts
make cleanSupports macOS, Linux, and Windows. Each platform has build scripts and OS integration assets in packaging/. The internal/browser/, internal/platform/, and internal/tray/ packages use platform-specific build files (_darwin.go, _linux.go, _windows.go).
File associations. macOS reads them from the .app bundle's Info.plist. Linux gets them from the
freedesktop MIME database, which packaging/linux/install.sh writes into ~/.local/share with no sudo.
Windows writes them itself, under HKCU\Software\Classes, on every launch, so a binary that moved
keeps a working association.
Undoing it. htmlclay.exe --unregister on Windows removes the file associations and the Start on
Login entry, then exits; it runs before the single-instance lock, so it works whether or not the app is
running. bash uninstall.sh in the Linux tarball removes the binary, the desktop entry, the MIME
package, the icons, and the autostart entry. Neither touches the config directory, which holds the
trusted-folder list and every saved version.
Linux permission prompts are drawn by zenity, falling back to kdialog. With neither installed
the app says so at startup, in the log, in a notification, and in a permanent tray row, because every
prompt then fails closed. The folder picker also falls back to the XDG desktop portal
(org.freedesktop.portal.FileChooser), which needs no helper binary, so trusting a folder still works
on a machine with neither tool.
./scripts/release.sh --minor # or --major, --patch (the default), or --version=1.9.0That bumps the version in cmd/htmlclay/main.go, tags and pushes, triggers the CI release workflow (test on three platforms, sign, notarize, upload to R2), publishes the website, and installs the new build into /Applications.
Three things about this repo are easy to trip over.
The website deploys itself on every push to main. htmlclay.com is a Cloudflare Worker fed by a git integration, not by CI and not by a manual wrangler deploy. Any push to main redeploys website/ about 25 seconds later. There is no deploy command to run, and adding one only races the integration.
Download links are stamped after CI, not during the version bump. scripts/stamp-website.js writes the version into website/index.html, anchored on the data-version and data-mac-dmg attributes, so new spots on the page need no change to the script. Stamping during the bump would auto-deploy links to a dmg that CI has not uploaded yet, leaving them broken for the few minutes a build takes.
htmlclay-release-info.json on R2 is not a website file. It is the feed the in-app update checker polls, and its URL is compiled into every shipped binary (internal/update/update.go). Removing it would silently and permanently break update checks for installs already in the wild.
Signing, tagging and CI gates. Run ./scripts/release.sh detached and poll it: Apple signing and notarization plus Azure Trusted Signing run inside GitHub Actions from repository secrets with no local credentials, and release.sh watches the exact version-bump SHA and creates the git tag only after macOS, Ubuntu and Windows go test ./... pass. Watch two cross-platform traps macOS misses: Linux folder-name checks are case-sensitive, and platform.DirIdentity returns "" on Windows so tests must assert platform.DirIdentity(dir) rather than a non-empty string. Check live shipped state by comparing cmd/htmlclay/main.go against https://download.htmlclay.com/htmlclay-release-info.json (GitHub releases stay empty, and git status -sb requires git fetch --tags first).
To reinstall the current release locally without cutting a new one:
bash scripts/install-local.sh # version from cmd/htmlclay/main.go
bash scripts/install-local.sh 1.1.0 # or an explicit version