# Raycast Extension Dev

> Build, verify, and ship Raycast extensions in TypeScript with @raycast/api. Use when the user says "Raycast extension", "ray build", "ray develop", "ray lint", "menu bar command", "MenuBarExtra", "useCachedPromise", "raycast-env.d.ts", "no-view command", "Raycast on Windows", "platforms field", "runPowerShellScript", "AI extension", "Raycast AI tool", "ai.yaml", "ai evals", "suggested prompts", or wants to publish to the Raycast Store. Covers the launchd PATH trap, the typecheck ray build skips, optimistic-update reconcile timing, cross-platform macOS/Windows seams, and the store-readiness checks ray lint never runs.

- Skill: `daviddwlee84/raycast-extension-dev` (Agent Skill, multi-file: 25 files)
- Install (CLI): `npx skillmds@latest add daviddwlee84/raycast-extension-dev`
- Raw SKILL.md: https://api.skillmd.com/api/skills/daviddwlee84/raycast-extension-dev/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: daviddwlee84 (https://skillmd.com/u/daviddwlee84)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/daviddwlee84/raycast-extension-dev

---


# Raycast extension development

A Raycast extension is a manifest (`package.json`) plus React components, bundled
by esbuild and run inside Raycast's own Node 22 process — which launchd starts,
so it has none of your shell environment. The API surface is well documented at
<https://developers.raycast.com>. Read that for what `List` and `Form` accept.

This skill is the other 20%: what the toolchain does **not** check, what the
runtime does **not** provide, and what the store checks that the linter does not.
Every claim here was paid for once in a shipped extension; the `## Gotchas`
section is the compressed bill.

**macOS is the default, not the premise.** Raycast ships on Windows too, and
`platforms` in the manifest decides who can see your extension. The UI layer is
100% shared and domain logic nearly so; only an OS adapter at the bottom differs.
Declare both platforms and subtract, rather than defaulting to macOS by silence —
`references/cross-platform.md`. The launchd, Homebrew-prefix, and `sips` material
below is genuinely macOS-scoped and labelled as such.

## When to use

- "Build me a Raycast extension for X" → Workflow A, then B if X is a CLI.
- "Wrap this CLI / API in Raycast" → Workflow B. The seam design is the decision
  that matters; everything else is recoverable.
- "It works in `npm run dev` but not from Raycast" → the launchd PATH gotcha.
  This is the most common report and the dev terminal cannot reproduce it.
- "The menu bar shows the wrong count / nothing / stale data" → Workflow E.
- "My action flashes the new state, reverts, then re-applies" → Workflow C.
- "Get this ready for the Raycast Store" → Workflow F and
  `scripts/check-store-readiness.sh`.
- "Make this work on Windows" / "why isn't my extension on Windows" → the
  `platforms` field, then `references/cross-platform.md`.
- "Add AI to this" / "the @extension prompt list is empty" → Workflow G.
- Reviewing an existing extension: run the gate (Workflow A) first. It usually
  finds something, because `ray build` alone never would have.

## When NOT to use

- **Raycast Script Commands** — shell scripts in a different repo, no manifest,
  no React, no `@raycast/api`. Entirely separate surface.
- **Generic React/TSX quality** (hook rules, component structure, a11y) → the
  `react-best-practices` skill is the better authority. This skill deliberately
  restates none of it.
- **The semantics of the CLI being wrapped** — `pueue`'s flags, `gh`'s JSON —
  belong to that tool's own skill or its `--help`.
- **Taking the store screenshots.** This skill can verify their count and
  dimensions. It cannot capture a window, and no CLI can — see Workflow F.

## The gate

`ray build` — the default `-e dev` build, which is what `npm run build`,
`just build`, and `ray develop` all run — proves only that a bundle exists.

| Command | Catches | Misses |
| --- | --- | --- |
| `tsc --noEmit` | types | manifest, formatting, runtime shape |
| `node dev-check.js` | your own invariants, wire shapes, generated argv | anything you did not assert |
| `ray lint` | manifest schema, icons, ESLint, Prettier, **reserved-shortcut collisions**, and — **only under `CI=true`** — `package-lock.json` registry hosts | types |
| `ray build` (`-e dev`) | syntax, that esbuild can bundle it | **types — esbuild strips them without checking** |
| `ray build -e dist` | the above **plus types** — it shells out to `tsc -p tsconfig.json --noEmit` | manifest, formatting |

