5分钟创建一个插件
一切基于JavaScript运行——SDK已内置在Yoki中,无需npm install。本指南将引导你从零开始创建第一个插件。
步骤1:创建文件夹
每个插件在 ~/yoki/plugins/ 中有自己的文件夹:
mkdir ~/yoki/plugins/my-plugin或使用CLI自动生成:
npx create-yoki-plugin生成 plugin.json + main.js 即可使用步骤2:添加 plugin.json
此文件向Yoki描述你的插件——名称、触发关键词和要运行的脚本。
{
"name": "My Plugin",
"keyword": "my",
"icon": "star",
"version": "1.0.0",
"protocol": "v2",
"commands": [
{
"name": "main",
"title": "My Plugin",
"mode": "detail",
"exec": "main.js",
"takes_query": true
}
]
}步骤3:编写脚本
脚本从Yoki读取输入,处理后返回响应。
const { readInput, writeResponse, detail } = require("@yoki/plugin-sdk")
async function main() {
const input = await readInput()
const query = input.query || ""
if (!query) {
writeResponse(detail('<div style="padding:16px;color:#888">Type something after <b>my</b></div>'))
return
}
writeResponse(detail(
`<div style="padding:16px">
<div style="font-size:24px;font-weight:bold;color:#4FC3F7">${query.toUpperCase()}</div>
<div style="margin-top:8px;color:#888">Your input, uppercased</div>
</div>`,
[{ label: "Original", value: query }, { label: "Result", value: query.toUpperCase() }],
[{ title: "Copy result", type: "copy", value: query.toUpperCase() }]
))
}
main()步骤4:测试
无需Yoki即可测试——将JSON传入脚本:
echo '{"query":"hello world","command":"main","context":{}}' | node main.js你应该看到:
{"type":"detail","markdown":"<div style=\"padding:16px\"><div style=\"font-size:24px;font-weight:bold;color:#4FC3F7\">HELLO WORLD</div><div style=\"margin-top:8px;color:#888\">Your input, uppercased</div></div>","metadata":[{"label":"Original","value":"hello world"},{"label":"Result","value":"HELLO WORLD"}],"actions":[{"title":"Copy result","type":"copy","value":"HELLO WORLD"}]}打开Yoki,输入 my hello world——你会看到显示"HELLO WORLD"的卡片。Enter复制。
步骤5:下一步
继续阅读SDK完整参考文档。
SDK参考
Yoki SDK每个函数的详细文档和示例。无需安装。
const { readInput, writeResponse, detail, list, background, error, stripKeyword, escHtml } = require("@yoki/plugin-sdk")readInput() — Reads what the user typed and context from Yoki.writeResponse(resp) — Sends your response back to Yoki.detail(html, metadata?, actions?) — Shows a rich card with HTML content, a metadata sidebar, and action buttons.list(items) — Shows a scrollable list of items.background(hud, notification?) — Runs an action without showing any UI.error(message, details?) — Shows an error message to the user.stripKeyword(query, ...keywords) — Removes a keyword prefix from the query string.escHtml(str) — Escapes HTML special characters (&, <, >, ").readInput()
Reads what the user typed and context from Yoki. Call this once at the start of your script. Returns an object with the user's query, which command was triggered, and context (paths, locale, credentials).
async readInput(): Promise<object>const input = await readInput()
const query = input.query // "hello world"const input = await readInput()
const dataDir = input.context.data_dir
// e.g. "C:/Users/you/yoki/plugins/my-plugin/data"
// Use this to store config, cache, tokens — it persists across runsconst input = await readInput()
const apiKey = input.context.credentials?.api_key || ""
if (!apiKey) {
writeResponse(error("API key not configured"))
return
}// input = {
// query: "hello world", — what the user typed after keyword
// command: "main", — which command (from plugin.json)
// action: "search", — "search" | "execute" | "refresh"
// context: {
// yoki_version: "1.0.8.2",
// os: "windows",
// locale: "en", — user's language
// plugin_dir: "C:/.../my-plugin",
// data_dir: "C:/.../my-plugin/data",
// credentials: { ... } — from credentials service
// },
// preferences: {}
// }writeResponse(resp)
Sends your response back to Yoki. Call this once at the end of your script. Pass the result from any builder function — detail(), list(), error(), etc.
writeResponse(resp: object): voidwriteResponse(detail("<div>Hello!</div>"))writeResponse(error("Something went wrong", "Please try again later"))detail(html, metadata?, actions?)
Shows a rich card with HTML content, a metadata sidebar, and action buttons. When the user presses Enter, the first action with type "copy" is triggered.
detail(
html: string,
metadata?: [{ label: string, value: string }, ...],
actions?: [{ title: string, type: string, value?: string }, ...]
)writeResponse(detail(
'<div style="padding:16px;font-size:20px">Hello, World!</div>'
))writeResponse(detail(
'<div style="padding:16px"><h2>2 + 2 = 4</h2></div>',
[
{ label: "Hex", value: "0x4" },
{ label: "Binary", value: "0b100" },
{ label: "Prime", value: "No" }
]
))writeResponse(detail(
'<div style="padding:16px;font-size:24px;color:#4FC3F7">42</div>',
[{ label: "Expression", value: "6 * 7" }],
[{ title: "Copy result", type: "copy", value: "42" }]
))writeResponse(detail(
'<div style="padding:16px"><h2>Now Playing</h2><p>Mind Mirage — Windows 96</p></div>',
[{ label: "Duration", value: "5:00" }],
[
{ title: "Pause", type: "yoki_run", value: "sp pause", variant: "primary" },
{ title: "Next", type: "yoki_run", value: "sp next" },
{ title: "Copy track", type: "copy", value: "Mind Mirage — Windows 96" },
{ title: "Open in Spotify", type: "open_url", url: "https://open.spotify.com/track/..." }
]
))list(items)
Shows a scrollable list of items. Each item has a title, subtitle, icon, and optional actions. Use for search results, bookmarks, file lists.
list(items: [{
id: string, title: string, subtitle?: string,
icon?: string, actions?: Action[]
}, ...])writeResponse(list([
{ id: "1", title: "First result", subtitle: "Description here" },
{ id: "2", title: "Second result", subtitle: "Another description" },
]))writeResponse(list([
{
id: "gh",
title: "GitHub",
subtitle: "https://github.com · Bookmarks Bar",
icon: "🌐",
actions: [
{ title: "Open", shortcut: "enter", type: "open_url", url: "https://github.com" },
{ title: "Copy URL", shortcut: "cmd+c", type: "copy", value: "https://github.com" }
]
}
]))const input = await readInput()
const query = input.query.toLowerCase()
const allItems = [
{ id: "1", title: "GitHub", subtitle: "Code hosting" },
{ id: "2", title: "Google", subtitle: "Search engine" },
{ id: "3", title: "Gmail", subtitle: "Email" },
]
const filtered = query
? allItems.filter(i => i.title.toLowerCase().includes(query))
: allItems
writeResponse(list(filtered))background(hud, notification?)
Runs an action without showing any UI. Shows a brief HUD toast. Optionally sends a system notification. The user must press Enter to trigger it (never fires on keystroke). Use for play/pause, save, toggle.
background(hud: string, notification?: { title: string, body: string })writeResponse(background("Done!"))writeResponse(background(
"Playing: Mind Mirage",
{ title: "Spotify", body: "Now playing: Mind Mirage — Windows 96" }
))error(message, details?)
Shows an error message to the user. Use when something goes wrong — missing input, API failure, runtime not installed. Plugin crashes are also caught automatically.
error(message: string, details?: string)writeResponse(error("Not found"))writeResponse(error("API request failed", "Check your internet connection and try again"))const input = await readInput()
if (!input.query) {
writeResponse(error("No query", "Type something after the keyword"))
return
}stripKeyword(query, ...keywords)
Removes a keyword prefix from the query string. Useful when your plugin has subcommands (e.g. "search", "play", "list").
stripKeyword(query: string, ...keywords: string[]): stringstripKeyword("search hello world", "search", "s")
// → "hello world"
stripKeyword("s hello world", "search", "s")
// → "hello world" (matches short alias too)
stripKeyword("search", "search", "s")
// → "" (exact match returns empty string)escHtml(str)
Escapes HTML special characters (&, <, >, "). Use this when inserting user input into your HTML to prevent broken rendering or XSS.
escHtml(s: string): stringconst input = await readInput()
const safe = escHtml(input.query)
writeResponse(detail(`<div>${safe}</div>`))
// input: "<b>bold</b>"
// safe: "<b>bold</b>"
// renders as text, not HTML协议 v2
当用户输入关键词时,Yoki启动脚本,将V2Input JSON写入stdin,从stdout读取V2Response。隐藏窗口,5秒超时(可配置)。
{
"version": "2.0",
"command": "greet",
"action": "search",
"query": "Ada",
"context": {
"yoki_version": "1.0.8.2",
"os": "windows",
"locale": "en",
"plugin_dir": "C:/Users/you/yoki/plugins/hello",
"data_dir": "C:/Users/you/yoki/plugins/hello/data",
"credentials": {}
},
"preferences": {}
}{
"type": "detail",
"markdown": "<div style=\"padding:16px\"><h2>Hello, Ada!</h2></div>",
"metadata": [
{ "label": "Name", "value": "Ada" }
],
"actions": [
{ "title": "Copy", "type": "copy", "value": "Hello, Ada!" }
]
}V2Input字段
响应模式
type字段决定Yoki如何渲染你的响应。
带图标、标题、副标题和操作的可滚动项目。
带元数据侧边栏和操作按钮的HTML卡片。Enter复制第一个copy操作。
列数可配置的可视网格。
无UI — 带HUD提示的副作用。Enter触发。
多字段输入表单。即将推出。
带可选详情的错误。插件崩溃会被自动捕获。
detail mode
writeResponse(detail(
'<div style="padding:16px"><h2>Now Playing</h2><p>Mind Mirage — Windows 96</p></div>',
[{ label: "Duration", value: "5:00" }, { label: "Album", value: "Enchanted Instrumentals" }],
[{ title: "Pause", type: "yoki_run", value: "sp pause", variant: "primary" },
{ title: "Copy", type: "copy", value: "Mind Mirage" }]
))list mode
writeResponse(list([
{ id: "1", title: "Mind Mirage", subtitle: "Windows 96 · 5:00", icon: "music",
actions: [{ title: "Play", type: "yoki_run", value: "sp play spotify:track:xyz" },
{ title: "Copy", type: "copy", value: "Mind Mirage" }] }
]))background mode
writeResponse(background("Playing: Mind Mirage", { title: "Spotify", body: "Now playing" }))权限
插件在清单中声明所需权限。Yoki在安装时显示确认对话框。
凭据
插件不应嵌入API密钥。声明所需内容即可。
{
"name": "Spotify",
"keyword": "sp",
"protocol": "v2",
"credentials": {
"slug": "spotify",
"service": "spotify",
"keys": ["client_id"]
},
"commands": [...]
}# Plugin reads credentials from V2Input.context — never embeds them.
import sys, json
inp = json.loads(sys.stdin.read())
client_id = (inp.get("context") or {}).get("credentials", {}).get("client_id")
if not client_id:
# Yoki couldn't fetch creds (offline / not signed in) — surface a
# graceful setup-needed response instead of crashing.
print(json.dumps({"type": "error", "error": "Sign in to Yoki to use this plugin"}))
sys.exit(0)
# ... rest of plugin uses client_id ...清单参考
Yoki理解的所有字段。添加$schema启用IDE自动补全。
{
"$schema": "https://raw.githubusercontent.com/yoki-run/yoki/main/plugin.schema.json",
"name": "string (required) display name",
"keyword": "string (required) trigger word, e.g. 'sp'",
"icon": "string icon name or emoji",
"description": "string shown in Plugins Manager",
"version": "string semver, e.g. '1.0.0'",
"author": "string",
"homepage": "string url",
"license": "string spdx, e.g. 'MIT'",
"categories": "string[] e.g. ['media', 'tools']",
"yoki_min": "string min Yoki version, e.g. '1.0.8.0'",
"protocol": "'v2'",
"permissions": {
"network": "string[] hostname whitelist for HTTPS",
"filesystem": "string[] 'read:./data', 'write:./data'",
"clipboard": "boolean",
"notifications": "boolean",
"shell": "boolean",
"ai": "boolean"
},
"credentials": {
"slug": "string defaults to keyword",
"service": "string informational",
"keys": "string[] credential names"
},
"commands": [
{
"name": "string (required) internal id",
"title": "string (required) user-facing label",
"mode": "'list' | 'detail' | 'grid' | 'background' | 'form'",
"exec": "string (required) script path relative to plugin dir",
"keywords": "string[] sub-keyword aliases",
"takes_query": "boolean true = query passed verbatim",
"refresh": "number ms — auto re-run interval",
"timeout": "number ms — default 5000"
}
]
}操作类型
在按钮或列表项操作上设置action.type:
操作图标
icon字段的三种形式:
插件发现
本地测试
# 不启动Yoki测试任何插件
echo '{"query":"test","command":"main","context":{}}' | node main.js展示
使用JavaScript SDK构建的官方插件。克隆任何一个作为起点。
spSpotify完全控制 — 当前播放、搜索、播放、设备、播放列表。
- OAuth 2.0 PKCE in pure stdlib
- Auto-refresh detail card
- Inline SVG icons
- Credentials service integration
math高级计算器 — 三角函数、积分、导数、ASCII图表。
- Safe expression parser (no eval)
- Symbolic differentiation (chain rule)
- Numerical integration (Simpson)
- ASCII function plots
bm即时搜索Chrome、Edge、Brave、Vivaldi书签。
- Reads local bookmark files (no API)
- Multi-browser detection
- Multi-word fuzzy search
- Open in browser or copy URL
下一步
插件系统已可用于生产环境。