Extensions — concepts

SHERU is extensible along two independent axes. Extensions add function — data sources, commands, menu items, openers, launchers. Themes add presentation — the entire look of the UI. The two never mix: an extension has no pixels to clash with a theme, and a theme has no logic. This page is the concept map for the function axis: the nouns you use and the layering underneath them.

Two orthogonal axes: function ⊥ presentation

The function axis and the presentation axis are fully decoupled, so any theme renders any set of installed extensions:

  FUNCTION axis                                 PRESENTATION axis
  ┌──────────────────────────┐                  ┌──────────────────────────┐
  │ Extension                │                  │ Theme                    │
  │  = manifest + code       │   @sheru-app/ui      │  = tokens + styling      │
  │  contributes data        │  ◄──seam────►    │  renders the headless    │
  │  + registers commands    │ (headless parts) │  contract; no logic      │
  └──────────────────────────┘                  └──────────────────────────┘
        what is there                                  how it looks

The seam between them is the closed @sheru-app/ui headless contract — a governed vocabulary of semantic UI primitives plus design tokens. Extensions emit only semantic structure (declarative data + addressable logic); themes own 100% of how those things look. Because an extension never produces HTML, CSS, or pixels, installing one can never make a theme "only fit part of the UI."

A theme is therefore pure data, never code (tokens + a scoped CSS string + chrome config + declarative glyph data), sandbox-by-construction because there is nothing to run. The token contract is versioned ("1.0"), and the token schema is organized into roughly a dozen semantic groups (identity surfaces & text, chrome, bevel, controls, inputs, toolbar, sidebar, file list, modal dialogs, marquee, terminal, scrollbar, focus & motion) totaling on the order of ~80 tokens. The theme's icon field is the declarative glyph spec (iconGlyphs / FileIconData / ControlGlyphData) — data, not a React component table. For the full presentation-axis design see themes. Themes are explicitly not a contribution point of an extension; they are their own axis and their own marketplace.

The two user-facing nouns: Extension and Connector

The user perceives exactly two nouns. Everything else on this page is internal and never surfaced as product vocabulary.

Extension — the distribution unit

An Extension is a VSCode-style distribution unit: a directory with one manifest.json plus an optional JS/TS entry module and assets. It is a data package — it has no executable of its own. It is a container of contributions, not a feature; one extension can fill several contribution points. Built-in capabilities ship as built-in extensions, identical in shape to third-party ones. Its identity is the extensionId (surfaced as NodeInfo.extensionId), and it is what a developer ships.

Connector — one contribution point

A Connector is one contribution point — and the most prominent — defined as "a connection to a third-party data source," projected as a browsable file tree the file browser can list, read, and (capability permitting) act on. It is what a user adds: adding it mints a node the file browser operates. Which node an extension can mint follows its provider class, and each class has a fixed capability ceiling:

provider class the user's term backing runtime ceiling
service projection service connector out-of-process sidecar (GitHub, Notion, Linear) host-gated, credentialed (trusted) read-only projection, forever
fs-provider device out-of-process sidecar (adb — Android devices) host-gated, credentialed (trusted) faithful filesystem — may declare write / delete / paste, a terminal, processes, and a clipboard, clamped to the class ceiling
fs-engine (built-in, not an extension) file connector rclone preset (S3-compatible object storage, WebDAV, SMB, SFTP, FTP) in-process, trusted read + upload; in-place writes where the backend supports it (SFTP)

The two classes an extension ships are service projection and fs-provider; fs-engine is built into the app. The class colors the icon and sets the ceiling; nothing else branches on it. There is no "mount" or "drive" in the API — every class presents identically as a node, distinguished only by the services it serves and its icon.

The local disk is not a connector (a connector is third-party). It is the built-in local node (provider = "builtin-local", full write), shown as plain furniture ("This Mac", Home, Locations), never under the Connections group.

Why Extension ≠ instance (the lifecycle boundary)

These name different things, and the distinction is load-bearing for the API — it is why the extension-host machinery coexists with the node lifecycle (this is not drift):

One extension can back many instances (two GitHub accounts, three buckets, N enumerated devices), each its own node id. The rule: the package is an extension; the user-added instance is a node. "Connector" survives as the user-facing word for connecting one.

The layering: Contribution Point → Contribution → Command

Underneath the two user-facing nouns, the model layers three internal primitives. Only the last carries logic.

Command — the logic primitive

A Command is an addressable unit of logic: a stable id plus a handler. It is the only thing that carries logic, and it is also the agent-tool surface. A command is invoked from many entry points — a context-menu item, a keybinding, a launcher, the agent — all routed to one handler by id.

A command must be addressable-by-id, never an inline closure, because the things that trigger it are declarative data rendered by native AppKit, by the WebView engine, or marshaled across a process boundary — none of which can carry a JS closure. Indirection through an id is what makes one handler reachable from every surface.

Contribution — the declarative data

A Contribution is a pure, serializable descriptor placed into a typed contribution point. It carries no logic; instead it references a command. A context-menu item is a contribution that points at a command id; a connector is a contribution that declares how to back a Source. This is deliberately VSCode's contributes model.

Contribution Point — the typed slot

A Contribution Point is a typed slot a Contribution goes into:

point contributes owner
nodes a node provider (provider class = service | fs-provider) core
commands addressable logic (id + handler); also the agent-tool surface core
menus context-menu item → references a command, gated by a when predicate core
keybindings key chord → command core
openers file match (ext / uti / mime) → command that opens it core
launchers double-click match (.app / uti) → command core
sidebarItems a pinned row → target (command | connector source | view) core
panels a bottom-panel view (gains a view field) advanced