Measured on a scaffold with one genuine `TS2345`: `ray build` exits **0** and
prints `ready - built extension successfully`; `ray build -e dist` exits **1**
and reports the error. So the dist build is a stronger pre-submit check than it
looks — but it is not the build you run all day, which is exactly why the type
error survives long enough to ship.

All four, in that order, every time:

```make
typecheck: npx tsc --noEmit -p tsconfig.json
verify:    npx tsc --outDir .build/verify --module commonjs --target ES2022 \
             --lib ES2023 --esModuleInterop --strict src/lib/dev-check.ts \
             && node .build/verify/dev-check.js
check: typecheck verify lint build
```

`assets/Justfile.template` is this, complete, plus a `dist` recipe. Two real bugs
shipped past `ray build` in the source extension: a `MutatePromise<GroupMap>`
passed where `MutatePromise<State>` was expected (it would have emptied the list
on every action), and a `ReactNode`/`JSX.Element` clash. Neither printed a line.

## Surface map

The complete list of extension entry points. There is nothing else.

| Entry point | Manifest | Platform | Notes |
| --- | --- | --- | --- |
| View command | `"mode": "view"` | both | pushes a view onto the navigation stack |
| No-view command | `"mode": "no-view"` | both | runs and exits; **exports `async function Command(props)`, not a component** |
| Menu bar command | `"mode": "menu-bar"` | **macOS** | returns a `MenuBarExtra`. Pins the *whole extension* to macOS |
| AI tools | `"tools": [...]` | macOS; Windows **undocumented but shipped** | invoked by Raycast AI. Workflow G |
| Script commands | separate repo | — | shell scripts, not extensions |

**There is no widget API** — no Notification Center, Control Center, or
Live-Activity surface. A `menu-bar` command is the only way to show something
without opening Raycast, and that constrains the whole design.

## Repo layout

**The extension IS the repo root.** Not a subdirectory: `ray build`, `develop`,
and `lint` all resolve `./package.json` and write `./raycast-env.d.ts` relative
to cwd, and `ray publish` copies the extension directory *verbatim* into
`extensions/<name>/` of the review PR.

```text
package.json          the manifest — commands, preferences, arguments, tools
ai.yaml               AI instructions + evals, when the extension has tools
raycast-env.d.ts      GENERATED by ray build. Commit it.
assets/               extension-icon.png (512x512) + monochrome menu-bar SVG
metadata/             3-6 store screenshots at 2000x1250
src/<command>.tsx     one file per commands[].name — the names must match
src/tools/<tool>.ts   one file per tools[].name — the names must match
src/lib/              shared code
src/lib/<backend>/    the transport seam (Workflow B)
src/lib/platform/     the OS adapter, when platforms lists both
```

Consequence of the verbatim copy: anything else in the root — notes, task lists,
a docs site, a `.venv` — rides along into the review PR. Use an allowlist export
to a scratch directory if that bothers you; a `.gitignore` will not help, because
`publish` copies the directory, not the git index.

## Workflow A — scaffold or retrofit

```bash
bash scripts/new-raycast-extension.sh --dir ./my-ext --name my-ext \
  --author <your-raycast-username> --platforms macOS,Windows \
  --command tasks:view --tool list-tasks
cd my-ext && npm install
npm run build          # this is what GENERATES raycast-env.d.ts
just check             # the four-stage gate
```

`--platforms` defaults to `macOS`. Pass `macOS,Windows` unless something in the
design is platform-specific — the script refuses `Windows` alongside a
`menu-bar` command, because `platforms` has no per-command form.

For an existing extension, skip the script and copy `assets/Justfile.template`,
`assets/tsconfig.json.template`, and `assets/dev-check.ts.template` in. The one
non-obvious line is `"include": ["src/**/*", "raycast-env.d.ts"]` — omit it and
the generated `Preferences` globals are invisible to `tsc`.

Then **open the command from Raycast root search**, not from the `ray develop`
console. The console inherits your interactive environment; Raycast does not.
Anything environment-dependent that you only tested in the terminal is untested.

## Workflow B — wrap a CLI behind a transport seam

Model every mutation as a **data union**, never as argv strings:

```ts
export type Mutation =
  | { op: "add"; command: string; group?: string; label?: string }
  | { op: "kill"; ids?: number[]; group?: string };

export interface Transport {
  read(o?: ReadOptions): Promise<State>;
  mutate(m: Mutation, o?: Options): Promise<number | void>;
}
```

