docs: add per-feature docs site and deploy to imsg.sh
Some checks failed
CI / build (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled

Per-feature pages (install, quickstart, permissions, chats, history,
watch, send, groups, attachments, json, rpc, completions, advanced-imcore,
troubleshooting) plus an Apple-styled static-site builder rendering them
to dist/docs-site. GitHub Pages workflow deploys on every docs/ change to
imsg.sh.
This commit is contained in:
Peter Steinberger 2026-05-05 19:09:09 +01:00
parent c16daed4b4
commit bbd6b93a1e
No known key found for this signature in database
22 changed files with 2184 additions and 122 deletions

54
.github/workflows/pages.yml vendored Normal file
View File

@ -0,0 +1,54 @@
name: pages
on:
push:
branches:
- main
paths:
- "docs/**"
- "scripts/build-docs-site.mjs"
- "scripts/docs-site-assets.mjs"
- "Makefile"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
name: Deploy docs
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Check out
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
- name: Build docs site
run: make docs-site
- name: Configure Pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Upload artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: dist/docs-site
- name: Deploy
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0

1
.gitignore vendored
View File

@ -39,6 +39,7 @@ Package.resolved
# Build artifacts
bin/
dist/
# Node.js / pnpm
pnpm-lock.yaml

View File

@ -1,6 +1,6 @@
SHELL := /bin/bash
.PHONY: help format lint test build imsg clean build-dylib
.PHONY: help format lint test build imsg clean build-dylib docs-site
help:
@printf "%s\n" \
@ -10,6 +10,7 @@ help:
"make build - universal release build into bin/" \
"make build-dylib - build injectable dylib for Messages.app" \
"make imsg - clean rebuild + run debug binary (ARGS=...)" \
"make docs-site - build the imsg.sh docs site into dist/docs-site" \
"make clean - swift package clean"
format:
@ -52,6 +53,10 @@ imsg:
swift build -c debug --product imsg
./.build/debug/imsg $(ARGS)
docs-site:
node scripts/build-docs-site.mjs
clean:
swift package clean
@rm -f .build/release/imsg-bridge-helper.dylib
@rm -rf dist/docs-site

1
docs/CNAME Normal file
View File

@ -0,0 +1 @@
imsg.sh

View File

@ -1,4 +1,7 @@
# Releasing
---
title: Releasing
description: "Cutting an imsg release: changelog, version bump, signed/notarized build, tag, GitHub release, Homebrew tap update."
---
## Release notes source
- GitHub Release notes come from `CHANGELOG.md` for the matching version section (`## X.Y.Z - YYYY-MM-DD`).

99
docs/advanced-imcore.md Normal file
View File

@ -0,0 +1,99 @@
---
title: Advanced IMCore features
description: "Read receipts, typing indicators, IMCore status, and Messages launch control — opt-in, SIP-disabled, and increasingly limited on macOS 26."
---
Most `imsg` workflows — `chats`, `history`, `watch`, `send`, `react` — are explicitly designed to *not* require any private framework or process injection. They go through Messages.app's published surfaces (SQLite, AppleScript, file events) and need only the documented permissions covered in [Permissions](permissions.md).
The features documented here are the exception. They drive Messages.app from the inside via a helper dylib injected into the Messages process, and they trigger several macOS protections you have to disable to use them.
You almost certainly do not need any of this for normal use.
## What's in scope
- `imsg read --to <handle> [--chat-id <id>]` — mark a chat as read.
- `imsg typing --to <handle> [--duration 5s] [--stop true]` — show or stop the typing indicator.
- `imsg launch [--dylib <path>] [--kill-only]` — launch Messages.app with the helper dylib injected.
- `imsg status` — read-only IMCore bridge status.
## Why they're separate
These features depend on private IMCore APIs that aren't reachable from outside the Messages process. To touch them, `imsg` injects a small helper dylib into Messages.app via `DYLD_INSERT_LIBRARIES` (built by `make build-dylib`).
That injection requires three things to be true on the target machine:
1. **SIP disabled.** System Integrity Protection blocks `DYLD_INSERT_LIBRARIES` into protected system apps. Without disabling SIP, the launch step refuses to proceed.
2. **Library validation off.** macOS 26 (Tahoe) tightened library validation; even with SIP off, a dylib that isn't signed against Messages' team identifier can be rejected.
3. **No private-entitlement gate.** macOS 26 also added `imagent` entitlement checks that can refuse direct IMCore clients regardless of injection success.
You should expect at least one of these gates to be active on a current macOS install. The features are documented because they remain useful for research, testing, and CI — not because they're stable user-facing functionality.
## Building and launching
```bash
make build-dylib # produces .build/release/imsg-bridge-helper.dylib (arm64e)
imsg launch # launches Messages.app with the dylib injected
imsg status # confirms the bridge is up
```
`imsg launch` refuses to inject when SIP is enabled. There's no override.
`imsg status` is read-only. It does not auto-launch or auto-inject. Run `imsg launch` first.
To revert: re-enable SIP from Recovery mode (`csrutil enable`), then reboot.
## Read receipts
```bash
imsg read --to "+14155551212"
imsg read --to "+14155551212" --chat-id 42
imsg read --to "+14155551212" --chat-identifier "iMessage;+;chat..."
imsg read --to "+14155551212" --chat-guid "iMessage;+;chat..."
```
Marks the chat for that handle as read. Useful when you want a programmatic agent to clear the unread counter in Messages without spawning a UI action.
## Typing indicators
```bash
imsg typing --to "+14155551212" --duration 5s
imsg typing --to "+14155551212" --duration 30s --service imessage
imsg typing --to "+14155551212" --stop true
```
Displays or hides the "typing" bubble on the recipient's device.
`--service` accepts `imessage`, `sms`, or `auto`. The IMCore typing chat lookup normalizes across `iMessage`, `SMS`, and `any` prefixes so the same handle works on either service.
On macOS 26, typing indicators frequently fail with an entitlement error. `imsg` reports this as an advanced-feature setup error rather than a misleading "chat not found" — see `CHANGELOG.md` 0.6.0 for the issue history.
## Status
```bash
imsg status
imsg status --json
```
Reports whether Messages is running, whether the helper dylib is loaded, and whether the IMCore bridge is responding. Read-only; safe to run on any machine.
When the bridge isn't loaded, `status` prints the reason rather than attempting to fix it. Use `imsg launch` if you want to bring it up.
## Launching Messages with a custom dylib
```bash
imsg launch --dylib /path/to/custom.dylib
imsg launch --kill-only # quit Messages without launching
imsg launch --json # machine-readable launch result
```
`--kill-only` is the inverse: it tears Messages down (to drop a stale injection) without relaunching.
## When to use any of this
The honest answer for most readers: **don't**. The macOS 26 limits make these features unstable in production. They're useful when:
- You're doing macOS / Messages.app research.
- You're running CI inside a controlled VM with SIP disabled by configuration.
- You need a typing-indicator demo on a single hand-tuned machine.
For agent integrations, prefer the standard CLI surfaces (`send`, `react`, `watch`). They cover the realistic interaction surface without touching SIP.

68
docs/attachments.md Normal file
View File

@ -0,0 +1,68 @@
---
title: Attachments
description: "Attachment metadata, resolved paths, and optional model-friendly conversion for CAF audio and GIF images."
---
`imsg` reports attachment metadata only. It never copies, modifies, or uploads the underlying files. Optional conversion exposes cached, model-friendly variants for CAF audio and GIF images.
## Reading attachments
```bash
imsg history --chat-id 42 --attachments --json
imsg watch --chat-id 42 --attachments --json
```
Each message gains an `attachments` array. Per-attachment fields:
| Field | Type | Notes |
|-------|------|-------|
| `filename` | string | Stored filename inside Messages' attachments dir. |
| `transfer_name` | string | Original filename as sent. |
| `uti` | string | Apple Uniform Type Identifier. |
| `mime_type` | string | Best-effort MIME from UTI. |
| `byte_size` | int | Size in bytes. |
| `is_sticker` | bool | True for sticker-pack attachments. |
| `missing` | bool | True when the file couldn't be located on disk. |
| `path` | string | Resolved absolute path under `~/Library/Messages/Attachments/`. |
| `converted_path` | string | Set only with `--convert-attachments`; see below. |
| `converted_mime_type` | string | Set only with `--convert-attachments`. |
When an attachment is referenced in `chat.db` but the underlying file has been pruned (Messages can age out big files), `missing` is `true` and `path` may be empty.
## Converted variants
```bash
imsg history --chat-id 42 --attachments --convert-attachments --json
imsg watch --chat-id 42 --attachments --convert-attachments --json
```
This adds `converted_path` and `converted_mime_type` to attachments where conversion is supported:
- **CAF audio → M4A.** Messages' on-device voice memos are stored as CAF; most LLMs and downstream tools want M4A.
- **GIF image → first-frame PNG.** Useful when a static thumbnail is enough for downstream models.
Originals are never modified. Converted files live alongside in a cache directory and are reused on subsequent reads.
`--convert-attachments` requires `ffmpeg` on `PATH`. If `ffmpeg` is missing, the command still succeeds — `converted_path` is simply omitted from the output and the original metadata is unchanged.
`brew install ffmpeg` to enable.
## Sending attachments
```bash
imsg send --to "+14155551212" --file ~/Desktop/photo.jpg
imsg send --to "Jane Appleseed" --file ~/Desktop/voice.m4a
imsg send --chat-id 42 --file ~/Desktop/note.pdf
```
`--file` accepts any regular file. Audio files (`.m4a`, `.caf`, `.aiff`, …) ride the same code path as images and documents.
Before invoking AppleScript, `imsg` stages the file under `~/Library/Messages/Attachments/imsg/`. Messages reads attachments from inside its own attachments directory more reliably than from `~/Desktop` or `~/Downloads`, particularly under newer macOS sandboxing.
The staged copies live under `imsg/`, distinct from Messages' own subdirectories, and are not pruned automatically. Clear them periodically if disk space matters.
## Why not just copy or upload?
The CLI's contract is "read what's there, send what you give it." Anything beyond that — bulk archival, cloud upload, format conversion at rest — is left to callers, who know their retention and privacy requirements. The conversion feature is the one exception, and only because some receive-side formats (CAF, animated GIF) are awkward for downstream tools to handle.
If you want a full archive workflow, pipe `--attachments --json` through your own scripts and copy the files out of `~/Library/Messages/Attachments/` yourself.

80
docs/chats.md Normal file
View File

@ -0,0 +1,80 @@
---
title: Chats
description: "List recent conversations and inspect a single chat's identifiers, participants, and routing hints."
---
`imsg chats` lists conversations sorted by most recent activity. `imsg group` zooms in on one chat. Both work for direct chats and group threads.
## List recent chats
```bash
imsg chats --limit 20
imsg chats --limit 20 --json | jq -s
```
Columns (text mode): `id`, `name`, `service`, `last_message_at`.
`name` is the resolved display name when available — group title, contact match, or raw handle as a fallback.
## Inspect one chat
```bash
imsg group --chat-id 42
imsg group --chat-id 42 --json
```
Use this before scripting a send. It returns identifier, GUID, service, participants, group/direct flag, and account routing hints in one shot.
`imsg group` works for direct chats too, despite the name. Treat it as "chat detail," not "groups only."
## Chat object
Every chat object — from `chats`, `group`, or any nested chat metadata in `history`/`watch` — includes:
| Field | Type | Notes |
|-------|------|-------|
| `id` | int | `chat.ROWID`. Stable within one Messages database. Preferred routing handle. |
| `name` | string | Display name, contact match, or raw handle fallback. |
| `display_name` | string | `chat.display_name` (group title) when set. |
| `contact_name` | string | Resolved Contacts name when permission granted. |
| `identifier` | string | `chat.chat_identifier` — Messages' portable handle. |
| `guid` | string | `chat.guid` — Messages' portable GUID. |
| `service` | string | `iMessage`, `SMS`, etc. |
| `last_message_at` | ISO8601 | Newest activity in the chat. |
| `is_group` | bool | True when `identifier` or `guid` contains `;+;`. See [Groups](groups.md). |
| `participants` | array | External handles only. The local user is implicit; see below. |
| `account_id` | string | Routing diagnostic. Read-only. |
| `account_login` | string | Routing diagnostic. Read-only. |
| `last_addressed_handle` | string | Routing diagnostic. Read-only. |
## Routing identifiers — which one to use
Three handles can identify a chat. Pick by use case:
- **`chat_id`** (rowid): preferred. Fastest, most stable within one database. Use this whenever both reader and sender are on the same machine.
- **`chat_identifier`**: portable across DBs/installs. Use when you store handles externally and need to tolerate a Messages reset.
- **`chat_guid`**: also portable. Same use cases as `chat_identifier`.
For sends, `imsg send --chat-id` is preferred. `--chat-identifier` and `--chat-guid` are fallbacks for callers that only have the portable handle.
## Participants vs. local identity
`participants` lists external handles only. The local user is intentionally absent because Messages stores it implicitly per-message rather than on the chat row.
To distinguish your own messages from others':
- Use `is_from_me` on each message.
- For multi-number Apple IDs, check `destination_caller_id` on outgoing messages — it tells you which of your numbers Messages routed through.
`account_id`, `account_login`, and `last_addressed_handle` are diagnostic *reads* from Messages. AppleScript's `send` does not let `imsg` force a specific outbound number when several phone numbers share one Apple ID. The fields are there so you can audit what Messages picked, not steer it.
## Filtering tips
`imsg chats` does not take filter flags — it's designed to be cheap. Pipe through `jq` or `grep` for ad-hoc filtering:
```bash
imsg chats --json | jq -s 'map(select(.is_group == true))'
imsg chats --json | jq -s 'map(select(.service == "SMS"))'
```
For more targeted history queries with date and participant filters, use [`imsg history`](history.md).

68
docs/completions.md Normal file
View File

@ -0,0 +1,68 @@
---
title: Completions
description: "Shell completions for bash, zsh, and fish — plus an LLM-oriented Markdown reference."
---
`imsg completions` generates completion scripts for interactive shells and a Markdown CLI reference for in-context LLM use.
## Shell completions
### Bash
```bash
imsg completions bash > ~/.bash_completion.d/imsg
# or, system-wide:
sudo imsg completions bash > /usr/local/etc/bash_completion.d/imsg
```
Reload your shell, then tab-completion for `imsg` is live.
### Zsh
```bash
mkdir -p ~/.zsh/completions
imsg completions zsh > ~/.zsh/completions/_imsg
```
Make sure `~/.zsh/completions` is on `fpath` and `compinit` is called. A standard `~/.zshrc` snippet:
```zsh
fpath=(~/.zsh/completions $fpath)
autoload -U compinit && compinit
```
### Fish
```bash
imsg completions fish > ~/.config/fish/completions/imsg.fish
```
Fish picks up new completions on next launch; no extra setup required.
## LLM reference
```bash
imsg completions llm
```
Emits a Markdown CLI reference that documents every command, flag, argument, and example. It's designed to be embedded in an agent's system prompt or a tool's documentation index so the model always has accurate, current help for the locally installed version of `imsg`.
Because it's generated from the same `CommandSpec` the parser uses, the output is always in sync with the binary. There's no separate doc-comment drift.
A common pattern:
```bash
imsg completions llm > /tmp/imsg-help.md
# Embed /tmp/imsg-help.md in your agent's tool description, prompt, or memory.
```
## What's covered
The same source-of-truth (`CommandSpec`) feeds all four generators:
- Command names and abstracts.
- Flag names (long and short), argument labels, optional/required status, help text.
- Constrained value lists (e.g. `--service imessage|sms|auto`, `--reaction love|like|dislike|laugh|emphasis|question`).
- Per-command examples.
Completions and the LLM reference are emitted to stdout. There's no install step beyond redirecting to a file in the right location for your shell.

View File

@ -1,33 +1,51 @@
# Groups
---
title: Groups
description: "How imsg detects group chats, the identifiers that route to them, and the Tahoe-era failure modes."
---
Messages encodes group chats with a different identifier shape than direct chats. `imsg` surfaces that distinction explicitly so callers don't have to parse handles themselves.
## What counts as a group
- `chat.chat_identifier` or `chat.guid` contains `;+;`, for example
`iMessage;+;chat1234567890`.
- `SERVICE;-;TARGET` is a direct 1:1 chat, for example `iMessage;-;+15551234567`,
and is deliberately not flagged as a group.
- Direct chats typically use a single handle (phone/email) with no `;+;`.
- `chat.chat_identifier` or `chat.guid` contains `;+;`, for example `iMessage;+;chat1234567890`.
- `SERVICE;-;TARGET` is a direct 1:1 chat, for example `iMessage;-;+15551234567`. Deliberately not flagged as a group.
- Direct chats typically use a single handle (phone or email) with no `;+;`.
The `is_group` boolean on every chat object encodes this for you.
## Where the identifiers live
- `chat.ROWID` -> `chat_id` (stable within one DB).
- `chat.chat_identifier` -> group handle (used by Messages).
- `chat.guid` -> group GUID (often same chat handle semantics).
- `chat.display_name` -> group name (optional).
- `chat.account_id`, `chat.account_login`, `chat.last_addressed_handle` ->
read-only Messages routing hints for the local account/identity state.
- Participants in `chat_handle_join` + `handle`.
| Field | Source | Notes |
|-------|--------|-------|
| `chat.ROWID``chat_id` | local rowid | Stable within one DB. Preferred routing handle. |
| `chat.chat_identifier` | Messages | Portable group handle. |
| `chat.guid` | Messages | Portable GUID. Often the same shape as `chat_identifier` for groups. |
| `chat.display_name` | Messages | Optional group name. |
| `chat.account_id` / `account_login` / `last_addressed_handle` | Messages | Read-only routing diagnostics. |
| `participants` | `chat_handle_join` + `handle` | External handles only. |
## Sending to a group
- `imsg send --chat-id <rowid>` (preferred; DB local).
- `imsg send --chat-identifier <handle>` (portable).
- `imsg send --chat-guid <guid>` (portable).
- Uses AppleScript `chat id "<handle>"` for group sends (Jared pattern).
- Attachments supported same as direct sends.
- On macOS 26/Tahoe, Messages.app can report success while creating an empty
unjoined SMS row instead of delivering to the group. `imsg` detects that ghost
row and reports the send as failed.
Pick the most stable identifier you have:
```bash
imsg send --chat-id 42 --text "hi" # preferred (DB local)
imsg send --chat-identifier "iMessage;+;chat1234567890" --text "hi" # portable
imsg send --chat-guid "iMessage;+;chat1234567890" --text "hi" # portable
```
Group sends use AppleScript `chat id "<handle>"` (the "Jared pattern"). Attachments work the same as direct sends; see [Send](send.md).
### Tahoe ghost-row failure
On macOS 26 (Tahoe), Messages.app sometimes reports AppleScript success while writing an empty unjoined SMS row instead of delivering to the target group. `imsg send` detects that ghost row by inspecting `chat.db` after the AppleScript call and reports an error rather than success.
This check is automatic for chat-target sends. Direct sends (`--to`) aren't affected.
## Inbound metadata (JSON)
The direct CLI (`imsg chats`, `imsg history`, `imsg watch`) and JSON-RPC surface include:
`imsg chats`, `imsg history`, and `imsg watch` — and the JSON-RPC equivalents — all include the same group fields:
- `chat_id`
- `chat_identifier`
- `chat_guid`
@ -38,26 +56,31 @@ The direct CLI (`imsg chats`, `imsg history`, `imsg watch`) and JSON-RPC surface
- `participants` (array of handles)
- `is_group`
`chat_id` is preferred for routing within one machine/DB.
Within one machine and one Messages database, `chat_id` is the preferred routing key. For sync across machines (or after a Messages reset), persist `chat_identifier` or `chat_guid` instead.
### Participants exclude the local user
`participants` is sourced from Messages.app's `chat_handle_join` table, which
stores external handles. The local user's handle is implicit and message-specific:
use `is_from_me` plus `destination_caller_id` on sent messages when that distinction
matters.
`participants` is sourced from Messages' `chat_handle_join` table, which only stores external handles. Your own handle is implicit and message-specific.
When the distinction matters, combine these per-message fields:
- `is_from_me` — outbound vs. inbound.
- `destination_caller_id` (outbound only) — which of your numbers Messages routed through.
### Multiple local identities
Messages.app can store multiple local-account hints for a chat, but its
AppleScript `send` command does not expose a `from` or account selector. `imsg`
reports `account_id`, `account_login`, `last_addressed_handle`, and sent-message
`destination_caller_id` so callers can diagnose routing, but normal sends cannot
force a specific phone number when several numbers share one Apple ID.
## Focused group lookup
- `imsg group --chat-id <rowid>` prints id, identifier, guid, name, service,
`is_group`, and participants for one chat. It works for direct chats too and
supports `--json`.
Messages stores per-chat hints for which of your numbers should be used (`account_id`, `account_login`, `last_addressed_handle`). `imsg` exposes these as diagnostics, but its `send` cannot force a specific outbound number — AppleScript `send` has no `from` selector. To change the default for new outbound traffic, adjust Messages' Settings → iMessage section.
## Focused chat lookup
```bash
imsg group --chat-id 42
imsg group --chat-id 42 --json
```
`imsg group` prints id, identifier, GUID, name, service, `is_group`, participants, and routing hints for one chat. It works for direct chats too; treat it as a "chat detail" command rather than groups-only.
## Notes
- Group send uses chat handle, not `buddy`.
- Messages from self may have empty `sender`; prefer `SenderName` + chat metadata.
- Group send uses the chat handle, not `buddy`.
- Outgoing messages from the local user can have an empty `sender` value. Prefer `sender_name` plus chat metadata when displaying who sent what.

84
docs/history.md Normal file
View File

@ -0,0 +1,84 @@
---
title: History
description: "Read message history from one chat with optional date, participant, and attachment filters."
---
`imsg history` reads messages from a single chat in chronological order. It's the bread-and-butter command for one-shot reads — search, archive, summarize, transcribe.
## Basic read
```bash
imsg history --chat-id 42 --limit 50
imsg history --chat-id 42 --limit 50 --json | jq -s
```
`--limit` defaults to 50 and applies *after* filters. So `--limit 20 --start ...` returns up to 20 messages from inside the date window, not 20 messages globally then date-filtered.
## Date windows
```bash
imsg history --chat-id 42 \
--start 2026-05-01T00:00:00Z \
--end 2026-05-06T00:00:00Z \
--json
```
Both bounds accept ISO 8601 with explicit timezone. Either bound is optional:
```bash
# Everything since May 1st.
imsg history --chat-id 42 --start 2026-05-01T00:00:00Z --json
# Everything before May 6th.
imsg history --chat-id 42 --end 2026-05-06T00:00:00Z --json
```
## Participant filters
For group chats, narrow to messages from specific people:
```bash
imsg history --chat-id 42 --participants "+14155551212,jane@example.com" --json
```
Match is on the message's `sender` (raw handle), not the resolved contact name. Pass a comma-separated list.
## Attachments
`--attachments` adds an `attachments` array to each message containing filename, UTI, MIME type, byte count, and resolved on-disk path:
```bash
imsg history --chat-id 42 --attachments --json
```
`--convert-attachments` additionally exposes model-friendly variants when `ffmpeg` is available — CAF audio → M4A, GIF → first-frame PNG. See [Attachments](attachments.md).
## Recovering text from attributed bodies
Some Messages rows store rich text in a binary `attributedBody` column with the plain `text` column empty. `imsg history` decodes the typed-stream payload (including UTF-16LE BOM bodies) and surfaces the recovered text in the standard `text` field. No flag needed; this is on by default.
If a message is still empty, the source row genuinely had no text — usually a sticker, link preview, or attachment-only message.
## Reactions in history
Tapback rows (`Liked "..."`, `Loved "..."`, etc.) are hidden from `history` output by design. They'd otherwise duplicate every reacted message. To see tapbacks, use [`imsg watch --reactions`](watch.md#reactions); the live stream surfaces add and remove events with `is_reaction`, `reaction_type`, and `reacted_to_guid`.
## Performance
JSON history batches attachment and reaction lookups in one pass per request, so large `--limit` values stay cheap. Reading 1000 messages with `--attachments --json` is bound by SQLite, not by per-row queries.
For very large reads, prefer streaming through `jq` rather than buffering the whole result:
```bash
imsg history --chat-id 42 --limit 5000 --json \
| jq -c 'select(.is_from_me == false)' \
> inbound.ndjson
```
## Message object
See [JSON output](json.md#message) for the canonical schema. Every history result has at minimum:
`id`, `chat_id`, `chat_identifier`, `chat_guid`, `chat_name`, `participants`, `is_group`, `guid`, `reply_to_guid`, `destination_caller_id`, `sender`, `sender_name`, `is_from_me`, `text`, `created_at`.
When `--attachments` is set, also: `attachments[]`. Reactions only appear in `watch --reactions` output.

50
docs/index.md Normal file
View File

@ -0,0 +1,50 @@
---
title: Overview
permalink: /
description: "imsg is a macOS command-line tool for Messages.app — read your local chat database, stream new iMessage/SMS rows, send text and files through Messages automation, and expose the same surfaces over JSON and JSON-RPC."
---
## Try it
After granting Full Disk Access (covered in the [Quickstart](quickstart.md)), every workflow is a one-liner.
```bash
# List the 10 most recent chats.
imsg chats --limit 10 --json | jq -s
# Read history from one chat, with attachment metadata.
imsg history --chat-id 42 --limit 20 --attachments --json
# Stream new messages live, including tapbacks.
imsg watch --chat-id 42 --reactions --json
# Send a message — auto-pick iMessage or SMS.
imsg send --to "+14155551212" --text "on my way"
# Send a file (image, audio, document).
imsg send --to "Jane Appleseed" --file ~/Desktop/voice.m4a
```
`--json` emits newline-delimited JSON on stdout; human progress and warnings always go to stderr so pipes stay parseable.
## What imsg does
- **Local-first reads.** Chats, history, and attachments come straight from `~/Library/Messages/chat.db` — no network round-trip, no daemon.
- **Live streams.** `imsg watch` follows filesystem events on `chat.db` and falls back to a lightweight poll when macOS drops the event.
- **Send through Messages.app.** Text, attachments, and standard tapbacks ride Messages' AppleScript automation surface — no private send APIs.
- **Group-aware.** Direct chats, group threads, participants, GUIDs, and per-chat account routing hints all show up in JSON output.
- **Built for agents.** Stable JSON-RPC over stdio, deterministic JSON schemas, and `imsg completions llm` for in-context CLI help.
- **Contacts integration.** Resolves names from your Address Book when permission is granted, while keeping raw handles in the output.
- **Attachment-aware.** Reports filenames, UTIs, byte counts, and resolved paths. Optional `--convert-attachments` exposes model-friendly CAF→M4A and GIF→PNG variants.
## Pick your path
- **Trying it.** [Install](install.md) → [Quickstart](quickstart.md). Five minutes from `brew install` to a streaming watch.
- **Wiring up an agent.** [JSON output](json.md) and [JSON-RPC](rpc.md) cover the stable contracts; [completions](completions.md) shows how to feed the CLI reference into an LLM.
- **Sending messages.** [Send](send.md) and [React](send.md#standard-tapbacks) explain text/file/group sends and how the Tahoe ghost-row check works.
- **Diagnosing access.** [Permissions](permissions.md) and [Troubleshooting](troubleshooting.md).
- **Advanced IMCore.** [Read receipts, typing, status, launch](advanced-imcore.md). SIP-disabled and increasingly limited on macOS 26.
## Project
Active development; the [changelog](https://github.com/steipete/imsg/blob/main/CHANGELOG.md) tracks what shipped recently. Released under the [MIT license](https://github.com/steipete/imsg/blob/main/LICENSE). Not affiliated with Apple.

60
docs/install.md Normal file
View File

@ -0,0 +1,60 @@
---
title: Install
description: "Install imsg with Homebrew, build it from source, or pin a specific release."
---
`imsg` ships as a signed, notarized universal macOS binary. It runs on macOS 14 (Sonoma) and newer, including macOS 26 (Tahoe).
## Homebrew
```bash
brew install steipete/tap/imsg
```
This is the recommended path. Homebrew downloads the universal binary for your architecture, installs it onto your `PATH`, and tracks updates with `brew upgrade`.
To uninstall:
```bash
brew uninstall imsg
brew untap steipete/tap # optional
```
## Build from source
```bash
git clone https://github.com/steipete/imsg.git
cd imsg
make build
./bin/imsg --help
```
`make build` runs the universal release build through Swift Package Manager and patches `SQLite.swift` with the repo's required adjustments. The binary lands at `bin/imsg`.
For day-to-day development:
```bash
make imsg ARGS="chats --limit 5"
```
This is a clean debug rebuild that runs the resulting binary with the supplied arguments.
## Verify the install
```bash
imsg --version
imsg chats --limit 3
```
If `chats` returns `unable to open database file` or `authorization denied`, jump to [Permissions](permissions.md). The CLI is installed correctly; macOS just hasn't granted it Full Disk Access yet.
## Optional dependencies
- **`ffmpeg`** on your `PATH`. Required only for `--convert-attachments`; see [Attachments](attachments.md).
- **`jq`**. Not required, but every example here uses it to pretty-print JSON streams.
## What you don't need
- No Node, Python, or Ruby runtime.
- No background daemon, launch agent, or login item.
- No private API patches. Default reads use a read-only handle on `chat.db`; sends use Messages' published AppleScript surface. Only the [advanced IMCore features](advanced-imcore.md) need a helper dylib, and even those are off by default.

103
docs/json.md Normal file
View File

@ -0,0 +1,103 @@
---
title: JSON output
description: "The stable JSON schema imsg emits for chats, messages, attachments, and reaction events."
---
Every read command supports `--json`. Output is **newline-delimited JSON (NDJSON)**: one self-contained JSON object per line. This shape works equally well for streaming consumers and for batch readers that pipe through `jq -s` to materialize an array.
```bash
imsg chats --json | jq -s
imsg history --chat-id 42 --json | jq -s
imsg watch --chat-id 42 --json
```
Human progress, prompts, and warnings are written to **stderr**, not stdout. Stdout is reserved for parseable JSON so pipelines stay clean.
## Chat
Returned by `imsg chats`, `imsg group`, and embedded in nested chat references in messages.
| Field | Type | Notes |
|-------|------|-------|
| `id` | int | `chat.ROWID`. Stable within one DB. Preferred routing handle. |
| `name` | string | Display name, contact match, or raw handle fallback. |
| `display_name` | string | Group title from `chat.display_name`. Empty for direct chats without a custom name. |
| `contact_name` | string | Resolved Contacts name (when permission granted). |
| `identifier` | string | `chat.chat_identifier`. Portable. |
| `guid` | string | `chat.guid`. Portable. |
| `service` | string | `iMessage`, `SMS`, etc. |
| `last_message_at` | ISO8601 | Newest activity time. |
| `is_group` | bool | True when identifier or guid contains `;+;`. |
| `participants` | array of strings | External handles only; local user implicit. |
| `account_id` | string | Routing diagnostic. Read-only. |
| `account_login` | string | Routing diagnostic. Read-only. |
| `last_addressed_handle` | string | Routing diagnostic. Read-only. |
## Message
Returned by `imsg history`, `imsg watch`, and the JSON-RPC `messages.history` and `watch.subscribe` notifications.
| Field | Type | Notes |
|-------|------|-------|
| `id` | int | rowid. Use as the `--since-rowid` cursor in watch. |
| `chat_id` | int | Always present. Preferred routing handle. |
| `chat_identifier` | string | Portable handle. |
| `chat_guid` | string | Portable GUID. |
| `chat_name` | string | Display name for the chat. |
| `participants` | array | External handles. |
| `is_group` | bool | True for group threads. |
| `guid` | string | Message GUID. Stable across machines. |
| `reply_to_guid` | string | When set, this message is an inline reply to that GUID. |
| `destination_caller_id` | string | Outgoing only — which of your numbers Messages routed through. |
| `sender` | string | Raw handle. Empty for some self-sent messages. |
| `sender_name` | string | Resolved Contacts name when permission granted. |
| `is_from_me` | bool | True for outbound. |
| `text` | string | Plain text. Recovered from `attributedBody` when `text` column is empty. |
| `created_at` | ISO8601 | Message timestamp. |
| `attachments` | array | Present when `--attachments` is set. See below. |
| `thread_originator_guid` | string | For inline-reply threads. |
### Reaction extensions
Present on `imsg watch --reactions` events:
| Field | Type | Notes |
|-------|------|-------|
| `is_reaction` | bool | `true` for tapback events. |
| `reaction_type` | string | `love`, `like`, `dislike`, `laugh`, `emphasis`, `question`, or a custom emoji marker. |
| `reaction_emoji` | string | Custom emoji, when present. |
| `is_reaction_add` | bool | `true` for add, `false` for remove. |
| `reacted_to_guid` | string | The message guid this tapback targets. |
`history` deliberately hides reaction rows so they don't duplicate the reacted message. Reaction events only surface in the live watch stream.
## Attachment
Inside the `attachments` array on a message:
| Field | Type | Notes |
|-------|------|-------|
| `filename` | string | Stored filename. |
| `transfer_name` | string | Original filename as sent. |
| `uti` | string | Apple UTI. |
| `mime_type` | string | Best-effort MIME. |
| `byte_size` | int | Size in bytes. |
| `is_sticker` | bool | Sticker-pack attachments. |
| `missing` | bool | Underlying file not on disk. |
| `path` | string | Resolved absolute path. |
| `converted_path` | string | Present with `--convert-attachments`. |
| `converted_mime_type` | string | Present with `--convert-attachments`. |
## Conventions
- Every numeric field is a JSON number. `id`, `chat_id`, and `byte_size` are integers; nothing requires 64-bit JSON-string encoding.
- Times are ISO 8601 with explicit timezone (typically `Z`).
- Strings that aren't applicable are omitted, not set to `null`. Test with `field in obj`, not `obj.field === null`.
- Booleans are explicit `true` / `false`, never 0/1.
- Arrays are always present when documented (possibly empty).
## Stability
The JSON schema is treated as a public API. Field renames or removals are tracked in `CHANGELOG.md` with a "change" or "deprecation" note and gated to a minor release.
The 0.2.0 → 0.3.0 cycle did one large rename (camelCase → snake_case). Since 0.3.0 the schema has been additive only.

63
docs/permissions.md Normal file
View File

@ -0,0 +1,63 @@
---
title: Permissions
description: "Full Disk Access, Automation, Contacts — what imsg needs and why."
---
`imsg` is local-only, but Messages.app data sits behind macOS privacy gates. Three permissions cover every feature; only the first is mandatory.
## Full Disk Access — required
`imsg` reads `~/Library/Messages/chat.db` directly. macOS denies that path to every process that hasn't been added to **Full Disk Access**.
Grant it under **System Settings → Privacy & Security → Full Disk Access**.
You almost always need to add at least two entries:
- The terminal app you'll launch `imsg` from (Terminal.app, iTerm2, Ghostty, WezTerm, Alacritty, …).
- The built-in Terminal at `/System/Applications/Utilities/Terminal.app`. macOS sometimes consults this default grant even when you're using a different terminal.
If `imsg` is launched indirectly — by an editor's task runner, a Node script, an SSH session, an automation gateway — the *parent* process needs the grant, not the terminal you opened. Add that parent app too.
After changing entries, quit and relaunch the parent process. macOS only re-reads Full Disk Access on launch.
`imsg` opens `chat.db` read-only. It does not pass SQLite's `immutable=1` flag because immutable handles can miss WAL-backed updates that Messages writes during normal use.
## Automation — required for sends and tapbacks
`imsg send`, `imsg react`, `imsg typing`, and `imsg read` drive Messages.app via AppleScript. macOS gates that under **Automation**.
The first time you run a send, macOS prompts:
> "Terminal" wants to control "Messages".
Approve it, or pre-approve under **System Settings → Privacy & Security → Automation → Messages**. Toggle the terminal (or wrapper app) on.
If you previously denied the prompt, the toggle will appear here and you can re-enable it without re-prompting.
## Contacts — optional
When granted, `imsg` resolves names from your Address Book and includes them as `contact_name` / `display_name` / `sender_name` in JSON output. Raw `handle` and `sender` values are always preserved, so automation that keys on phone numbers or email addresses is unaffected.
Grant it under **System Settings → Privacy & Security → Contacts**.
If you skip this, JSON output simply leaves the resolved name fields empty. Nothing else changes.
## Why these grants live in three different places
macOS treats each gate as a separate consent decision:
| Gate | What it protects | Triggered by |
|------|------------------|--------------|
| Full Disk Access | `~/Library/Messages/`, Mail, Safari history, … | `imsg chats`, `history`, `watch`, `group`, anything that opens `chat.db`. |
| Automation | One app driving another via Apple Events | `imsg send`, `react`, `read`, `typing`. |
| Contacts | Address Book entries | Name resolution in any read or send command. |
Only Full Disk Access is mandatory. Skip Automation if you don't send. Skip Contacts if you don't need name resolution. The CLI degrades cleanly — it tells you which gate is missing instead of silently failing.
## Stale grants after updates
After Homebrew, terminal, or macOS updates, Full Disk Access entries can go stale. The symptom is `unable to open database file` or empty output even though the entry looks toggled on.
Fix it by toggling the entry **off**, then **on** again. macOS regenerates the underlying TCC record. Do the same after replacing the parent app (e.g. updating Ghostty).
See [Troubleshooting](troubleshooting.md) for the full diagnosis loop.

96
docs/quickstart.md Normal file
View File

@ -0,0 +1,96 @@
---
title: Quickstart
description: "Five minutes from brew install to streaming Messages over stdout."
---
Goal: install `imsg`, grant the two permissions it needs, and walk through the read → watch → send loop.
## 1. Install
```bash
brew install steipete/tap/imsg
imsg --version
```
If you'd rather build from source, follow [Install](install.md).
## 2. Grant Full Disk Access
`imsg` reads `~/Library/Messages/chat.db` directly. macOS protects that file behind Full Disk Access.
1. **System Settings → Privacy & Security → Full Disk Access.**
2. Add the terminal you'll run `imsg` from (Terminal.app, iTerm2, Ghostty, WezTerm, …).
3. If your shell launches `imsg` from another app — an editor, a Node process, an SSH server — grant Full Disk Access to that parent process too.
4. Quit and re-open the terminal so the new grant takes effect.
Sanity-check:
```bash
imsg chats --limit 3
```
You should see the three most recent conversations. If not, see [Permissions](permissions.md).
## 3. Read history
```bash
# Pick a chat from `imsg chats`, then:
imsg history --chat-id 42 --limit 10
imsg history --chat-id 42 --limit 10 --json | jq -s
```
`--json` is one JSON object per line. Pipe it to `jq -s` to materialize an array, or stream it to whatever consumer you're wiring up.
Filter by date or participant:
```bash
imsg history --chat-id 42 \
--start 2026-05-01T00:00:00Z \
--end 2026-05-06T00:00:00Z \
--json
```
## 4. Stream new messages
```bash
imsg watch --chat-id 42 --json
```
Leave it running. Send yourself a message from another device — you'll see the row arrive within a second or so. To include tapbacks:
```bash
imsg watch --chat-id 42 --reactions --json
```
To resume from a saved cursor (useful for agents that store the last seen `id`):
```bash
imsg watch --chat-id 42 --since-rowid 9000 --json
```
See [Watch](watch.md) for debounce tuning, the polling fallback, and the full event schema.
## 5. Send a message
Sending requires one more permission:
1. **System Settings → Privacy & Security → Automation → Messages.**
2. Toggle on the terminal (and any wrapper app) so it can drive Messages.app.
Then:
```bash
imsg send --to "+14155551212" --text "hi"
imsg send --to "Jane Appleseed" --text "see attached" --file ~/Desktop/note.pdf
imsg send --chat-id 42 --text "same thread"
```
`send --to` accepts a phone number, an iMessage email, or a contact name (resolved via Contacts). For groups, prefer `--chat-id`. See [Send](send.md) for service selection (`imessage`, `sms`, `auto`) and the Tahoe ghost-row failure check.
## 6. Where to go next
- [Chats](chats.md) — what each field in a chat object means.
- [JSON output](json.md) — the stable schema agents should consume.
- [JSON-RPC](rpc.md) — same surfaces, but over stdio with a single long-running process.
- [Attachments](attachments.md) — metadata, original paths, and CAF/GIF conversion.
- [Troubleshooting](troubleshooting.md) — when reads silently return nothing.

View File

@ -1,141 +1,168 @@
# RPC
---
title: JSON-RPC
description: "Long-running JSON-RPC 2.0 over stdio for chats, history, watch, and send — same surfaces as the CLI, one process."
---
Goal: signal-style JSON-RPC without a daemon. Clawdis spawns `imsg rpc` and talks over stdio.
`imsg rpc` exposes the read and send surfaces over JSON-RPC 2.0 on stdin/stdout. It's designed for agents and gateways that want a single long-lived process for chats, history, send, and watch — without a TCP port, daemon, or system service.
## Transport
- stdin/stdout, one JSON object per line.
- JSON-RPC 2.0 framing (`jsonrpc`, `id`, `method`, `params`).
- One JSON object per line on stdin (request) and stdout (response/notification).
- JSON-RPC 2.0 framing: `jsonrpc`, `id`, `method`, `params`.
- Notifications omit `id`.
- Stderr is reserved for human-readable diagnostics.
## Lifecycle
- Gateway spawns one `imsg rpc` process.
- Process stays alive for watch + send.
- No TCP port, no daemon install.
- The host process spawns one `imsg rpc` child.
- The child stays alive across many requests and one-or-more watch subscriptions.
- No TCP port. No launch agent. No `imsg` daemon to install.
The pattern intentionally mirrors language servers and the way `imsg`'s parent gateway (Clawdis) supervises subprocesses — a single signal-style child that exits cleanly when stdin closes.
## Methods
### `chats.list`
Params:
- `limit` (int, default 20)
Result:
- `{ "chats": [Chat] }`
```json
{ "chats": [Chat] }
```
### `messages.history`
Params:
- `chat_id` (int, required, preferred identifier)
- `chat_id` (int, required) — preferred identifier.
- `limit` (int, default 50)
- `participants` (array, optional)
- `start` / `end` (ISO8601, optional)
- `attachments` (bool, default false)
- `participants` (array of handle strings, optional)
- `start` / `end` (ISO 8601, optional)
- `attachments` (bool, default `false`)
Result:
- `{ "messages": [Message] }`
```json
{ "messages": [Message] }
```
### `watch.subscribe`
Params:
- `chat_id` (int, optional)
- `since_rowid` (int, optional)
- `participants` (array, optional)
- `start` / `end` (ISO8601, optional)
- `attachments` (bool, default false)
- `include_reactions` (bool, default false)
- `debounce_ms` / `debounceMs` (int milliseconds, default 500)
Result:
- `{ "subscription": 1 }`
Notifications:
- `{"jsonrpc":"2.0","method":"message","params":{"subscription":1,"message":<Message>}}`
The RPC default debounce is intentionally higher than the CLI default so macOS
has time to settle follow-up writes such as `is_from_me` updates on outbound
messages. Clients that need lower latency can pass `debounce_ms`. Watch streams
also perform a lightweight periodic poll so missed filesystem events or rotated
SQLite sidecar files do not leave long-running providers silent.
Params:
- `chat_id` (int, optional) — omit for all-chat stream.
- `since_rowid` (int, optional) — exclusive cursor.
- `participants` (array, optional)
- `start` / `end` (ISO 8601, optional)
- `attachments` (bool, default `false`)
- `include_reactions` (bool, default `false`)
- `debounce_ms` (int, default `500`)
Result:
```json
{ "subscription": 1 }
```
Notifications (one per emitted message):
```json
{
"jsonrpc": "2.0",
"method": "message",
"params": {
"subscription": 1,
"message": { ... }
}
}
```
The RPC default debounce (`500ms`) is intentionally higher than the CLI default (`250ms`). RPC's typical caller is an agent that just sent a message and is waiting for the inbound echo to settle (`is_from_me` correction, attachment metadata, …). 500ms is enough for those follow-ups to land before the message is emitted.
Like the CLI watch, RPC watch backs filesystem events with a low-frequency poll so a missed event or a rotated SQLite sidecar doesn't leave the subscription silent.
### `watch.unsubscribe`
Params:
- `subscription` (int, required)
Result:
- `{ "ok": true }`
```json
{ "ok": true }
```
### `send`
Params (direct):
Params (direct send):
- `to` (string, required)
- `text` (string, optional)
- `file` (string, optional)
- `service` ("imessage"|"sms"|"auto", optional)
- `service` (`imessage` | `sms` | `auto`, optional)
- `region` (string, optional)
Params (group):
- `chat_id` or `chat_identifier` or `chat_guid` (one required; `chat_id` preferred)
- `text` / `file` as above
Params (chat target):
- `chat_id` *or* `chat_identifier` *or* `chat_guid` — exactly one. `chat_id` is preferred.
- `text` / `file` as above.
Result:
- `{ "ok": true, "id": 1979, "guid": "8DF..." }`
`id` and `guid` are best-effort. `send` returns them when the sent row can be
observed in `chat.db` after Messages accepts the send. Attachment-only sends,
delayed database writes, or ambiguous direct sends may still return only
`{ "ok": true }`.
```json
{ "ok": true, "id": 1979, "guid": "8DF..." }
```
For chat-target sends, `send` also checks for the Tahoe Messages.app failure
mode where AppleScript returns success but writes an empty outgoing SMS row that
is not joined to the target chat. That case is reported as an error instead of
`{ "ok": true }`.
`id` and `guid` are best-effort. `send` returns them when the inserted row can be observed in `chat.db` after Messages accepts the send. Attachment-only sends, delayed database writes, or ambiguous direct sends may return only `{"ok": true}`.
For chat-target sends, `send` also performs the [Tahoe ghost-row check](send.md#tahoe-ghost-row-protection): if Messages writes an empty unjoined SMS row instead of delivering, the call returns an error rather than `{"ok": true}`.
## Objects
### Chat
- `id` (int)
- `name` (string)
- `identifier` (string)
- `guid` (string, optional)
- `service` (string)
- `last_message_at` (ISO8601)
- `account_id` (string, optional)
- `account_login` (string, optional)
- `last_addressed_handle` (string, optional)
- `participants` (array, optional)
- `is_group` (bool, optional)
See [JSON output → Chat](json.md#chat). Every field documented there appears in the RPC `chats.list` response.
### Message
- `id` (rowid)
- `chat_id` (always present; preferred handle for routing)
- `guid` (string)
- `reply_to_guid` (string, optional)
- `destination_caller_id` (string, optional)
- `sender`
- `is_from_me`
- `text`
- `created_at`
- `attachments` (array)
- `reactions` (array)
- `chat_identifier`
- `chat_guid`
- `chat_name`
- `participants`
- `is_group`
`account_id`, `account_login`, `last_addressed_handle`, and sent-message
`destination_caller_id` are read-only routing diagnostics from Messages. The
AppleScript send API does not expose a `from` account or phone-number selector.
See [JSON output → Message](json.md#message). When `include_reactions: true`, message notifications also include the reaction extension fields (`is_reaction`, `reaction_type`, `reaction_emoji`, `is_reaction_add`, `reacted_to_guid`).
`account_id`, `account_login`, `last_addressed_handle`, and outgoing `destination_caller_id` are read-only routing diagnostics; the AppleScript send API does not expose a `from` selector.
## Examples
Request:
```
Request `chats.list`:
```json
{"jsonrpc":"2.0","id":"1","method":"chats.list","params":{"limit":10}}
```
Response:
```
```json
{"jsonrpc":"2.0","id":"1","result":{"chats":[...]}}
```
Subscribe:
```
Subscribe to a chat:
```json
{"jsonrpc":"2.0","id":"2","method":"watch.subscribe","params":{"chat_id":1}}
```
Notification:
```
Notification on each new message:
```json
{"jsonrpc":"2.0","method":"message","params":{"subscription":2,"message":{...}}}
```
Send and receive verification:
```json
{"jsonrpc":"2.0","id":"3","method":"send","params":{"to":"+14155551212","text":"hi"}}
{"jsonrpc":"2.0","id":"3","result":{"ok":true,"id":1979,"guid":"8DF..."}}
```

122
docs/send.md Normal file
View File

@ -0,0 +1,122 @@
---
title: Send
description: "Send text and files to direct chats and groups through Messages.app automation, plus standard tapbacks."
---
`imsg send` rides Messages' published AppleScript surface — no private send APIs, no IMCore injection. Sending requires Automation permission for Messages (see [Permissions](permissions.md)).
## Direct sends
```bash
imsg send --to "+14155551212" --text "hi"
imsg send --to "jane@example.com" --text "hi"
imsg send --to "Jane Appleseed" --text "hi"
```
`--to` accepts:
- An E.164 phone number (`+14155551212`) — best.
- A locally-formatted phone number (`415-555-1212`). Pair with `--region US` if you need to override the default.
- An iMessage email address.
- A contact name. Resolved through Address Book; requires Contacts permission.
For unambiguous routing, prefer phone numbers in E.164 form.
## Group sends
You'll typically want `--chat-id`:
```bash
imsg send --chat-id 42 --text "same thread"
```
Use `--chat-identifier` or `--chat-guid` when only the portable handles are available:
```bash
imsg send --chat-identifier "iMessage;+;chat1234567890" --text "hi"
imsg send --chat-guid "iMessage;+;chat1234567890" --text "hi"
```
See [Groups](groups.md) for how Messages encodes group handles.
## Files and audio
```bash
imsg send --to "+14155551212" --text "see attached" --file ~/Desktop/note.pdf
imsg send --to "Jane Appleseed" --file ~/Desktop/voice.m4a
imsg send --chat-id 42 --file ~/Desktop/screenshot.png
```
Both `--text` and `--file` can be supplied together.
Before handing the file to Messages, `imsg` stages it under `~/Library/Messages/Attachments/imsg/`. Messages reads attachments from there reliably across macOS versions; sending directly from `~/Desktop` or `~/Downloads` can hit sandbox-related send failures.
Audio files (`.m4a`, `.caf`, `.aiff`, etc.) send the same way as any other file. Messages exposes them as audio messages on the receiving side.
## Service selection
```bash
imsg send --to "+14155551212" --text "hi" --service auto # default
imsg send --to "+14155551212" --text "hi" --service imessage
imsg send --to "+14155551212" --text "hi" --service sms
```
- `auto` — Messages picks. iMessage when the recipient is an Apple device; SMS when not, given Text Message Forwarding is enabled.
- `imessage` — force iMessage. Fails fast if the recipient isn't on iMessage.
- `sms` — force SMS relay. Requires Text Message Forwarding enabled on your iPhone for this Mac.
For groups, omit `--service`. Group sends always use the chat's existing service.
## Region for phone normalization
```bash
imsg send --to "415-555-1212" --text "hi" --region US
```
Defaults to `US`. Pass an ISO 3166-1 alpha-2 country code to normalize locally-formatted numbers.
## Confirming what was sent
Default text mode prints `sent` on success. JSON mode emits `{"status":"sent"}`.
The [JSON-RPC `send` method](rpc.md#send) goes further: it includes the rowid and GUID of the inserted message when it can observe the row in `chat.db` after Messages accepts the send. Use RPC when you need a verified send acknowledgment.
## Tahoe ghost-row protection
On macOS 26 (Tahoe), Messages.app has a failure mode where AppleScript reports success but writes an empty outgoing SMS row that isn't joined to the target chat. The send looks fine to the caller but never reaches the recipient.
`imsg send` for chat-target sends (`--chat-id`, `--chat-identifier`, `--chat-guid`) checks for this ghost row after the AppleScript call returns. If it finds one, the command reports an error rather than `sent`. Direct sends (`--to`) are not affected by this failure mode.
This check landed in 0.6.0; see `CHANGELOG.md` for the issue history.
## Standard tapbacks
```bash
imsg react --chat-id 42 --reaction love
imsg react --chat-id 42 --reaction like
imsg react --chat-id 42 --reaction dislike
imsg react --chat-id 42 --reaction laugh
imsg react --chat-id 42 --reaction emphasis
imsg react --chat-id 42 --reaction question
```
`react` sends only the six standard tapbacks Messages.app exposes reliably through automation. After the AppleScript call, `imsg` confirms the reaction selection in Messages' UI before reporting success — this guards against silent UI rejections.
Custom emoji tapbacks can be *read* in `watch --reactions` output, but `react` rejects them rather than taking a no-op AppleScript path. There is no published automation surface that sends arbitrary emoji tapbacks reliably.
## Outgoing routing — what you can and can't control
`imsg` reports per-chat routing diagnostics — `account_id`, `account_login`, `last_addressed_handle`, and per-message `destination_caller_id`. They tell you which Apple ID and which of your numbers Messages routed through.
You cannot use `send` to *force* a specific outgoing number when several phone numbers share one Apple ID. AppleScript's `send` has no `from` or account selector. The fields are diagnostic, not steering. If you need to force a specific number, change the default in Messages' settings.
## What requires what
| Send variant | Permission | macOS limits |
|--------------|------------|--------------|
| `send --to <handle>` | Automation → Messages | None unique to this command. |
| `send --chat-id` (groups) | Automation → Messages | Tahoe ghost-row check active. |
| `send --file` | Automation → Messages | Files are auto-staged in Messages' attachments dir. |
| `react` | Automation → Messages + UI scripting | Only the six standard tapbacks are sendable. |
| `read` (mark as read) | [Advanced IMCore](advanced-imcore.md) | SIP-disabled, dylib injection, increasingly limited on macOS 26. |
| `typing` (typing indicator) | [Advanced IMCore](advanced-imcore.md) | Same as `read`. |

108
docs/troubleshooting.md Normal file
View File

@ -0,0 +1,108 @@
---
title: Troubleshooting
description: "Common reasons reads return nothing, sends silently fail, or watch goes quiet — and how to diagnose each one."
---
Most `imsg` issues come down to a permissions gate that hasn't taken effect yet, or a Messages.app behavior change on a recent macOS update. This page walks through the standard diagnoses.
## Reads return `unable to open database file`
The terminal (or its parent process) doesn't have Full Disk Access yet.
1. **System Settings → Privacy & Security → Full Disk Access.**
2. Add the terminal you're running `imsg` from.
3. Add `/System/Applications/Utilities/Terminal.app` even if you don't use it directly — macOS sometimes consults the default terminal grant.
4. If `imsg` is launched indirectly (editor task runner, Node script, SSH session, automation gateway), grant Full Disk Access to that *parent* app, not just the terminal you opened.
5. Quit and relaunch the parent process.
If reads still fail, **toggle the entry off and back on**. Full Disk Access entries can go stale after Homebrew, terminal, or macOS updates. The entry looks correct but no longer carries the underlying TCC grant.
Confirm:
```bash
sqlite3 ~/Library/Messages/chat.db 'pragma quick_check;'
```
If `sqlite3` works but `imsg` doesn't, the parent process of `imsg` is still missing the grant. If `sqlite3` also fails, fix Full Disk Access first.
## Reads succeed but return zero rows
Messages.app isn't signed in, or `chat.db` doesn't exist.
```bash
ls -la ~/Library/Messages/chat.db
```
If the file is missing, open Messages.app and complete iMessage / SMS Forwarding setup. The database is created lazily on first sign-in.
## Sends fail with `not authorized to send Apple events`
Automation permission is missing.
1. **System Settings → Privacy & Security → Automation → Messages.**
2. Toggle the terminal (or wrapper app) on.
3. Re-run the send.
If the toggle isn't visible, run a send once to trigger the prompt, then approve.
## Sends look successful but never arrive
Two possible causes:
**Tahoe ghost-row failure (group sends).** On macOS 26, Messages.app sometimes reports AppleScript success while writing an empty unjoined SMS row instead of delivering. `imsg send` for chat-target sends already detects this and reports an error instead of `sent`. If you're still seeing silent failures with `--chat-id`/`--chat-identifier`/`--chat-guid`, make sure you're on `imsg` 0.6.0 or newer (`imsg --version`).
**Service mismatch.** A send to a phone number with `--service imessage` fails fast if the recipient isn't on iMessage. With `--service sms`, Text Message Forwarding must be enabled on your iPhone for this Mac. With `--service auto`, Messages picks; this is the recommended default.
## `imsg watch` goes silent after a while
macOS occasionally drops or coalesces filesystem events, especially after sleep/wake or under heavy I/O. Older versions of `imsg watch` could go silent in that window.
`imsg` 0.6.0 added a low-frequency polling fallback that runs alongside the event watcher. If the cursor falls behind, the poll catches up. Make sure you're on 0.6.0+ (`imsg --version`).
If you're already on 0.6.0+ and watch still misses messages, file an issue with:
- macOS version (`sw_vers`).
- `imsg --version`.
- A reproduction including the exact `imsg watch` flags.
- The output of `ls -la ~/Library/Messages/chat.db*` taken just after the silence.
## `react` fails with `unsupported reaction`
`imsg react` only sends the six standard tapbacks Messages.app exposes reliably through automation: `love`, `like`, `dislike`, `laugh`, `emphasis`, `question`.
Custom emoji tapbacks can be *read* in `watch --reactions` output, but `react` rejects them rather than taking a no-op AppleScript path. There's no automation surface that sends arbitrary emoji tapbacks reliably.
## `imsg` reports a different version than `brew`
Stale Homebrew install or a manually-built binary on `PATH` ahead of the formula:
```bash
which imsg
brew list --versions imsg
```
If `which imsg` doesn't point at the Homebrew prefix, remove the older binary or reorder your `PATH`.
## Contacts names are missing in JSON output
Contacts permission isn't granted, or the contact isn't matched.
1. Confirm under **System Settings → Privacy & Security → Contacts** that the terminal/wrapper app is enabled.
2. Raw handles are always preserved in `sender`, `chat_identifier`, etc. The optional `contact_name` / `sender_name` fields are simply omitted when no match is found.
If you want partial fallback names (initials, or formatted handles), do that in your consumer — `imsg` doesn't synthesize names that aren't in your Address Book.
## Advanced IMCore features fail
See [Advanced IMCore features](advanced-imcore.md). Most likely SIP is enabled (required to be off), library validation is rejecting the helper dylib, or macOS 26's `imagent` entitlement check is blocking the IMCore client. These are macOS-level gates `imsg` cannot work around.
## Filing issues
If you've worked through the relevant section above and are stuck, open an issue at <https://github.com/steipete/imsg/issues>.
Useful context:
- `imsg --version`.
- `sw_vers` (macOS version).
- The exact command you ran and the full output (with any sensitive content redacted).
- Whether `sqlite3 ~/Library/Messages/chat.db 'pragma quick_check;'` succeeds or fails.

102
docs/watch.md Normal file
View File

@ -0,0 +1,102 @@
---
title: Watch
description: "Stream new iMessage and SMS rows live, with filesystem-event triggers and a poll-based fallback."
---
`imsg watch` follows `chat.db` and emits each new message as soon as Messages writes it. It's the right primitive for agents, dashboards, notifiers, and anything that wants near-real-time inbound.
## Stream all chats
```bash
imsg watch --json
```
You'll see every new inbound and outbound message across every chat the database covers.
## Stream one chat
```bash
imsg watch --chat-id 42 --json
```
`--chat-id` is the simplest filter. For more advanced filtering use `--participants`, `--start`, `--end`, all of which mirror [`history`](history.md).
## Resuming from a cursor
For long-lived consumers — agents, sync jobs — store the last `id` (rowid) you successfully processed and resume:
```bash
imsg watch --chat-id 42 --since-rowid 9000 --json
```
`--since-rowid` is exclusive: `9000` means "everything strictly after rowid 9000."
If you don't pass `--since-rowid`, watch starts at the newest message at the moment of launch. Messages written before then are not replayed; use [`history`](history.md) for that.
## Reactions
By default, tapback events are excluded so the stream stays focused on actual messages. Opt in with `--reactions`:
```bash
imsg watch --chat-id 42 --reactions --json
```
Reaction events extend the message object with:
- `is_reaction``true` for tapback events.
- `reaction_type``love`, `like`, `dislike`, `laugh`, `emphasis`, `question`, or a custom emoji string.
- `reaction_emoji` — for custom emoji tapbacks.
- `is_reaction_add``true` when added, `false` when removed.
- `reacted_to_guid` — the message guid this tapback targets.
## Attachments
```bash
imsg watch --chat-id 42 --attachments --json
imsg watch --chat-id 42 --attachments --convert-attachments --json
```
Attachment metadata is reported the same way as [`history`](history.md). `--convert-attachments` requires `ffmpeg` on `PATH`; see [Attachments](attachments.md).
## Debounce
```bash
imsg watch --chat-id 42 --debounce 250ms --json
```
When Messages writes a message, it often follows up with WAL flushes, attachment metadata updates, and `is_from_me` corrections within a few milliseconds. The debouncer collapses those into one stable emission per row.
- CLI default: `250ms`.
- RPC default: `500ms` (RPC's typical caller is an agent more sensitive to outbound echo races).
Lower the debounce if you need lower latency and can tolerate occasional duplicate emissions during database churn. Raise it if downstream consumers can't keep up.
`--debounce` accepts Go-style durations: `100ms`, `1s`, `2s500ms`.
## How it knows when to read
The watcher listens for `kqueue` filesystem events on:
- `~/Library/Messages/chat.db`
- `~/Library/Messages/chat.db-wal`
- `~/Library/Messages/chat.db-shm`
Whenever any of those files change, the watcher checks for new rows past the cursor.
## Polling fallback
macOS sometimes drops or coalesces filesystem events — especially under heavy I/O, after sleep/wake, or when Messages rotates the WAL sidecars. Without intervention, a watch session can go silent while the database keeps changing.
`imsg watch` runs a low-frequency poll alongside the event watcher. If the cursor falls behind the actual rowid, the poller catches up and emits the missed rows. You don't configure this — it's always on.
This is the fix for the long-standing "watch goes silent after a while" class of bug. See `CHANGELOG.md` 0.6.0 entry.
## URL preview deduplication
When you send a link, Messages writes a "balloon" placeholder row first, then later replaces it once the preview metadata is fetched. Without dedup, watch would emit both. `imsg watch` deduplicates these without dropping unrelated messages from other chats — the dedup is keyed precisely on the balloon update path, not on text similarity.
## Output schema
Each line is a complete JSON object. See [JSON output → Message](json.md#message) for the full field list. For tapback events also see the reaction fields above.
Lines are flushed immediately when stdout is buffered (e.g. piped through `jq -c`), so downstream consumers don't experience batching artifacts.

565
scripts/build-docs-site.mjs Normal file
View File

@ -0,0 +1,565 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { brandMarkSvg, css, faviconSvg, js, preThemeScript, themeToggleHtml } from "./docs-site-assets.mjs";
const root = process.cwd();
const docsDir = path.join(root, "docs");
const outDir = path.join(root, "dist", "docs-site");
const repoBase = "https://github.com/steipete/imsg";
const repoEditBase = `${repoBase}/edit/main/docs`;
const cname = readCname();
const siteBase = cname ? `https://${cname}` : "";
const productName = "imsg";
const productTagline = "Messages.app from your terminal";
const productDescription =
"A macOS command-line tool for Messages.app — read your local chat database, stream new iMessage and SMS messages, send text and files through Messages automation, and expose the same surfaces over JSON and JSON-RPC.";
const brewInstall = "brew install steipete/tap/imsg";
const sections = [
["Start", ["index.md", "install.md", "quickstart.md", "permissions.md"]],
["Read", ["chats.md", "history.md", "watch.md", "groups.md", "attachments.md"]],
["Send", ["send.md"]],
["Integrate", ["json.md", "rpc.md", "completions.md"]],
["Operate", ["troubleshooting.md", "advanced-imcore.md", "RELEASING.md"]],
];
const buildExcludes = [];
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });
const allPages = allMarkdown(docsDir).map((file) => {
const rel = path.relative(docsDir, file).replaceAll(path.sep, "/");
const raw = fs.readFileSync(file, "utf8");
const { frontmatter, body } = parseFrontmatter(raw);
const cleaned = stripStrayDirectives(body);
const title = frontmatter.title || firstHeading(cleaned) || titleize(path.basename(rel, ".md"));
return { file, rel, title, outRel: outPath(rel, frontmatter), markdown: cleaned, frontmatter };
});
const pages = allPages.filter((page) => !buildExcludes.some((re) => re.test(page.rel)));
const pageMap = new Map(pages.map((page) => [page.rel, page]));
const permalinkMap = new Map();
for (const page of pages) {
if (page.frontmatter.permalink) {
permalinkMap.set(normalizePermalink(page.frontmatter.permalink), page);
}
}
const nav = sections
.map(([name, rels]) => ({
name,
pages: rels.map((rel) => pageMap.get(rel)).filter(Boolean),
}))
.filter((section) => section.pages.length);
const sectionByRel = new Map();
for (const section of nav) for (const page of section.pages) sectionByRel.set(page.rel, section.name);
const orderedPages = nav.flatMap((s) => s.pages);
for (const page of pages) {
const html = markdownToHtml(page.markdown, page.rel);
const toc = tocFromHtml(html);
const idx = orderedPages.findIndex((p) => p.rel === page.rel);
const prev = idx > 0 ? orderedPages[idx - 1] : null;
const next = idx >= 0 && idx < orderedPages.length - 1 ? orderedPages[idx + 1] : null;
const sectionName = sectionByRel.get(page.rel) || "Reference";
const pageOut = path.join(outDir, page.outRel);
fs.mkdirSync(path.dirname(pageOut), { recursive: true });
fs.writeFileSync(pageOut, layout({ page, html, toc, prev, next, sectionName }), "utf8");
}
fs.writeFileSync(path.join(outDir, "favicon.svg"), faviconSvg(), "utf8");
fs.writeFileSync(path.join(outDir, ".nojekyll"), "", "utf8");
if (cname) fs.writeFileSync(path.join(outDir, "CNAME"), cname, "utf8");
validateLinks(outDir);
console.log(`built docs site: ${path.relative(root, outDir)}`);
function readCname() {
for (const candidate of [path.join(docsDir, "CNAME"), path.join(root, "CNAME")]) {
if (fs.existsSync(candidate)) return fs.readFileSync(candidate, "utf8").trim();
}
return "";
}
function parseFrontmatter(raw) {
const match = raw.match(/^---\n([\s\S]*?)\n---\n?/);
if (!match) return { frontmatter: {}, body: raw };
const fm = {};
for (const line of match[1].split("\n")) {
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/);
if (!m) continue;
let value = m[2];
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
fm[m[1]] = value;
}
return { frontmatter: fm, body: raw.slice(match[0].length) };
}
function stripStrayDirectives(body) {
return body
.replace(/\r\n/g, "\n")
.split("\n")
.filter((line) => !/^\s*\{:\s*[^}]*\}\s*$/.test(line))
.map((line) => line.replace(/\s*\{:\s*[^}]*\}\s*$/, ""))
.join("\n");
}
function normalizePermalink(value) {
let v = value.trim();
if (!v) return "/";
if (!v.startsWith("/")) v = `/${v}`;
if (v.length > 1 && v.endsWith("/")) v = v.slice(0, -1);
return v;
}
function allMarkdown(dir) {
return fs
.readdirSync(dir, { withFileTypes: true })
.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return allMarkdown(full);
return entry.name.endsWith(".md") ? [full] : [];
})
.sort();
}
function outPath(rel, frontmatter = {}) {
if (frontmatter.permalink) {
const permalink = normalizePermalink(frontmatter.permalink);
if (permalink === "/") return "index.html";
return `${permalink.slice(1)}/index.html`;
}
if (rel === "index.md") return "index.html";
if (rel === "README.md") return "index.html";
if (rel.endsWith("/README.md")) return rel.replace(/README\.md$/, "index.html");
return rel.replace(/\.md$/, ".html");
}
function firstHeading(markdown) {
return markdown.match(/^#\s+(.+)$/m)?.[1]?.trim();
}
function titleize(input) {
return input.replaceAll("-", " ").replace(/\b\w/g, (m) => m.toUpperCase());
}
function markdownToHtml(markdown, currentRel) {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const html = [];
let paragraph = [];
let list = null;
let fence = null;
let blockquote = [];
const flushParagraph = () => {
if (!paragraph.length) return;
html.push(`<p>${inline(paragraph.join(" "), currentRel)}</p>`);
paragraph = [];
};
const closeList = () => {
if (!list) return;
html.push(`</${list}>`);
list = null;
};
const flushBlockquote = () => {
if (!blockquote.length) return;
const inner = markdownToHtml(blockquote.join("\n"), currentRel);
html.push(`<blockquote>${inner}</blockquote>`);
blockquote = [];
};
const splitRow = (line) => {
let trimmed = line.trim();
if (trimmed.startsWith("|")) trimmed = trimmed.slice(1);
if (trimmed.endsWith("|") && !trimmed.endsWith("\\|")) trimmed = trimmed.slice(0, -1);
const cells = [];
let current = "";
for (let idx = 0; idx < trimmed.length; idx++) {
const char = trimmed[idx];
if (char === "\\" && trimmed[idx + 1] === "|") {
current += "\\|";
idx += 1;
continue;
}
if (char === "|") {
cells.push(current.trim().replace(/\\\|/g, "|"));
current = "";
continue;
}
current += char;
}
cells.push(current.trim().replace(/\\\|/g, "|"));
return cells;
};
const isDivider = (line) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(line);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const fenceMatch = line.match(/^```([\w+-]+)?\s*$/);
if (fenceMatch) {
flushParagraph();
closeList();
flushBlockquote();
if (fence) {
html.push(`<pre><code class="language-${escapeAttr(fence.lang)}">${escapeHtml(fence.lines.join("\n"))}</code></pre>`);
fence = null;
} else {
fence = { lang: fenceMatch[1] || "text", lines: [] };
}
continue;
}
if (fence) {
fence.lines.push(line);
continue;
}
if (/^>\s?/.test(line)) {
flushParagraph();
closeList();
blockquote.push(line.replace(/^>\s?/, ""));
continue;
}
flushBlockquote();
if (!line.trim()) {
flushParagraph();
closeList();
continue;
}
if (/^\s*---+\s*$/.test(line)) {
flushParagraph();
closeList();
html.push("<hr>");
continue;
}
const heading = line.match(/^(#{1,4})\s+(.+)$/);
if (heading) {
flushParagraph();
closeList();
const level = heading[1].length;
const text = heading[2].trim();
const id = slug(text);
const inner = inline(text, currentRel);
if (level === 1) {
html.push(`<h1 id="${id}">${inner}</h1>`);
} else {
html.push(`<h${level} id="${id}"><a class="anchor" href="#${id}" aria-label="Anchor link">#</a>${inner}</h${level}>`);
}
continue;
}
if (line.trimStart().startsWith("|") && line.includes("|", line.indexOf("|") + 1) && isDivider(lines[i + 1] || "")) {
flushParagraph();
closeList();
const header = splitRow(line);
const aligns = splitRow(lines[i + 1]).map((cell) => {
const left = cell.startsWith(":");
const right = cell.endsWith(":");
return right && left ? "center" : right ? "right" : left ? "left" : "";
});
i += 1;
const rows = [];
while (i + 1 < lines.length && lines[i + 1].trimStart().startsWith("|")) {
i += 1;
rows.push(splitRow(lines[i]));
}
const th = header.map((c, idx) => `<th${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(c, currentRel)}</th>`).join("");
const tb = rows.map((r) => `<tr>${r.map((c, idx) => `<td${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(c, currentRel)}</td>`).join("")}</tr>`).join("");
html.push(`<table><thead><tr>${th}</tr></thead><tbody>${tb}</tbody></table>`);
continue;
}
const bullet = line.match(/^\s*-\s+(.+)$/);
const numbered = line.match(/^\s*\d+\.\s+(.+)$/);
if (bullet || numbered) {
flushParagraph();
const tag = bullet ? "ul" : "ol";
if (list && list !== tag) closeList();
if (!list) {
list = tag;
html.push(`<${tag}>`);
}
html.push(`<li>${inline((bullet || numbered)[1], currentRel)}</li>`);
continue;
}
paragraph.push(line.trim());
}
flushParagraph();
closeList();
flushBlockquote();
return html.join("\n");
}
function inline(text, currentRel) {
const stash = [];
let out = text.replace(/`([^`]+)`/g, (_, code) => {
stash.push(`<code>${escapeHtml(code)}</code>`);
return `\u0000${stash.length - 1}\u0000`;
});
out = escapeHtml(out)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, "$1<em>$2</em>")
.replace(/(^|[^_])_([^_\s][^_]*?)_(?!_)/g, "$1<em>$2</em>")
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => `<a href="${escapeAttr(rewriteHref(href, currentRel))}">${label}</a>`)
.replace(/&lt;(https?:\/\/[^\s<>]+)&gt;/g, '<a href="$1">$1</a>');
out = out.replace(/\\\|/g, "|");
out = out.replace(/&lt;br&gt;/g, "<br>");
return out.replace(/\u0000(\d+)\u0000/g, (_, i) => stash[Number(i)]);
}
function rewriteHref(href, currentRel) {
if (/^(https?:|mailto:|tel:|#)/.test(href)) return href;
const [raw, hash = ""] = href.split("#");
if (!raw) return hash ? `#${hash}` : "";
if (raw.startsWith("/")) {
const target = permalinkMap.get(normalizePermalink(raw));
if (target) {
const currentOut = pageMap.get(currentRel)?.outRel || outPath(currentRel);
const out = hrefToOutRel(target.outRel, currentOut);
return hash ? `${out}#${hash}` : out;
}
return href;
}
if (!raw.endsWith(".md")) return href;
const from = path.posix.dirname(currentRel);
const target = path.posix.normalize(path.posix.join(from, raw));
let rewritten = pageMap.get(target)?.outRel || outPath(target);
const currentOut = pageMap.get(currentRel)?.outRel || outPath(currentRel);
rewritten = hrefToOutRel(rewritten, currentOut);
return `${rewritten}${hash ? `#${hash}` : ""}`;
}
function tocFromHtml(html) {
const items = [];
const re = /<h([23]) id="([^"]+)">([\s\S]*?)<\/h[23]>/g;
let m;
while ((m = re.exec(html))) {
const text = m[3]
.replace(/<a class="anchor"[^>]*>.*?<\/a>/, "")
.replace(/<[^>]+>/g, "")
.trim();
items.push({ level: Number(m[1]), id: m[2], text });
}
if (items.length < 2) return "";
return `<nav class="toc" aria-label="On this page"><h2>On this page</h2>${items
.map((i) => `<a class="toc-l${i.level}" href="#${i.id}">${escapeHtml(i.text)}</a>`)
.join("")}</nav>`;
}
function isHomePage(page) {
if (page.frontmatter.permalink && normalizePermalink(page.frontmatter.permalink) === "/") return true;
return page.rel === "index.md" || page.rel === "README.md";
}
function homeHero(page) {
const description = page.frontmatter.description || productDescription;
const installRel = pageMap.get("install.md")?.outRel
? hrefToOutRel(pageMap.get("install.md").outRel, page.outRel)
: "install.html";
const quickstartRel = pageMap.get("quickstart.md")?.outRel
? hrefToOutRel(pageMap.get("quickstart.md").outRel, page.outRel)
: "quickstart.html";
const surfaces = ["Chats", "History", "Watch", "Send", "React", "Groups", "Attachments", "JSON", "JSON-RPC"];
return `<header class="home-hero">
<p class="eyebrow">macOS · Messages.app</p>
<h1>${escapeHtml(productTagline)}</h1>
<p class="lede">${escapeHtml(description)}</p>
<div class="home-cta">
<a class="btn btn-primary" href="${quickstartRel}">Quickstart</a>
<a class="btn btn-ghost" href="${repoBase}" rel="noopener">GitHub</a>
</div>
<div class="home-install" aria-label="Install with Homebrew">
<span class="prompt" aria-hidden="true">$</span>
<code>${escapeHtml(brewInstall)}</code>
</div>
<div class="home-services" aria-label="Surfaces">
${surfaces.map((s) => `<span>${escapeHtml(s)}</span>`).join("")}
</div>
<p class="muted"><a href="${installRel}">Other install options </a></p>
</header>`;
}
function standardHero(page, sectionName, editUrl) {
return `<header class="hero">
<div class="hero-text">
<p class="eyebrow">${escapeHtml(sectionName)}</p>
<h1>${escapeHtml(page.title)}</h1>
</div>
<div class="hero-meta">
<a class="repo" href="${repoBase}" rel="noopener">GitHub</a>
<a class="edit" href="${escapeAttr(editUrl)}" rel="noopener">Edit page</a>
</div>
</header>`;
}
function layout({ page, html, toc, prev, next, sectionName }) {
const depth = page.outRel.split("/").length - 1;
const rootPrefix = depth ? "../".repeat(depth) : "";
const editUrl = `${repoEditBase}/${page.rel}`;
const home = isHomePage(page);
const prevNext = !home && (prev || next) ? pageNavHtml(prev, next, page.outRel) : "";
const heroBlock = home ? homeHero(page) : standardHero(page, sectionName, editUrl);
const articleClass = home ? "doc doc-home" : "doc";
const tocBlock = home ? "" : toc;
const titleSuffix = home ? `${productName}${productTagline}` : `${page.title}${productName}`;
const description = page.frontmatter.description || (home ? productDescription : `${page.title}${productName} CLI documentation.`);
const canonicalUrl = pageCanonicalUrl(page);
const socialImage = siteBase ? `${siteBase}/favicon.svg` : `${rootPrefix}favicon.svg`;
const socialMeta = [
["link", "rel", "canonical", "href", canonicalUrl],
["meta", "property", "og:type", "content", "website"],
["meta", "property", "og:site_name", "content", productName],
["meta", "property", "og:title", "content", titleSuffix],
["meta", "property", "og:description", "content", description],
["meta", "property", "og:url", "content", canonicalUrl],
["meta", "property", "og:image", "content", socialImage],
["meta", "name", "twitter:card", "content", "summary"],
["meta", "name", "twitter:title", "content", titleSuffix],
["meta", "name", "twitter:description", "content", description],
["meta", "name", "twitter:image", "content", socialImage],
].map(tagHtml).join("\n ");
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(titleSuffix)}</title>
<meta name="description" content="${escapeAttr(description)}">
${socialMeta}
<link rel="icon" href="${rootPrefix}favicon.svg" type="image/svg+xml">
<script>${preThemeScript()}</script>
<style>${css()}</style>
</head>
<body${home ? ' class="home"' : ""}>
<button class="nav-toggle" type="button" aria-label="Toggle navigation" aria-expanded="false">
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button>
<div class="shell">
<aside class="sidebar">
<div class="sidebar-head">
<a class="brand" href="${hrefToOutRel("index.html", page.outRel)}" aria-label="${productName} docs home">
<span class="mark" aria-hidden="true">${brandMarkSvg()}</span>
<span><strong>${escapeHtml(productName)}</strong><small>Messages.app CLI</small></span>
</a>
${themeToggleHtml()}
</div>
<label class="search"><span>Search</span><input id="doc-search" type="search" placeholder="watch, send, rpc, groups"></label>
<nav>${navHtml(page)}</nav>
</aside>
<main>
${heroBlock}
<div class="doc-grid${home ? " doc-grid-home" : ""}">
<article class="${articleClass}">${html}${prevNext}</article>
${tocBlock}
</div>
</main>
</div>
<script>${js()}</script>
</body>
</html>`;
}
function pageCanonicalUrl(page) {
if (!siteBase) return page.outRel;
if (page.outRel === "index.html") return `${siteBase}/`;
const rel = page.outRel.endsWith("/index.html") ? page.outRel.slice(0, -"index.html".length) : page.outRel;
return `${siteBase}/${rel}`;
}
function tagHtml([tag, k1, v1, k2, v2]) {
return tag === "link" ? `<link ${k1}="${v1}" ${k2}="${escapeAttr(v2)}">` : `<meta ${k1}="${v1}" ${k2}="${escapeAttr(v2)}">`;
}
function pageNavHtml(prev, next, currentOutRel) {
const cell = (page, dir) => {
if (!page) return "";
return `<a class="page-nav-${dir}" href="${hrefToOutRel(page.outRel, currentOutRel)}"><small>${dir === "prev" ? "Previous" : "Next"}</small><span>${escapeHtml(page.title)}</span></a>`;
};
return `<nav class="page-nav" aria-label="Pager">${cell(prev, "prev")}${cell(next, "next")}</nav>`;
}
function navHtml(currentPage) {
return nav
.map((section) => `<section><h2>${escapeHtml(section.name)}</h2>${section.pages.map((page) => {
const href = hrefToOutRel(page.outRel, currentPage.outRel);
const active = page.rel === currentPage.rel ? " active" : "";
return `<a class="nav-link${active}" href="${href}">${escapeHtml(navTitle(page))}</a>`;
}).join("")}</section>`)
.join("");
}
function navTitle(page) {
if (page.rel === "index.md") return "Overview";
return page.title;
}
function hrefToOutRel(targetOutRel, currentOutRel) {
const currentDir = path.posix.dirname(currentOutRel);
if (targetOutRel.endsWith("/index.html")) {
const targetDir = targetOutRel.slice(0, -"index.html".length);
const rel = path.posix.relative(currentDir, targetDir || ".") || ".";
return rel.endsWith("/") ? rel : `${rel}/`;
}
if (targetOutRel === "index.html") {
const rel = path.posix.relative(currentDir, ".") || ".";
return rel.endsWith("/") ? rel : `${rel}/`;
}
return path.posix.relative(currentDir, targetOutRel) || path.posix.basename(targetOutRel);
}
function slug(text) {
return text.toLowerCase().replace(/`/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
}
function escapeAttr(value) {
return escapeHtml(value);
}
function validateLinks(outputDir) {
const failures = [];
const placeholderHrefs = /^(url|path|file|dir|name)$/i;
for (const file of allHtml(outputDir)) {
const html = fs.readFileSync(file, "utf8");
for (const match of html.matchAll(/href="([^"]+)"/g)) {
const href = match[1];
if (/^(#|https?:|mailto:|tel:|javascript:)/.test(href)) continue;
if (placeholderHrefs.test(href)) continue;
const [rawPath, anchor = ""] = href.split("#");
const targetPath = rawPath
? path.resolve(path.dirname(file), rawPath)
: file;
const target = fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
? path.join(targetPath, "index.html")
: targetPath;
if (!fs.existsSync(target)) {
failures.push(`${path.relative(outputDir, file)}: ${href} -> missing ${path.relative(outputDir, target)}`);
continue;
}
if (anchor) {
const targetHtml = fs.readFileSync(target, "utf8");
if (!targetHtml.includes(`id="${anchor}"`) && !targetHtml.includes(`name="${anchor}"`)) {
failures.push(`${path.relative(outputDir, file)}: ${href} -> missing anchor`);
}
}
}
}
if (failures.length) {
throw new Error(`broken docs links:\n${failures.join("\n")}`);
}
}
function allHtml(dir) {
return fs
.readdirSync(dir, { withFileTypes: true })
.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return allHtml(full);
return entry.name.endsWith(".html") ? [full] : [];
})
.sort();
}

