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.
Files changed (38) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.js +39 -7
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/guides/create-acrylblends.md +4 -2
  5. package/docs/framework/workshop/README.md +2 -2
  6. package/docs/framework/workshop/facilitator.md +1 -1
  7. package/lib/cordis/door.d.ts +45 -2
  8. package/lib/cordis/door.js +120 -0
  9. package/package.json +1 -1
  10. package/plugins/acryl-door/plugin.json +3 -0
  11. package/plugins/acryl-door/src/cli/acryl.ts +63 -6
  12. package/plugins/acryl-door/src/host/door.ts +75 -1
  13. package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
  14. package/plugins/acryl-door/src/host/verbs.ts +11 -1
  15. package/plugins/acryl-door/src/index.ts +2 -1
  16. package/plugins/builder/index.ts +13 -35
  17. package/plugins/builder/package.ts +186 -0
  18. package/plugins/builder/plugin.json +3 -1
  19. package/plugins/extensions/index.ts +21 -17
  20. package/plugins/extensions/plugin-store.ts +27 -0
  21. package/plugins/extensions/plugin.json +1 -1
  22. package/plugins/extensions/stage.ts +15 -5
  23. package/plugins/manifest/plugin.json +1 -1
  24. package/plugins/plugin-share/README.md +51 -0
  25. package/plugins/plugin-share/index.ts +467 -0
  26. package/plugins/plugin-share/plugin.json +15 -0
  27. package/plugins/plugin-share/publish.ts +65 -0
  28. package/plugins/plugin-share/pull-check.ts +129 -0
  29. package/plugins/plugin-share/pulled-records.ts +70 -0
  30. package/templates/agent/.gitattributes +2 -0
  31. package/templates/agent/blend.yaml +2 -0
  32. package/templates/agent/src/app.ts +4 -0
  33. package/templates/agent/template.json +1 -0
  34. package/templates/blank/.gitattributes +2 -0
  35. package/templates/chat/.gitattributes +2 -0
  36. package/templates/nodejs/.gitattributes +2 -0
  37. package/templates/notes/.gitattributes +2 -0
  38. 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`. `--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.
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.2.0 or newer**; `npm create acrylblends@latest` gives you that. An app you made with an older release gets the
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.2.0 or newer** (the release that carries the frame). A room on an older release is on the previous handout;
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
@@ -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 {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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` runs. It finds the app from the folder it is started in (the nearest
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 try again\n`)
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.',