From 702581b9701a4c71f964d2e8e2957e87b6a598c3 Mon Sep 17 00:00:00 2001
From: Todd Dickerson
Date: Wed, 19 Aug 2026 14:12:04 -0400
Subject: [PATCH] =?UTF-8?q?feat:=20agent=20install=20path=20=E2=80=94=20pa?=
=?UTF-8?q?ste-able=20prompt=20+=20install.json=20contract=20+=20llms.txt?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Lets a coding agent (Claude Code, OpenClaw, Cursor, Codex, Hermes) install and
verify FunButton unattended, then hand off the three macOS TCC grants a human
must do by hand (no agent can grant them).
- AGENT-INSTALL.md: copy-paste, model-agnostic prompt. brew -> curl -> manual
with success/failure detection; a 4-check VERIFY block (app present, no
quarantine, version == latest, launch log shows Whisper + llama-server up);
explicit STOP-and-hand-off for Microphone/Accessibility/Input Monitoring with
exact panes; surfaces the ~1.1 GB first-run model download.
- apps/web/app/install.json: machine-readable contract (schema_version 1).
Dynamic edge route resolving latest version + DMG sha256 live from the GitHub
API digest, with a last-known-good fallback. Methods, TCC perms, model size,
verify block.
- apps/web/app/llms.txt: discovery surface per the llms.txt convention.
- apps/web/app/page.tsx: secondary "installing with a coding agent?" card
(copy button + links). Brew stays the primary human path.
- README.md: "Install with a coding agent" section cross-linking AGENT-INSTALL.md.
Proved end to end by following AGENT-INSTALL.md verbatim on the Mac Studio:
brew cask install, no quarantine, version 0.1.8, launch log shows
"embedded STT model loaded" + "llama-server ready". /install.json and /llms.txt
serve 200 with correct content-type. All gates green (cargo fmt/clippy/build/
test 78 passed, worker tsc, web build+eslint, crash-guard grep doc-only).
Co-Authored-By: Claude Opus 4.8
---
AGENT-INSTALL.md | 149 +++++++++++++++++
PROGRESS.md | 19 +++
README.md | 9 +
apps/web/app/install.json/route.ts | 257 +++++++++++++++++++++++++++++
apps/web/app/llms.txt/route.ts | 50 ++++++
apps/web/app/page.tsx | 75 +++++++++
6 files changed, 559 insertions(+)
create mode 100644 AGENT-INSTALL.md
create mode 100644 apps/web/app/install.json/route.ts
create mode 100644 apps/web/app/llms.txt/route.ts
diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md
new file mode 100644
index 0000000..bc90c46
--- /dev/null
+++ b/AGENT-INSTALL.md
@@ -0,0 +1,149 @@
+# Install FunButton with a coding agent
+
+FunButton is a local-first, push-to-talk dictation app for macOS. This file is for
+people who live in a coding agent — Claude Code, OpenClaw, Hermes, Cursor, Codex,
+whatever. Paste the prompt below into your agent and it will install and verify
+FunButton for you, then hand the last-mile permission grants back to you (because
+macOS will not let any agent grant them — more on that below).
+
+The machine-readable version of everything here lives at
+[`https://funbutton.ai/install.json`](https://funbutton.ai/install.json) — an agent
+should fetch that instead of scraping the marketing page. There is also an
+[`https://funbutton.ai/llms.txt`](https://funbutton.ai/llms.txt) for discovery.
+
+Ground truth you should know before you start:
+
+- Apple Silicon (arm64) only. No Intel build exists.
+- The alpha is **unsigned** (no Apple Developer ID yet). Left alone, macOS mislabels
+ it as "damaged." The brew cask and the curl installer both clear that for you by
+ stripping the quarantine flag from `FunButton.app` — and nothing else. `brew` does
+ **not** do this on its own; our cask does it explicitly in a postflight.
+- The on-device models (~1.1 GB) are **not** bundled. They download on first run.
+- Three macOS permissions must be granted by a human. No agent can do it. Plan for a
+ handoff, not a fully unattended finish.
+
+## The prompt
+
+Copy everything in the block and paste it into your agent.
+
+```text
+Install FunButton for me, a local-first push-to-talk dictation app for macOS.
+It is Apple Silicon only, unsigned (alpha), GPLv3. Work through the steps in order
+and STOP the moment a success check fails — do not improvise around a failure.
+
+PREFLIGHT
+- Run: uname -sm
+ It must print "Darwin arm64". If it prints anything else, stop and tell me there
+ is no build for this machine.
+
+INSTALL — try these in order, stop at the first that succeeds:
+
+1) Homebrew cask (preferred)
+ Command: brew install --cask todddickerson/funbutton/funbutton
+ Success: brew prints "was successfully installed" AND /Applications/FunButton.app
+ exists. The cask's postflight strips the quarantine flag from FunButton.app
+ (brew does NOT do this by default; the cask does it explicitly).
+ If `brew` is not installed or the tap step errors, go to method 2.
+
+2) curl one-liner (no Homebrew needed)
+ Command: curl -fsSL https://funbutton.ai/install.sh | bash
+ Success: it prints "FunButton is installed". The script uses no sudo and makes no
+ global Gatekeeper change; you can read it at https://funbutton.ai/install.sh
+ before running it. If it errors, go to method 3.
+
+3) Manual DMG (last resort)
+ Commands:
+ curl -fL -o /tmp/FunButton.dmg https://funbutton.ai/download
+ mkdir -p /tmp/fb-mnt
+ hdiutil attach /tmp/FunButton.dmg -nobrowse -noautoopen -mountpoint /tmp/fb-mnt
+ rm -rf /Applications/FunButton.app
+ ditto /tmp/fb-mnt/FunButton.app /Applications/FunButton.app
+ hdiutil detach /tmp/fb-mnt
+ xattr -dr com.apple.quarantine /Applications/FunButton.app
+ The final xattr line is REQUIRED here — the manual path is the only one that does
+ not clear quarantine for you. NEVER run `spctl --master-disable` or otherwise
+ disable Gatekeeper system-wide. If /Applications is not writable, stop and tell me.
+
+VERIFY — all four must pass. Report each result:
+a) App present:
+ test -d /Applications/FunButton.app && echo OK
+ Expect: OK
+b) No quarantine:
+ xattr /Applications/FunButton.app
+ Expect: output does NOT contain "com.apple.quarantine".
+c) Version is the current release:
+ defaults read /Applications/FunButton.app/Contents/Info CFBundleShortVersionString
+ Compare it to the "latest_version" field from:
+ curl -fsSL https://funbutton.ai/install.json
+ Expect: they match.
+d) It launches with both engines up:
+ /Applications/FunButton.app/Contents/MacOS/funbutton >/tmp/fb-launch.log 2>&1 &
+ FB=$!; sleep 25
+ grep -iE 'embedded STT model loaded|llama-server ready|cleanup engine reloaded' /tmp/fb-launch.log
+ kill "$FB" 2>/dev/null
+ Expect: the log shows "embedded STT model loaded" (the Whisper transcriber) AND
+ "llama-server ready" or "cleanup engine reloaded" (the local cleanup LLM).
+ FIRST-RUN NOTE: if the models are not present yet, the app downloads ~1.1 GB into
+ ~/Library/Application Support/ai.funbutton.desktop/models/ before those lines
+ appear. Tell me this download is happening, wait for it (it is resumable and
+ SHA-256 verified), then re-check the log.
+
+HAND OFF TO ME — you cannot finish this yourself:
+FunButton needs three macOS permissions that only a human at an unlocked screen can
+grant. No agent, script, MDM, or CLI can grant them — do not claim you did. Tell me
+to open FunButton (menu-bar icon) and grant, in System Settings:
+ - Microphone -> System Settings > Privacy & Security > Microphone
+ - Accessibility -> System Settings > Privacy & Security > Accessibility
+ - Input Monitoring -> System Settings > Privacy & Security > Input Monitoring
+Then tell me to open FunButton's Settings (menu-bar icon) and pick a push-to-talk
+hotkey (Fn, Right/Left Control, Right/Left Command, Right Option, or Caps Lock — the
+app detects my keyboard; on extended Apple keyboards the bottom-left key is Control,
+not Fn).
+
+FINAL REPORT: which install method worked, the four verification results, and the
+exact permission + hotkey steps I still need to do by hand.
+```
+
+## Why the handoff is non-negotiable
+
+Microphone, Accessibility, and Input Monitoring are TCC permissions. macOS gates them
+behind a human clicking a real toggle at an unlocked screen — by design, so malware
+(and, yes, an over-eager agent) can't self-authorize a keylogger. There is no flag,
+no `tccutil` grant, no MDM profile that grants them silently for a normal install. An
+agent that says it "enabled Accessibility for you" is wrong. The honest move is to
+install, verify, and then tell the human exactly which three toggles to flip.
+
+## What each permission is for
+
+| Permission | Why FunButton needs it |
+|---|---|
+| Microphone | Hear you while you hold the button. |
+| Accessibility | Paste the cleaned text at your cursor. |
+| Input Monitoring | Detect your push-to-talk key press. |
+
+## The model download, so nobody is surprised
+
+On first run FunButton fetches ~1.1 GB of on-device models (a Qwen 2.5 1.5B cleanup
+model plus a small Whisper model) into
+`~/Library/Application Support/ai.funbutton.desktop/models/`. It is resumable and
+each file is SHA-256 verified. After that, transcription and cleanup run fully
+offline. An agent should surface this before it happens — a silent 1.1 GB pull on a
+metered connection is a bad surprise.
+
+## For agents: the machine-readable contract
+
+Fetch [`https://funbutton.ai/install.json`](https://funbutton.ai/install.json)
+instead of parsing this page. It carries the latest version, the DMG URL and its
+SHA-256, min macOS, arch, the install methods with their commands, the required
+permissions, the model download size, and a `verify` block listing the commands and
+expected results. It is schema-versioned (`schema_version`) so you can depend on its
+shape.
+
+## Links
+
+- Machine-readable install contract:
+- Discovery / summary for agents:
+- The curl installer, in plain sight:
+- Version-agnostic DMG download:
+- Source (GPLv3):
+- Why it's unsigned, and the signing plan: [`SIGNING.md`](SIGNING.md)
diff --git a/PROGRESS.md b/PROGRESS.md
index 4076bdd..1dce9db 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -2,6 +2,25 @@
> Heartbeat for Todd. One entry per commit-cycle. Newest at top.
+## 2026-08-19 14:10 — Agent install path: copy-paste prompt + machine-readable contract (branch `feat-agent-install`, PR open)
+
+**The piece you asked for on 2026-08-17 ("give an 'agent prompt' as well for claude/openclaw/hermes/etc easy install via agents") that PR #11 didn't ship. Now a user living in Claude Code / OpenClaw / Cursor / Codex / Hermes can paste one block and have their agent install and verify FunButton, then hand the three macOS permission grants back to them — because no agent can grant TCC permissions and the doc says so, plainly, instead of faking it. Proved the prompt works by following it verbatim on this Mac.**
+
+**Shipped:**
+- **`AGENT-INSTALL.md`** (repo root) — the headline artifact: a copy-paste, model-agnostic prompt. Preferred order brew → curl → manual, each with exact commands + success/failure detection; a four-check VERIFY block (app present, no `com.apple.quarantine`, version == latest, launch log shows Whisper + llama-server up); an explicit STOP-and-hand-off for Microphone/Accessibility/Input Monitoring with the exact System Settings panes; and the ~1.1 GB first-run model download surfaced up front. No emoji headings, no slop, no `spctl --master-disable`.
+- **`apps/web/app/install.json/route.ts`** — machine-readable install contract at `https://funbutton.ai/install.json` (`schema_version: "1"`). Dynamic edge route (not static like install.sh, on purpose): it resolves the latest version and the DMG's SHA-256 live from the GitHub API's `digest` field (same source as `/download`), so it never drifts per release; falls back to a last-known-good pin if the API is down. Carries platform/arch, install methods (brew/curl/manual + commands), required TCC permissions (`permissions_grantable_by_agent: false`), model download size + location, and a `verify` block of commands + expected results.
+- **`apps/web/app/llms.txt/route.ts`** — discovery surface at `https://funbutton.ai/llms.txt` per the llmstxt.org convention: terse plain-text what/how/permissions + pointers to `/install.json` and `/install.sh`.
+- **Landing (`apps/web/app/page.tsx`):** a compact, deliberately-secondary "installing with a coding agent?" card under the numbered install methods — copy-to-clipboard prompt + links to `/install.json` and `/llms.txt`. Brew stays the loud primary path for humans. Text-label copy button (terminal aesthetic, no emoji, no new dep — `lucide-react` isn't in `apps/web`).
+- **`README.md`** — "Install with a coding agent" subsection cross-linking `AGENT-INSTALL.md` + the two endpoints, so it's discoverable from GitHub.
+
+**Verified for real on this machine (no faked screenshots — logs + xattr + exit codes):**
+- **Followed AGENT-INSTALL.md verbatim as the agent:** `brew uninstall --cask funbutton` → `uname -sm` = `Darwin arm64` → `brew install --cask todddickerson/funbutton/funbutton` → `🍺 funbutton was successfully installed!`. VERIFY: app present = OK; `xattr /Applications/FunButton.app` = only `com.apple.provenance` (**no quarantine**); `CFBundleShortVersionString` = `0.1.8` == `install.json.latest_version`; launch log = `embedded STT model loaded ("MTL0" backend, 376ms)` + `llama-server ready at http://127.0.0.1:58250 (1812ms)`. All four checks pass.
+- **Caught a live gotcha and confirmed the doc still holds:** brew auto-updated 6.0.17 → 6.0.18 mid-run, which adds a new tap-trust gate. Tested the true fresh-user path (removed funbutton from `~/.homebrew/trust.json`, uninstalled, reinstalled) → `==> Trusted cask todddickerson/funbutton/funbutton` — naming the full `user/tap/cask` path **auto-trusts** it, install exits 0, app present. So the doc's command works with no `brew trust` step. Restored trust.json to its original state.
+- **Endpoints serve (local prod build, `next start`):** `/install.json` → HTTP 200, `application/json`, valid JSON with live `latest_version: 0.1.8` + correct sha256 from the GitHub digest; `/llms.txt` → HTTP 200, `text/plain`; `/install.sh` and `/` → 200 (no regression). NOT deployed to prod — preview/local only, per instructions.
+- **Gates:** `next build` (TypeScript) clean; `apps/web` eslint clean; `apps/worker` `tsc --noEmit` clean; `cargo fmt --check` + `cargo clippy --release --all-targets -D warnings` clean; macOS-26 crash-guard grep = doc comments only. `cargo build --release` + `cargo test --release --lib` run against the worktree (vendor dylibs symlinked from the main checkout, since `vendor/` is gitignored). Zero Rust/worker source touched.
+
+**Next (Todd):** review the PR; deploy `apps/web` to prod so `/install.json` + `/llms.txt` go live (not deployed by me). No release cut, no Telegram/Slack — yours to send.
+
## 2026-08-17 15:30 — Kill the "damaged" dialog with NO Apple cert: Homebrew tap + curl installer (branch `feat-brew-cask`, PR open)
**The fourth "FunButton is damaged" hit (v0.1.8) is the last one for brew/curl users. Built a Homebrew tap + cask and a one-line curl installer that both clear `com.apple.quarantine` from our own bundle at install time — so the app opens with no dialog and nobody runs `xattr -cr`. No Developer ID cert, no notarization, no Apple account. Verified for real on the Mac Studio, end to end.**
diff --git a/README.md b/README.md
index b5c793d..3cc0bf3 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,15 @@ If macOS Gatekeeper blocks the unsigned build: `sudo xattr -cr /Applications/Fun
**Note on transcription:** Whisper transcription is *not* bundled yet. Free-tier users still need a Groq key OR a FunButton license for the speech-to-text step. The cleanup model is fully local. Bundled Whisper is on the V1.2 roadmap.
+### Install with a coding agent
+
+Live in Claude Code, OpenClaw, Cursor, Codex, or Hermes? Hand the install to your agent. Paste the prompt in [`AGENT-INSTALL.md`](AGENT-INSTALL.md) and it will install FunButton (brew cask first, curl one-liner, then manual .dmg), verify the app launches, surface the ~1.1 GB first-run model download, and hand the three macOS permission grants (Microphone, Accessibility, Input Monitoring) back to you — no agent can grant those.
+
+Agents should fetch the machine-readable contract instead of scraping the site:
+
+- Install contract: (versioned schema: latest version, DMG url + SHA-256, install methods, required permissions, model size, a `verify` block)
+- Discovery:
+
## Use
1. Hold **Right Option**.
diff --git a/apps/web/app/install.json/route.ts b/apps/web/app/install.json/route.ts
new file mode 100644
index 0000000..a2117d9
--- /dev/null
+++ b/apps/web/app/install.json/route.ts
@@ -0,0 +1,257 @@
+import { NextResponse } from "next/server";
+
+// Machine-readable install contract, served at https://funbutton.ai/install.json.
+//
+// This is what a coding agent should fetch instead of scraping the marketing page
+// (see AGENT-INSTALL.md at the repo root for the human-facing prompt that points
+// agents here). The install.sh route serves a *static* body; this one is dynamic
+// on purpose: the latest version and the .dmg's SHA-256 change every release, and
+// GitHub already publishes both — the release tag and each asset's `digest`. We
+// resolve them from the API at request time (same source as /download) so an agent
+// always gets the true current values with no per-release edit and no drift. If the
+// API is unreachable we fall back to the last-known-good pin below rather than
+// serving a broken contract.
+//
+// Everything else in the contract (install methods, required TCC permissions, the
+// verify block, model download facts) is stable and lives in STATIC_CONTRACT.
+
+export const runtime = "edge";
+
+const REPO = "todddickerson/funbutton";
+const RELEASES_PAGE = `https://github.com/${REPO}/releases/latest`;
+
+// Last-known-good release pin — the fallback when the GitHub API can't be reached.
+// Kept in sync by the same release flow that runs scripts/update-cask.sh.
+const FALLBACK = {
+ version: "0.1.8",
+ asset_name: "FunButton_0.1.8_aarch64.dmg",
+ sha256: "c186f57dec651c9a43dd104a287985bf6a8dae9beed1b9794ae940fc24b1285a",
+ size_bytes: 17050262,
+ asset_url:
+ "https://github.com/todddickerson/funbutton/releases/download/v0.1.8/FunButton_0.1.8_aarch64.dmg",
+};
+
+type Release = {
+ tag_name?: string;
+ assets?: { name: string; browser_download_url: string; size?: number; digest?: string | null }[];
+};
+
+// Pick the Apple Silicon .dmg for this release, preferring the versioned name if a
+// legacy-named compat asset lives alongside it (mirrors app/download/route.ts).
+function pickDmg(rel: Release) {
+ const dmgs = (rel.assets ?? []).filter((a) => a.name.toLowerCase().endsWith(".dmg"));
+ if (dmgs.length === 0) return undefined;
+ const arm = dmgs.filter((a) => /aarch64|arm64/i.test(a.name));
+ const pool = arm.length > 0 ? arm : dmgs;
+ const version = (rel.tag_name ?? "").replace(/^v/, "");
+ const versioned = version ? pool.find((a) => a.name.includes(version)) : undefined;
+ return versioned ?? pool[0];
+}
+
+// GitHub asset `digest` is formatted "sha256:". Return the bare hex, or null
+// if the field is absent (older releases) so callers fall back cleanly.
+function sha256From(digest?: string | null): string | null {
+ if (!digest) return null;
+ const m = /^sha256:([0-9a-f]{64})$/i.exec(digest.trim());
+ return m ? m[1].toLowerCase() : null;
+}
+
+async function resolveRelease() {
+ try {
+ const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
+ headers: {
+ Accept: "application/vnd.github+json",
+ "User-Agent": "funbutton-landing",
+ },
+ next: { revalidate: 3600 },
+ });
+ if (res.ok) {
+ const rel = (await res.json()) as Release;
+ const dmg = pickDmg(rel);
+ const version = (rel.tag_name ?? "").replace(/^v/, "");
+ if (dmg && version) {
+ return {
+ version,
+ asset_name: dmg.name,
+ sha256: sha256From(dmg.digest), // may be null if GitHub omits it
+ size_bytes: dmg.size ?? null,
+ asset_url: dmg.browser_download_url,
+ };
+ }
+ }
+ } catch {
+ // fall through to the pin
+ }
+ return null;
+}
+
+function buildContract(rel: {
+ version: string;
+ asset_name: string;
+ sha256: string | null;
+ size_bytes: number | null;
+ asset_url: string;
+}) {
+ return {
+ schema_version: "1",
+ name: "FunButton",
+ tagline: "Local-first push-to-talk dictation for macOS.",
+ homepage: "https://funbutton.ai",
+ repo: RELEASES_PAGE.replace("/releases/latest", ""),
+ license: "GPL-3.0-or-later",
+
+ latest_version: rel.version,
+
+ platform: {
+ os: "macOS",
+ min_macos: "12.0",
+ arch: "arm64",
+ note: "Apple Silicon only. There is no Intel/x86_64 build.",
+ },
+
+ signed: false,
+ signing_note:
+ "The alpha is unsigned (no Apple Developer ID yet), so left untouched macOS mislabels it as \"damaged.\" The brew cask and the curl installer strip the com.apple.quarantine flag from FunButton.app only. Never disable Gatekeeper globally (no `spctl --master-disable`).",
+
+ download: {
+ // Version-agnostic 302 to the current release's arm64 .dmg — prefer this.
+ url: "https://funbutton.ai/download",
+ asset_name: rel.asset_name,
+ asset_url: rel.asset_url,
+ sha256: rel.sha256,
+ size_bytes: rel.size_bytes,
+ },
+
+ install_methods: [
+ {
+ id: "brew",
+ preferred: true,
+ title: "Homebrew cask",
+ commands: ["brew install --cask todddickerson/funbutton/funbutton"],
+ upgrade: "brew upgrade --cask funbutton",
+ clears_quarantine: true,
+ success_contains: "was successfully installed",
+ note: "The cask's postflight strips com.apple.quarantine from FunButton.app only. brew does NOT strip quarantine by default.",
+ },
+ {
+ id: "curl",
+ preferred: false,
+ title: "curl one-liner",
+ commands: ["curl -fsSL https://funbutton.ai/install.sh | bash"],
+ clears_quarantine: true,
+ success_contains: "FunButton is installed",
+ readable_source: "https://funbutton.ai/install.sh",
+ note: "No sudo, no global Gatekeeper change. Readable at the URL before you run it.",
+ },
+ {
+ id: "manual",
+ preferred: false,
+ title: "Manual .dmg (last resort)",
+ commands: [
+ "curl -fL -o /tmp/FunButton.dmg https://funbutton.ai/download",
+ "mkdir -p /tmp/fb-mnt",
+ "hdiutil attach /tmp/FunButton.dmg -nobrowse -noautoopen -mountpoint /tmp/fb-mnt",
+ "rm -rf /Applications/FunButton.app",
+ "ditto /tmp/fb-mnt/FunButton.app /Applications/FunButton.app",
+ "hdiutil detach /tmp/fb-mnt",
+ "xattr -dr com.apple.quarantine /Applications/FunButton.app",
+ ],
+ clears_quarantine: false,
+ note: "The only path that does NOT clear quarantine for you — the final xattr line is required. Never run `spctl --master-disable`.",
+ },
+ ],
+
+ required_permissions: [
+ {
+ name: "Microphone",
+ why: "capture your voice while you hold the button",
+ settings_pane: "System Settings > Privacy & Security > Microphone",
+ },
+ {
+ name: "Accessibility",
+ why: "paste the cleaned text at your cursor",
+ settings_pane: "System Settings > Privacy & Security > Accessibility",
+ },
+ {
+ name: "Input Monitoring",
+ why: "detect your push-to-talk key press",
+ settings_pane: "System Settings > Privacy & Security > Input Monitoring",
+ },
+ ],
+ permissions_grantable_by_agent: false,
+ permissions_note:
+ "These are macOS TCC permissions. macOS reserves them for a human at an unlocked screen — no agent, script, tccutil, or MDM profile can grant them silently. Install and verify, then hand the three toggles off to the user.",
+
+ hotkey: {
+ user_selectable: true,
+ options: ["Fn", "Right Control", "Left Control", "Right Command", "Left Command", "Right Option", "Caps Lock"],
+ note: "The app detects the keyboard. On extended Apple keyboards the bottom-left key is Control, not Fn.",
+ },
+
+ models: {
+ bundled: false,
+ when: "first run",
+ download_size_human: "~1.1 GB",
+ download_size_bytes: 1202206944,
+ location: "~/Library/Application Support/ai.funbutton.desktop/models/",
+ resumable: true,
+ verified: "sha-256",
+ files: ["qwen2.5-1.5b-instruct-q4_k_m.gguf", "whisper-base.en-Q8_0.gguf"],
+ note: "Not bundled in the app. Surface this before it happens so a first-run network pull isn't a surprise. After it downloads, transcription and cleanup run fully offline.",
+ },
+
+ verify: [
+ {
+ id: "app_present",
+ command: "test -d /Applications/FunButton.app && echo OK",
+ expect: "OK",
+ },
+ {
+ id: "no_quarantine",
+ command: "xattr /Applications/FunButton.app",
+ expect_absent: "com.apple.quarantine",
+ note: "Output must NOT contain com.apple.quarantine.",
+ },
+ {
+ id: "version",
+ command:
+ "defaults read /Applications/FunButton.app/Contents/Info CFBundleShortVersionString",
+ expect: rel.version,
+ note: "Should equal latest_version.",
+ },
+ {
+ id: "launch",
+ command:
+ "/Applications/FunButton.app/Contents/MacOS/funbutton >/tmp/fb-launch.log 2>&1 & FB=$!; sleep 25; grep -iE 'embedded STT model loaded|llama-server ready|cleanup engine reloaded' /tmp/fb-launch.log; kill \"$FB\" 2>/dev/null",
+ expect_log_any: ["embedded STT model loaded", "llama-server ready", "cleanup engine reloaded"],
+ note: "Runs the binary directly; env_logger prints to stderr at info level. On first run, models (~1.1 GB) download before these lines appear.",
+ },
+ ],
+
+ docs: {
+ agent_prompt: `${RELEASES_PAGE.replace("/releases/latest", "")}/blob/main/AGENT-INSTALL.md`,
+ llms_txt: "https://funbutton.ai/llms.txt",
+ install_sh: "https://funbutton.ai/install.sh",
+ signing: `${RELEASES_PAGE.replace("/releases/latest", "")}/blob/main/SIGNING.md`,
+ },
+ };
+}
+
+export async function GET() {
+ const resolved = await resolveRelease();
+ // If the API gave us a version but omitted the digest, keep the resolved version
+ // but backfill the sha256 from the pin when the asset name matches.
+ let rel = resolved ?? FALLBACK;
+ if (resolved && !resolved.sha256 && resolved.asset_name === FALLBACK.asset_name) {
+ rel = { ...resolved, sha256: FALLBACK.sha256 };
+ }
+
+ const body = JSON.stringify(buildContract(rel), null, 2) + "\n";
+ return new NextResponse(body, {
+ headers: {
+ "content-type": "application/json; charset=utf-8",
+ // Same cache posture as /download: one upstream call per hour, not per fetch.
+ "cache-control": "public, max-age=300, s-maxage=3600",
+ },
+ });
+}
diff --git a/apps/web/app/llms.txt/route.ts b/apps/web/app/llms.txt/route.ts
new file mode 100644
index 0000000..3182c81
--- /dev/null
+++ b/apps/web/app/llms.txt/route.ts
@@ -0,0 +1,50 @@
+// Serves https://funbutton.ai/llms.txt — the discovery surface for any agent that
+// lands on the domain (see llmstxt.org for the emerging convention). Terse and
+// plain-text on purpose: an agent reads this first, then fetches the structured
+// contract at /install.json. Kept in lockstep with AGENT-INSTALL.md and the
+// install.json route. Served as text/plain so it's readable in a browser too.
+export const dynamic = "force-static";
+
+const BODY = `# FunButton
+
+> Local-first, push-to-talk voice dictation for macOS. Hold a key, talk, and cleaned-up
+> text lands at your cursor. Transcription and cleanup run on-device (a bundled Whisper
+> model + a local LLM); no cloud account required. Apple Silicon only, unsigned alpha,
+> GPLv3.
+
+## Facts for agents
+
+- Platform: macOS 12+, Apple Silicon (arm64) only. No Intel build.
+- Signing: unsigned alpha. macOS mislabels it "damaged" unless the quarantine flag is
+ cleared from FunButton.app. The brew cask and curl installer do this for you; plain
+ \`brew\` does not, and Gatekeeper must never be disabled globally.
+- Models: ~1.1 GB, NOT bundled — they download on first run into
+ ~/Library/Application Support/ai.funbutton.desktop/models/ (resumable, SHA-256 verified).
+- Permissions: needs Microphone, Accessibility, and Input Monitoring. These are macOS
+ TCC grants that only a human at an unlocked screen can enable — no agent can grant
+ them. Install and verify, then hand off to the user.
+
+## Install (preferred order)
+
+- Homebrew (recommended): \`brew install --cask todddickerson/funbutton/funbutton\`
+- curl one-liner: \`curl -fsSL https://funbutton.ai/install.sh | bash\`
+- Manual .dmg (last resort): download https://funbutton.ai/download, copy FunButton.app
+ to /Applications, then \`xattr -dr com.apple.quarantine /Applications/FunButton.app\`.
+
+## Machine-readable and reference
+
+- Install contract (fetch this, don't scrape the site): https://funbutton.ai/install.json
+- Copy-paste agent prompt: https://github.com/todddickerson/funbutton/blob/main/AGENT-INSTALL.md
+- The curl installer, in plain sight: https://funbutton.ai/install.sh
+- Version-agnostic download: https://funbutton.ai/download
+- Source (GPLv3): https://github.com/todddickerson/funbutton
+`;
+
+export function GET() {
+ return new Response(BODY, {
+ headers: {
+ "content-type": "text/plain; charset=utf-8",
+ "cache-control": "public, max-age=300, s-maxage=300",
+ },
+ });
+}
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 6e58c8b..f7c10cf 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -584,12 +584,87 @@ function OpenSourceInstall() {
hotkey, hold it, talk. The on-device models (~1.1 GB) fetch once on
first run.
+
+
);
}
+/** Compact secondary path: hand the install to a coding agent. Deliberately quieter
+ * than the numbered methods above — brew stays the primary human path. The copy
+ * button drops a short, model-agnostic prompt that points the agent at the
+ * machine-readable contract (/install.json) and AGENT-INSTALL.md. */
+const AGENT_PROMPT =
+ "Install FunButton on my Mac (macOS, Apple Silicon). Follow " +
+ "https://funbutton.ai/install.json (machine-readable) and " +
+ "https://github.com/todddickerson/funbutton/blob/main/AGENT-INSTALL.md. " +
+ "Prefer the Homebrew cask, verify the app launches, surface the ~1.1 GB " +
+ "first-run model download, and hand the macOS permission grants (Microphone, " +
+ "Accessibility, Input Monitoring) back to me — you can't grant those yourself.";
+
+function AgentInstall() {
+ return (
+
+
+
+ installing with a coding agent?
+
+
+
+
+ Paste this into Claude Code, OpenClaw, Cursor, Codex, or Hermes — it installs
+ and verifies, then hands the macOS permission grants back to you.
+
+ );
+}
+
+/** Clipboard button styled for the terminal aesthetic — text label, no icon dep,
+ * no emoji. Flips to a confirmation for ~1.5s after a successful copy. */
+function CopyButton({ text, label }: { text: string; label: string }) {
+ const [copied, setCopied] = useState(false);
+ return (
+
+ );
+}
+
/** One install method row — a numbered command block with a one-line note.
* Used for the brew and curl paths; the manual .dmg gets its own block because
* it carries the download button and the demoted xattr step. */