themes is not in this table — it is the separate presentation axis.

Trust model

Privilege follows the contribution point, not the extension. There are three trust tiers:

A service projection is read-only by contract: a manifest that claims write or paste capability is rejected, and it can only act through host-mediated command callbacks, never as a filesystem write. An fs-provider may serve real writes — but only up to its class ceiling, and the actual mutation still runs through a host-gated tool (e.g. adb), never with any privilege beyond its granted execTools.

The runner is explicitly not a sandbox. sheru-extension-runner loads arbitrary code (await import(entry)), so a local extension keeps full host access like any installed CLI. Therefore extensions are trusted (first-party / vetted); the exec / secret / http callbacks are the sanctioned interface and review surface, not an enforced security boundary. Untrusted third-party logic would require an actual sandbox.

What a manifest looks like

An extension ships a manifest.json (validated against the published schema) describing the node it serves. A service-projection manifest (GitHub):

{
  "id": "github",
  "name": "GitHub",
  "class": "service",
  "icon": "github",
  "runtime": { "type": "sheru-ts", "entry": "extension.js" },
  "execTools": ["gh"]
}

Required fields are id, name, class, runtime. The id matches ^[a-z0-9-]+$ and must equal the directory name; class is "service" (a read-only projection; the legacy spelling "connector" is still accepted) or "fs-provider" (a faithful filesystem); runtime.type is const "sheru-ts" with runtime.entry the module the runner imports. execTools / secrets are the host.exec / host.secret allow-lists, allowHttp defaults false, and schemaVersion defaults to the current schema version (2) — the core requires an exact match. An fs-provider additionally declares a services block (its fs write/delete/paste bits, a terminal command template, a proc block, and device nodes.enumerate); a service projection omits it. See the manifest reference.

The extension model is designed around a multi-contribution manifest: an extension can grow beyond a single node into a contributes bag with keys nodes / commands / menus / keybindings / openers / launchers / sidebarItems, command ids namespaced by the extension id. For the full picture see the authoring guide and the manifest reference.

What you write as an author

An author touches exactly two things: the entry module's default export, and the host-gated ExtensionContext handed to every handler. The id is not in the definition — it lives once in the manifest, and the runner supplies it. The node is written as handler groups keyed by service:

// index.ts — built on @sheru-app/extension-sdk
import { defineExtension } from "@sheru-app/extension-sdk";
import { commands, invokeCommand, listPage, read } from "./github";

let repos: string[] = [];

export default defineExtension({
  node: {
    init(params) {
      const list = (params.params as { repos?: unknown }).repos;
      repos = Array.isArray(list) ? list.filter((x): x is string => typeof x === "string") : [];
    },
    fs: {
      list: (params, ctx) => listPage(params, repos, ctx),
      read: (params, ctx) => read(params.path, params.maxBytes, ctx),
    },
    commands: {
      list: (params) => ({ commands: commands(params.path) }),
      invoke: (params, ctx) => invokeCommand(params.command, params.path, params.args, ctx),
    },
  },
});

The legacy flat connector: { listPage, read, commands, invokeCommand } shape is still accepted for a read-only service projection and is mapped onto node.fs / node.commands; new code writes node.

Only fs.list and fs.read are required; the fs mutation verbs, commands, terminal, proc, clipboard, and nodes groups are optional and declared per manifest. node.describe is fully synthesized (id from the manifest, protocol version) — what the node serves comes from the validated manifest, so there is no capability block to author. Handlers reach the host only through ctx:

// the host-gated SPI — every handler receives this
export function makeGh(ctx: ExtensionContext): Gh {
  return async (args: string[]) => {
    const r = await ctx.execBytes("gh", args);          // only binaries in the manifest's execTools
    const stdout = new TextDecoder().decode(r.stdout);
    if (r.code !== 0) { /* throw with decoded stderr */ }
    return JSON.parse(stdout);
  };
}

ctx.execBytes(bin, args, { stdin? }) runs a manifest-allowed binary; ctx.httpBytes(method, url, { headers?, body? }) performs host-mediated HTTP (only if allowHttp); ctx.secret(key) fetches a manifest-declared secret. The raw ctx.exec / ctx.http variants mirror the base64 wire exactly. See the authoring guide.

How the host runs an extension

An extension is data, never its own executable. The host runs one signed binary, sheru-extension-runner. The host spawns it with a scrubbed environment, pointed at the extension's directory. The runner then:

  1. reads the directory's manifest.json (the single source of truth for the id + runtime.entry; the host has already validated it);
  2. imports the entry module's default export;
  3. checks the default export carries a node (or the legacy connector), otherwise exits with code 2;
  4. calls runExtension(id, def), which owns the stdio loop.
// the SDK's stdio loop — authors never call this; the runner does
runExtension(id: string, def: ExtensionDefinition): Promise<void>

The id always comes from the manifest, never the extension code — which is why per-extension code-signing collapses to one binary. The sub-protocol (EXTENSION_PROTOCOL = 2, exact-match) is line-delimited JSON over one shared stdio pipe, multiplexed by request id: downward requests (core → runner) are service-namespaced — node.describe, fs.* (list / read plus an fs-provider's mutation verbs), commands.list / commands.invoke, terminal.describe, proc.*, clipboard.*, nodes.enumerate; upward callbacks (runner → core) are host.exec / host.http / host.secret — the sanctioned host-gated door, gated by the manifest's execTools / secrets / allowHttp. An fs-provider may also emit a fire-and-forget { kind: "event", name: "nodes.changed" } when its device set changes. stderr is a free-form log drain. For the full wire and lifecycle, see the runner reference.

Where to go next