If `mutate` took a `string[]` of argv, a future socket or HTTP transport would
have to parse argv back into intent — that is not a seam, it is a shell.
`assets/transport.ts.template` is the skeleton, with the argv builder as a pure
function so `dev-check` can assert it without spawning anything.

Rules that are not negotiable:

- **Resolve an absolute binary path.** Preference first, `existsSync`-validated so
  a stale value falls through, then probe **both** `/opt/homebrew/bin` and
  `/usr/local/bin`, then `~/.cargo/bin`, `~/.local/bin`, `/usr/bin`, `/bin`.
- **`execFile` with an argv array. Never `shell: true`.** Nothing re-parses your
  quoting, and a user-supplied command stays one argv element.
- **Raise `maxBuffer`.** The 1 MB default turns a large JSON read into a parse
  error rather than a clear failure. 64 MB is a reasonable ceiling.
- **Set a timeout, and know that `execFile` reports its own timeout as a SIGTERM
  kill**, not a distinct code.
- **Classify stderr into a discriminated `kind`** — `binary-not-found`,
  `daemon-not-running`, `bad-arguments`, `timeout`, `command-failed` — and keep
  the full text on the error for a `Detail` fence while the `message` stays one
  line for a toast title.
- **Strip secret-bearing fields at the parse boundary** (Workflow C explains why).
- **Global flags precede the subcommand** for clap-based CLIs.

Read `references/runtime-and-subprocess.md` **when** the extension shells out,
resolves a path, streams output, or reports `spawn … ENOENT`.

## Workflow C — data, cache, and mutations

```ts
const state = useCachedPromise(
  (group: string, account: string) => read({ group, account }),
  [group, account],                       // <-- arguments, not closure captures
  { keepPreviousData: true, abortable: stateAbort },
);
```

- **Everything the fetcher depends on must be an argument.** The argument array
  is what keys the cache and triggers refetch. A closed-over variable changes
  silently and keeps serving the previous account's data.
- **One `abortable` ref per independent read**, or a superseded read cancels an
  unrelated in-flight one.
- **`execute: false` for conditional fetches**, and `keepPreviousData: false`
  when the value is per-selection.
- **`useCachedPromise` writes to a disk-backed `Cache`.** Every field of the
  resolved value lands on disk in plaintext. Strip environment snapshots, tokens,
  and auth headers in the parse function, not at render time.

For mutations, the default `mutate()` revalidates on success — which is wrong for
any backend that acknowledges before it applies:

```ts
await state.mutate(run(mutation), {
  optimisticUpdate: (data) => predict(data, mutation),
  rollbackOnError: true,
  shouldRevalidateAfter: false,          // the load-bearing line
});
setTimeout(() => state.revalidate(), FAST_MS);
setTimeout(() => state.revalidate(), SETTLE_MS);
```

Measured on one such daemon: ack in ~22 ms, applied ~280 ms later, so the
built-in revalidate returned pre-change state and visibly undid the action.
`400` and `1500` worked there. **Measure your own backend** — those two numbers
are a worked example, not a constant.

And an optimistic updater must **return its input unchanged when the outcome is
unknowable**. A new server-assigned id, a scheduler decision, a reordering: a
wrong prediction flickers exactly like no prediction, and is also wrong.

Read `references/data-and-state.md` **when** wiring `useCachedPromise`/`mutate`,
when an action reverts and re-applies, or when deciding what to persist and under
which key.

## Workflow D — failure states as data

One descriptor, N renderers. The alternative — an `errorMarkdown()` helper per
surface — drifts, and the menu bar cannot render a `List.EmptyView` anyway.

```ts
export interface ErrorDescriptor {
  icon: Image.ImageLike;
  title: string;
  shortTitle: string;    // a few words — the list column is a third of the window
  description: string;   // one line, for List.EmptyView
  markdown: string;      // a full page, for Detail
  actions: ErrorAction[];// DATA: { id, title, icon, copy? | url? | run? }
  structural: boolean;   // is the backend gone, or was this read just flaky?
}
```

`structural` is the decision that matters. `useCachedPromise` keeps serving its
last good result when a fetch fails — correct for a flaky read, wrong when the
backend is gone, because then the cached list renders a dead system as live.

| Situation | Render |
| --- | --- |
| first read failed, no data | `<List><ErrorEmptyView/></List>` — the whole screen |
| structural failure, cached data present | a persistent banner row atop the list |
| transient failure | an ordinary toast |

