diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index ddee272..9c1c40f 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -15,10 +15,10 @@ jobs:
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- - run: deno fmt --check extensions scripts
- - run: deno lint extensions scripts
- - run: deno check extensions/*/commands/*.ts
+ - run: deno fmt --check extensions examples scripts
+ - run: deno lint extensions examples scripts
+ - run: deno check extensions/*/commands/*.ts examples/*/commands/*.ts
# Tests may use files, the environment and programs (fake CLIs, `sh -n`), never the network.
- - run: deno test --allow-read --allow-write --allow-env --allow-run extensions
+ - run: deno test --allow-read --allow-write --allow-env --allow-run extensions examples
- name: Verify registry entries (clones each extension at its commit)
run: deno run -A scripts/validate.ts
diff --git a/README.md b/README.md
index acc550a..e035f2f 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,275 @@
-# Backslash extensions
+

-The registry of [Backslash](https://github.com/backslash-app/backslash) extensions, and the first-party extensions
-themselves.
+Backslash extensions
+
+The extensions of Backslash, the launcher for Linux,
+and everything you need to write your own: an afternoon is plenty.
+
+
+ Build an extension ·
+ Cheat sheet ·
+ Publish ·
+ All extensions ·
+ Full guide
+
+
+---
+
+## Use them
+
+Open **Browse Extensions** in Backslash, or from a terminal:
```sh
-backslash search # browse the registry (or "Browse Extensions" in the launcher)
+backslash search # browse the registry
backslash install github # install, after showing what it is allowed to do
backslash update # update everything installed
```
-## How the registry works
+## Build your first extension
+
+An extension is a folder: a `backslash.toml` manifest and one TypeScript file per command. No build step, no
+`node_modules`, no bundler. The launcher draws the interface (search, keyboard navigation, preview panel, action panel,
+loading and errors), so your code only says **what** to show.
+
+We'll build **Release Radar**: the latest release of the GitHub repositories you follow, with the release notes in the
+preview panel. It covers what most extensions need: a web API, a list with details and actions, preferences, a secret,
+permissions and tests. The finished code is in [`examples/release-radar`](examples/release-radar).
+
+### 1. Create it
+
+You need [Backslash](https://github.com/backslash-app/backslash#install) and [Deno](https://deno.com)
+(`backslash install-runtime` downloads it if you don't have it).
+
+```sh
+backslash new release-radar # a working extension with one command
+cd release-radar
+backslash dev # opens the launcher with it loaded, logs in this terminal, reloads on save
+```
+
+```
+release-radar/
+├── backslash.toml what it is and what it may do
+├── commands/
+│ └── hello.ts one file per command
+└── deno.json editor support only (types for @backslash/api)
+```
+
+### 2. Describe it: `backslash.toml`
+
+```toml
+[extension]
+id = "release-radar" # [a-z0-9-]+, unique in the registry
+title = "Release Radar"
+description = "The latest release of the GitHub repositories you follow"
+version = "0.1.0"
+author = "you"
+icon = "bs-tag" # a Backslash glyph, a theme icon name or an emoji
+license = "MIT"
+
+# Everything not listed is denied, and the sandbox enforces it.
+# Users read these lines before the first run.
+[permissions]
+net = ["api.github.com", "avatars.githubusercontent.com"]
+
+[[commands]]
+name = "releases" # → commands/releases.ts
+title = "Latest Releases" # what users type to find it
+description = "The newest release of each repository you follow"
+icon = "bs-tag"
+keywords = ["github", "release", "version", "changelog"]
+
+[[preferences]] # Settings → Extensions → Configure
+name = "repos"
+title = "Repositories"
+description = "owner/name, separated by commas: denoland/deno, rust-lang/rust"
+required = true # asked before the first run
+
+[[preferences]]
+name = "token"
+title = "GitHub token"
+description = "Optional: raises the rate limit from 60 to 5,000 requests an hour"
+type = "secret" # stored in the system keyring, never in a file
+```
+
+Rename `commands/hello.ts` to `commands/releases.ts`.
+
+### 3. Keep the logic in `lib.ts`
+
+Put what you can in plain functions, away from the launcher: they are easy to read, review and test.
+
+```ts
+// lib.ts
+export interface Release {
+ tag_name: string;
+ name: string | null;
+ html_url: string;
+ published_at: string;
+ body: string | null;
+ author: { login: string; avatar_url: string };
+}
+
+/** "denoland/deno, rust-lang/rust" → ["denoland/deno", "rust-lang/rust"], ignoring anything else. */
+export function parseRepos(text: string): string[] {
+ const repos = text.split(/[\s,]+/).filter((r) => /^[\w.-]+\/[\w.-]+$/.test(r));
+ return [...new Set(repos)];
+}
+
+/** A short age for the list: "today", "3d", "2mo", "1y". */
+export function age(date: string, now = Date.now()): string {
+ const days = Math.floor((now - Date.parse(date)) / 86_400_000);
+ if (days < 1) return "today";
+ if (days < 30) return `${days}d`;
+ if (days < 365) return `${Math.floor(days / 30)}mo`;
+ return `${Math.floor(days / 365)}y`;
+}
+```
+
+### 4. Write the command
+
+A command default-exports a view. Here, a `list` whose items are loaded once; the launcher filters them as you type.
+
+```ts
+// commands/releases.ts
+import { copy, Icons, list, meta, open } from "@backslash/api";
+import { age, parseRepos, type Release } from "../lib.ts";
+
+export default list({
+ placeholder: "Filter releases…",
+ empty: { title: "No releases yet", description: "Add repositories in Settings → Extensions", icon: Icons.tag },
+ status: (items) => `${items.length} repositories`, // the footer line
+ async items({ preferences, signal }) {
+ const headers: Record = { Accept: "application/vnd.github+json" };
+ if (preferences.token) headers.Authorization = `Bearer ${preferences.token}`;
+
+ const found = await Promise.all(
+ parseRepos(String(preferences.repos)).map(async (repo) => {
+ // `signal` is aborted when the user leaves: slow requests are cancelled for free.
+ const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { headers, signal });
+ return res.ok ? { repo, release: (await res.json()) as Release } : null;
+ }),
+ );
+
+ return found
+ .filter((f) => f !== null)
+ .sort((a, b) => b.release.published_at.localeCompare(a.release.published_at))
+ .map(({ repo, release }) => ({
+ id: repo, // keeps the selection when the list refreshes
+ title: repo,
+ subtitle: release.name ?? release.tag_name,
+ icon: { source: Icons.tag, tint: "purple" },
+ tags: [release.tag_name],
+ accessories: [{ text: age(release.published_at) }],
+ detail: {
+ // the preview panel on the right
+ markdown: release.body ?? "_No release notes._",
+ metadata: [
+ meta.text("Version", release.tag_name, { icon: Icons.tag }),
+ meta.text("Published", new Date(release.published_at).toLocaleDateString()),
+ meta.person("Author", release.author.login, release.author.avatar_url),
+ ],
+ },
+ actions: [
+ // first = Enter, second = Ctrl+Enter, all of them in Ctrl+K
+ open(release.html_url, "Open Release"),
+ copy(release.tag_name, "Copy Version"),
+ open(`https://github.com/${repo}`, "Open Repository", { shortcut: "o" }),
+ ],
+ }));
+ },
+});
+```
+
+That's the whole command. Throwing an error anywhere shows it in the launcher.
+
+### 5. Try it
+
+```sh
+backslash dev # in the launcher, with logs; save a file to reload
+backslash run release-radar/releases --json # or straight from a terminal, to see exactly what you return
+```
+
+`console.log` goes to the `backslash dev` output. Set your preferences in **Settings → Extensions → Release Radar** (the
+form also opens by itself the first time, because `repos` is required).
+
+### 6. Test it
+
+```ts
+// lib_test.ts
+import { assertEquals } from "jsr:@std/assert@1";
+import { age, parseRepos } from "./lib.ts";
+
+Deno.test("repositories: commas, spaces, duplicates and junk", () => {
+ assertEquals(parseRepos("denoland/deno, rust-lang/rust\nnot a repo, denoland/deno"), [
+ "denoland/deno",
+ "rust-lang/rust",
+ ]);
+});
+
+Deno.test("age of a release", () => {
+ const now = Date.parse("2026-10-28T12:00:00Z");
+ assertEquals(age("2026-10-25T12:00:00Z", now), "3d");
+ assertEquals(age("2024-10-01T12:00:00Z", now), "2y");
+});
+```
+
+```sh
+deno test # and deno check commands/*.ts, deno fmt, deno lint
+```
+
+### 7. Share it
+
+Push the folder to a public git repository. Anyone can install it right away with
+`backslash install https://github.com/you/release-radar` (they are told it isn't reviewed), and you can
+[list it in the registry](#publish-it) so it shows up in **Browse Extensions**.
+
+## Cheat sheet
+
+Everything below comes from `@backslash/api`. The
+[full guide](https://github.com/backslash-app/backslash/blob/main/docs/EXTENSIONS.md) has the details.
+
+| I want to… | Use |
+| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
+| Show a list filtered by the launcher | `list({ items })`: an array, an async function, or an async generator that yields as results arrive |
+| Search as the user types (web APIs) | `list({ search(query, { signal }) })`: debounced, the previous call is aborted |
+| Show tiles (emoji, images, colors) | `list({ layout: "grid", columns: 8, items })` |
+| Offer views of the same data | `filter: { options: ["Assigned", "Created"] }`, read `ctx.filter`; Tab cycles them |
+| Show a page of text | `detail({ markdown, metadata })`; `markdown` can be an async generator to stream an answer |
+| Ask for input | `form({ fields: [field.text(…), field.dropdown(…)], submit(values) })` |
+| Run something without a window | `mode = "no-view"` in the manifest and `run(async (ctx) => "Done")` |
+| Take an argument (`Add Task buy milk`) | `[[commands.arguments]]` in the manifest, read `ctx.args` |
+| Open, copy or paste | `open(url)`, `copy(text)`, `paste(text)` actions |
+| Run your own code on Enter | `action("Archive", async () => refresh())`; return a view to navigate, or `close()`, `pop()`, `refresh()`, `replace(view)` |
+| Group actions or nest them | `{ section: "Copy" }` on an action, `submenu("Move to", [...])` |
+| Guard a dangerous action | `{ destructive: true, confirm: "Delete the branch?" }` |
+| Settings and API keys | `[[preferences]]`, `type = "secret"` for tokens, read `ctx.preferences` |
+| Sign in with OAuth | `oauth.token(provider)`: PKCE, browser, refresh and keyring are handled |
+| Remember something | `storage.get(key)` / `storage.set(key, value)`: private JSON store, no permission needed |
+| Run a program | `exec("git", ["status"])`, with the program in `[permissions] run` |
+| Tell the user | `toast("Saved", { style: "success" })` |
+| Show a live count in the root search | `updateCommandMetadata({ subtitle: "3 in progress" })` |
+| Rich rows | `subtitle`, `tags`, `accessories` (text, pills, avatars), `section`, `keywords` |
+| Rich previews | `detail.stats`, `detail.markdown`, `detail.metadata` with `meta.text/tags/person/link/progress` |
+| Crisp icons | `Icons.pullRequest`, `Icons.inProgress`…, optionally `{ source, tint: "green" }` |
+| Use a library | `import { format } from "npm:date-fns@4"` or `jsr:` imports, or map them in `deno.json` |
+| Write it in another language | `[runtime] kind = "exec"` and speak the [protocol](https://github.com/backslash-app/backslash/blob/main/docs/PROTOCOL.md) (not sandboxed) |
+
+## What makes a good extension
+
+- **One job, done well.** A title users would type, a description that says what they get, `keywords` for the words they
+ might use instead (other languages too).
+- **Fast first paint.** Pass `signal` to `fetch`, load in parallel, yield partial results from an async generator when a
+ source is slow, and cache in `storage` when the data barely changes.
+- **Useful at a glance.** Put the state users came for in the row (status, counts, age) and the rest in the preview
+ panel. Prefer `Icons` glyphs to emoji and theme icons: they look the same on every desktop.
+- **Good empty and error states.** An `empty` message that says what to do next; errors thrown with a sentence a user
+ understands ("GitHub refused the token: check it in Settings").
+- **The narrowest permissions.** Specific hosts and programs, never `["*"]` when a list will do. Tokens are
+ `type = "secret"` preferences, never in code or `storage`.
+- **Keyboard first.** The action users want most is first (Enter), the second one is Ctrl+Enter, and frequent ones get a
+ `shortcut`.
+- **Tested.** Parsing and formatting live in `lib.ts` with a `lib_test.ts` next to it.
+
+## Publish it
`registry/.toml` is a small file per extension. It points to the extension's code **wherever it lives** (your own
repository), pinned to a commit, with a checksum of its files and the list of what it is allowed to do:
@@ -29,8 +289,17 @@ permissions = ["Access api.github.com", "Access avatars.githubusercontent.com",
commands = ["Search GitHub Repositories", "My Pull Requests", "My GitHub Issues"]
```
-Backslash installs exactly that commit and refuses anything whose files, version or permissions differ from the entry.
-So:
+To publish:
+
+1. Commit and push your extension to a public git repository (it can be one of several in the same repository).
+2. Run `backslash publish path/to/extension`. It prints the registry entry for your last commit.
+3. Open a pull request here adding that file as `registry/.toml`.
+4. For a new version: bump `version` in `backslash.toml`, commit, push, run `backslash publish` again and open a pull
+ request updating the file.
+
+CI checks every entry: it clones your repository at the commit, recomputes the checksum and compares the manifest and
+permissions (`deno run -A scripts/validate.ts`). Backslash then installs exactly that commit and refuses anything whose
+files, version or permissions differ. So:
- **You keep your code** in your repository; publishing a version is a one-file pull request.
- **Reviews are small and meaningful**: reviewers read the code at one commit and see the permissions it gets. Nothing
@@ -38,21 +307,6 @@ So:
- **Users see the permissions before installing**, and are asked again when an update wants more. The sandbox (Deno)
enforces them.
-## Publishing an extension
-
-1. Write it (see the [extension guide](https://github.com/backslash-app/backslash/blob/main/docs/EXTENSIONS.md)), commit
- and push it to a public git repository.
-2. Run `backslash publish path/to/extension`. It prints the registry entry for your last commit.
-3. Open a pull request adding that file as `registry/.toml`.
-4. To release a new version: bump `version` in `backslash.toml`, commit, push, run `backslash publish` again, and open a
- pull request updating the file.
-
-CI checks every entry: it clones your repository at the commit, recomputes the checksum and compares the manifest and
-permissions (`deno run -A scripts/validate.ts`).
-
-Anyone can also install straight from a repository, without the registry:
-`backslash install https://github.com/you/your-extension` (users are told it is not reviewed).
-
### Review guidelines
- The extension does what its title and description say, nothing else.
@@ -61,9 +315,30 @@ Anyone can also install straight from a repository, without the registry:
- Secrets are `type = "secret"` preferences, never hardcoded.
- Destructive actions are marked `destructive` and ask for confirmation.
+## Contributing to this repository
+
+The first-party extensions live in [`extensions/`](extensions); each is a good example to start from. They follow the
+same layout as the tutorial: `backslash.toml`, `commands/`, the logic in `lib.ts` (and `api.ts` for a web API) with
+`lib_test.ts` next to it.
+
+```sh
+git clone https://github.com/backslash-app/plugins && cd plugins
+git clone https://github.com/backslash-app/backslash .backslash # the SDK types for @backslash/api
+deno check extensions/*/commands/*.ts
+deno test --allow-read --allow-write --allow-env --allow-run extensions examples # what CI runs
+deno fmt && deno lint
+backslash dev extensions/github # live reload in the launcher
+```
+
+Their registry entries are regenerated with `scripts/update-entries.sh` after a change is merged. Extension icons can be
+emoji, theme icon names, or Backslash's own glyphs (`bs-repo`, `bs-sparkle`…, see `Icons` in the SDK), which look the
+same on every desktop.
+
+Ideas for new extensions and bug reports are welcome in the [issues](https://github.com/backslash-app/plugins/issues).
+
## First-party extensions
-98 extensions, plus npm and System bundled with the launcher.
+Every extension we maintain, plus npm and System that come with the launcher. More are added all the time.
| Extension | What it does | Permissions |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -165,17 +440,3 @@ Anyone can also install straight from a repository, without the registry:
| [World Clock](extensions/world-clock) | The time in your cities, offsets, day and night, and time conversions like “3pm NY in Paris” | none |
| [YouTube](extensions/youtube) | Search YouTube with thumbnails, views and durations, open videos in the browser or play them in mpv in the background, and browse channels | `www.googleapis.com`, `i.ytimg.com`; runs `systemd-run`, `systemctl`, `mpv`…; reads /etc/os-release |
| [Zotero](extensions/zotero) | Search your Zotero library by title, author or tag, copy APA citations and BibTeX, and open PDFs | `127.0.0.1:23119`; runs `sqlite3`; reads ~/Zotero, ~/.cache/backslash-zotero… |
-
-Their registry entries are regenerated with `scripts/update-entries.sh` after a change is merged.
-
-Extension icons can be emoji, theme icon names, or Backslash's own glyphs (`bs-repo`, `bs-sparkle`…, see `Icons` in the
-SDK), which look the same on every desktop.
-
-## Developing here
-
-```sh
-git clone https://github.com/backslash-app/backslash .backslash # for the SDK types
-deno check extensions/*/commands/*.ts
-deno test --allow-read --allow-write --allow-env --allow-run extensions # what CI runs
-backslash dev extensions/github # live reload in the launcher
-```
diff --git a/examples/release-radar/backslash.toml b/examples/release-radar/backslash.toml
new file mode 100644
index 0000000..d25e82e
--- /dev/null
+++ b/examples/release-radar/backslash.toml
@@ -0,0 +1,31 @@
+[extension]
+id = "release-radar"
+title = "Release Radar"
+description = "The latest release of the GitHub repositories you follow"
+version = "0.1.0"
+author = "you"
+icon = "bs-tag"
+license = "MIT"
+
+# Everything not listed is denied. Users see these lines before the first run.
+[permissions]
+net = ["api.github.com", "avatars.githubusercontent.com"]
+
+[[commands]]
+name = "releases"
+title = "Latest Releases"
+description = "The newest release of each repository you follow"
+icon = "bs-tag"
+keywords = ["github", "release", "version", "changelog"]
+
+[[preferences]]
+name = "repos"
+title = "Repositories"
+description = "owner/name, separated by commas: denoland/deno, rust-lang/rust"
+required = true
+
+[[preferences]]
+name = "token"
+title = "GitHub token"
+description = "Optional: raises the rate limit from 60 to 5,000 requests an hour"
+type = "secret"
diff --git a/examples/release-radar/commands/releases.ts b/examples/release-radar/commands/releases.ts
new file mode 100644
index 0000000..063f82d
--- /dev/null
+++ b/examples/release-radar/commands/releases.ts
@@ -0,0 +1,44 @@
+import { copy, Icons, list, meta, open } from "@backslash/api";
+import { age, parseRepos, type Release } from "../lib.ts";
+
+export default list({
+ placeholder: "Filter releases…",
+ empty: { title: "No releases yet", description: "Add repositories in Settings → Extensions", icon: Icons.tag },
+ status: (items) => `${items.length} repositories`,
+ async items({ preferences, signal }) {
+ const headers: Record = { Accept: "application/vnd.github+json" };
+ if (preferences.token) headers.Authorization = `Bearer ${preferences.token}`;
+
+ const found = await Promise.all(
+ parseRepos(String(preferences.repos)).map(async (repo) => {
+ const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { headers, signal });
+ return res.ok ? { repo, release: (await res.json()) as Release } : null;
+ }),
+ );
+
+ return found
+ .filter((f) => f !== null)
+ .sort((a, b) => b.release.published_at.localeCompare(a.release.published_at))
+ .map(({ repo, release }) => ({
+ id: repo,
+ title: repo,
+ subtitle: release.name ?? release.tag_name,
+ icon: { source: Icons.tag, tint: "purple" },
+ tags: [release.tag_name],
+ accessories: [{ text: age(release.published_at) }],
+ detail: {
+ markdown: release.body ?? "_No release notes._",
+ metadata: [
+ meta.text("Version", release.tag_name, { icon: Icons.tag }),
+ meta.text("Published", new Date(release.published_at).toLocaleDateString()),
+ meta.person("Author", release.author.login, release.author.avatar_url),
+ ],
+ },
+ actions: [
+ open(release.html_url, "Open Release"),
+ copy(release.tag_name, "Copy Version"),
+ open(`https://github.com/${repo}`, "Open Repository", { shortcut: "o" }),
+ ],
+ }));
+ },
+});
diff --git a/examples/release-radar/lib.ts b/examples/release-radar/lib.ts
new file mode 100644
index 0000000..e26ebed
--- /dev/null
+++ b/examples/release-radar/lib.ts
@@ -0,0 +1,24 @@
+/** What we read from `GET /repos/{owner}/{repo}/releases/latest`. */
+export interface Release {
+ tag_name: string;
+ name: string | null;
+ html_url: string;
+ published_at: string;
+ body: string | null;
+ author: { login: string; avatar_url: string };
+}
+
+/** "denoland/deno, rust-lang/rust" → ["denoland/deno", "rust-lang/rust"], ignoring anything else. */
+export function parseRepos(text: string): string[] {
+ const repos = text.split(/[\s,]+/).filter((r) => /^[\w.-]+\/[\w.-]+$/.test(r));
+ return [...new Set(repos)];
+}
+
+/** A short age for the list: "today", "3d", "2mo", "1y". */
+export function age(date: string, now = Date.now()): string {
+ const days = Math.floor((now - Date.parse(date)) / 86_400_000);
+ if (days < 1) return "today";
+ if (days < 30) return `${days}d`;
+ if (days < 365) return `${Math.floor(days / 30)}mo`;
+ return `${Math.floor(days / 365)}y`;
+}
diff --git a/examples/release-radar/lib_test.ts b/examples/release-radar/lib_test.ts
new file mode 100644
index 0000000..718ed29
--- /dev/null
+++ b/examples/release-radar/lib_test.ts
@@ -0,0 +1,18 @@
+import { assertEquals } from "@std/assert";
+import { age, parseRepos } from "./lib.ts";
+
+Deno.test("repositories: commas, spaces, duplicates and junk", () => {
+ assertEquals(parseRepos("denoland/deno, rust-lang/rust\nnot a repo, denoland/deno"), [
+ "denoland/deno",
+ "rust-lang/rust",
+ ]);
+ assertEquals(parseRepos(""), []);
+});
+
+Deno.test("age of a release", () => {
+ const now = Date.parse("2026-10-28T12:00:00Z");
+ assertEquals(age("2026-10-28T08:00:00Z", now), "today");
+ assertEquals(age("2026-10-25T12:00:00Z", now), "3d");
+ assertEquals(age("2026-08-01T12:00:00Z", now), "2mo");
+ assertEquals(age("2024-10-01T12:00:00Z", now), "2y");
+});