@chrismessina/raycast-kit
v0.2.0
Published
House-style primitives for Raycast extensions: failure toasts that always carry a Copy Error action, safe error unwrapping, and count-aware copy
Maintainers
Readme
@chrismessina/raycast-kit
House-style primitives for Raycast extensions. Zero runtime dependencies;
@raycast/api is a peer.
Companion to @chrismessina/raycast-logger.
Install
npm install @chrismessina/raycast-kitAt a glance
import { showError, failToast } from "@chrismessina/raycast-kit";
import { getErrorMessage, isAbortError } from "@chrismessina/raycast-kit/errors";
import { countOf } from "@chrismessina/raycast-kit/plural";
import { formatBytes } from "@chrismessina/raycast-kit/bytes";
await showError(error, { title: "Couldn't Load Devices" }); // toast + Copy Error, redacted
failToast(toast, error, { title: "Export Failed" }); // same, on an existing toast
getErrorMessage(error); // never "[object Object]"
countOf(1, "device"); // "1 device", not "1 devices"
formatBytes(1536); // "1.5 KB"One rule before your first import: UI code imports the root; pure logic and tests import the subpaths (
/errors,/plural).@raycast/apiis types-only and won't resolve outside the Raycast host — see Which entry point to import from.
What you get for free
showError and failToast both guarantee, by construction:
- A Copy Error action. An error the user can't copy is an error they can't report.
- Redaction before the text reaches the toast or the clipboard. Bearer tokens, labeled secrets, provider-shaped keys, JWTs, PEM blocks, AWS key ids, URL-authority credentials, emails. This is the one worth caring about even if you skip the rest — see below.
- Aborts swallowed. A user typing the next keystroke cancels the in-flight request; that isn't a failure and shouldn't toast.
- A clipboard payload richer than the toast — title, message, stack frames, and any request context you pass.
Why this exists
Every export here earned its place in a fleet audit (2026-07-25, 24 self-authored extensions, ~85k LOC). The rule wasn't "this might be handy" — it was "this is hand-written many times over, and often hand-written wrong."
| Pattern | Hand-written | Repos |
| --- | --- | --- |
| Toast.Style.Failure | 124 | 20 |
| …of which carry a Copy Error action | 26 | 9 |
| error instanceof Error ? … : … ternary | 98 | 16 |
| Count-bearing copy (${n} items, item(s)) | 34 | 11 |
The toast row is the point. House Style requires every failure toast to offer a
Copy Error action — an error the user can't copy is an error they can't report —
and it was ~20% adopted, because the rule lives in a checklist while the
ergonomic path (showFailureToast from @raycast/utils, which has no copy
action) points the other way. raycast-ios-apps alone had 51 failure toasts and
zero copy actions.
This package makes the compliant thing the easy thing. Three consequences worth knowing before the line-count argument:
- It closes a latent secret leak. Nearly every hand-rolled failure toast in the
fleet copies unredacted error text to the clipboard. A thrown SDK error routinely
carries an
authorizationheader or anx-api-key— one screenshot or pasted GitHub issue away from disclosure.showErrorredacts by default; the hand-rolled version can't, because nobody remembers to. - Zero runtime dependencies,
@raycast/apias a peer. Nothing to justify to a Store reviewer on bundle size. - The compliant call is the shortest one. That's the mechanism — not discipline.
showError(error, options)
A failure toast with the Copy Error action already attached.
import { showError } from "@chrismessina/raycast-kit";
try {
await loadDevices();
} catch (error) {
await showError(error, { title: "Couldn't Load Devices" });
}With a retry and request context on the clipboard:
await showError(error, {
title: "Search Failed",
action: { title: "Try Again", onAction: () => revalidate() },
copyContext: `GET ${url} → ${response.status}`,
});- Copy Error is always primary; your
actionbecomes secondary. - The clipboard gets more than the toast shows — title, message, your
copyContext, and the stack frames when the thrown value had a stack. - Aborts are swallowed by default. A user typing the next keystroke cancels
the in-flight request; that is not a failure and shouldn't toast. Pass
ignoreAbort: falseto opt out. - Returns the
Toast(so you can mutate it later), orundefinedfor an ignored abort.
failToast(toast, error, options)
The progress-toast pattern — show an animated toast, then flip it when the work
settles — cannot use showError, which creates a new toast. failToast mutates
an existing one in place, with the same Copy-Error guarantee.
const toast = await showToast({ style: Toast.Style.Animated, title: "Exporting…" });
try {
await exportIcons();
toast.style = Toast.Style.Success;
toast.title = "Exported";
} catch (error) {
failToast(toast, error, { title: `Failed to export ${app.name}'s icons` });
}Returns true if the toast was turned into a failure, false for an ignored
abort (in which case the toast is left untouched). These mutation sites are
exactly where Copy-Error actions went missing in the fleet — attaching one by
hand costs six lines every time.
It clears a stale secondaryAction. A progress toast usually carries a "Cancel"
for the work that just failed; leaving it attached offers an action that no longer
means anything. Pass action to set a new one (typically "Try Again").
getErrorMessage(error: unknown)
The one canonical unwrap. Replaces the instanceof Error ternary, and handles
what that ternary gets wrong.
getErrorMessage(new Error("Boom")) // "Boom"
getErrorMessage("just a string") // "just a string"
getErrorMessage({ message: "from API" }) // "from API"
getErrorMessage({ error: { message: "rate limited" } }) // "rate limited"
getErrorMessage({ a: 1 }) // '{"a":1}' — never "[object Object]"
getErrorMessage(new TypeError("")) // "TypeError" — never an empty toast
getErrorMessage(null) // "An unknown error occurred."String(error) on a plain object yields "[object Object]", which is the useless
message users actually report. This never does that.
Credentials are redacted, and output is capped. showError puts this text in a
toast and on the clipboard, so it can end up in a screenshot or pasted into a
GitHub issue. A thrown SDK error routinely carries an authorization header or an
x-api-key — a realistic Anthropic 401 payload was putting a live sk-ant-… key
into both before this was added. Masked: bearer tokens, labeled secrets
(api_key/token/password/…), provider-shaped keys (sk-…, ghp_…, xoxb-…),
JWTs, PEM private-key blocks, AWS access key ids, credentials in a URL
authority (postgres://user:pw@host), and email addresses; output is clamped
to 800 characters, because a 50 KB response body is not a toast. A message you
pass yourself is redacted too — it routinely interpolates error text, and an
unredacted toast is screenshot-able. redactSecrets is
exported if you need it directly. This mirrors the redaction in
raycast-logger — deliberately,
since both packages protect the same secrets from the same payloads.
It cannot throw. Every property read goes through a guarded accessor: a hostile
value ({ get message() { throw … } }, a Proxy that traps every read) returns the
generic message rather than replacing the user's real failure with an unrelated one.
isAbortError(error: unknown)
try {
await fetch(url, { signal });
} catch (error) {
if (!isAbortError(error)) {
await showError(error, { title: "Search Failed" });
}
}Recognizes AbortError, TimeoutError, and code: "ABORT_ERR" — verified
against what Node's real fetch throws on an aborted AbortController, not just
a synthetic error.
countOf(count, singular, options?)
Count copy that agrees across zero, one, and many. House Style prohibits
"${n} item(s)" and always-plural "${n} items" (which says "1 items") in
anything the user reads.
countOf(0, "device") // "0 devices"
countOf(0, "device", { zero: "No devices" }) // "No devices"
countOf(1, "device") // "1 device"
countOf(7, "device") // "7 devices"
countOf(2, "match") // "2 matches"
countOf(2, "city") // "2 cities"
countOf(2, "person") // "2 people"
countOf(1234, "item") // "1,234 items"plural(count, singular, pluralForm?) is exported separately when you need only
the noun.
On the -f and -o classes: these use allow-lists, not regex rules, because
English splits them with no reliable pattern — leaf→leaves but chef→chefs,
roof→roofs, belief→beliefs; potato→potatoes but cello→cellos,
avocado→avocados. A naive -f$/-o$ rule produces "cheves", "rooves",
"believes", and "celloes" — and a helper that mangles ordinary words is worse
than the ternary it replaces. Anything outside the lists takes a plain -s; pass
an explicit plural for the rest.
Which entry point to import from
| Import from | Gives you | Needs the Raycast runtime? |
| --- | --- | --- |
| @chrismessina/raycast-kit | everything (convenience barrel) | yes |
| @chrismessina/raycast-kit/toast | showError, failToast, buildClipboardText | yes |
| @chrismessina/raycast-kit/errors | getErrorMessage, isAbortError, redactSecrets | no |
| @chrismessina/raycast-kit/plural | countOf, plural | no |
Why the split: @raycast/api ships types only — no loadable JavaScript. The
Raycast host injects it at runtime, so it cannot resolve under plain node, tsx, or
a test runner. The root export includes showError/failToast, which import it.
What breaks if you get it wrong: importing the root into a module you test
headlessly fails to resolve @raycast/api — and the error names toast.js, never this
package's export map, so it doesn't look like an import-path problem:
Error: Cannot find module '@raycast/api'
Require stack:
- node_modules/@chrismessina/raycast-kit/dist/toast.js
- node_modules/@chrismessina/raycast-kit/dist/index.js
- src/utils/your-pure-module.tsThe fix is one import path, not a local copy of the helper: switch that module to
@chrismessina/raycast-kit/errors.
(This is real: it cost the first adopter all 11 of a module's headless fixtures and a hand-rolled duplicate helper before they found the subpath.)
Development
npm install
npm run build # tsc → dist/
npm test # node --test (83 tests)
npm run typecheck # tsc --noEmitScope
Deliberately narrow. The audit also considered date formatting, text truncation,
and number formatting and rejected all three: eight formatDate
implementations across the fleet had eight different signatures — they shared a
name, not a behavior — and consolidating them would have meant inventing a
superset nobody wanted. truncate looked fleet-wide at 79 uses until 40 of them
turned out to be in one extension.
Generic helpers belong in @raycast/utils,
which 19 of 24 extensions already import. This package holds only what encodes a
house-style rule that would otherwise depend on memory.
Empty-state components (List.EmptyView, 55 uses / 15 repos) are the strongest
v0.2.0 candidate — they need JSX, and the collapsed-newline rule is worth encoding
in a component.
License
MIT
formatBytes(bytes, options)
import { formatBytes, formatSpeed } from "@chrismessina/raycast-kit";
formatBytes(0); // "0 B"
formatBytes(512); // "512 B" — bytes are always whole
formatBytes(1536); // "1.5 KB"
formatBytes(1073741824); // "1.0 GB"
formatSpeed(1536); // "1.5 KB/s"| Option | Default | Effect |
| --- | --- | --- |
| base | 1024 | Divisor between units. 1000 matches macOS Finder. |
| precision | 1 | Fraction digits for KB and above. Bytes stay whole regardless. |
| trimZeros | false | 1 MB instead of 1.0 MB. |
The default is the developer-tooling convention, which is what Raycast's audience
expects: base 1024, one decimal from KB up, whole bytes below. Note it is technically
mislabelled — dividing by 1024 and calling the result KB is what KiB means. Pass
base: 1000 when the number sits next to something the user can also read in Finder,
which reports decimal, or the two will disagree by ~7% at GB.
The two conventions do not always render differently: at the default precision: 1
the 4.9% gap at MB rounds away and 1 MiB reads "1.0 MB" under both. It shows at GB
("1.0 GB" vs "1.1 GB") or at higher precision.
0, negatives, NaN and Infinity all return "0 B" rather than indexing the unit
ladder out of bounds — Math.log(0) is -Infinity and Math.log(-1) is NaN, either
of which otherwise puts "NaN undefined" on screen. The ladder clamps at PB.
formatSpeed takes the same options, so a progress line's rate and its total cannot
silently end up on different bases.