Actions stay data so every renderer maps them to its own primitive — `Action` in
a panel, a `Clipboard.copy` call in the menu bar.
`assets/error-descriptor.tsx.template` has all four renderers.

## Workflow E — the menu bar

```tsx
<MenuBarExtra icon={icon} title={n > 0 ? String(n) : undefined} isLoading={isLoading}>
```

- **There is no badge API.** The count *is* the `title`, and `undefined` is what
  removes it.
- **`isLoading` is a contract, not a hint.** Never set: Raycast renders then
  immediately unloads. Stuck true: the whole tree re-runs every tick.
- **`interval` is manifest-only** — a preference cannot change it, and Raycast
  renders its own control. Use `"1m"`; the docs contradict themselves on the floor.
- **Background refresh is off by default for store installs.** Until the user runs
  the command once, a fresh install shows nothing. Put this at the top of the README.
- **Raycast restores the item from a database on restart**, not by re-running the
  command, so a stale render survives invisibly. Show an `Updated HH:MM` row.
- **`confirmAlert` cannot present here** — it renders in the Raycast window, which
  is closed while the menu is open. Destructive items go behind `alternate` (⌥),
  and the truly dangerous ones are not offered at all.
- **Two identical items at the same level cross their `onAction` handlers.** Prefix
  every row with its id.
- **Cap the item count.** 400 rows must not become 400 menu items every minute.
- **Never return `null` on error** — that removes the item entirely from someone
  who deliberately enabled it. Render an error menu instead.
- Feedback is `showHUD`, not `showToast`. There is no window.

Read `references/menu-bar.md` **when** the extension has or is gaining a
`mode: "menu-bar"` command, or the menu bar shows stale data, a wrong count, or
nothing at all.

## Workflow F — ship to the store

```text
[ ] author is your REGISTERED Raycast username
[ ] license: "MIT" AND an MIT LICENSE file
[ ] at least one category
[ ] platforms declared EXPLICITLY — ["macOS"] if any command is menu-bar or any
    macOS-only API is used, otherwise ["macOS", "Windows"]
[ ] ai.evals present if tools[] is non-empty, or the prompt list ships empty
[ ] package-lock.json committed — npm ci must work from a clean checkout
[ ] assets/extension-icon.png at exactly 512x512, readable light and dark
[ ] a monochrome template SVG for the menu bar, tinted at runtime
[ ] CHANGELOG.md opening with `## [Initial Version] - {PR_MERGE_DATE}`
[ ] README covering setup, and any background-refresh default
[ ] 3-6 PNGs at exactly 2000x1250 in metadata/
[ ] tsc --noEmit, ray lint, and ray build -e dist all clean
```

Then `bash scripts/check-store-readiness.sh .` — because **`ray lint` exits 0
with a completely empty `metadata/`**. Screenshot count and dimensions, icon
size, the CHANGELOG placeholder, and a real `author` are review-time
requirements the linter never runs.

Three steps cannot be automated, and pretending otherwise wastes an afternoon:

1. **Screenshots.** Raycast's Window Capture, triggered by a hotkey with
   "Save to Metadata" ticked. That option only appears once a `metadata/` folder
   exists, so create it (with a README inside) before you start.
2. **`ray login`** is a browser OAuth flow.
3. **`npx @raycast/api@latest publish`** opens a pull request against
   `raycast/extensions`. CI runs, then a human reviews. Days, not seconds.

Note `ray build` defaults to `-e dev`. Run `ray build -e dist` at least once
before submitting — it is the build the store actually produces.

Read `references/store-publishing.md` **when** the user asks to publish or make
something store-ready, or when `check-store-readiness.sh` fails and you want the
reasoning behind a check.

## Workflow G — add an AI tool layer

If Workflow B's transport already returns structured data, a tool is a thin
wrapper and the remaining work is prompt-shaped:

```ts
// src/tools/list-tasks.ts   <- filename MUST equal tools[].name
type Input = {
  /** Only return tasks in this group. Omit for every group. */
  group?: string;
  /** Only return tasks in this state. */
  status?: "queued" | "running" | "success" | "failed";
};

