acrylblends 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -1
- package/dist/index.js +39 -7
- package/dist/index.js.map +1 -1
- package/docs/framework/guides/create-acrylblends.md +4 -2
- package/docs/framework/workshop/README.md +2 -2
- package/docs/framework/workshop/facilitator.md +1 -1
- package/lib/cordis/door.d.ts +45 -2
- package/lib/cordis/door.js +120 -0
- package/package.json +1 -1
- package/plugins/acryl-door/plugin.json +3 -0
- package/plugins/acryl-door/src/cli/acryl.ts +63 -6
- package/plugins/acryl-door/src/host/door.ts +75 -1
- package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
- package/plugins/acryl-door/src/host/verbs.ts +11 -1
- package/plugins/acryl-door/src/index.ts +2 -1
- package/plugins/builder/index.ts +13 -35
- package/plugins/builder/package.ts +186 -0
- package/plugins/builder/plugin.json +3 -1
- package/plugins/extensions/index.ts +21 -17
- package/plugins/extensions/plugin-store.ts +27 -0
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/stage.ts +15 -5
- package/plugins/manifest/plugin.json +1 -1
- package/plugins/plugin-share/README.md +51 -0
- package/plugins/plugin-share/index.ts +467 -0
- package/plugins/plugin-share/plugin.json +15 -0
- package/plugins/plugin-share/publish.ts +65 -0
- package/plugins/plugin-share/pull-check.ts +129 -0
- package/plugins/plugin-share/pulled-records.ts +70 -0
- package/templates/agent/.gitattributes +2 -0
- package/templates/agent/blend.yaml +2 -0
- package/templates/agent/src/app.ts +4 -0
- package/templates/agent/template.json +1 -0
- package/templates/blank/.gitattributes +2 -0
- package/templates/chat/.gitattributes +2 -0
- package/templates/nodejs/.gitattributes +2 -0
- package/templates/notes/.gitattributes +2 -0
- package/templates/todo/.gitattributes +2 -0
|
@@ -55,7 +55,7 @@ bun create acrylblends@latest my-app --template nodejs
|
|
|
55
55
|
| `[source]` | A template from outside the package: `github:owner/repo[#ref]`, `@scope/name[@version]` or a packed `.tgz` file. See [Templates from a repository or a package](#templates-from-a-repository-or-a-package). | `github:acme/starter` |
|
|
56
56
|
| `--template <template>` | One of `blank`, `nodejs`, `notes`, `chat`, `todo`, `agent` (the same list as `acrylblends init --template`; `acryl-app` is accepted as `agent`). Skips the template question; a bare name after the directory means the same. | `--template notes` |
|
|
57
57
|
| `--from <id \| source>` | A starter from the registry by its id (`acrylblends.notes-desktop`), or any source above. Only starters that declare `runtime: acrylblends` are made; an ACRYL app starter is refused. See [Starters from the registry](#starters-from-the-registry). | `-- --from acrylblends.notes-desktop` |
|
|
58
|
-
| `--registry <source>` | Read the ids from this registry instead of the public one: `github:owner/repo` or a folder, with an `index.json` (at its root or in `registry/`). | `-- --from login.thing --registry github:acryldev/workshop-2026-11-20` |
|
|
58
|
+
| `--registry <source>` | Read the ids from this registry instead of the public one: `github:owner/repo`, an `https://` address, or a folder, with an `index.json` (at its root or in `registry/`). | `-- --from login.thing --registry github:acryldev/workshop-2026-11-20` |
|
|
59
59
|
| `--install` | Install the dependencies with the package manager after creating the project. For a template from a source, the install scripts it will run are listed first. | `--install` |
|
|
60
60
|
| `--pm <packageManager>` | The package manager for `--install`: `npm`, `pnpm`, `yarn` or `bun`. Defaults to the one that started the initializer. | `--pm pnpm` |
|
|
61
61
|
| `--yes`, `-y` | Ask nothing: use `nodejs` unless `--template` is given, and the directory `acryl-app`. | `-y` |
|
|
@@ -78,7 +78,9 @@ npm run dev
|
|
|
78
78
|
- A starter with `runtime: acrylblends` is an `acrylblends` app: this command downloads its folder, gives the manifest to the new app (`metadata.id: acrylblends.my-notes`, `visibility: private`; the starter's id and licence stay the starter's), checks it for anything that looks like a secret, and writes it. Nothing in it is installed or run: `npm install` is yours to run, and the dependencies of the starter (a desktop starter brings `electron`) are in its `package.json`.
|
|
79
79
|
- A starter for the ACRYL app (`runtime: acryl`, the older `acryl.*` entries) is refused with one line that says so, because its plugins run only inside the ACRYL app. Nothing is written.
|
|
80
80
|
- An unknown id lists the starters this command can make.
|
|
81
|
-
- The ids come from `https://acrylblends.github.io/registry/index.json
|
|
81
|
+
- The ids come from `https://acrylblends.github.io/registry/index.json`, and only the starter you asked for is downloaded, as one archive checked against the integrity in the index; an archive that does not match is refused. When the site cannot give the starter, the registry's repository is read instead. `--registry github:owner/repo` reads another registry with the same layout (`index.json` and `blends/<id>/`), a team's own or a workshop's; `--registry ./folder` reads one on disk.
|
|
82
|
+
|
|
83
|
+
A registry on any static host works with `--registry https://...`, if its `index.json` names an `archive` and an `integrity` for each entry: `workshop-registry/scripts/registry-archives.mjs` in this repository writes both when you deploy, on the copy you publish.
|
|
82
84
|
|
|
83
85
|
To publish a starter of your own, see [the registry README](https://github.com/acrylblends/acrylblends.github.io/tree/main/registry): the folder of your app under `blends/<id>/` with an `acryl.template.json`, in a pull request.
|
|
84
86
|
|
|
@@ -8,7 +8,7 @@ app around it: a folder of Cordis plugins with a `blend.yaml`, running on your m
|
|
|
8
8
|
The frame is the workshop's point. You do not edit a folder and alt-tab to a browser: your app is a page inside the workspace, the agents work in
|
|
9
9
|
the same folder the frame shows, and every change you or an agent makes is one restart away from the page.
|
|
10
10
|
|
|
11
|
-
This guide is for **acrylblends 1.
|
|
11
|
+
This guide is for **acrylblends 1.3.0 or newer**; `npm create acrylblends@latest` gives you that. An app you made with an older release gets the
|
|
12
12
|
frame with the upgrade in block 6.
|
|
13
13
|
|
|
14
14
|
## 0. Before you start (10 minutes)
|
|
@@ -81,7 +81,7 @@ between watching and building.
|
|
|
81
81
|
## 1. Create the app; the frame opens on it (10 minutes)
|
|
82
82
|
|
|
83
83
|
```sh
|
|
84
|
-
npm create acrylblends@latest my-app agent
|
|
84
|
+
npm create acrylblends@latest my-app agent -- --desktop
|
|
85
85
|
cd my-app
|
|
86
86
|
npm install
|
|
87
87
|
npm run dev
|
|
@@ -5,7 +5,7 @@ app is created, then **opened inside ACRYL's IDE** (projects on the left, the ap
|
|
|
5
5
|
chat model works on the app from inside the frame, and coding agents run in terminal tabs in the same folder. The data layer, the desktop window and
|
|
6
6
|
the panel-first flow of the previous handout are gone.
|
|
7
7
|
|
|
8
|
-
The handout is written for **acrylblends 1.
|
|
8
|
+
The handout is written for **acrylblends 1.3.0 or newer** (the release whose `create --desktop` makes the framed app). A room on an older release is on the previous handout;
|
|
9
9
|
check with `npx acrylblends@latest --version` before the day.
|
|
10
10
|
|
|
11
11
|
## Before the room: put this on every machine
|
package/lib/cordis/door.d.ts
CHANGED
|
@@ -84,7 +84,7 @@ declare class ShellChannel {
|
|
|
84
84
|
}
|
|
85
85
|
//#endregion
|
|
86
86
|
//#region ../../plugins/acryl-door/src/host/door.d.ts
|
|
87
|
-
type DoorErrorCode = 'invalid' | 'unknown-verb' | 'unavailable' | 'not-exposed' | 'outside-app' | 'failed' | ShellErrorCode;
|
|
87
|
+
type DoorErrorCode = 'invalid' | 'unknown-verb' | 'unavailable' | 'not-exposed' | 'outside-app' | 'failed' | 'refused' | ShellErrorCode;
|
|
88
88
|
declare class DoorError extends Error {
|
|
89
89
|
readonly code: DoorErrorCode;
|
|
90
90
|
constructor(code: DoorErrorCode, message: string);
|
|
@@ -96,6 +96,41 @@ interface ExtensionsPort {
|
|
|
96
96
|
reload(): Promise<unknown>;
|
|
97
97
|
list(): readonly unknown[];
|
|
98
98
|
}
|
|
99
|
+
/** The slice of acrylblends' `pluginShare` service the pull verb uses. */
|
|
100
|
+
interface PluginSharePort {
|
|
101
|
+
pull(source: string): Promise<{
|
|
102
|
+
readonly ok: boolean;
|
|
103
|
+
readonly action?: string;
|
|
104
|
+
readonly reason?: string;
|
|
105
|
+
readonly next?: string;
|
|
106
|
+
}>;
|
|
107
|
+
planPublish(name: string, options?: PublishOptionsView): Promise<PublishPlanView | {
|
|
108
|
+
readonly ok: false;
|
|
109
|
+
readonly reason: string;
|
|
110
|
+
readonly next: string;
|
|
111
|
+
}>;
|
|
112
|
+
publish(name: string, confirmed: PublishPlanView, options?: PublishOptionsView): Promise<{
|
|
113
|
+
readonly ok: boolean;
|
|
114
|
+
readonly reason?: string;
|
|
115
|
+
}>;
|
|
116
|
+
}
|
|
117
|
+
/** Who a plan publishes as, and under which license; the service's defaults when absent. */
|
|
118
|
+
interface PublishOptionsView {
|
|
119
|
+
readonly owner?: string;
|
|
120
|
+
readonly license?: string;
|
|
121
|
+
}
|
|
122
|
+
/** A plan as the door shows it. The service owns it; the door passes it back unchanged. */
|
|
123
|
+
interface PublishPlanView {
|
|
124
|
+
readonly owner: string;
|
|
125
|
+
readonly repo: string;
|
|
126
|
+
readonly version: string;
|
|
127
|
+
readonly visibility: string;
|
|
128
|
+
readonly license: string;
|
|
129
|
+
readonly createsRepository: boolean;
|
|
130
|
+
readonly files: readonly string[];
|
|
131
|
+
readonly digest: string;
|
|
132
|
+
readonly pullLine: string;
|
|
133
|
+
}
|
|
99
134
|
/** The slice of acrylblends' `tools` registry (the `agent` plugin's) the tool verbs use, and where the built-in chat finds the verbs. */
|
|
100
135
|
interface ToolsPort {
|
|
101
136
|
/** @returns the function that removes the tool. @throws when the name is taken. */
|
|
@@ -128,6 +163,8 @@ interface DoorDeps {
|
|
|
128
163
|
readonly appBase?: string | undefined;
|
|
129
164
|
/** Read at each call: the services may come and go while the door runs. */
|
|
130
165
|
readonly extensions: () => ExtensionsPort | undefined;
|
|
166
|
+
/** Read at each call, as `extensions` is; absent where the app has no plugin-share row. */
|
|
167
|
+
readonly pluginShare?: () => PluginSharePort | undefined;
|
|
131
168
|
readonly tools: () => ToolsPort | undefined;
|
|
132
169
|
/** The app tools the door offers; anything else is refused before it reaches the registry. */
|
|
133
170
|
readonly expose: readonly string[];
|
|
@@ -171,6 +208,12 @@ declare class Door {
|
|
|
171
208
|
/** Run one call on the page and record it. The audit holds which control was touched and how it ended, never what was typed. */
|
|
172
209
|
private callPage;
|
|
173
210
|
private plugin;
|
|
211
|
+
private share;
|
|
212
|
+
/** Only a plan: no argument of any verb can publish, so an agent never says yes for a person. */
|
|
213
|
+
private planPublish;
|
|
214
|
+
/** The person's yes, from their own terminal. Not a verb: MCP and the chat do not have it. */
|
|
215
|
+
publishConfirmed(name: string, confirmed: PublishPlanView, options: PublishOptionsView): Promise<unknown>;
|
|
216
|
+
private pull;
|
|
174
217
|
/** A folder named by an agent, resolved from the app folder; it must be inside the app. */
|
|
175
218
|
private insideApp;
|
|
176
219
|
private offeredTools;
|
|
@@ -185,7 +228,7 @@ declare class Door {
|
|
|
185
228
|
/** Every verb, in the order `brief` lists them. */
|
|
186
229
|
/** The verbs that only use the page: what a page-only door (a production assistant) offers. */
|
|
187
230
|
declare const PAGE_VERBS: readonly ["look", "act"];
|
|
188
|
-
declare const VERBS: readonly ["brief", "look", "act", "plugin-list", "plugin-add", "plugin-remove", "plugin-reload", "tool-list", "tool-call", "window-list", "window-open", "window-focus", "window-close", "window-resize", "menu-list", "menu-click"];
|
|
231
|
+
declare const VERBS: readonly ["brief", "look", "act", "plugin-list", "plugin-add", "plugin-pull", "plugin-publish", "plugin-remove", "plugin-reload", "tool-list", "tool-call", "window-list", "window-open", "window-focus", "window-close", "window-resize", "menu-list", "menu-click"];
|
|
189
232
|
type Verb = (typeof VERBS)[number];
|
|
190
233
|
/** The JSON Schema of a verb's arguments, as MCP lists it. */
|
|
191
234
|
interface ArgumentsSchema {
|
package/lib/cordis/door.js
CHANGED
|
@@ -306,6 +306,8 @@ const VERBS = [
|
|
|
306
306
|
"act",
|
|
307
307
|
"plugin-list",
|
|
308
308
|
"plugin-add",
|
|
309
|
+
"plugin-pull",
|
|
310
|
+
"plugin-publish",
|
|
309
311
|
"plugin-remove",
|
|
310
312
|
"plugin-reload",
|
|
311
313
|
"tool-list",
|
|
@@ -461,6 +463,42 @@ const VERB_SPECS = {
|
|
|
461
463
|
additionalProperties: false
|
|
462
464
|
}
|
|
463
465
|
},
|
|
466
|
+
"plugin-pull": {
|
|
467
|
+
usage: "plugin pull <source>: fetch a plugin someone released (github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz), check it and start it, live",
|
|
468
|
+
description: "Fetch a plugin someone released, without running anything; check its digest, that it works on every OS and that this app has the services it needs; then install and start it without a restart. A refusal says why and changes nothing.",
|
|
469
|
+
arguments: {
|
|
470
|
+
type: "object",
|
|
471
|
+
properties: { source: {
|
|
472
|
+
type: "string",
|
|
473
|
+
description: "github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz path or https address"
|
|
474
|
+
} },
|
|
475
|
+
required: ["source"],
|
|
476
|
+
additionalProperties: false
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
"plugin-publish": {
|
|
480
|
+
usage: "plugin publish <name> [--owner <account>] [--license <id>]: show what publishing this plugin would send; in a person's own terminal, ask them",
|
|
481
|
+
description: "Show what publishing a plugin would send: the account, repository, version, license, files and the line a friend pulls it with. Sends nothing. Only a person can say yes, in their own terminal or with Share in the frame; tell them the plan and how.",
|
|
482
|
+
arguments: {
|
|
483
|
+
type: "object",
|
|
484
|
+
properties: {
|
|
485
|
+
name: {
|
|
486
|
+
type: "string",
|
|
487
|
+
description: "a plugin of plugins/"
|
|
488
|
+
},
|
|
489
|
+
owner: {
|
|
490
|
+
type: "string",
|
|
491
|
+
description: "the GitHub account or organization; the signed-in one when absent"
|
|
492
|
+
},
|
|
493
|
+
license: {
|
|
494
|
+
type: "string",
|
|
495
|
+
description: "an SPDX license id; MIT when absent"
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
required: ["name"],
|
|
499
|
+
additionalProperties: false
|
|
500
|
+
}
|
|
501
|
+
},
|
|
464
502
|
"plugin-remove": {
|
|
465
503
|
usage: "plugin remove <name>: stop and remove an installed plugin",
|
|
466
504
|
description: "Stop and remove an installed plugin.",
|
|
@@ -615,6 +653,14 @@ var DoorError = class extends Error {
|
|
|
615
653
|
this.name = "DoorError";
|
|
616
654
|
}
|
|
617
655
|
};
|
|
656
|
+
/** What a plan says, for a person deciding. */
|
|
657
|
+
function renderPublishPlan(plan) {
|
|
658
|
+
return [
|
|
659
|
+
`Publish to ${plan.owner}/${plan.repo}${plan.createsRepository ? " (a new repository)" : ""}, ${plan.visibility}, version ${plan.version}, license ${plan.license}.`,
|
|
660
|
+
`Files: ${plan.files.join(", ")}.`,
|
|
661
|
+
`A friend gets exactly these bytes with: ${plan.pullLine}`
|
|
662
|
+
].join("\n");
|
|
663
|
+
}
|
|
618
664
|
/** A shell answer as text an agent reads: windows and menu items one to a line. */
|
|
619
665
|
function renderShell(result) {
|
|
620
666
|
if ("windows" in result) return result.windows.length === 0 ? "no windows are open" : result.windows.map((window) => `${String(window.id)} ${JSON.stringify(window.title)} ${window.path} ${String(window.width)}x${String(window.height)}${window.x === void 0 || window.y === void 0 ? "" : `@${String(window.x)},${String(window.y)}`}${window.focused ? " focused" : ""}`).join("\n");
|
|
@@ -662,6 +708,8 @@ var Door = class {
|
|
|
662
708
|
}
|
|
663
709
|
case "plugin-list": return this.plugin((extensions) => extensions.list(), false);
|
|
664
710
|
case "plugin-add": return this.plugin((extensions) => extensions.install(this.insideApp(input.folder)));
|
|
711
|
+
case "plugin-pull": return this.pull(input.source);
|
|
712
|
+
case "plugin-publish": return this.planPublish(input);
|
|
665
713
|
case "plugin-remove": {
|
|
666
714
|
const name = input.name;
|
|
667
715
|
if (typeof name !== "string" || name === "") throw new DoorError("invalid", "plugin remove needs a plugin name");
|
|
@@ -905,6 +953,50 @@ var Door = class {
|
|
|
905
953
|
text: JSON.stringify(value, null, 2)
|
|
906
954
|
};
|
|
907
955
|
}
|
|
956
|
+
share() {
|
|
957
|
+
const share = this.deps.pluginShare?.();
|
|
958
|
+
if (share === void 0) throw new DoorError("unavailable", "plugin-share is not running in this app. If the app just changed it is restarting, so try again in a moment. An app made by an older version does not have it, and upgrade does not add it: run `npx acrylblends add plugin-share` and follow its README");
|
|
959
|
+
return share;
|
|
960
|
+
}
|
|
961
|
+
/** Only a plan: no argument of any verb can publish, so an agent never says yes for a person. */
|
|
962
|
+
async planPublish(input) {
|
|
963
|
+
const { name, owner, license } = input;
|
|
964
|
+
if (typeof name !== "string" || name === "") throw new DoorError("invalid", "plugin publish needs a plugin name, e.g. plugin publish pomodoro");
|
|
965
|
+
const value = await this.share().planPublish(name, {
|
|
966
|
+
...typeof owner === "string" && owner !== "" ? { owner } : {},
|
|
967
|
+
...typeof license === "string" && license !== "" ? { license } : {}
|
|
968
|
+
});
|
|
969
|
+
if ("ok" in value) throw new DoorError("refused", `${value.reason}. ${value.next}.`);
|
|
970
|
+
return {
|
|
971
|
+
value,
|
|
972
|
+
text: `${renderPublishPlan(value)}\nNothing was sent. Only a person can say yes: they run \`npm run acryl -- plugin publish ${name}\` in their own terminal, or press Share in the frame. \`--license <id>\` changes the license before the yes.`
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
/** The person's yes, from their own terminal. Not a verb: MCP and the chat do not have it. */
|
|
976
|
+
async publishConfirmed(name, confirmed, options) {
|
|
977
|
+
const value = await this.share().publish(name, confirmed, options);
|
|
978
|
+
this.deps.audit.record({
|
|
979
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
980
|
+
tool: "plugin-publish",
|
|
981
|
+
target: {
|
|
982
|
+
role: "plugin",
|
|
983
|
+
name: `${confirmed.owner}/${confirmed.repo}@${confirmed.version}`
|
|
984
|
+
},
|
|
985
|
+
outcome: value.ok ? "ok" : "refused",
|
|
986
|
+
approval: "token"
|
|
987
|
+
});
|
|
988
|
+
return value;
|
|
989
|
+
}
|
|
990
|
+
async pull(source) {
|
|
991
|
+
if (typeof source !== "string" || source === "") throw new DoorError("invalid", "plugin pull needs a source, e.g. plugin pull github:someone/pomodoro@0.1.0");
|
|
992
|
+
const value = await this.share().pull(source);
|
|
993
|
+
if (!value.ok) throw new DoorError("refused", `${value.reason ?? "the pull was refused"}. ${value.next ?? "nothing changed"}.`);
|
|
994
|
+
if (value.action !== "unchanged" && this.deps.reloadOnChange !== false) await this.deps.channel.reloadAll();
|
|
995
|
+
return {
|
|
996
|
+
value,
|
|
997
|
+
text: JSON.stringify(value, null, 2)
|
|
998
|
+
};
|
|
999
|
+
}
|
|
908
1000
|
/** A folder named by an agent, resolved from the app folder; it must be inside the app. */
|
|
909
1001
|
insideApp(folder) {
|
|
910
1002
|
if (typeof folder !== "string" || folder === "") throw new DoorError("invalid", "plugin add needs a folder, e.g. plugins/pomodoro");
|
|
@@ -1196,6 +1288,33 @@ function operatorRoutes(prefix, secret, door, reportError) {
|
|
|
1196
1288
|
reportError(`run the door verb ${verb}`, cause);
|
|
1197
1289
|
return c.json(error("the verb could not be run"), 500);
|
|
1198
1290
|
}
|
|
1291
|
+
}).post(`${prefix}/publish-confirmed`, operatorOnly(secret), jsonBody, async (c) => {
|
|
1292
|
+
const body = await readJson(c);
|
|
1293
|
+
const { name, confirm, owner, license } = body === INVALID_JSON || typeof body !== "object" || body === null ? {} : body;
|
|
1294
|
+
if (typeof name !== "string" || typeof confirm !== "object" || confirm === null) return c.json({
|
|
1295
|
+
ok: false,
|
|
1296
|
+
code: "invalid",
|
|
1297
|
+
message: "the body is { name, confirm }"
|
|
1298
|
+
}, 400);
|
|
1299
|
+
try {
|
|
1300
|
+
const value = await door.publishConfirmed(name, confirm, {
|
|
1301
|
+
...typeof owner === "string" ? { owner } : {},
|
|
1302
|
+
...typeof license === "string" ? { license } : {}
|
|
1303
|
+
});
|
|
1304
|
+
return c.json({
|
|
1305
|
+
ok: true,
|
|
1306
|
+
value,
|
|
1307
|
+
text: JSON.stringify(value, null, 2)
|
|
1308
|
+
});
|
|
1309
|
+
} catch (cause) {
|
|
1310
|
+
if (cause instanceof DoorError) return c.json({
|
|
1311
|
+
ok: false,
|
|
1312
|
+
code: cause.code,
|
|
1313
|
+
message: cause.message
|
|
1314
|
+
}, 409);
|
|
1315
|
+
reportError("publish a plugin", cause);
|
|
1316
|
+
return c.json(error("the plugin could not be published"), 500);
|
|
1317
|
+
}
|
|
1199
1318
|
});
|
|
1200
1319
|
}
|
|
1201
1320
|
/** The upgrade request comes from this app's own page: a loopback peer on this port, and an `Origin` that is exactly this app's origin. Nothing else is read as proof. */
|
|
@@ -1473,6 +1592,7 @@ function apply(ctx, rawConfig = {}) {
|
|
|
1473
1592
|
},
|
|
1474
1593
|
...appBase === "" ? {} : { appBase },
|
|
1475
1594
|
extensions: () => ctx.get("extensions"),
|
|
1595
|
+
pluginShare: () => ctx.get("pluginShare"),
|
|
1476
1596
|
tools: () => ctx.get("tools"),
|
|
1477
1597
|
expose: config.tools?.expose ?? [],
|
|
1478
1598
|
reloadOnChange: config.reloadOnChange !== false
|
package/package.json
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
"devDependencies": {
|
|
12
12
|
"ws": "8.21.3"
|
|
13
13
|
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"acryl": "node plugins/acryl-door/src/cli/acryl.ts"
|
|
16
|
+
},
|
|
14
17
|
"mount": "mount it after acryl-node-server and extensions in development builds: root.plugin(door); add <script type=\"module\" src=\"/api/acryl-door/page.js\"></script> to the pages; the command is .acryl/bin/acryl",
|
|
15
18
|
"gitignore": [".acryl/"],
|
|
16
19
|
"omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* The door's operator client: what `<app>/.acryl/bin/acryl`
|
|
3
|
+
* The door's operator client: what `npm run acryl -- <verb>` and `<app>/.acryl/bin/acryl` run. It finds the app from the folder it is started in (the nearest
|
|
4
4
|
* `.acryl/door.json` upward, or `--dir`), never through PATH or a machine-wide registry, so it reaches only the app running from that
|
|
5
5
|
* folder. The secret is read from that file and sent as a `Bearer` header; it is never printed.
|
|
6
6
|
*
|
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
* Exit codes: 0 done, 1 the verb failed (its code and message are printed), 2 a usage error, 3 no running app in this folder.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { createInterface } from 'node:readline/promises'
|
|
13
14
|
import { existsSync, realpathSync } from 'node:fs'
|
|
14
15
|
import { dirname, join, resolve } from 'node:path'
|
|
15
16
|
import { fileURLToPath } from 'node:url'
|
|
16
17
|
import { readDoorFile, STATE_DIR, DOOR_FILE } from '../host/door-file.ts'
|
|
17
18
|
|
|
18
19
|
const ACT_OPS = new Set(['click', 'type', 'select', 'press', 'scroll', 'wait', 'reload', 'navigate'])
|
|
19
|
-
const VALUED = new Set(['--dir', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--id', '--width', '--height', '--window', '--tool', '--args', '--path'])
|
|
20
|
+
const VALUED = new Set(['--dir', '--owner', '--license', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--id', '--width', '--height', '--window', '--tool', '--args', '--path'])
|
|
20
21
|
const FLAGS = new Set(['--json', '--submit', '--no-clear', '--gone', '--force', '--help'])
|
|
21
22
|
|
|
22
23
|
export class UsageError extends Error {}
|
|
@@ -42,7 +43,7 @@ const USAGE = `usage: acryl <verb> [options] [--json] [--dir <app folder>]
|
|
|
42
43
|
act navigate --path <path of the app, e.g. /demo>
|
|
43
44
|
act wait (--text <t> | --role <role> [--name <n>]) [--gone] [--timeout-ms <ms>]
|
|
44
45
|
(look and every act take --window <n>: the desktop window whose page it is for; window list prints the numbers)
|
|
45
|
-
plugin list | plugin add <folder> | plugin remove <name> | plugin reload
|
|
46
|
+
plugin list | plugin add <folder> | plugin pull <source> | plugin publish <name> [--owner <account>] [--license <id>] | plugin remove <name> | plugin reload
|
|
46
47
|
window list | window open [--path <p>] | window focus --id <n> | window close --id <n> [--force]
|
|
47
48
|
window resize --id <n> --width <px> --height <px>
|
|
48
49
|
menu list | menu click --path "File>New Note" [--force]
|
|
@@ -138,8 +139,15 @@ export function parseInvocation(argv: readonly string[], env: NodeJS.ProcessEnv
|
|
|
138
139
|
case 'plugin': {
|
|
139
140
|
if (second === 'list' || second === 'reload') return make(`plugin-${second}`)
|
|
140
141
|
if (second === 'add') { if (third === undefined) throw new UsageError('plugin add needs a folder, e.g. plugin add plugins/pomodoro'); return make('plugin-add', { folder: third }) }
|
|
142
|
+
if (second === 'pull') { if (third === undefined) throw new UsageError('plugin pull needs a source, e.g. plugin pull github:someone/pomodoro@0.1.0'); return make('plugin-pull', { source: third }) }
|
|
143
|
+
if (second === 'publish') {
|
|
144
|
+
if (third === undefined) throw new UsageError('plugin publish needs a plugin name, e.g. plugin publish pomodoro')
|
|
145
|
+
const owner = values.get('--owner')
|
|
146
|
+
const license = values.get('--license')
|
|
147
|
+
return make('plugin-publish', { name: third, ...(owner === undefined ? {} : { owner }), ...(license === undefined ? {} : { license }) })
|
|
148
|
+
}
|
|
141
149
|
if (second === 'remove') { if (third === undefined) throw new UsageError('plugin remove needs a plugin name'); return make('plugin-remove', { name: third }) }
|
|
142
|
-
throw new UsageError(`plugin needs list, add, remove or reload\n${USAGE}`)
|
|
150
|
+
throw new UsageError(`plugin needs list, add, pull, publish, remove or reload\n${USAGE}`)
|
|
143
151
|
}
|
|
144
152
|
case 'window': {
|
|
145
153
|
const id = integer(values, '--id')
|
|
@@ -199,6 +207,11 @@ export interface Io {
|
|
|
199
207
|
readonly out: (text: string) => void
|
|
200
208
|
readonly err: (text: string) => void
|
|
201
209
|
readonly fetch?: typeof fetch
|
|
210
|
+
/**
|
|
211
|
+
* Ask the person at this terminal; absent when nobody is there (a pipe, an agent's shell), and then nothing that needs a yes can happen.
|
|
212
|
+
* @returns whether they said yes.
|
|
213
|
+
*/
|
|
214
|
+
readonly ask?: (question: string) => Promise<boolean>
|
|
202
215
|
}
|
|
203
216
|
|
|
204
217
|
/** Run one invocation. @returns the exit code. */
|
|
@@ -214,7 +227,7 @@ export async function main(argv: readonly string[], io: Io): Promise<number> {
|
|
|
214
227
|
const app = findApp(io.cwd, invocation.dir)
|
|
215
228
|
const file = app === undefined ? undefined : readDoorFile(app)
|
|
216
229
|
if (app === undefined || file === undefined) {
|
|
217
|
-
io.err(`no running app${app === undefined ? ` in ${io.cwd} or above it` : ` in ${app}`}: start it (npm run dev) in its folder, then
|
|
230
|
+
io.err(`no running app${app === undefined ? ` in ${io.cwd} or above it` : ` in ${app}`}: these commands reach the running app, so start it (npm run dev) in its folder in another terminal, then run the same line again\n`)
|
|
218
231
|
return 3
|
|
219
232
|
}
|
|
220
233
|
const request = io.fetch ?? fetch
|
|
@@ -225,7 +238,7 @@ export async function main(argv: readonly string[], io: Io): Promise<number> {
|
|
|
225
238
|
? await request(`${file.url}/api/acryl-door/verbs`, { headers })
|
|
226
239
|
: await request(`${file.url}/api/acryl-door/call`, { method: 'POST', headers, body: JSON.stringify({ verb: invocation.verb, args: invocation.args }) })
|
|
227
240
|
} catch {
|
|
228
|
-
io.err(`no running app in ${app}: ${file.url} does not answer (it stopped without removing .acryl/door.json?); start it again\n`)
|
|
241
|
+
io.err(`no running app in ${app}: ${file.url} does not answer (it stopped without removing .acryl/door.json?); start it (npm run dev), then run the same line again\n`)
|
|
229
242
|
return 3
|
|
230
243
|
}
|
|
231
244
|
if (response.status === 403) {
|
|
@@ -243,9 +256,52 @@ export async function main(argv: readonly string[], io: Io): Promise<number> {
|
|
|
243
256
|
return 1
|
|
244
257
|
}
|
|
245
258
|
io.out(invocation.json ? `${JSON.stringify(body.value)}\n` : `${body.text ?? ''}\n`)
|
|
259
|
+
if (invocation.verb === 'plugin-publish' && io.ask !== undefined && isPlan(body.value)) return confirmPublish(invocation, body.value, io, request, `${file.url}/api/acryl-door/publish-confirmed`, headers)
|
|
246
260
|
return 0
|
|
247
261
|
}
|
|
248
262
|
|
|
263
|
+
const isPlan = (value: unknown): value is PlanAsked =>
|
|
264
|
+
typeof value === 'object' && value !== null && 'owner' in value
|
|
265
|
+
|
|
266
|
+
/** What the prompt names. */
|
|
267
|
+
interface PlanAsked {
|
|
268
|
+
readonly owner: string
|
|
269
|
+
readonly repo: string
|
|
270
|
+
readonly version: string
|
|
271
|
+
readonly license: string
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The person's yes: only what they just read is published, and anything but a plain yes sends nothing. */
|
|
275
|
+
async function confirmPublish(
|
|
276
|
+
invocation: Invocation,
|
|
277
|
+
plan: PlanAsked,
|
|
278
|
+
io: Io,
|
|
279
|
+
request: typeof fetch,
|
|
280
|
+
url: string,
|
|
281
|
+
headers: Record<string, string>,
|
|
282
|
+
): Promise<number> {
|
|
283
|
+
const yes = await io.ask?.(`Publish ${plan.owner}/${plan.repo} v${plan.version} under ${plan.license}? Type yes to send it: `)
|
|
284
|
+
if (yes !== true) { io.out('Nothing was sent.\n'); return 0 }
|
|
285
|
+
const response = await request(url, { method: 'POST', headers, body: JSON.stringify({ name: invocation.args.name, owner: invocation.args.owner, license: invocation.args.license, confirm: plan }) })
|
|
286
|
+
const body = await response.json().catch(() => undefined) as { ok?: boolean; message?: string; value?: { ok?: boolean; releaseUrl?: string; pullLine?: string; reason?: string; next?: string } } | undefined
|
|
287
|
+
const result = body?.value
|
|
288
|
+
if (body?.ok !== true || result?.ok !== true) {
|
|
289
|
+
io.err(`Not published: ${result?.reason ?? body?.message ?? 'the app did not answer'}${result?.next === undefined ? '' : `. ${result.next}`}\n`)
|
|
290
|
+
return 1
|
|
291
|
+
}
|
|
292
|
+
io.out(`Published: ${result.releaseUrl ?? ''}\nA friend gets exactly these bytes with:\n ${result.pullLine ?? ''}\n`)
|
|
293
|
+
return 0
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function askAtTerminal(question: string): Promise<boolean> {
|
|
297
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout })
|
|
298
|
+
try {
|
|
299
|
+
return (await prompt.question(question)).trim().toLowerCase() === 'yes'
|
|
300
|
+
} finally {
|
|
301
|
+
prompt.close()
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
249
305
|
/** True when this file is the program Node was started with (not imported by a test). */
|
|
250
306
|
function isProgram(): boolean {
|
|
251
307
|
const entry = process.argv[1]
|
|
@@ -262,6 +318,7 @@ if (isProgram()) {
|
|
|
262
318
|
cwd: process.cwd(),
|
|
263
319
|
out: (text) => { process.stdout.write(text) },
|
|
264
320
|
err: (text) => { process.stderr.write(text) },
|
|
321
|
+
...(process.stdin.isTTY && process.stdout.isTTY ? { ask: askAtTerminal } : {}),
|
|
265
322
|
}).then((code) => { process.exitCode = code }, (cause: unknown) => {
|
|
266
323
|
process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`)
|
|
267
324
|
process.exitCode = 1
|
|
@@ -15,7 +15,7 @@ import type { UiChannel } from './channel.ts'
|
|
|
15
15
|
import { NO_DESKTOP, type ShellChannel } from './shell.ts'
|
|
16
16
|
import { VERB_SPECS, VERBS } from './verbs.ts'
|
|
17
17
|
|
|
18
|
-
export type DoorErrorCode = 'invalid' | 'unknown-verb' | 'unavailable' | 'not-exposed' | 'outside-app' | 'failed' | ShellErrorCode
|
|
18
|
+
export type DoorErrorCode = 'invalid' | 'unknown-verb' | 'unavailable' | 'not-exposed' | 'outside-app' | 'failed' | 'refused' | ShellErrorCode
|
|
19
19
|
|
|
20
20
|
export class DoorError extends Error {
|
|
21
21
|
readonly code: DoorErrorCode
|
|
@@ -35,6 +35,41 @@ export interface ExtensionsPort {
|
|
|
35
35
|
list(): readonly unknown[]
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** The slice of acrylblends' `pluginShare` service the pull verb uses. */
|
|
39
|
+
export interface PluginSharePort {
|
|
40
|
+
pull(source: string): Promise<{ readonly ok: boolean; readonly action?: string; readonly reason?: string; readonly next?: string }>
|
|
41
|
+
planPublish(name: string, options?: PublishOptionsView): Promise<PublishPlanView | { readonly ok: false; readonly reason: string; readonly next: string }>
|
|
42
|
+
publish(name: string, confirmed: PublishPlanView, options?: PublishOptionsView): Promise<{ readonly ok: boolean; readonly reason?: string }>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Who a plan publishes as, and under which license; the service's defaults when absent. */
|
|
46
|
+
export interface PublishOptionsView {
|
|
47
|
+
readonly owner?: string
|
|
48
|
+
readonly license?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A plan as the door shows it. The service owns it; the door passes it back unchanged. */
|
|
52
|
+
export interface PublishPlanView {
|
|
53
|
+
readonly owner: string
|
|
54
|
+
readonly repo: string
|
|
55
|
+
readonly version: string
|
|
56
|
+
readonly visibility: string
|
|
57
|
+
readonly license: string
|
|
58
|
+
readonly createsRepository: boolean
|
|
59
|
+
readonly files: readonly string[]
|
|
60
|
+
readonly digest: string
|
|
61
|
+
readonly pullLine: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What a plan says, for a person deciding. */
|
|
65
|
+
export function renderPublishPlan(plan: PublishPlanView): string {
|
|
66
|
+
return [
|
|
67
|
+
`Publish to ${plan.owner}/${plan.repo}${plan.createsRepository ? ' (a new repository)' : ''}, ${plan.visibility}, version ${plan.version}, license ${plan.license}.`,
|
|
68
|
+
`Files: ${plan.files.join(', ')}.`,
|
|
69
|
+
`A friend gets exactly these bytes with: ${plan.pullLine}`,
|
|
70
|
+
].join('\n')
|
|
71
|
+
}
|
|
72
|
+
|
|
38
73
|
/** The slice of acrylblends' `tools` registry (the `agent` plugin's) the tool verbs use, and where the built-in chat finds the verbs. */
|
|
39
74
|
export interface ToolsPort {
|
|
40
75
|
/** @returns the function that removes the tool. @throws when the name is taken. */
|
|
@@ -54,6 +89,8 @@ export interface DoorDeps {
|
|
|
54
89
|
readonly appBase?: string | undefined
|
|
55
90
|
/** Read at each call: the services may come and go while the door runs. */
|
|
56
91
|
readonly extensions: () => ExtensionsPort | undefined
|
|
92
|
+
/** Read at each call, as `extensions` is; absent where the app has no plugin-share row. */
|
|
93
|
+
readonly pluginShare?: () => PluginSharePort | undefined
|
|
57
94
|
readonly tools: () => ToolsPort | undefined
|
|
58
95
|
/** The app tools the door offers; anything else is refused before it reaches the registry. */
|
|
59
96
|
readonly expose: readonly string[]
|
|
@@ -118,6 +155,8 @@ export class Door {
|
|
|
118
155
|
}
|
|
119
156
|
case 'plugin-list': return this.plugin(extensions => extensions.list(), false)
|
|
120
157
|
case 'plugin-add': return this.plugin(extensions => extensions.install(this.insideApp(input.folder)))
|
|
158
|
+
case 'plugin-pull': return this.pull(input.source)
|
|
159
|
+
case 'plugin-publish': return this.planPublish(input)
|
|
121
160
|
case 'plugin-remove': {
|
|
122
161
|
const name = input.name
|
|
123
162
|
if (typeof name !== 'string' || name === '') throw new DoorError('invalid', 'plugin remove needs a plugin name')
|
|
@@ -308,6 +347,41 @@ export class Door {
|
|
|
308
347
|
return { value, text: JSON.stringify(value, null, 2) }
|
|
309
348
|
}
|
|
310
349
|
|
|
350
|
+
private share(): PluginSharePort {
|
|
351
|
+
const share = this.deps.pluginShare?.()
|
|
352
|
+
if (share === undefined) throw new DoorError('unavailable', 'plugin-share is not running in this app. If the app just changed it is restarting, so try again in a moment. An app made by an older version does not have it, and upgrade does not add it: run `npx acrylblends add plugin-share` and follow its README')
|
|
353
|
+
return share
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Only a plan: no argument of any verb can publish, so an agent never says yes for a person. */
|
|
357
|
+
private async planPublish(input: Record<string, unknown>): Promise<VerbAnswer> {
|
|
358
|
+
const { name, owner, license } = input
|
|
359
|
+
if (typeof name !== 'string' || name === '') throw new DoorError('invalid', 'plugin publish needs a plugin name, e.g. plugin publish pomodoro')
|
|
360
|
+
const value = await this.share().planPublish(name, {
|
|
361
|
+
...(typeof owner === 'string' && owner !== '' ? { owner } : {}),
|
|
362
|
+
...(typeof license === 'string' && license !== '' ? { license } : {}),
|
|
363
|
+
})
|
|
364
|
+
if ('ok' in value) throw new DoorError('refused', `${value.reason}. ${value.next}.`)
|
|
365
|
+
return { value, text: `${renderPublishPlan(value)}\nNothing was sent. Only a person can say yes: they run \`npm run acryl -- plugin publish ${name}\` in their own terminal, or press Share in the frame. \`--license <id>\` changes the license before the yes.` }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The person's yes, from their own terminal. Not a verb: MCP and the chat do not have it. */
|
|
369
|
+
async publishConfirmed(name: string, confirmed: PublishPlanView, options: PublishOptionsView): Promise<unknown> {
|
|
370
|
+
const value = await this.share().publish(name, confirmed, options)
|
|
371
|
+
this.deps.audit.record({ at: new Date().toISOString(), tool: 'plugin-publish', target: { role: 'plugin', name: `${confirmed.owner}/${confirmed.repo}@${confirmed.version}` }, outcome: value.ok ? 'ok' : 'refused', approval: 'token' })
|
|
372
|
+
return value
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private async pull(source: unknown): Promise<VerbAnswer> {
|
|
376
|
+
if (typeof source !== 'string' || source === '') throw new DoorError('invalid', 'plugin pull needs a source, e.g. plugin pull github:someone/pomodoro@0.1.0')
|
|
377
|
+
const value = await this.share().pull(source)
|
|
378
|
+
// A refusal is a failed command, so a script does not read it as done; it changed nothing, and the open pages stay.
|
|
379
|
+
if (!value.ok) throw new DoorError('refused', `${value.reason ?? 'the pull was refused'}. ${value.next ?? 'nothing changed'}.`)
|
|
380
|
+
// Neither did pulling what is already there change anything.
|
|
381
|
+
if (value.action !== 'unchanged' && this.deps.reloadOnChange !== false) await this.deps.channel.reloadAll()
|
|
382
|
+
return { value, text: JSON.stringify(value, null, 2) }
|
|
383
|
+
}
|
|
384
|
+
|
|
311
385
|
/** A folder named by an agent, resolved from the app folder; it must be inside the app. */
|
|
312
386
|
private insideApp(folder: unknown): string {
|
|
313
387
|
if (typeof folder !== 'string' || folder === '') throw new DoorError('invalid', 'plugin add needs a folder, e.g. plugins/pomodoro')
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { Acryl } from 'acryl-router'
|
|
9
9
|
import { UiControlError } from '../page/protocol.ts'
|
|
10
|
-
import { Door, DoorError } from './door.ts'
|
|
10
|
+
import { Door, DoorError, type PublishPlanView } from './door.ts'
|
|
11
11
|
import { error, INVALID_JSON, jsonBody, operatorOnly, readJson, type ReportError } from './http.ts'
|
|
12
12
|
import { VERBS } from './verbs.ts'
|
|
13
13
|
|
|
@@ -35,4 +35,24 @@ export function operatorRoutes(prefix: string, secret: string, door: Door, repor
|
|
|
35
35
|
return c.json(error('the verb could not be run'), 500)
|
|
36
36
|
}
|
|
37
37
|
})
|
|
38
|
+
// A person's yes to a plan, from the prompt in their own terminal. Not a verb, so MCP and the chat do not offer it. An agent with this machine's
|
|
39
|
+
// secret could still reach it: the door keeps honest agents from publishing by accident, the person's account is the real boundary.
|
|
40
|
+
.post(`${prefix}/publish-confirmed`, operatorOnly(secret), jsonBody, async (c) => {
|
|
41
|
+
const body = await readJson(c)
|
|
42
|
+
const { name, confirm, owner, license } = (body === INVALID_JSON || typeof body !== 'object' || body === null ? {} : body) as { name?: unknown; confirm?: unknown; owner?: unknown; license?: unknown }
|
|
43
|
+
if (typeof name !== 'string' || typeof confirm !== 'object' || confirm === null) {
|
|
44
|
+
return c.json({ ok: false, code: 'invalid', message: 'the body is { name, confirm }' } satisfies CallResponse, 400)
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const value = await door.publishConfirmed(name, confirm as PublishPlanView, {
|
|
48
|
+
...(typeof owner === 'string' ? { owner } : {}),
|
|
49
|
+
...(typeof license === 'string' ? { license } : {}),
|
|
50
|
+
})
|
|
51
|
+
return c.json({ ok: true, value, text: JSON.stringify(value, null, 2) } satisfies CallResponse)
|
|
52
|
+
} catch (cause) {
|
|
53
|
+
if (cause instanceof DoorError) return c.json({ ok: false, code: cause.code, message: cause.message } satisfies CallResponse, 409)
|
|
54
|
+
reportError('publish a plugin', cause)
|
|
55
|
+
return c.json(error('the plugin could not be published'), 500)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
38
58
|
}
|
|
@@ -9,7 +9,7 @@ import { MAX_SNAPSHOT_NODES, MAX_WAIT_MS } from '../page/protocol.ts'
|
|
|
9
9
|
/** The verbs that only use the page: what a page-only door (a production assistant) offers. */
|
|
10
10
|
export const PAGE_VERBS = ['look', 'act'] as const satisfies readonly Verb[]
|
|
11
11
|
|
|
12
|
-
export const VERBS = ['brief', 'look', 'act', 'plugin-list', 'plugin-add', 'plugin-remove', 'plugin-reload', 'tool-list', 'tool-call', 'window-list', 'window-open', 'window-focus', 'window-close', 'window-resize', 'menu-list', 'menu-click'] as const
|
|
12
|
+
export const VERBS = ['brief', 'look', 'act', 'plugin-list', 'plugin-add', 'plugin-pull', 'plugin-publish', 'plugin-remove', 'plugin-reload', 'tool-list', 'tool-call', 'window-list', 'window-open', 'window-focus', 'window-close', 'window-resize', 'menu-list', 'menu-click'] as const
|
|
13
13
|
export type Verb = (typeof VERBS)[number]
|
|
14
14
|
|
|
15
15
|
/** The JSON Schema of a verb's arguments, as MCP lists it. */
|
|
@@ -86,6 +86,16 @@ export const VERB_SPECS: Readonly<Record<Verb, VerbSpec>> = {
|
|
|
86
86
|
description: 'Check, install and start a plugin folder inside the app, without a restart.',
|
|
87
87
|
arguments: { type: 'object', properties: { folder: { type: 'string', description: 'a folder inside the app, e.g. plugins/pomodoro' } }, required: ['folder'], additionalProperties: false },
|
|
88
88
|
},
|
|
89
|
+
'plugin-pull': {
|
|
90
|
+
usage: 'plugin pull <source>: fetch a plugin someone released (github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz), check it and start it, live',
|
|
91
|
+
description: 'Fetch a plugin someone released, without running anything; check its digest, that it works on every OS and that this app has the services it needs; then install and start it without a restart. A refusal says why and changes nothing.',
|
|
92
|
+
arguments: { type: 'object', properties: { source: { type: 'string', description: 'github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz path or https address' } }, required: ['source'], additionalProperties: false },
|
|
93
|
+
},
|
|
94
|
+
'plugin-publish': {
|
|
95
|
+
usage: 'plugin publish <name> [--owner <account>] [--license <id>]: show what publishing this plugin would send; in a person\'s own terminal, ask them',
|
|
96
|
+
description: 'Show what publishing a plugin would send: the account, repository, version, license, files and the line a friend pulls it with. Sends nothing. Only a person can say yes, in their own terminal or with Share in the frame; tell them the plan and how.',
|
|
97
|
+
arguments: { type: 'object', properties: { name: { type: 'string', description: 'a plugin of plugins/' }, owner: { type: 'string', description: 'the GitHub account or organization; the signed-in one when absent' }, license: { type: 'string', description: 'an SPDX license id; MIT when absent' } }, required: ['name'], additionalProperties: false },
|
|
98
|
+
},
|
|
89
99
|
'plugin-remove': {
|
|
90
100
|
usage: 'plugin remove <name>: stop and remove an installed plugin',
|
|
91
101
|
description: 'Stop and remove an installed plugin.',
|