Building extensions
Package commands, live sources, AI tools and user preferences into a reusable extension, inspired by Raycast.
An extension is a portable bundle of functionality. If you’ve built a Raycast extension, this will feel familiar. The difference is that it’s framework-agnostic and runs in any web app that uses Qikbar.
import { defineExtension, list, action } from '@qikbar/qikbar'
export default defineExtension({
name: 'hacker-news',
title: 'Hacker News',
icon: 'https://news.ycombinator.com/favicon.ico',
description: 'Browse top stories',
commands: [
{
name: 'top',
title: 'Top Stories',
view: list({
title: 'Top Stories',
items: async ({ signal }) => {
const ids: number[] = await fetch('https://hacker-news.firebaseio.com/v0/topstories.json', { signal }).then((r) => r.json())
const stories = await Promise.all(
ids.slice(0, 30).map((id) => fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`, { signal }).then((r) => r.json())),
)
return stories.map((s) => ({
title: s.title,
subtitle: new URL(s.url ?? 'https://news.ycombinator.com').hostname,
url: s.url ?? `https://news.ycombinator.com/item?id=${s.id}`,
accessories: [{ text: `▲ ${s.score}` }, { text: `${s.descendants ?? 0} comments` }],
actions: [action.open(`https://news.ycombinator.com/item?id=${s.id}`, { title: 'Open Comments', shortcut: 'mod+o' })],
}))
},
filter: true,
}),
},
],
})
Install it:
import hackerNews from './hacker-news'
createQikbar({ extensions: [hackerNews] })
// or at runtime
const uninstall = bar.use(hackerNews)
Anatomy
| Field | Description |
|---|---|
name |
Required. A unique id (kebab-case). Namespaces storage, ids and tool names. |
title |
Required. The display name. It’s the default section and subtitle for its commands. |
icon, description, author, version |
Metadata. |
commands |
ExtensionCommand[]: same fields as commands plus a required name. |
sources |
Sources that contribute to the root search. |
tools |
AI tools. Exposed to the model as name__tool. |
preferences |
Settings the extension needs (see below). |
setup(ctx) |
Called once when installed. Return a cleanup function. |
Command ids are ${extension.name}.${command.name}, which keeps them stable for frecency and AI.
Preferences
Declare what you need. Qikbar resolves values in this order: values passed at install time, then values the user entered, then defaults.
defineExtension({
name: 'github',
title: 'GitHub',
preferences: [
{ name: 'token', title: 'Personal access token', type: 'password', required: true, description: 'Needs repo scope' },
{ name: 'org', title: 'Default organization', type: 'text', placeholder: 'acme' },
{ name: 'drafts', title: 'Include draft PRs', type: 'checkbox', default: false },
{ name: 'sort', title: 'Sort by', type: 'select', default: 'updated', options: [
{ title: 'Recently updated', value: 'updated' },
{ title: 'Most stars', value: 'stars' },
] },
],
commands: [{ name: 'prs', title: 'My Pull Requests', view: (ctx) => prList(ctx.preferences.token) }],
})
If a required preference is missing, the first time the user runs a command, Qikbar shows a Configure GitHub form. It saves the values (namespaced in storage) and then runs the command. Users can change them later with the Configure GitHub action in the action panel (⌘.).
Apps can also provide values when installing, so users never see the form:
createQikbar({ extensions: [[github, { token: session.githubToken, org: 'acme' }]] })
// or
bar.use(github, { token: session.githubToken })
Preference types: text, password, number, checkbox and select.
Storage
ctx.storage is namespaced to the extension: qikbar:ext:<name>:<key>.
const favorites = ctx.storage.get<string[]>('favorites') ?? []
AI tools
tools: [
{
name: 'create_issue',
description: 'Create a GitHub issue in a repository',
parameters: {
type: 'object',
properties: { repo: { type: 'string', description: 'owner/name' }, title: { type: 'string' }, body: { type: 'string' } },
required: ['repo', 'title'],
},
confirm: (a) => `Create issue “${a.title}” in ${a.repo}?`,
run: async (args, ctx) => {
const issue = await gh(ctx.preferences.token).createIssue(args)
return { url: issue.html_url, number: issue.number }
},
},
]
The model sees this tool as github__create_issue.
Lifecycle with setup
setup({ bar, preferences, storage }) {
const off = bar.on('open', () => prefetch())
const timer = setInterval(sync, 60_000)
return () => {
off()
clearInterval(timer)
}
}
Patterns
Factory functions make extensions configurable:
export function jira(options: { site: string }) {
return defineExtension({ name: 'jira', title: 'Jira', commands: [/* uses options.site */] })
}
Commands that run in place return false:
{ name: 'refresh', title: 'Refresh Cache', run: async (ctx) => { await refresh(); ctx.toast('Refreshed'); return false } }
Views that update use ctx.render. See Re-rendering.
Compared with Raycast
| Raycast | Qikbar |
|---|---|
package.json manifest |
An Extension object |
mode: "view" |
view: … or run returning a view |
mode: "no-view" |
run: … |
<List>, <Grid>, <Detail>, <Form> |
list(), grid(), detail(), form() |
<ActionPanel> / <Action> |
actions: Action[] / action.* helpers |
useNavigation().push |
ctx.push(view) |
showToast, confirmAlert |
ctx.toast, ctx.confirm |
LocalStorage |
ctx.storage |
getPreferenceValues() |
ctx.preferences |
arguments |
arguments (inline in the search bar) |
AI.ask |
ctx.ai.ask, ctx.ai.stream |
AI Extensions (tools) |
tools |