Files

110 lines
4.0 KiB
Markdown

# 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.