export default async function tool(input: Input) {
  const state = await read({ group: input.group });      // the existing transport
  return state.tasks.filter((t) => !input.status || t.status === input.status);
}
```

- **JSDoc on the `Input` fields IS the parameter schema.** An undocumented field
  is one the model guesses at. Use string unions, not `string`, for anything
  enumerable. Say **how to obtain** each value — name the sibling tool that
  returns the ids — not just what it means.
- **Return expected failures as data.** An unhandled throw is caught by Raycast,
  which shows an error screen and ends the run; `{ error: "daemon not running" }`
  lets the model tell the user something useful instead.
- **Destructive tools are allowed.** Export a `confirmation` —
  `Tool.Confirmation<Input>` runs before the tool and can return `undefined` to
  skip itself when the call turns out to be harmless. (This supersedes the older
  advice in this skill that a first version had to be read-only.)
- **`ai.yaml` is where the extension-wide `instructions` go**, and they are
  injected as a system message whenever the extension is mentioned.
- **`ai.evals[].input` renders as the Suggested Prompts list** under
  `@your-extension`. No evals means a working tool set that nobody knows how to
  address. Three prompts is the difference between "what is this" and a demo.

```yaml
# ai.yaml
instructions: |
  Never restart, kill, or clean anything unless the user explicitly asks.
evals:
  - input: "@my-ext What's running right now, and how long has each one been going?"
  - input: "@my-ext Why did my failed tasks fail? Read the logs — don't fix anything yet."
