diff --git a/skills/hermes-desktop-plugins/SKILL.md b/skills/hermes-desktop-plugins/SKILL.md new file mode 100644 index 000000000000..277bd0d10cea --- /dev/null +++ b/skills/hermes-desktop-plugins/SKILL.md @@ -0,0 +1,136 @@ +--- +name: hermes-desktop-plugins +description: Write desktop app plugins that add UI panes and commands. +version: 1.0.0 +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [desktop, plugins, ui, extension] + category: productivity + related_skills: [] +--- + +# Hermes Desktop Plugins Skill + +Write plugins for the Hermes desktop app: statusbar items, layout panes, +command-palette commands, keybinds, routes, and themes. A plugin is a single +plain-JavaScript ESM file the app loads at runtime — no build step, no repo +changes. This skill does not cover backend plugins (`~/.hermes/plugins/`); +those are Python and documented separately. + +## When to Use + +- The user asks for a new desktop UI element (a pane, a statusbar widget, a + dashboard, a command) without modifying the app itself. +- You want to surface data you compute (via gateway RPC) inside the app. + +## Prerequisites + +- The Hermes desktop app (it loads plugins; the CLI/gateway alone does not). +- Write access to `$HERMES_HOME/desktop-plugins/` (usually + `~/.hermes/desktop-plugins/`). + +## How to Run + +1. Create `$HERMES_HOME/desktop-plugins//plugin.js` from + `templates/plugin.js` (relative to this skill directory) — that's + `~/.hermes/...` by default, or `~/.hermes/profiles//...` under a + named profile. Keep `` equal to the plugin `id`. +2. The desktop app watches that directory: the plugin loads within a few + seconds of the file landing, and every later save hot-reloads it in + place. No reload step. (Fallback if it doesn't appear: ⌘K → + **Reload desktop plugins**.) +3. If loading fails the app shows a toast naming the error — fix the file + and save again. + +## Quick Reference + +The ONLY import surface is `@hermes/plugin-sdk` (plus `react` / +`react/jsx-runtime`, which resolve to the app's own React — write UI with +`jsx()` calls, not JSX syntax; the file is not compiled). + +- `host.state.*` — readonly reactive atoms: `activeSessionId`, `cwd`, + `gateway`, `model`, `profile`, `viewport`. Read with `.get()` in handlers, + `useValue(atom)` in components. +- `host.request(method, params)` — gateway JSON-RPC (sessions, config, + skills, cron — everything the app uses). +- `host.onEvent(type, fn)` — live gateway events (`'*'` for all). Returns a + disposer. +- `host.notify({ kind, message })`, `host.navigate(path)`, `host.logs(...)`, + `host.status()`, `haptic('tap')`. +- `ctx.register({ id, area, order?, render?, data? })` — contribute UI. + Key areas: `'statusBar.right'`/`'statusBar.left'` (chips), + `'panes'` (layout zones — set `title` and + `data: { placement, dock?, width?, height? }`; the pane auto-joins a + matching zone), `PALETTE_AREA` (⌘K commands), `KEYBINDS_AREA` (rebindable + actions). +- Pane placement: `placement: 'left'|'right'|'bottom'|'main'` is the + semantic role — the pane stacks (tabs) with existing panes of that role. + To land on a specific EDGE instead, add `dock: { pane, pos }` — the same + gesture as dragging onto a pane's drop chip. `pane` is any pane id + (`workspace` is the main thread; also `sessions`, `terminal`, `files`, + `review`, `logs`), `pos` is `'top'|'bottom'|'left'|'right'|'center'`. + E.g. "below the conversation" = `dock: { pane: 'workspace', pos: 'bottom' }` + — declare a `height` (e.g. `'200px'`) so it doesn't take half the zone. +- Full PAGES: register `area: ROUTES_AREA` with `data: { path: '/my-page' }` + and a `render` — the page mounts in the workspace (main) pane like any + built-in view. Make it reachable with a sidebar nav row: + `ctx.register({ id: 'nav', area: SIDEBAR_NAV_AREA, data: { path: '/my-page', label: 'My Page', codicon: 'project' } })` + (renders below Artifacts, lights up at the route) — and/or a + `PALETTE_AREA` command calling `host.navigate('/my-page')`. +- `ctx.storage.get/set/remove` — persistence namespaced to your plugin. +- Users manage plugins in Settings → Plugins (enable/disable live, reveal + folder). A disabled plugin stays disabled across restarts — don't fight + it; the user turned you off. +- UI: the app's design language, importable directly — `Button`, `Input`, + `Textarea`, `Select*`, `Switch`, `Checkbox`, `SegmentedControl`, `Tabs*`, + `Dialog*`, `ConfirmDialog`, `DropdownMenu*`, `ContextMenu*`, `Popover*`, + `Tip`/`Tooltip*`, `Badge`, `Kbd`/`KbdGroup`, `SearchField`, `ScrollArea`, + `Separator`, `Skeleton`, `GlyphSpinner`, `EmptyState`, `ErrorState`, + `CopyButton`, `StatusDot`, `LogView`, `Codicon`, `DecodeText`, plus `cn` + and `icons.*`. Prefer these over hand-rolled elements so the plugin looks + native; style with theme vars, never hardcoded colors. + +## Procedure + +1. Pick a short kebab-case `id`; the folder name must match. +2. Start from `templates/plugin.js`; keep the default export shape + (`{ id, name, register(ctx) }`). +3. For a pane, register `area: 'panes'` with a `placement` hint and a + `render` returning your component — the app places it into a sensible + zone automatically; the user can drag it anywhere afterwards. +4. Fetch data with `host.request` and/or subscribe with `host.onEvent`; + never poll faster than a few seconds. +5. Write the file with your file tools, then ask the user to run + **Reload desktop plugins** from ⌘K. + +## Pitfalls + +- NEVER hardcode colors or backgrounds (`#000`, `black`, `rgb(...)`). Panes + already sit on the app's editor background — leave the background alone + and use theme variables for everything else: `var(--ui-text-secondary)`, + `var(--ui-text-quaternary)`, `var(--ui-stroke-secondary)`, + `var(--ui-accent)`. For canvas drawing, resolve them once with + `getComputedStyle(canvas).getPropertyValue('--ui-accent')`. +- Reference only what you imported — a component you forgot to import + (e.g. `StatusDot`) is a ReferenceError at render. Double-check every + identifier in your `jsx()` calls appears in the import line. +- Canvas panes MUST track their container with a `ResizeObserver` and + re-size the canvas (width/height attributes, not just CSS) — panes resize + constantly (sash drags, layout switches); a mount-time-only size leaves + blank space or blurry scaling. +- JSX syntax will not parse — the file loads uncompiled. Use + `jsx('div', { children: ... })` from `react/jsx-runtime`. +- Do not import anything except `@hermes/plugin-sdk`, `react`, and + `react/jsx-runtime`; other specifiers fail to resolve. +- Handlers must read state imperatively (`$atom.get()`), never from render + closures — rapid events will otherwise see stale values. +- Keep components small; subscribe (`useValue`) only in the leaf that + renders the value. + +## Verification + +- The plugin's UI appears after **Reload desktop plugins**. +- No error toast ("Plugin failed to load") appears; if it does, the + message names the failure — fix and reload. +- For panes: the new zone is visible and draggable like any core pane. diff --git a/skills/hermes-desktop-plugins/templates/plugin.js b/skills/hermes-desktop-plugins/templates/plugin.js new file mode 100644 index 000000000000..803c11d2da76 --- /dev/null +++ b/skills/hermes-desktop-plugins/templates/plugin.js @@ -0,0 +1,73 @@ +/** + * Hermes desktop plugin template. Save as: + * /desktop-plugins//plugin.js (folder name == id) + * where is ~/.hermes by default, or ~/.hermes/profiles/ + * when running a named profile (`hermes -p `). Run `hermes doctor` (or + * check the app's Settings → Plugins folder path) if unsure which is active. + * Then run "Reload desktop plugins" from ⌘K in the desktop app. + * + * Plain ESM, loaded uncompiled — UI is jsx() calls, not JSX syntax. + * Only these imports resolve: @hermes/plugin-sdk, react, react/jsx-runtime. + */ + +import { cn, haptic, host, Tip, useValue } from '@hermes/plugin-sdk' +import { jsx, jsxs } from 'react/jsx-runtime' + +function MyPane() { + const gateway = useValue(host.state.gateway) + + return jsxs('div', { + className: 'flex h-full flex-col gap-2 p-3 text-sm', + children: [ + jsx('div', { className: 'font-medium', children: 'My Plugin Pane' }), + jsx('div', { + className: 'text-(--ui-text-tertiary)', + children: `gateway: ${gateway}` + }) + ] + }) +} + +function MyChip() { + return jsx(Tip, { + label: 'My plugin — click me', + children: jsx('button', { + className: cn( + 'inline-flex h-full items-center gap-1 px-1.5 text-[0.6875rem] transition-colors', + 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground' + ), + type: 'button', + onClick: () => { + haptic('tap') + host.notify({ kind: 'info', message: 'Hello from my plugin!' }) + }, + children: 'my-plugin' + }) + }) +} + +export default { + id: 'my-plugin', // must match the folder name + name: 'My Plugin', + register(ctx) { + // A layout pane — auto-placed by the placement hint; user can drag it. + // To land on a specific edge instead of stacking, add a dock gesture, + // e.g. below the conversation: + // data: { placement: 'bottom', dock: { pane: 'workspace', pos: 'bottom' }, height: '200px' } + ctx.register({ + id: 'pane', + area: 'panes', + title: 'my plugin', + data: { placement: 'right', width: '237px' }, + render: () => jsx(MyPane, {}) + }) + + // A statusbar chip. + ctx.register({ + id: 'chip', + area: 'statusBar.right', + order: 130, + render: () => jsx(MyChip, {}) + }) + } +}