Plugins
The SDK
@escouade-dev/plugin writes a plugin of Escouade in TypeScript: the protocol’s types, a client that speaks it, inside Escouade in WASM or under Node, a fake Escouade for the plugin’s tests, and the escouade-plugin command. It is under the MIT license, like the protocol’s schema, and depends on nothing.
A first plugin
npm create escouade-plugin@latest my-plugin
cd my-plugin
npm install
npm test # its handlers, against the fake Escouade, in memory
npm run validate # the manifest, field by field
npm run bench # the conformance bench, on the real program
The command asks for the kind of plugin; --kind program, --kind rust or --kind declarative chooses without asking, --id and --name give its id and its name.
- A program (
program): a TypeScript plugin with its tests.src/plugin.tssays when a ticket is ready to test and refuses force pushes;src/main.tsstarts it;npm run buildbundles both intodist/plugin.mjs, whichrun.jsin the manifest names: Escouade runs it inside the app, in WASM, without Node. - A module in Rust (
rust): the same plugin, on the Rust kit, with its tests and itsCargo.lock. Nopackage.json, nonode_modules. - A declarative plugin (
declarative): a theme, described in the manifest. Nothing runs.
To try it in Escouade, load its folder in Settings › Plugins, in developer mode. Escouade runs a plugin as its repository holds it, without npm install: commit dist/ before you tag a version.
The client
The program template, as npm create escouade-plugin writes it: what the plugin does, apart so that its tests drive it without a process,
sdk/create-escouade-plugin/templates/program/src/plugin.ts
// What the plugin does: its handlers, kept apart from main.ts so that the tests drive them without a process.
import { createPlugin } from '@escouade-dev/plugin';
export const plugin = createPlugin();
// A fact of permissions.events: a ticket changed columns (its data says from and to).
plugin.onEvent('ticket.column', (fact, p) => {
// A ticket's fact says its ticket; the fact's type, one for every fact, leaves it optional.
if (fact.data.to === 'review') p.log.info(`${fact.ticket?.key ?? ''} is ready to test (${fact.project?.name ?? ''})`);
});
// A permission an agent asks for, before the user is asked (permissions.decide). Answer within 500 ms.
plugin.decidePermission(({ request }) => {
const command = typeof request.input?.command === 'string' ? request.input.command : '';
if (request.kind === 'command' && /\bgit\s+push\b.*(--force\b|\s-f\b)/.test(command)) {
return { decision: 'deny', message: 'No force push in this team: push a new commit instead.' };
}
// Leaves it to the user, as without the plugin.
return { decision: 'ask' };
});
and the program, which run.js names once bundled into dist/plugin.mjs:
sdk/create-escouade-plugin/templates/program/src/main.ts
// The program (run.js in escouade-plugin.json, once bundled): speaks the protocol to Escouade, which runs it inside the
// app in WASM; under Node too (the bench of escouade-plugin, a test), on stdin and stdout. start() finds which.
import { plugin } from './plugin.ts';
void plugin.start();
| Helper | What it does |
|---|---|
onEvent(name, handler) |
Runs handler on each fact of name, or of a pattern ending in * (ticket.*), or of a list. The facts must be in permissions.events. A handler that fails goes to the plugin’s journal; the others still run. |
decidePermission(handler) |
Answers permissions/decide: allow, deny with a message, or ask, which leaves it to the user. Without a handler, the answer is ask. |
secret(key) |
Asks Escouade for one of its secrets (secrets/get); null when none is kept. Another key rejects with the error -32001. |
settings(project?) |
Asks for its settings (settings/get); with a project’s id, those its .escouade.yml gives it (plugins.<id>) go first. |
log.debug/info/warn/error(message) |
A line of its journal (log). |
file(path) |
Reads a file of its folder as text (files/read), a subfolder included (data/words.txt), 256 KB at most, under Node as in WASM: never its manifest, a hidden file or a path that leaves it (RpcError ERRORS.refused); a missing file fails with ERRORS.notFound. |
fs.read/text/write/stat/list/canon(file) |
The folders of permissions.files, in WASM (the files). |
runProgram(params, onOut?) |
Runs a line of permissions.commands, in WASM: exited, write, close, kill (the programs). |
openStream(p, url, init?), openSocket(p, url, headers?) |
Imported apart: a response read as it comes and a WebSocket, in WASM (the network). |
handle(method, handler) |
Serves another method (a later extension point). Throw new RpcError(code, message) to answer an error. |
onInitialize(handler), onShutdown(handler) |
Runs at initialize, or before the answer to shutdown (800 ms at most: Escouade waits a second). |
call(method, params), notify(method, params) |
A call or a notification to Escouade, typed for its methods. |
start() |
Speaks the protocol to Escouade: to its WASM host when the app runs the plugin, on the standard input and output under Node (the bench, a test); the same code on both. Ends the plugin after shutdown. |
The client answers initialize with version 1 of the protocol, whatever is offered (Escouade stops a plugin that does not speak its own), serves batches, answers a method it does not know with -32601, and never writes anything but the protocol on its standard output: write your traces on the standard error, which goes to the journal.
The types
The protocol’s types come from one schema, plugin-protocol-1.json: its methods, the parameters and answers of each, their delays, the facts and the error codes. The SDK’s TypeScript is generated from it, the manifest’s types from plugin-1.json, and Escouade’s tests hold the core to the same schema: a change on one side without the other fails the tests.
| Type | What it is |
|---|---|
Fact, FactName, FACT_NAMES |
A fact (events/fact): its name, its version, when, and the project, the ticket and the agent by their ids and names. |
DecideParams, PermissionRequest, Decision |
A permission to decide, and the answer. |
InitializeParams, InitializeResult |
The handshake. |
ToPlugin, ToEscouade |
Each method, its parameters and its answer, by direction. |
TIMEOUTS_MS, DEFAULT_TIMEOUT_MS, ERRORS |
The delays of Escouade’s calls, and the error codes. |
Manifest |
escouade-plugin.json. |
@escouade-dev/plugin/manifest-check gives the checks of the manifest that the store and Escouade make: validate(manifest) and message(error, 'en').
The tests
@escouade-dev/plugin/testing gives a fake Escouade, in memory: it sends the plugin the same messages as the app and the bench, without starting a process.
The template’s tests:
sdk/create-escouade-plugin/templates/program/test/plugin.test.ts
// The plugin's handlers, driven by the SDK's fake Escouade, in memory. `npm run bench` plays the real program.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { connect } from '@escouade-dev/plugin/testing';
import { plugin } from '../src/plugin.ts';
test('it speaks version 1 of the protocol', async () => {
const escouade = connect(plugin);
const hello = await escouade.initialize();
assert.equal(hello.protocolVersion, 1);
});
test('a ticket moved to review is said in the journal', async () => {
const escouade = connect(plugin);
await escouade.initialize();
await escouade.fact('ticket.column', { ticket: { key: 'APP-7' }, data: { from: 'doing', to: 'review' } });
assert.deepEqual(escouade.logs, [{ level: 'info', message: 'APP-7 is ready to test (Banc)' }]);
});
test('a force push is refused, the rest left to the user', async () => {
const escouade = connect(plugin);
await escouade.initialize();
const push = await escouade.decide({ request: { input: { command: 'git push --force origin main' } } });
assert.equal(push.decision, 'deny');
assert.equal((await escouade.decide()).decision, 'ask');
});
fact(name, fields) sends a fact and waits for its handlers; decide(fields) asks for a decision; call and notify send anything else. The fields you give are merged over those of the bench (the project “Banc”, the ticket BEN-1, a npm test command). The fake keeps what the plugin logged (logs), the secrets it asked for (secretsAsked) and its traces; secrets gives their values, and a key outside them is refused, as in Escouade.
The command
The SDK installs escouade-plugin in the plugin’s node_modules, for its npm scripts:
| Command | What it does |
|---|---|
escouade-plugin validate [folder] |
Reads the manifest and says what is wrong, field by field, and checks that the programs of run are in the folder. |
escouade-plugin test [folder] |
The conformance bench: Escouade’s own. |
escouade-plugin dev [folder] |
The fake Escouade: facts, decisions and calls you type, to a running program. |
escouade-plugin pack [folder] |
The plugin’s archive and its SHA-256. |
escouade-plugin build [folder] |
Compiles run.js or run.wasm to WASM, as the store does: packs/<id>-<version>.wasm and its .sha256. |
escouade-plugin fetch [folder] |
Downloads the dependencies of a run.wasm from crates.io, running nothing of the plugin. |
test and dev run the escouade-plugin that ships with Escouade, so that a plugin is judged by the bench of the app: the one of ESCOUADE_PLUGIN_BIN, else the one on the PATH, else the app’s (%LOCALAPPDATA%\Escouade on Windows, Escouade.app on macOS, /usr/bin with the .deb). Without one, the command says where to get it.
The archive
escouade-plugin pack writes packs/<id>-<version>.tar.gz and its .sha256, with every file of the folder but .git, node_modules and packs. The archive is the same, to the byte, from the same files, on any system: the dates are zeroed, and the programs of run and the scripts that start with #! are executable. A symbolic link, or an archive past 50 MB, is refused, as by the store.
--target windows-x64 (or macos-arm64, linux-x64…) names the archive of one system, <id>-<version>-windows-x64.tar.gz.
Without Node
A plugin in JS or TypeScript ships as its bundle, named by run.js:
sdk/create-escouade-plugin/templates/program/escouade-plugin.json (excerpt)
"run": { "js": "dist/plugin.mjs" },
Escouade runs it inside the app, in WASM, with no Node on the user’s machine: one isolated instance per plugin, which keeps its state from one call to the next like a process. The store compiles the bundle when it is submitted, and the app downloads that module and checks it against the version’s version.json; a plugin installed from a folder or a git repository is compiled by the app. The install window says “Runs inside Escouade, isolated”, and permissions.network is enforced there: fetch goes through Escouade, over https, to those hosts only (see the protocol).
- The same code on both transports:
start()speaks to Escouade’s WASM host when the app runs the plugin, on standard input and output under Node (its tests, a plugin started by a command). A plugin written for the SDK runs unchanged on both sides. - Its files:
readFileSync(new URL('../rules.json', globalThis._importMeta_.url), 'utf8')still reads a JSON file at the root of its folder;p.file()reads the others. The standardfetchserves the network, through Escouade in WASM. - What the bundle may import:
node:fsandnode:readline, which Escouade serves, and nothing else; it is one file, withoutexport. Any otherimportfails its compilation, andescouade-plugin validatesays so in your language (“the bundle imports node:child_process, which a plugin in WASM does not have (only node:fs, node:readline)”).setTimeoutandclearTimeoutexist there. - SDK 0.3 or later: a bundle made with SDK 0.2 does not run in WASM; rebuild it (
npm run build). - The tools, without Node:
escouade-plugin buildmakes the module as the store does, to check that it compiles;testanddevplay the bundle in WASM, in the app’s host. The Javy that compiles it (9.1.0) is downloaded the first time it is needed into~/.escouade/toolsand checked by its hash; the app andescouade-pluginshare it. Node only serves the author, fornpm run buildand the plugin’s tests; whoever installs it never needs it. No more need to make it an executable (deno compile,bun build --compile) for people without Node. - What the plugin can ask for on top:
p.fsfor the folders ofpermissions.files,p.runProgramfor the programs ofpermissions.commands,openStreamandopenSocketfor the network (see the calls). The store no longer publishes arun.command.
In CI
jobs:
plugin:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
- run: npm run typecheck && npm test && npm run build
- run: npx escouade-plugin validate .
# The bench comes with Escouade: install its .deb, or give the path of its escouade-plugin.
- run: npx escouade-plugin test .
env:
ESCOUADE_PLUGIN_BIN: /usr/bin/escouade-plugin
Escouade’s CI does the same on the templates of npm create escouade-plugin, against the bench of each build.