```

`npx ray evals` runs them, but it needs AI access and network, so it stays a
manual step rather than joining `just check`.

Read `references/ai-extensions.md` **when** adding `tools[]`, writing `ai.yaml`,
or deciding whether the AI layer is worth building at all.

## Available scripts

- **`scripts/new-raycast-extension.sh`** — scaffold a gated extension: manifest,
  tsconfig, flat ESLint config, Justfile gate, `dev-check` harness, one stub per
  command, one stub per AI tool, `ai.yaml` when there are tools, placeholder icon.
  - Flags: `--dir DIR --name NAME [--title T] [--author U]
    [--platforms macOS[,Windows]] [--command NAME:MODE[:TITLE]]…
    [--tool NAME[:TITLE]]… [--license SPDX] [--no-verify-harness]
    [--dry-run] [--force] [--json] [--help]`
  - Exit: `0` written · `1` bad args · `2` target not empty · `3` bundled assets
    missing · `4` post-write self-check failed
  - Refuses `Windows` together with a `menu-bar` command — `platforms` is
    extension-level, so that combination cannot be expressed.
  - Does not run `npm install`. It prints the ordered next steps instead.
- **`scripts/check-store-readiness.sh`** — the store preconditions `ray lint`
  does not check. JSON array of `{id, status, detail, fix}` on stdout.
  - Flags: `[DIR] [--json] [--quiet] [--strict] [--help]`
  - Exit: `0` all pass · `1` bad args · `2` not an extension directory · `3` a
    required tool is missing (check skipped, **not** passed) · `4` a check failed

Deliberately absent, so nobody re-adds them: a wrapper around
`tsc && ray lint && ray build` (that is a four-line Justfile recipe, already an
asset); a PATH-probe script (it would run in *your* shell — the one environment
that hides the bug); a manifest validator (`ray lint` already validates against
the published schema, and a copy would drift).

## Bundled assets

| Asset | Use |
| --- | --- |
| `Justfile.template` | the four-stage gate, plus the `([ -d node_modules ] \|\| npm install) &&` self-bootstrap every recipe needs, because `ray` ships inside `@raycast/api` |
| `tsconfig.json.template` | working config; note `include` must list `raycast-env.d.ts` |
| `eslint.config.mjs.template` | the flat-config incantation for `@raycast/eslint-config` v2 |
| `package.json.template` | manifest skeleton with a known-good dependency set |
| `dev-check.ts.template` | the no-test-runner harness, with the argv-vs-`--help` cross-check |
| `transport.ts.template` | the `Mutation` data-union seam |
| `tool.ts.template` | an AI tool: JSDoc'd `Input` plus a ready-to-uncomment `confirmation` |
| `ai.yaml.template` | `instructions` plus three `evals` — which are also the Suggested Prompts |
| `error-descriptor.tsx.template` | `ErrorDescriptor` and its four renderers |
| `metadata-README.md.template` | drop into `metadata/` so "Save to Metadata" appears |
| `extension-icon.placeholder.png` | a real 512x512 so a fresh scaffold passes `ray lint` — and its sha256 is what `check-store-readiness.sh` fails on |

## Reference files

Load one only when its condition fires. Do not preload.

| Reference | Read it **when** |
| --- | --- |
| `references/runtime-and-subprocess.md` | the extension shells out, resolves a binary path, spawns or streams a subprocess, or reports `spawn … ENOENT` / "works in my terminal but not from Raycast" |
| `references/manifest-and-commands.md` | adding or renaming a command, adding preferences or arguments, wiring one command to launch another, or considering AI tools |
| `references/data-and-state.md` | wiring `useCachedPromise`/`usePromise`/`mutate`, an action visibly reverts and re-applies, or deciding what to persist and under which key |
| `references/ui-patterns.md` | building a List/Detail/Form surface, adding a dropdown or shortcut, rendering program output as markdown, or designing empty and error states |
| `references/menu-bar.md` | the extension has or is gaining a `mode: "menu-bar"` command, or the menu bar shows stale data, a wrong count, or nothing |
| `references/cross-platform.md` | targeting Windows, a shortcut does nothing on one OS, shelling out to a platform-specific script, or an extension is missing from the Windows store |
| `references/ai-extensions.md` | adding `tools[]`, writing `ai.yaml`, calling `AI.ask`/`useAI`, deciding whether an AI layer is worth it, or the `@extension` Suggested Prompts list is empty |
| `references/store-publishing.md` | the user asks to publish, submit, or "make this store-ready", or `check-store-readiness.sh` reports a failure you want the reasoning behind |

## See also

- `react-best-practices` — hooks and component rules. A Raycast extension is
  React; this skill covers only what is Raycast-specific.
- `verifiable-surfaces` — `--help`/`--dry-run`/exit-code design, if you bundle
  helper scripts alongside the extension.
- `project-knowledge-harness` — the `pitfalls/` + `backlog/` + `TODO.md` format
  the gotchas below were originally recorded in.
- `pueue-job-queue` — the CLI the source extension wraps, as an example of what
  "the wrapped tool's own skill" means.
- `claude-api` — if the extension talks to a model directly instead of through
  `AI.ask`. Raycast's `AI.Model` enum is its own thing and churns fast.

## Gotchas

- **`ray build` does not typecheck in its default `dev` environment.** esbuild
  strips types without checking them, so a genuine `TS2345` still prints
  `ready - built extension successfully` and exits 0. `ray build -e dist` *does*
  typecheck (it runs `tsc -p tsconfig.json --noEmit`) — but `npm run build`,
  `just build`, and `ray develop` are all the dev build, which is why the error
  survives long enough to ship. Keep `tsc --noEmit` in the gate.
- **`ray lint` does not typecheck either.** It runs ESLint, Prettier, and manifest
  and icon validation. It alone catches reserved-shortcut collisions, so you need
  both, and neither is sufficient.
- **Raycast runs extensions under launchd, which never sources a shell rc.** `PATH`
  is roughly `/usr/bin:/bin`, so a bare `pueue` / `gh` / `uv` fails with
  `spawn … ENOENT`. Probe **both** `/opt/homebrew/bin` (Apple Silicon) and
  `/usr/local/bin` (Intel) — hardcoding either breaks half of all Macs.
- **The dev terminal hides the PATH bug completely.** `ray develop`'s console
  inherits your interactive `PATH`, so a resolver that fails in production passes
  there. Exercise every environment-dependent change from Raycast root search.
- **Starting a daemon *from* Raycast poisons every job it later runs.** The child
  inherits Raycast's stripped environment. Only offer a one-click start when a
  service manager owns the daemon.
- **Never hand-write a `Preferences` or `Arguments` interface.** `ray build`
  generates `raycast-env.d.ts` with global `Preferences.<Command>` namespaces
  whose fields are *required*; consuming those turns manifest/code drift into a
  compile error. Commit it, and regenerate after every manifest edit.
- **Raycast has no multi-line preference type.** The allowed set is exactly
  `appPicker | checkbox | dropdown | password | textfield | file | directory`;
  `"type": "textarea"` fails `ray lint` with `must be equal to one of the allowed
  values`. `Form.TextArea` is a form component and unrelated. A "one per line"
  setting can only ever hold one entry — pick a single-line separator, and say why
  in the docs or a reader will assume it is arbitrary taste.
- **Manifest strings have schema minimums, and the error does not name the
  field.** `description` ≥ 16 chars, each `commands[].description` ≥ 12,
  `preferences[].description` ≥ 8, `tools[].description` ≥ 12. A one-word command
  description fails `ray lint` with a bare
  `must NOT have fewer than 12 characters` and a line number.
- **`useCachedPromise` persists to a disk-backed `Cache`.** Anything in the
  resolved value is written to disk in plaintext. One measured payload was 21x
  larger with its environment snapshot than without, and that snapshot contained
  every secret in the submitting shell.
- **Fetcher dependencies must be arguments to the hook, not closure captures.**
  The argument array is the cache key. A capture changes silently and keeps
  serving the previous account's data.
- **`mutate()` revalidates immediately by default, and many backends ack before
  they apply.** The follow-up read overwrites the optimistic update with
  truth-as-of-a-moment-ago and the action visibly undoes itself. Use
  `shouldRevalidateAfter: false` plus two delayed `revalidate()` calls — and
  measure your own backend rather than copying anyone's milliseconds.
- **An optimistic updater must return its input unchanged when the outcome is
  unknowable.** A wrong prediction flickers worse than no prediction.
- **Serving cached data on failure is right for flaky reads and wrong for
  structural ones.** A cached list renders a dead backend as live, with a toast
  that scrolls away as the only hint. Carry a `structural` flag and render a
  persistent banner.
- **Scope `useCachedState` and `LocalStorage` keys** by whatever the value belongs
  to — account, host, workspace. One shared key meant switching machines left a
  list filtered by a group that only existed elsewhere, showing nothing.
- **A `List.Dropdown` silently resets when its `value` is not among its children.**
  Seed a static fallback so the first paint is never empty, and render a synthetic
  `(gone)` item for an unknown current value.
- **Raycast has taken more shortcuts than `ray lint` knows about.** `⌘K` and
  `⌘P` are reserved (Open Action Panel, Open Search Bar Dropdown) and lint fails
  on them. But while an extension is *in development* Raycast also injects a
  Debug section into every action panel — `⌘R`, `⇧⌘S`, `⇧⌘D`, `⇧⌘X`, `⌘⌥D` —
  and **those pass lint, pass typecheck, and silently win over yours.** Your own
  action just does nothing, on your machine only. Full table, plus the real key
  combinations behind every `Keyboard.Shortcut.Common.*` (`Remove` is `⌃X`, not
  `⌘⌫`; `Duplicate` is `⌘D`, not `⌘⇧D`), in
  `references/ui-patterns.md` → *Shortcuts Raycast has already taken*.
- **A hardcoded `cmd` modifier is silently dropped on Windows**, and a `windows`
  modifier is silently dropped on macOS. Nothing throws, nothing lints — the
  action just renders with no key beside it. `Keyboard.Shortcut` is a union: use
  `Keyboard.Shortcut.Common.*` (Raycast maps those per platform) or the
  two-branch `{ macOS: {...}, Windows: {...} }` form, where the type makes both
  branches mandatory. Note the capital `W` — lowercase `windows` is deprecated
  and still compiles.
- **`platforms` is extension-level; there is no per-command form.** So a single
  `mode: "menu-bar"` command pins every other command in the extension to macOS.
  Splitting the menu bar into its own extension is the only way to have both.
- **The two authorities disagree on what `platforms` defaults to.** The changelog
  says `["macOS"]`; the live JSON schema says *"the extension is assumed to be
  available on all platforms"*. It is not in the schema's `required` list either,
  so nothing forces the question. Write the field explicitly every time — the
  alternative is either hiding a portable extension or advertising an untested
  one, with no way to tell which from the manifest.
- **A tool call CAN ask for confirmation.** Earlier guidance in this skill said
  otherwise and was wrong: `Tool.Confirmation<Input>` is exported, runs before the
  tool, and returning `undefined` skips it so the prompt only appears when the
  call is actually destructive. Its `style` is `Action.Style`, reused — there is
  no `Tool`-local style enum. So a first version does **not** have to be
  read-only.
- **AI access is a runtime capability, not a subscription check.** Use
  `environment.canAccess(AI)`. A user may be inside the free message allowance, on
  Pro, on a Custom Provider via `providers.yaml`, or running a local Ollama model
  — the dev docs' *"you need to subscribe to Raycast Pro"* has not kept up. A
  `tools[]` entry never needs the check at all, since Raycast invokes it only
  after the user has cleared whatever gate applies.
- **`tools[]` with no `ai.evals` ships an empty Suggested Prompts list.**
  `evals[].input` is what renders under `@your-extension`; the schema requires
  only `input`, so there is no excuse for zero. `usedAsExample` defaults to true —
  set it false on regression cases with contrived phrasing.
- **`mocks` and `expected` are not in the published schema at all**, and the eval
  item does not set `additionalProperties: false` — so they validate by omission.
  A typo in either key passes `ray lint` and simply does nothing. Same for
  `tools[]` itself, which is `additionalProperties: true`.
- **`@raycast/api` bundles its own copy of `@types/react`.** Type props that hold
  JSX as `React.JSX.Element`, not `React.ReactNode` — the root `ReactNode` is a
  structurally different type that silently fails to match `ActionPanel`'s
  children.
- **`ray lint` runs MORE checks when it thinks it is in CI.** Locally it prints
  five steps; with `CI=true` it prints seven — `validate package-lock.json` and
  `validate other lock files` appear only there, and nothing announces the
  difference. Your local gate is a *subset* of the remote one, which is backwards.
  Put `CI=true` in the lint recipe so they cannot diverge. The check that bites:
  every `resolved` URL in the lockfile must be `registry.npmjs.org`, so a global
  npm registry pointing at a mirror (`registry.npmmirror.com`, an internal
  Artifactory) writes a lockfile the store rejects. Fix by host substitution —
  the tarballs are identical, so `integrity` stays valid, and
  `npm install --package-lock-only --registry=…` will *not* rewrite URLs already
  in the file.
- **`@types/node` must match Raycast's runtime (Node 22), not your shell's node.**
  Typing against 24 lets you write APIs that compile and throw at runtime.
- **Never `shell: true`.** Use `execFile` with an argv array. And raise
  `maxBuffer` — the 1 MB default truncates a large JSON read into a confusing
  parse error instead of a clear failure.
- **Global flags of clap-based CLIs must precede the subcommand.** `foo status
  --color never` exits **2** with a clap error; `foo --color never status` is
  correct. Exit codes are 0/1/2, not 0/1. Assert your generated argv against the
  CLI's own `--help` at verify time so a misplaced flag fails in the gate.
- **There is no test runner in a Raycast extension, and adding one is store-review
  noise.** Keep pure modules free of `@raycast/api` imports and assert them from
  one `dev-check.ts`, compiled by the already-installed `tsc` and run under
  `node`. That import discipline is the entire precondition — a barrel that pulls
  in the transport pulls in `@raycast/api` with it.
- **A `mode: "no-view"` command exports `async function Command(props)`, not a
  React component.** There is no window, so feedback is `showHUD`.
- **A tool's entry point is `src/tools/<name>.ts`, mapped from `tools[].name`** —
  the same filename-equals-manifest-name rule as commands, in a different
  directory. `tools[].description` has a 12-character minimum, like a command's.
- **Tool schema extraction is the one thing `ray build` DOES enforce.** An `any`,
  `unknown`, numeric union, intersection, or `Pick`/`Partial` in a tool's `Input`
  aborts the build with `Error: extracting tool schemas failed` — in the same dev
  build that happily ships a `TS2345` everywhere else. Supported: `string`,
  `number`, `boolean`, `string[]`, string unions (become `enum`), optional fields,
  nested object literals, type aliases.
- **`interface Input extends Base {}` extracts as `{}` — silently.** No error,
  build succeeds, and the model is handed a tool that looks like it takes no
  arguments. Declare members directly or use a type alias.
- **Tool JSDoc only counts on its own line.** `/** doc */ field: string` on one
  line, and `//` comments, produce a property with **no description** and no
  warning — documented-looking in the editor, invisible to the model.
- **`ray lint` passes with a completely empty `metadata/`.** Screenshots, icon
  dimensions, the CHANGELOG placeholder, and a registered `author` are review-time
  requirements it never checks.
- **The extension IS the repo root, and `ray publish` copies the directory
  verbatim** — not the git index. Ignored-but-present directories ride along into
  the review PR. Export an allowlisted subset if that matters.
- **Fence untrusted program output in markdown**, and neutralise embedded
  backticks with a zero-width space. A build log full of `#` otherwise renders as
  headings, and a fence inside the output closes yours early.
- **`launchCommand` + `launchContext` is the supported cross-command path**, but
  push a view only once (guard with a ref — the receiving command re-renders on
  every poll) and wait for dependent async state before acting on the context.
  Acting early opens the previous account's data and looks like it worked.
- **A deeplink triggered from outside Raycast prompts for confirmation.**
  `launchCommand` from inside does not. So an external nudge is a poor substitute,
  and some verification genuinely needs a keypress rather than a URL.

