# raycast-api [![AI Slop Inside](https://sladge.net/badge.svg)](https://sladge.net) Python client for `backend.raycast.com`. Bring-your-own-credentials: the signing secret and algorithm are extracted from a local Raycast install at setup time - nothing sensitive ships with the package. Not affiliated with Raycast. You need your own account, subscription, and a copy of the desktop app on macOS to run discovery once. ## Install As a CLI tool (pulls in the discovery extra): ```bash uv tool install "raycast-api[discovery] @ git+https://git.kotikot.com/beaver/raycast-api" ``` As a library inside another project (runtime only, no discovery deps): ```bash uv add "raycast-api @ git+https://git.kotikot.com/beaver/raycast-api" ``` The runtime needs only `aiohttp`. The `discovery` extra adds `esprima` (a JS AST parser) and is required only for `init` / `refresh`. ## Credentials ```bash raycast-api init # extract signing spec + secret from the local app → ./config.json export RAYCAST_BEARER='rca_...' # your OAuth access token, sniffed from the desktop client raycast-api ask --stream 'hello, raycast' ``` `init` writes `config.json` (chmod 600) containing the signing spec, the launcher-derived secret, and bundle/launcher hashes used as a cache key. Re-run `raycast-api refresh` after a Raycast update - the cache invalidates automatically when either hash changes. ## Use ```python import asyncio from raycast_api import Client, Config, Message async def main() -> None: async with Client( config=Config.load("config.json"), bearer_token="rca_...", device_id="<64 hex>", # any stable per-install id ) as client: result = await client.chat.complete( model="Claude Sonnet 4.6", messages=[Message.user("hello, raycast")], ) print(result.text) asyncio.run(main()) ``` Streaming: ```python async for chunk in client.chat.stream(model="...", messages=[...]): if chunk.text: print(chunk.text, end="", flush=True) ``` Endpoints: `client.chat`, `client.models`, `client.me`, `client.files`. Interrupted streams resume via `client.chat.resume(buffer_id=..., last_event_id=...)`. ## CLI ``` raycast-api init [--app-path PATH] [--output config.json] [--force] [--no-cache] raycast-api refresh [--app-path PATH] [--config config.json] raycast-api inspect [--config config.json] [--verify | --app-path PATH] [--quiet] raycast-api ask PROMPT [--bearer ...] [--device-id ...] [--model ...] [--stream] ``` - `init` - run discovery, write `config.json`. Cached by `(bundle_hash, launcher_hash)`. - `refresh` - bypass the cache and re-derive. Use after a launcher rebuild that didn't touch the JS bundle. - `inspect` - print the saved config with the secret redacted. `--verify` rechecks hashes against a live Raycast install; `--quiet` collapses to an exit code (`0` current, `1` stale, `2` unverifiable) for scripting. - `ask` - one-shot smoke test against `chat.complete`. ## How the signing scheme is recovered Raycast signs every request with a rotated-alphabet HMAC over a canonical string. `init` recovers this end-to-end from your local install, without running any of Raycast's code: 1. **Launcher.** Read the Mach-O binary, find the 64-hex `signature_secret` by anchored byte-pattern. 2. **Bundle.** Locate `index.mjs` inside the app, hash it for the cache key. 3. **AST.** Parse the bundle with `esprima`. Walk for the signing function by structural shape - it takes `(method, path, body, ts, key)`, calls `crypto.createHmac`, and joins its inputs with a single character. From its caller recover the rot transform: alphabet ranges and shift counts are literals in the source. 4. **Spec.** Emit a portable `signing_spec` (ranges, join char, HMAC and body-hash algorithms, key/output encodings). The runtime signer reads only that - no version-specific code anywhere in the package. As long as Raycast keeps a rot-transform over an HMAC, a future build only needs `raycast-api refresh`, not a release.