View File

@ -0,0 +1,280 @@
export function css() {
return `
:root{
--ink:#1d1d1f;
--text:#1d1d1f;
--muted:#6e6e73;
--subtle:#86868b;
--bg:#fbfbfd;
--paper:#ffffff;
--tint:#0071e3;
--tint-hover:#0077ed;
--tint-soft:rgba(0,113,227,.10);
--bubble-blue:#0a84ff;
--bubble-grey:#e9e9eb;
--line:#d2d2d7;
--line-soft:#f0f0f3;
--code-bg:#1d1d1f;
--code-fg:#f5f5f7;
--code-inline-fg:#1d1d1f;
--pill-border:#d2d2d7;
--shadow-card:0 1px 2px rgba(0,0,0,.04),0 6px 24px rgba(0,0,0,.06);
--scrollbar:#c7c7cc;
--radius-lg:18px;
--radius-md:12px;
--radius-sm:8px;
}
:root[data-theme="dark"]{
--ink:#f5f5f7;
--text:#e8e8ed;
--muted:#a1a1a6;
--subtle:#6e6e73;
--bg:#000000;
--paper:#1c1c1e;
--tint:#0a84ff;
--tint-hover:#409cff;
--tint-soft:rgba(10,132,255,.16);
--bubble-blue:#0a84ff;
--bubble-grey:#2c2c2e;
--line:#2c2c2e;
--line-soft:#1c1c1e;
--code-bg:#0a0a0a;
--code-fg:#f5f5f7;
--code-inline-fg:#f5f5f7;
--pill-border:#2c2c2e;
--shadow-card:0 1px 2px rgba(0,0,0,.4),0 8px 28px rgba(0,0,0,.5);
--scrollbar:#3a3a3c;
}
:root{color-scheme:light}
:root[data-theme="dark"]{color-scheme:dark}
*{box-sizing:border-box}
html{scroll-behavior:smooth;scroll-padding-top:24px;-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","SF Pro Display","Inter",ui-sans-serif,system-ui,Segoe UI,sans-serif;line-height:1.6;overflow-x:hidden;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-feature-settings:"ss01","ss02","cv11";letter-spacing:-0.003em;transition:background-color .25s ease,color .25s ease}
::selection{background:var(--tint);color:#fff}
a{color:var(--tint);text-decoration:none;transition:color .15s ease}
a:hover{color:var(--tint-hover)}
.shell{display:grid;grid-template-columns:280px minmax(0,1fr);min-height:100vh}
.sidebar{position:sticky;top:0;height:100vh;overflow:auto;padding:28px 22px 32px;background:var(--paper);border-right:1px solid var(--line);scrollbar-width:thin;scrollbar-color:var(--line) transparent;transition:background-color .25s ease,border-color .25s ease;backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px)}
.sidebar::-webkit-scrollbar{width:6px}
.sidebar::-webkit-scrollbar-thumb{background:var(--line);border-radius:6px}
.sidebar-head{display:flex;align-items:center;gap:10px;margin-bottom:24px}
.brand{display:flex;align-items:center;gap:12px;color:var(--ink);text-decoration:none;flex:1;min-width:0}
.brand:hover{color:var(--ink)}
.brand .mark{display:flex;align-items:center;justify-content:center;flex:0 0 32px;height:32px;width:32px;border-radius:9px;background:linear-gradient(135deg,#34c759 0%,#0a84ff 60%,#5e5ce6 100%);box-shadow:0 1px 1px rgba(0,0,0,.05),0 4px 10px rgba(10,132,255,.25)}
.brand .mark svg{width:18px;height:18px;color:#fff}
.brand strong{display:block;font-size:1.05rem;line-height:1.1;font-weight:600;letter-spacing:-0.01em;color:var(--ink)}
.brand small{display:block;color:var(--muted);font-size:.74rem;margin-top:3px;font-weight:400;letter-spacing:0}
.theme-toggle{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:34px;height:34px;border-radius:50%;border:1px solid var(--line);background:var(--paper);color:var(--muted);cursor:pointer;padding:0;transition:border-color .15s ease,color .15s ease,background-color .18s ease,transform .12s ease}
.theme-toggle:hover{border-color:var(--ink);color:var(--ink)}
.theme-toggle:active{transform:scale(.92)}
.theme-toggle svg{width:16px;height:16px;display:block}
.theme-icon-sun{display:none}
:root[data-theme="dark"] .theme-icon-sun{display:block}
:root[data-theme="dark"] .theme-icon-moon{display:none}
.search{display:block;margin:0 0 22px}
.search span{display:block;color:var(--muted);font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin-bottom:7px}
.search input{width:100%;border:1px solid var(--line);background:var(--paper);border-radius:10px;padding:9px 14px;font:inherit;font-size:.92rem;color:var(--text);outline:none;transition:border-color .15s ease,box-shadow .15s ease,background-color .18s ease}
.search input::placeholder{color:var(--subtle)}
.search input:focus{border-color:var(--tint);box-shadow:0 0 0 4px var(--tint-soft)}
nav section{margin:0 0 18px}
nav h2{font-size:.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:0 0 6px;font-weight:600}
.nav-link{display:block;color:var(--text);text-decoration:none;border-radius:7px;padding:6px 11px;margin:1px 0;font-size:.93rem;line-height:1.4;transition:background .15s ease,color .15s ease;letter-spacing:-0.005em}
.nav-link:hover{background:var(--line-soft);color:var(--ink)}
.nav-link.active{background:var(--tint-soft);color:var(--tint);font-weight:600}
main{min-width:0;padding:32px clamp(20px,4.5vw,64px) 96px;max-width:1200px;margin:0 auto;width:100%}
.hero{display:flex;align-items:flex-end;justify-content:space-between;gap:22px;border-bottom:1px solid var(--line);padding:8px 0 22px;margin-bottom:8px;flex-wrap:wrap}
.hero-text{min-width:0;flex:1 1 320px}
.eyebrow{margin:0 0 8px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.06em;font-size:.7rem}
.hero h1{font-size:2.4rem;line-height:1.08;letter-spacing:-0.022em;margin:0;font-weight:700;color:var(--ink)}
.hero-meta{display:flex;gap:8px;flex:0 0 auto;flex-wrap:wrap}
.repo,.edit,.btn-ghost{border:1px solid var(--line);color:var(--text);text-decoration:none;border-radius:980px;padding:6px 14px;font-weight:500;font-size:.83rem;background:var(--paper);transition:border-color .15s ease,color .15s ease,background .15s ease}
.repo:hover,.edit:hover,.btn-ghost:hover{border-color:var(--ink);color:var(--ink)}
.edit{color:var(--muted)}
.home-hero{padding:24px 0 36px;margin-bottom:8px;border-bottom:1px solid var(--line)}
.home-hero h1{font-size:clamp(2.6rem,5vw,3.75rem);line-height:1.04;letter-spacing:-0.028em;margin:0 0 .35em;font-weight:700;color:var(--ink);background:linear-gradient(180deg,var(--ink) 0%,var(--ink) 70%,var(--muted) 130%);-webkit-background-clip:text;background-clip:text}
.home-hero .lede{font-size:1.18rem;line-height:1.55;color:var(--muted);margin:0 0 1.6em;max-width:60ch;letter-spacing:-0.005em}
.home-cta{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin:0 0 22px}
.home-cta .btn{display:inline-flex;align-items:center;gap:7px;border-radius:980px;padding:10px 22px;font-weight:500;font-size:.95rem;text-decoration:none;transition:background .15s ease,border-color .15s ease,color .15s ease,transform .12s ease}
.home-cta .btn-primary{background:var(--tint);color:#fff;border:1px solid var(--tint)}
.home-cta .btn-primary:hover{background:var(--tint-hover);border-color:var(--tint-hover);color:#fff}
.home-cta .btn-ghost{padding:10px 22px}
.home-install{display:flex;align-items:center;gap:12px;background:var(--code-bg);color:var(--code-fg);border-radius:14px;padding:12px 12px 12px 18px;font:500 .9rem/1.2 ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;max-width:32em;border:1px solid #2c2c2e;letter-spacing:0}
.home-install .prompt{color:#86868b;user-select:none;flex:0 0 auto}
.home-install code{flex:1;background:transparent;border:0;color:var(--code-fg);font:inherit;padding:0;white-space:pre;overflow:hidden;text-overflow:ellipsis}
.home-install .copy{flex:0 0 auto;background:rgba(255,255,255,.10);color:var(--code-fg);border:1px solid rgba(255,255,255,.18);border-radius:980px;padding:5px 13px;font:500 .72rem/1 -apple-system,"SF Pro Text",sans-serif;cursor:pointer;transition:background .15s ease,border-color .15s ease;letter-spacing:.01em}
.home-install .copy:hover{background:rgba(255,255,255,.18)}
.home-install .copy.copied{background:var(--tint);border-color:var(--tint)}
.home-services{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0 22px}
.home-services span{display:inline-block;padding:4px 12px;border:1px solid var(--line);border-radius:980px;font-size:.78rem;color:var(--muted);background:var(--paper);font-weight:500;letter-spacing:0}
.muted{color:var(--muted);font-size:.92rem}
.muted a{color:var(--tint)}
.doc-grid{display:grid;grid-template-columns:minmax(0,1fr);gap:48px;margin-top:24px}
.doc-grid-home{margin-top:8px}
@media(min-width:1180px){.doc-grid{grid-template-columns:minmax(0,72ch) 220px;justify-content:start}.doc-grid-home{grid-template-columns:minmax(0,76ch);justify-content:start}}
.doc{min-width:0;max-width:72ch;overflow-wrap:break-word}
.doc-home{max-width:76ch}
.doc h1{font-size:2.6rem;line-height:1.05;letter-spacing:-0.024em;margin:0 0 .4em;font-weight:700;color:var(--ink)}
body:not(.home) .doc>h1:first-child{display:none}
.doc h2{font-size:1.55rem;line-height:1.18;margin:2.1em 0 .55em;font-weight:600;letter-spacing:-0.018em;color:var(--ink);position:relative}
.doc h3{font-size:1.18rem;margin:1.7em 0 .4em;position:relative;font-weight:600;color:var(--ink);letter-spacing:-0.012em}
.doc h4{font-size:1rem;margin:1.4em 0 .25em;color:var(--ink);position:relative;font-weight:600;letter-spacing:-0.008em}
.doc h2:first-child,.doc h3:first-child,.doc h4:first-child{margin-top:.2em}
.doc :is(h2,h3,h4) .anchor{position:absolute;left:-1.05em;top:0;color:var(--subtle);opacity:0;text-decoration:none;font-weight:400;padding-right:.3em;transition:opacity .12s ease,color .12s ease}
.doc :is(h2,h3,h4):hover .anchor{opacity:.7}
.doc :is(h2,h3,h4) .anchor:hover{opacity:1;color:var(--tint);text-decoration:none}
.doc p{margin:0 0 1.05em;letter-spacing:-0.003em}
.doc ul,.doc ol{padding-left:1.4rem;margin:0 0 1.15em}
.doc li{margin:.3em 0}
.doc li>p{margin:0 0 .4em}
.doc strong{font-weight:600;color:var(--ink)}
.doc em{font-style:italic}
.doc code{font-family:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;font-size:.86em;background:var(--line-soft);border:1px solid var(--line);border-radius:6px;padding:.1em .4em;color:var(--code-inline-fg);letter-spacing:0}
.doc pre{position:relative;overflow:auto;background:var(--code-bg);color:var(--code-fg);border-radius:12px;padding:16px 20px;margin:1.4em 0;font-size:.86em;line-height:1.62;scrollbar-width:thin;scrollbar-color:#3a3a3c transparent;border:1px solid #2c2c2e;letter-spacing:0}
.doc pre::-webkit-scrollbar{height:8px;width:8px}
.doc pre::-webkit-scrollbar-thumb{background:#3a3a3c;border-radius:8px}
.doc pre code{display:block;background:transparent;border:0;color:inherit;padding:0;font-size:1em;white-space:pre}
.doc pre .copy{position:absolute;top:10px;right:10px;background:rgba(255,255,255,.08);color:var(--code-fg);border:1px solid rgba(255,255,255,.18);border-radius:980px;padding:4px 12px;font:500 .7rem/1 -apple-system,"SF Pro Text",sans-serif;cursor:pointer;opacity:0;transition:opacity .15s ease,background .15s ease,border-color .15s ease;letter-spacing:.01em}
.doc pre:hover .copy,.doc pre .copy:focus{opacity:1}
.doc pre .copy:hover{background:rgba(255,255,255,.16)}
.doc pre .copy.copied{background:var(--tint);border-color:var(--tint);opacity:1}
.doc blockquote{margin:1.4em 0;padding:14px 18px;border-left:3px solid var(--tint);background:var(--tint-soft);border-radius:0 12px 12px 0;color:var(--text)}
.doc blockquote p:last-child{margin-bottom:0}
.doc table{width:100%;border-collapse:collapse;margin:1.3em 0;font-size:.93em}
.doc th,.doc td{border-bottom:1px solid var(--line);padding:10px 12px;text-align:left;vertical-align:top;letter-spacing:-0.003em}
.doc th{font-weight:600;color:var(--ink);background:var(--line-soft);border-bottom:1px solid var(--line)}
.doc hr{border:0;border-top:1px solid var(--line);margin:2.4em 0}
.toc{position:sticky;top:24px;align-self:start;font-size:.86rem;padding-left:14px;border-left:1px solid var(--line);max-height:calc(100vh - 48px);overflow:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent}
.toc::-webkit-scrollbar{width:5px}
.toc::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px}
.toc h2{font-size:.66rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:0 0 10px;font-weight:600}
.toc a{display:block;color:var(--muted);text-decoration:none;padding:4px 0 4px 10px;line-height:1.35;border-left:2px solid transparent;margin-left:-12px;transition:color .12s ease,border-color .12s ease;letter-spacing:-0.003em}
.toc a:hover{color:var(--ink)}
.toc a.active{color:var(--tint);border-left-color:var(--tint);font-weight:500}
.toc-l3{padding-left:22px!important;font-size:.94em}
@media(max-width:1179px){.toc{display:none}}
.page-nav{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:56px;border-top:1px solid var(--line);padding-top:24px}
.page-nav>a{display:block;border:1px solid var(--line);background:var(--paper);border-radius:14px;padding:14px 18px;text-decoration:none;color:var(--text);transition:border-color .15s ease,transform .15s ease,box-shadow .15s ease,background-color .18s ease}
.page-nav>a:hover{border-color:var(--tint);box-shadow:var(--shadow-card);color:var(--ink)}
.page-nav small{display:block;color:var(--muted);font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px;font-weight:600}
.page-nav span{display:block;font-weight:600;line-height:1.3;color:var(--ink);letter-spacing:-0.008em}
.page-nav-prev{text-align:left}
.page-nav-next{text-align:right;grid-column:2}
.page-nav-prev:only-child{grid-column:1}
.nav-toggle{display:none;position:fixed;top:14px;right:14px;top:calc(14px + env(safe-area-inset-top, 0px));right:calc(14px + env(safe-area-inset-right, 0px));z-index:20;width:42px;height:42px;border-radius:50%;background:var(--paper);border:1px solid var(--line);color:var(--ink);cursor:pointer;padding:11px 10px;flex-direction:column;align-items:stretch;justify-content:space-between;box-shadow:var(--shadow-card)}
.nav-toggle span{display:block;width:100%;height:2px;flex:0 0 2px;background:currentColor;border-radius:2px;transition:transform .2s ease,opacity .2s ease}
.nav-toggle[aria-expanded="true"] span:nth-child(1){transform:translateY(8px) rotate(45deg)}
.nav-toggle[aria-expanded="true"] span:nth-child(2){opacity:0}
.nav-toggle[aria-expanded="true"] span:nth-child(3){transform:translateY(-8px) rotate(-45deg)}
@media(max-width:900px){
.shell{display:block}
.sidebar{position:fixed;inset:0 30% 0 0;max-width:320px;height:100vh;z-index:15;transform:translateX(-100%);transition:transform .25s ease,background-color .25s ease,border-color .25s ease;box-shadow:0 18px 40px rgba(0,0,0,.18);background:var(--paper);pointer-events:none}
.sidebar.open{transform:translateX(0);pointer-events:auto}
.nav-toggle{display:flex}
main{padding:64px 18px 56px}
.hero{padding-top:6px}
.hero h1{font-size:1.85rem}
.home-hero h1{font-size:2.55rem}
.doc h1{font-size:2.15rem}
.hero-meta{width:100%;justify-content:flex-start}
.home-hero{padding-top:8px}
.doc{padding:0}
.doc-grid{margin-top:18px;gap:24px}
.doc :is(h2,h3,h4) .anchor{display:none}
}
@media(max-width:520px){
main{padding:60px 14px 48px}
.doc pre{margin-left:-14px;margin-right:-14px;border-radius:0;border-left:0;border-right:0}
.home-install{flex-wrap:wrap}
}
`;
}
export function js() {
return `
const themeRoot=document.documentElement;
function applyTheme(mode){themeRoot.dataset.theme=mode;document.querySelectorAll('[data-theme-toggle]').forEach(b=>b.setAttribute('aria-pressed',mode==='dark'?'true':'false'))}
function storedTheme(){try{return localStorage.getItem('theme')}catch(e){return null}}
function persistTheme(mode){try{localStorage.setItem('theme',mode)}catch(e){}}
applyTheme(themeRoot.dataset.theme==='dark'?'dark':'light');
document.querySelectorAll('[data-theme-toggle]').forEach(btn=>{btn.addEventListener('click',()=>{const next=themeRoot.dataset.theme==='dark'?'light':'dark';applyTheme(next);persistTheme(next)})});
const systemDark=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)');
function onSystemChange(e){if(storedTheme())return;applyTheme(e.matches?'dark':'light')}
if(systemDark){if(systemDark.addEventListener)systemDark.addEventListener('change',onSystemChange);else if(systemDark.addListener)systemDark.addListener(onSystemChange)}
const sidebar=document.querySelector('.sidebar');
const toggle=document.querySelector('.nav-toggle');
const mobileNav=window.matchMedia('(max-width: 900px)');
const sidebarFocusable='a[href],button,input,select,textarea,[tabindex]';
function setSidebarFocusable(enabled){
sidebar?.querySelectorAll(sidebarFocusable).forEach((el)=>{
if(enabled){
if(el.dataset.sidebarTabindex!==undefined){
if(el.dataset.sidebarTabindex)el.setAttribute('tabindex',el.dataset.sidebarTabindex);
else el.removeAttribute('tabindex');
delete el.dataset.sidebarTabindex;
}
}else if(el.dataset.sidebarTabindex===undefined){
el.dataset.sidebarTabindex=el.getAttribute('tabindex')??'';
el.setAttribute('tabindex','-1');
}
});
}
function setSidebarOpen(open){
if(!sidebar||!toggle)return;
sidebar.classList.toggle('open',open);
toggle.setAttribute('aria-expanded',open?'true':'false');
if(mobileNav.matches){
sidebar.inert=!open;
if(open)sidebar.removeAttribute('aria-hidden');
else sidebar.setAttribute('aria-hidden','true');
setSidebarFocusable(open);
}else{
sidebar.inert=false;
sidebar.removeAttribute('aria-hidden');
setSidebarFocusable(true);
}
}
setSidebarOpen(false);
toggle?.addEventListener('click',()=>setSidebarOpen(!sidebar?.classList.contains('open')));
document.addEventListener('click',(e)=>{if(!sidebar?.classList.contains('open'))return;if(sidebar.contains(e.target)||toggle?.contains(e.target))return;setSidebarOpen(false)});
document.addEventListener('keydown',(e)=>{if(e.key==='Escape')setSidebarOpen(false)});
const syncSidebarForViewport=()=>setSidebarOpen(sidebar?.classList.contains('open')??false);
if(mobileNav.addEventListener)mobileNav.addEventListener('change',syncSidebarForViewport);
else mobileNav.addListener?.(syncSidebarForViewport);
const input=document.getElementById('doc-search');
input?.addEventListener('input',()=>{const q=input.value.trim().toLowerCase();document.querySelectorAll('nav section').forEach(sec=>{let any=false;sec.querySelectorAll('.nav-link').forEach(a=>{const m=!q||a.textContent.toLowerCase().includes(q);a.style.display=m?'block':'none';if(m)any=true});sec.style.display=any?'block':'none'})});
function attachCopy(target,getText){const btn=document.createElement('button');btn.type='button';btn.className='copy';btn.textContent='Copy';btn.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(getText());btn.textContent='Copied';btn.classList.add('copied');setTimeout(()=>{btn.textContent='Copy';btn.classList.remove('copied')},1400)}catch{btn.textContent='Failed';setTimeout(()=>{btn.textContent='Copy'},1400)}});target.appendChild(btn)}
document.querySelectorAll('.doc pre').forEach(pre=>attachCopy(pre,()=>pre.querySelector('code')?.textContent??''));
document.querySelectorAll('.home-install').forEach(el=>attachCopy(el,()=>el.querySelector('code')?.textContent??''));
const tocLinks=document.querySelectorAll('.toc a');
if(tocLinks.length){const map=new Map();tocLinks.forEach(a=>{const id=a.getAttribute('href').slice(1);const el=document.getElementById(id);if(el)map.set(el,a)});const setActive=l=>{tocLinks.forEach(x=>x.classList.remove('active'));l.classList.add('active')};const obs=new IntersectionObserver(entries=>{const visible=entries.filter(e=>e.isIntersecting).sort((a,b)=>a.boundingClientRect.top-b.boundingClientRect.top);if(visible.length){const link=map.get(visible[0].target);if(link)setActive(link)}},{rootMargin:'-15% 0px -65% 0px',threshold:0});map.forEach((_,el)=>obs.observe(el))}
`;
}
export function preThemeScript() {
return `(function(){var s;try{s=localStorage.getItem('theme')}catch(e){}var d=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.dataset.theme=s||(d?'dark':'light')})();`;
}
export function themeToggleHtml() {
return `<button class="theme-toggle" type="button" aria-label="Toggle dark mode" aria-pressed="false" data-theme-toggle>
<svg class="theme-icon-moon" viewBox="0 0 20 20" aria-hidden="true"><path d="M14.6 12.1A6.5 6.5 0 0 1 7.4 2.7a6.5 6.5 0 1 0 7.2 9.4z" fill="currentColor"/></svg>
<svg class="theme-icon-sun" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="3.4" fill="currentColor"/><g stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="10" y1="2" x2="10" y2="4"/><line x1="10" y1="16" x2="10" y2="18"/><line x1="2" y1="10" x2="4" y2="10"/><line x1="16" y1="10" x2="18" y2="10"/><line x1="4.2" y1="4.2" x2="5.6" y2="5.6"/><line x1="14.4" y1="14.4" x2="15.8" y2="15.8"/><line x1="4.2" y1="15.8" x2="5.6" y2="14.4"/><line x1="14.4" y1="5.6" x2="15.8" y2="4.2"/></g></svg>
</button>`;
}
export function brandMarkSvg() {
return `<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path fill="currentColor" d="M12 3.2C6.9 3.2 2.8 6.5 2.8 10.6c0 2.4 1.4 4.5 3.6 5.9-.1 1-.4 2.2-1.1 3 1.7-.2 3.1-1 4-1.8 1 .3 1.8.4 2.7.4 5.1 0 9.2-3.3 9.2-7.5S17.1 3.2 12 3.2z"/></svg>`;
}
export function faviconSvg() {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="imsg">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#34c759"/>
<stop offset="60%" stop-color="#0a84ff"/>
<stop offset="100%" stop-color="#5e5ce6"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#g)"/>
<path fill="#ffffff" d="M32 14.4c-9.9 0-17.9 6.4-17.9 14.3 0 4.7 2.8 8.8 7.1 11.5-.3 1.9-.9 4.1-2.1 5.8 3.4-.4 6.1-1.9 7.8-3.5 1.6.4 3.3.7 5.1.7 9.9 0 17.9-6.4 17.9-14.5S41.9 14.4 32 14.4z"/>
</svg>`;
}