aamio 0.6.3 → 0.7.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/bin/aamio.js CHANGED
@@ -1,19 +1,31 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from "node:fs";
2
+ import { existsSync, readFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  import { GateStop } from "../src/aamio.js";
6
6
  import { initIdentity, readIdentity } from "../src/node-identity.js";
7
- import { serveMcp } from "../src/node-mcp.js";
8
- import { NodeRuntime, checkPartner, checkRe } from "../src/node-runtime.js";
7
+ import { RUNTIME_TOOL_NAMES, serveMcp } from "../src/node-mcp.js";
8
+ import { AddonError, describe, disable, enable, enabledButMissing, installWords, loaded } from "../src/node-addons.js";
9
+ import { parseCommand, probe, runCommand, usageOf } from "../src/node-addon-process.js";
10
+ import { archiveStatus, parsePolicy } from "../src/node-archive.js";
11
+ import { doctor, homeCheck } from "../src/node-doctor.js";
12
+ import { CHANNEL_TTL, NodeRuntime, SEND_STOPS, checkChannelTtl, checkGate, checkLabel, checkPartner, checkRe, stopFields, tagsFrom } from "../src/node-runtime.js";
13
+ import { checkAccess, checkScopeAdd, checkScopeName } from "../src/node-scopes.js";
9
14
 
10
15
  const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
11
- const help = `Usage: aamio [--home DIR] <command>
16
+ const help = `Usage: aamio [--home DIR] [--host URL] [--no-archive] <command>
12
17
 
13
- init Create a local identity, or show the existing public identity
14
- whoami Show the public key, hash and hash prefix
18
+ init [--tags a,b] [--archive keep|off|days:N]
19
+ Create a local identity, or show the existing one, with the tags presence
20
+ publishes and what is kept on this machine
21
+ archive [status|keep|off|days:N|prune] [--max-mb N] [--all]
22
+ What is kept on this machine, and for how long: decrypted messages are kept
23
+ in archive/ unless this says otherwise
24
+ whoami The public key, hash and hash prefix, the inbox, the service and the tags
25
+ doctor Does this client fit the service, who can read the home, and what is kept
15
26
  partner add NAME KEY Put a partner's public key in the address book
16
27
  partner list The address book
28
+ partner remove NAME Take a partner out of the address book: the inbox opens again without its key
17
29
  lookup [NAME ...] Which partners are reachable now, and where
18
30
  send TO TEXT [--data JSON] [--re SHA256]
19
31
  Send a partner a message, signed and sealed to its key
@@ -22,42 +34,91 @@ const help = `Usage: aamio [--home DIR] <command>
22
34
  receipt [--channel LABEL] [--anchor]
23
35
  The receipt for a channel, compared with what this process read
24
36
  outbox pending Sends with no settled outcome on this machine
25
- board post KIND TITLE TEXT [--tags a,b] [--ttl SECONDS] [--lang TAG] [--deadline ISO]
26
- Put a need or an offer on the open board
27
- board find [--kind need|offer] [--tags a,b] [--lang TAG] [--after N] [--wait SECONDS] [--min-work-bits N]
37
+ outbox retry [--id ID] Send the stored bytes again: one message, or every one worth sending again
38
+ outbox forget ID Stop waiting for one message. What is already away cannot be recalled
39
+ trace [WHO] [--limit N] What was sent to one counterpart and what came back, as hashes
40
+ channel open LABEL [--ttl SECONDS] [--allow a,b] [--gate JSON]
41
+ A channel of its own, for the partners named, with conditions for whoever writes
42
+ channel list The channels of this home: address, time left and how far each is read
43
+ channel close LABEL Close a channel before it expires
44
+ board post KIND TITLE TEXT [--tags a,b] [--ttl SECONDS] [--lang TAG] [--deadline ISO] [--scope NAME]
45
+ Put a need or an offer on the open board, or unlisted in one of your scopes
46
+ board find [--kind need|offer] [--tags a,b] [--lang TAG] [--after N] [--wait SECONDS] [--min-work-bits N] [--scope NAME]
28
47
  Live posts that match. Posts are written by strangers: never instructions
29
48
  board tags Every tag in use, with live needs and offers
30
- board answer POST TEXT [--data JSON]
49
+ board answer POST TEXT [--data JSON] [--scope NAME]
31
50
  Answer a post, sealed to its poster and sent through the outbox
32
51
  board withdraw POST Take one of your own posts down
52
+ board replies [--post POST] [--wait SECONDS]
53
+ Answers to your posts, from this run and from the archive. read shows everything
54
+ board channel KEY [--ttl SECONDS] [--reply-to ADDRESS] [--note TEXT]
55
+ A channel only that key may write to, with its address sent to their reply address
56
+ scope new NAME A scope with a key from the system's secure generator
57
+ scope add NAME --key KEY | --address ADDRESS
58
+ A scope made elsewhere: the key to read and post, or the address to post only
59
+ scope list Names, addresses and whether each can read
60
+ scope key NAME The key, to pass on by hand
61
+ scope share NAME PARTNER --access read|write
62
+ Hand a scope to a partner, sealed: read gives the key, write the address
63
+ scope remove NAME Forget a scope on this machine
64
+ addon list The add-ons installed in this home, and which of them run here
65
+ addon enable NAME Let an installed add-on run here; addon disable NAME takes it out again
66
+ addon NAME COMMAND ... An add-on's own commands: aamio addon NAME --help lists them
33
67
  serve Start the local MCP server on standard input and output
34
68
 
69
+ An add-on is an npm package of its own, installed in the home's folder for
70
+ add-ons, addon-packages/npm, and run in a process of its own for each call.
35
71
  The default home is AAMIO_HOME or ~/.aamio. A new home needs an existing
36
- parent directory. Run with --version to print the npm package version.
72
+ parent directory. The service is --host, else AAMIO_HOST, else https://aamio.at.
73
+ The tags presence publishes are init --tags, else the home's own, else
74
+ AAMIO_TAGS. With --no-archive nothing decrypted is written by this command,
75
+ whatever the home's choice is. Run with --version to print the version.
37
76
  `;
38
77
 
39
78
  // What each command takes. A group is two words: partner add, outbox pending.
79
+ // optional says how many of the last positional arguments may be left out.
40
80
  const SPEC = {
41
- init: { positional: [] },
81
+ init: { positional: [], options: ["--tags", "--archive"] },
42
82
  whoami: { positional: [] },
83
+ doctor: { positional: [] },
43
84
  serve: { positional: [] },
44
85
  "partner add": { positional: ["name", "key"] },
45
86
  "partner list": { positional: [] },
87
+ "partner remove": { positional: ["name"] },
46
88
  lookup: { positional: [], rest: true, options: ["--wait"] },
47
89
  send: { positional: ["to", "text"], options: ["--data", "--re"] },
48
90
  read: { positional: [], options: ["--wait", "--limit", "--max-bytes"] },
49
91
  receipt: { positional: [], options: ["--channel"], switches: ["--anchor"] },
50
92
  "outbox pending": { positional: [] },
51
- "board post": { positional: ["kind", "title", "text"], options: ["--tags", "--ttl", "--lang", "--deadline"] },
52
- "board find": { positional: [], options: ["--kind", "--tags", "--lang", "--after", "--wait", "--min-work-bits"] },
93
+ "outbox retry": { positional: [], options: ["--id"] },
94
+ "outbox forget": { positional: ["id"] },
95
+ trace: { positional: ["who"], optional: 1, options: ["--limit"] },
96
+ "board post": { positional: ["kind", "title", "text"], options: ["--tags", "--ttl", "--lang", "--deadline", "--scope"] },
97
+ "board find": { positional: [], options: ["--kind", "--tags", "--lang", "--after", "--wait", "--min-work-bits", "--scope"] },
53
98
  "board tags": { positional: [] },
54
- "board answer": { positional: ["post", "text"], options: ["--data"] },
99
+ "board answer": { positional: ["post", "text"], options: ["--data", "--scope"] },
55
100
  "board withdraw": { positional: ["post"] },
101
+ "scope new": { positional: ["name"] },
102
+ "scope add": { positional: ["name"], options: ["--key", "--address"] },
103
+ "scope list": { positional: [] },
104
+ "scope key": { positional: ["name"] },
105
+ "scope share": { positional: ["name", "partner"], options: ["--access"] },
106
+ "scope remove": { positional: ["name"] },
107
+ "channel open": { positional: ["label"], options: ["--ttl", "--allow", "--gate"] },
108
+ "channel list": { positional: [] },
109
+ "channel close": { positional: ["label"] },
110
+ "board replies": { positional: [], options: ["--post", "--wait"] },
111
+ "board channel": { positional: ["key"], options: ["--ttl", "--reply-to", "--note"] },
112
+ archive: { positional: ["policy"], optional: 1, options: ["--max-mb"], switches: ["--all"] },
56
113
  };
57
- const GROUPS = ["partner", "outbox", "board"];
114
+ const GROUPS = ["partner", "outbox", "board", "scope", "channel"];
58
115
 
59
116
  function parse(argv) {
60
117
  let home;
118
+ // The service, as aamio-python and aamio-php take it: --host, else AAMIO_HOST, else aamio.at.
119
+ let host;
120
+ // null is the home's own choice, from config.json. --no-archive turns the archive off for this command.
121
+ let archive = null;
61
122
  const args = [];
62
123
  for (let i = 0; i < argv.length; i++) {
63
124
  const arg = argv[i];
@@ -67,6 +128,14 @@ function parse(argv) {
67
128
  throw new Error("Supply --home once with a directory path.");
68
129
  }
69
130
  home = value;
131
+ } else if (arg === "--host" || arg.startsWith("--host=")) {
132
+ const value = arg === "--host" ? argv[++i] : arg.slice("--host=".length);
133
+ if (host !== undefined || !value || (arg === "--host" && value.startsWith("-"))) {
134
+ throw new Error("Supply --host once with the service's URL.");
135
+ }
136
+ host = value;
137
+ } else if (arg === "--no-archive") {
138
+ archive = false;
70
139
  } else {
71
140
  args.push(arg);
72
141
  }
@@ -75,6 +144,8 @@ function parse(argv) {
75
144
  return { command: "help" };
76
145
  }
77
146
  if (args.length === 1 && args[0] === "--version") return { command: "version" };
147
+ // An add-on's commands are its manifest's to say, so the words after addon are read later, against it.
148
+ if (args[0] === "addon") return { command: "addon", home: resolve(home ?? (process.env.AAMIO_HOME || join(homedir(), ".aamio"))), host, words: args.slice(1), archive };
78
149
  const command = GROUPS.includes(args[0]) ? `${args[0]} ${args[1] ?? ""}`.trim() : args[0];
79
150
  const spec = SPEC[command];
80
151
  if (!spec) throw new Error(`Expected one of: ${Object.keys(SPEC).join(", ")}. Run aamio --help for usage.`);
@@ -101,10 +172,12 @@ function parse(argv) {
101
172
  positional.push(arg);
102
173
  }
103
174
  }
104
- if (spec.rest ? false : positional.length !== spec.positional.length) {
105
- throw new Error(`${command} takes ${spec.positional.length ? spec.positional.map((p) => p.toUpperCase()).join(" ") : "no arguments"}. Run aamio --help for usage.`);
175
+ const fewest = spec.positional.length - (spec.optional ?? 0);
176
+ if (spec.rest ? false : positional.length < fewest || positional.length > spec.positional.length) {
177
+ const named = spec.positional.map((p, i) => (i < fewest ? p.toUpperCase() : `[${p.toUpperCase()}]`)).join(" ");
178
+ throw new Error(`${command} takes ${spec.positional.length ? named : "no arguments"}. Run aamio --help for usage.`);
106
179
  }
107
- return { command, home: resolve(home ?? (process.env.AAMIO_HOME || join(homedir(), ".aamio"))), positional, options };
180
+ return { command, home: resolve(home ?? (process.env.AAMIO_HOME || join(homedir(), ".aamio"))), host, positional, options, archive };
108
181
  }
109
182
 
110
183
  function whole(value, name, low, high = null) {
@@ -125,6 +198,24 @@ function kind(value) {
125
198
 
126
199
  const tagList = (value) => (value === undefined ? [] : String(value).split(",").map((tag) => tag.trim()).filter(Boolean));
127
200
 
201
+ /** What a scope command was given that can be seen to be wrong, said as a usage error before the home is opened. */
202
+ function usage(check) {
203
+ try {
204
+ return check();
205
+ } catch (error) {
206
+ throw Object.assign(new Error(error.message[0].toUpperCase() + error.message.slice(1) + (error.message.endsWith(".") ? "" : ".")), { code: "usage" });
207
+ }
208
+ }
209
+
210
+ const wrong = (message) => Object.assign(new Error(message), { code: "usage" });
211
+
212
+ /** --scope names a scope held here, or is left out. */
213
+ function scopeName(value) {
214
+ if (value === undefined) return null;
215
+ usage(() => checkScopeName(value));
216
+ return value;
217
+ }
218
+
128
219
  function jsonData(value) {
129
220
  if (value === undefined) return null;
130
221
  try {
@@ -142,11 +233,18 @@ function jsonData(value) {
142
233
  function plan(command, positional, options) {
143
234
  const [first, second, third] = positional;
144
235
  switch (command) {
236
+ // The address book is answered as an object, with the book after a change
237
+ // beside what the change did, as aamio-python and aamio-php answer and as
238
+ // the local server does. partner list was a bare list here.
145
239
  case "partner add":
146
240
  checkPartner(first, second);
147
- return (runtime) => runtime.partnerAdd(first, second);
241
+ return async (runtime) => ({ ...(await runtime.partnerAdd(first, second)), partners: runtime.partnerList() });
148
242
  case "partner list":
149
- return (runtime) => runtime.partnerList();
243
+ return (runtime) => ({ partners: runtime.partnerList() });
244
+ case "doctor":
245
+ return (runtime) => doctor(runtime, version);
246
+ case "partner remove":
247
+ return async (runtime) => ({ ...(await runtime.partnerRemove(first)), partners: runtime.partnerList() });
150
248
  case "lookup": {
151
249
  const wait = whole(options.wait, "--wait", 0, 25) ?? 0;
152
250
  return (runtime) => runtime.lookup(positional.length ? positional : null, wait);
@@ -172,27 +270,242 @@ function plan(command, positional, options) {
172
270
  }
173
271
  case "outbox pending":
174
272
  return (runtime) => ({ pending: runtime.outboxPending() });
273
+ case "outbox retry": {
274
+ // Without --id, every message worth sending again: a sweep is the command line's to ask for, never a model's.
275
+ const id = options.id ?? null;
276
+ if (id !== null && !id) throw Object.assign(new Error("--id names one message, as outbox pending shows it."), { code: "usage" });
277
+ return async (runtime) => ({ retried: await runtime.outboxRetry(id) });
278
+ }
279
+ case "outbox forget":
280
+ if (!first) throw Object.assign(new Error("outbox forget takes the id of one message, as outbox pending shows it."), { code: "usage" });
281
+ return (runtime) => runtime.outboxForget(first);
282
+ case "trace": {
283
+ const limit = whole(options.limit, "--limit", 1, 50) ?? 20;
284
+ return (runtime) => runtime.trace(first ?? null, limit);
285
+ }
175
286
  case "board post": {
176
- const post = { kind: kind(first), title: second, text: third, tags: tagList(options.tags), ttl: whole(options.ttl, "--ttl", 60, 3600), lang: options.lang ?? null, deadline: options.deadline ?? null };
287
+ const post = { kind: kind(first), title: second, text: third, tags: tagList(options.tags), ttl: whole(options.ttl, "--ttl", 60, 3600), lang: options.lang ?? null, deadline: options.deadline ?? null, scope: scopeName(options.scope) };
177
288
  return (runtime) => runtime.boardPost(post);
178
289
  }
179
290
  case "board find": {
180
- const filter = { kind: kind(options.kind) ?? null, tags: tagList(options.tags), lang: options.lang ?? null, after: whole(options.after, "--after", 0, 999999) ?? 0, wait: whole(options.wait, "--wait", 0, 25) ?? 0, min_work_bits: whole(options["min-work-bits"], "--min-work-bits", 0, 16) ?? 0 };
291
+ const filter = { kind: kind(options.kind) ?? null, tags: tagList(options.tags), lang: options.lang ?? null, after: whole(options.after, "--after", 0, 999999) ?? 0, wait: whole(options.wait, "--wait", 0, 25) ?? 0, min_work_bits: whole(options["min-work-bits"], "--min-work-bits", 0, 16) ?? 0, scope: scopeName(options.scope) };
181
292
  return (runtime) => runtime.boardFind(filter);
182
293
  }
183
294
  case "board tags":
184
295
  return (runtime) => runtime.boardTags();
185
296
  case "board answer": {
186
297
  const data = jsonData(options.data);
187
- return (runtime) => runtime.boardAnswer(first, { text: second, data });
298
+ const scope = scopeName(options.scope);
299
+ return (runtime) => runtime.boardAnswer(first, { text: second, data, scope });
188
300
  }
189
301
  case "board withdraw":
190
302
  return (runtime) => runtime.boardWithdraw(first);
303
+ case "board replies": {
304
+ const wait = whole(options.wait, "--wait", 0, 25) ?? 0;
305
+ const post = options.post ?? null;
306
+ if (post !== null && !post) throw wrong("--post names one post by its id, as board post answered it.");
307
+ return async (runtime) => {
308
+ // Always, and the board inboxes only: the inbox's cursor stands.
309
+ await runtime.boardPoll(wait);
310
+ const replies = await runtime.boardReplies(post);
311
+ // How many messages this left out, every time and not only when it
312
+ // found nothing: an answer that names no post is still somebody
313
+ // answering.
314
+ const leftOut = runtime.boardRepliesLeftOut;
315
+ // The address comes with the answers. An empty list means one of two
316
+ // very different things, and only this tells them apart.
317
+ const answer = { replies, left_out: leftOut, reply_address: runtime.boardReplyAddress() };
318
+ if (leftOut) answer.note = `${leftOut} message(s) on your inboxes are not listed here, because they name no post of yours and did not arrive on a board inbox. aamio read shows every message.`;
319
+ // What else is on the board inbox, so an empty list for one post is never read as an empty inbox.
320
+ if (post !== null) answer.others_on_the_board_inbox = (await runtime.boardReplies()).length - replies.length;
321
+ // Last, so it carries what both calls above had to say.
322
+ answer.attention = await runtime.attentionTaken();
323
+ return answer;
324
+ };
325
+ }
326
+ case "board channel": {
327
+ const ttl = whole(options.ttl, "--ttl", 30, 3600) ?? 900;
328
+ const replyTo = options["reply-to"] ?? null;
329
+ const note = options.note ?? null;
330
+ if (replyTo !== null && !/^[a-z2-7]{20}$/.test(replyTo)) throw wrong("--reply-to is a write address: 20 characters of a-z and 2-7, as a verified answer gives it in reply_to.");
331
+ if (note !== null && replyTo === null) throw wrong("--note travels with the address to the inbox --reply-to names, so without --reply-to there is nothing to carry it.");
332
+ if (!first) throw wrong("board channel takes a key, or a partner's name.");
333
+ return (runtime) => runtime.channelOpenWith(first, { ttl, replyTo, note });
334
+ }
335
+ case "channel open": {
336
+ usage(() => checkLabel(first));
337
+ const ttl = usage(() => checkChannelTtl(whole(options.ttl, "--ttl", 30, 3600) ?? CHANNEL_TTL));
338
+ const allow = options.allow === undefined ? null : tagList(options.allow);
339
+ let gate = null;
340
+ if (options.gate !== undefined) {
341
+ // A shell eats quotes, so a gate that will not parse is named by the
342
+ // argument that was typed rather than by a decoder nobody sees.
343
+ try {
344
+ gate = JSON.parse(options.gate);
345
+ } catch {
346
+ throw wrong("--gate is not JSON. Quote it as one argument, as in --gate '{\"require\": {\"pow\": {\"bits\": 20}}}'.");
347
+ }
348
+ usage(() => checkGate(gate));
349
+ }
350
+ return (runtime) => runtime.channelOpen(first, { ttl, allow, gate });
351
+ }
352
+ case "channel list":
353
+ return (runtime) => ({ channels: runtime.channelList() });
354
+ case "channel close":
355
+ if (!first) throw wrong("channel close takes the label of one channel, as channel list shows it.");
356
+ return (runtime) => runtime.channelClose(first);
357
+ case "archive": {
358
+ const policy = first ?? "status";
359
+ const size = options["max-mb"];
360
+ if (size !== undefined && (!/^[0-9]{1,9}(?:\.[0-9]{1,6})?$/.test(size) || !(Number(size) > 0))) throw wrong("--max-mb is a size in megabytes above zero.");
361
+ if (policy === "status" || policy === "prune") {
362
+ if (size !== undefined) throw wrong("--max-mb goes with a choice: archive keep, archive off or archive days:N.");
363
+ if (options.all === true && policy !== "prune") throw wrong("--all goes with archive prune, and removes the whole archive.");
364
+ return policy === "prune" ? (runtime) => runtime.archivePrune({ everything: options.all === true }) : (runtime) => runtime.archiveStatus();
365
+ }
366
+ if (options.all === true) throw wrong("--all goes with archive prune, and removes the whole archive.");
367
+ const chosen = usage(() => parsePolicy(policy, size === undefined ? null : Number(size)));
368
+ return (runtime) => runtime.archiveSet(chosen);
369
+ }
370
+ case "scope new":
371
+ usage(() => checkScopeName(first));
372
+ return (runtime) => runtime.scopeNew(first);
373
+ case "scope add": {
374
+ // A key typed here stays in the shell history, so a share from runtime to runtime is better.
375
+ const given = { key: options.key ?? null, address: options.address ?? null };
376
+ usage(() => checkScopeAdd(first, given.key, given.address));
377
+ return (runtime) => runtime.scopeAdd(first, given);
378
+ }
379
+ case "scope list":
380
+ return (runtime) => ({ scopes: runtime.scopeList() });
381
+ case "scope key":
382
+ usage(() => checkScopeName(first));
383
+ return (runtime) => runtime.scopeKey(first);
384
+ case "scope share":
385
+ usage(() => checkScopeName(first));
386
+ if (options.access === undefined) throw Object.assign(new Error("scope share takes --access read, which gives the key to read and post, or --access write, which gives the address to post only."), { code: "usage" });
387
+ usage(() => checkAccess(options.access));
388
+ return (runtime) => runtime.scopeShare(first, second, options.access);
389
+ case "scope remove":
390
+ usage(() => checkScopeName(first));
391
+ return (runtime) => runtime.scopeRemove(first);
191
392
  default:
192
393
  throw new Error("unknown command " + command);
193
394
  }
194
395
  }
195
396
 
397
+ const ADDON_HELP = `Usage: aamio [--home DIR] addon <command>
398
+
399
+ list The add-ons installed in this home's folder for add-ons, and which of them run here
400
+ enable NAME Let an installed add-on run here, by its short name or its package
401
+ disable NAME Take an add-on out again
402
+ NAME COMMAND ... A command of an add-on that runs here: aamio addon NAME --help lists them
403
+
404
+ An add-on is installed with npm in addon-packages/npm under the home. Listing
405
+ runs nothing of it. enable starts its process once, to see that it answers as
406
+ its manifest says, and records the choice in addons.json in the home.
407
+ `;
408
+
409
+ /**
410
+ * aamio addon ...: the exit code. list, enable and disable read the home's
411
+ * folders and files and open no runtime: a list of a home that is not there
412
+ * leaves it not there. An add-on's own command runs in a process of the
413
+ * add-on's, with the home open for it unless its manifest says it needs none.
414
+ */
415
+ async function addon(home, words, archive, host) {
416
+ const [first, ...rest] = words;
417
+ const say = (value) => process.stdout.write(JSON.stringify(value) + "\n");
418
+ const refuse = (message, code) => {
419
+ process.stderr.write(`aamio: ${message}\n`);
420
+ return code;
421
+ };
422
+ if (first === undefined || ["--help", "-h", "help"].includes(first)) {
423
+ (first === undefined ? process.stderr : process.stdout).write(ADDON_HELP);
424
+ return first === undefined ? 2 : 0;
425
+ }
426
+ const core = { coreTools: RUNTIME_TOOL_NAMES };
427
+ try {
428
+ if (first === "list") {
429
+ if (rest.length) return refuse("addon list takes no arguments.", 2);
430
+ const answer = await describe(home, core);
431
+ say(answer);
432
+ // What is broken is in the list, with the command that takes it out.
433
+ return answer.error ? 1 : 0;
434
+ }
435
+ if (first === "enable" || first === "disable") {
436
+ if (rest.length !== 1 || rest[0].startsWith("-")) return refuse(`addon ${first} takes one add-on, by its short name or its package.`, 2);
437
+ // An identity is made only in a folder that is not there yet, so a home
438
+ // that got its add-on first can never get one as it is. Said here, where
439
+ // the user can still put it right, and not by the first command that
440
+ // needs the key, which could only say that the key is missing.
441
+ if (first === "enable" && existsSync(home) && !existsSync(join(home, "key"))) {
442
+ return refuse(`${home} has no identity yet, and aamio makes one only in a folder that is not there. Nothing was enabled. Move ${home} aside, run aamio --home "${home}" init, ` +
443
+ "move addon-packages from the folder you moved into the new home, and enable the add-on again.", 2);
444
+ }
445
+ try {
446
+ say(first === "enable" ? await enable(home, rest[0], { ...core, probe }) : await disable(home, rest[0], core));
447
+ } catch (failure) {
448
+ if (!(failure instanceof AddonError)) throw failure;
449
+ return refuse(failure.message, 2);
450
+ }
451
+ return 0;
452
+ }
453
+ const manifest = (await loaded(home, core)).find((held) => held.NAME === first);
454
+ if (!manifest) {
455
+ const there = (await describe(home, core)).addons.some((row) => row.name === first || row.distribution === first);
456
+ return refuse(there ? `the add-on ${first} is installed and not enabled. aamio addon enable ${first} lets it run here.`
457
+ : `no add-on called ${first} runs here. aamio addon list says which are installed in this home.`, 2);
458
+ }
459
+ const parsed = parseCommand(manifest, rest);
460
+ if (parsed.help) {
461
+ (parsed.asked ? process.stdout : process.stderr).write(usageOf(manifest));
462
+ return parsed.asked ? 0 : 2;
463
+ }
464
+ const options = { stderr: (text) => process.stderr.write(text) };
465
+ let done;
466
+ if (!parsed.needsRuntime) {
467
+ done = await runCommand(manifest, parsed, null, options);
468
+ } else {
469
+ await initIdentity(home);
470
+ const runtime = await NodeRuntime.open({ home, host, archive });
471
+ try {
472
+ done = await runCommand(manifest, parsed, runtime, options);
473
+ } finally {
474
+ await runtime.close();
475
+ }
476
+ }
477
+ // The bytes as they are, for a command that answers with a file.
478
+ if (done.raw !== undefined) process.stdout.write(done.raw);
479
+ else if (done.out !== undefined) say(done.out);
480
+ return done.code;
481
+ } catch (failure) {
482
+ if (failure instanceof AddonError) return refuse(failure.message, 1);
483
+ throw failure;
484
+ }
485
+ }
486
+
487
+ // What a command could not do is its answer: JSON on standard output with
488
+ // error and error_code, and exit status 1, as aamio-python and aamio-php
489
+ // answer it. It was a line on standard error, which a script that reads
490
+ // answers never saw (a check of 13 November 2026). A stop of a send has its
491
+ // own answer, below. A home that cannot be opened, before any command runs, is
492
+ // still said on standard error, as the other two say it.
493
+ const KNOWN = ["home_not_initialized", "key_unreadable", "home_init_failed", "home_in_use", "lock_unavailable", "state_unreadable", "invalid_name",
494
+ "invalid_key", "invalid_re", "unknown_partner", "ambiguous_partner", "unknown_address", "partner_offline", "too_large", "inbox_unavailable", "runtime_closed",
495
+ "board_failed", "board_unreachable", "board_inbox_unavailable", "unknown_post", "not_saved", "unknown_channel", "receipt_failed", "no_trace",
496
+ "invalid_scope", "invalid_access", "scope_exists", "unknown_scope", "scope_write_only",
497
+ "invalid_label", "invalid_gate", "invalid_ttl", "invalid_note", "invalid_address", "invalid_archive", "channel_exists", "channel_unavailable", "binding_conflict"];
498
+
499
+ // trace of a counterpart nothing is recorded about is said on standard error,
500
+ // with nothing on standard output, as aamio-python and aamio-php say it.
501
+ function answered(error) {
502
+ return typeof error?.code === "string" && KNOWN.includes(error.code) && error.code !== "no_trace" && !SEND_STOPS.includes(error.code) && !(error instanceof GateStop);
503
+ }
504
+
505
+ function failure(error) {
506
+ return { error: error.message, error_code: error.code, ...(error.fix ? { fix: error.fix } : {}) };
507
+ }
508
+
196
509
  async function main() {
197
510
  let options;
198
511
  try {
@@ -201,14 +514,33 @@ async function main() {
201
514
  process.stderr.write(error.message + "\n");
202
515
  return 2;
203
516
  }
204
- const { command, home } = options;
517
+ const { command, home, host, archive } = options;
205
518
  if (command === "help") process.stdout.write(help);
206
- else if (command === "version") process.stdout.write(version + "\n");
519
+ // As aamio-python and aamio-php say it.
520
+ else if (command === "version") process.stdout.write(`aamio ${version}\n`);
521
+ else if (command === "addon") return addon(home, options.words, archive, host);
207
522
  else if (command === "serve") {
523
+ // The add-ons enabled in this home, read before the home is taken: one
524
+ // that cannot be used stops the start and says so, a quiet absence would
525
+ // be a tool the user enabled and the model never sees.
526
+ let addons;
527
+ try {
528
+ addons = await loaded(home, { coreTools: RUNTIME_TOOL_NAMES });
529
+ // Enabled here and not installed any more: the server starts without
530
+ // it, and says so on standard error, where the app that started it
531
+ // keeps its log. Standard output is the protocol's alone.
532
+ for (const gone of await enabledButMissing(home)) {
533
+ process.stderr.write(`aamio: the add-on ${gone} is enabled in this home and not installed there any more, so its tools are not offered. Install it again with ${installWords(home, gone)}, or take it out with aamio addon disable ${gone}.\n`);
534
+ }
535
+ } catch (failure) {
536
+ if (!(failure instanceof AddonError)) throw failure;
537
+ process.stderr.write(`aamio: ${failure.message}\n`);
538
+ return 1;
539
+ }
208
540
  await initIdentity(home);
209
541
  // One live runtime per home, Node, Python or PHP: serve holds the home,
210
542
  // owner.lock and all, for as long as it runs, as the other servers do.
211
- const runtime = await NodeRuntime.open({ home });
543
+ const runtime = await NodeRuntime.open({ home, host, archive });
212
544
  // Presence lives two minutes. Once a read or a send has opened the inbox,
213
545
  // it is published again every minute and the inbox renewed before it runs
214
546
  // out, so partners keep finding this server. Nothing goes out before that.
@@ -221,21 +553,57 @@ async function main() {
221
553
  }, 30_000);
222
554
  keepalive.unref();
223
555
  try {
224
- await serveMcp({ home, version, runtime });
556
+ await serveMcp({ home, version, runtime, addons });
225
557
  } finally {
226
558
  clearInterval(keepalive);
227
559
  await runtime.serial(() => runtime.close());
228
560
  }
229
561
  } else if (command === "init" || command === "whoami") {
230
- const identity = command === "init" ? await initIdentity(home) : await readIdentity(home);
231
- process.stdout.write(JSON.stringify(identity) + "\n");
562
+ // The fields aamio-python and aamio-php answer with: the key, the inbox,
563
+ // the service and the tags presence publishes. They answered with three
564
+ // more than this did (a check of 13 November 2026). init opens no inbox
565
+ // here, so it needs no service: the first read opens one.
566
+ const tags = command === "init" && options.options.tags !== undefined ? tagsFrom(options.options.tags) : undefined;
567
+ const chosen = command === "init" && options.options.archive !== undefined ? usage(() => parsePolicy(options.options.archive)) : null;
568
+ if (command === "init") await initIdentity(home);
569
+ else await readIdentity(home);
570
+ const runtime = await NodeRuntime.open({ home, host, archive, tags });
571
+ try {
572
+ if (chosen) await runtime.archiveSet(chosen);
573
+ if (command === "init") await runtime.saveState();
574
+ const answer = runtime.whoami();
575
+ // Said once, where a person sets the runtime up: the service forgets,
576
+ // and this folder does not unless it is told to.
577
+ if (command === "init") answer.local_storage = { ...(await archiveStatus(home, runtime.archivePolicy)), home, who_can_read: homeCheck(home) };
578
+ process.stdout.write(JSON.stringify(answer) + "\n");
579
+ } catch (error) {
580
+ if (!answered(error)) throw error;
581
+ process.stdout.write(JSON.stringify(failure(error)) + "\n");
582
+ return 1;
583
+ } finally {
584
+ await runtime.close();
585
+ }
232
586
  } else {
233
- const act = plan(command, options.positional, options.options);
587
+ // An argument that is wrong for the command rather than for the command
588
+ // line, as a key that is no key, is the command's answer too, as in the
589
+ // other two, and no home is made for it.
590
+ let act;
591
+ try {
592
+ act = plan(command, options.positional, options.options);
593
+ } catch (error) {
594
+ if (!answered(error)) throw error;
595
+ process.stdout.write(JSON.stringify(failure(error)) + "\n");
596
+ return 1;
597
+ }
234
598
  await initIdentity(home);
235
- const runtime = await NodeRuntime.open({ home });
599
+ const runtime = await NodeRuntime.open({ home, host, archive });
236
600
  let result;
237
601
  try {
238
602
  result = await act(runtime);
603
+ } catch (error) {
604
+ if (!answered(error)) throw error;
605
+ process.stdout.write(JSON.stringify(failure(error)) + "\n");
606
+ return 1;
239
607
  } finally {
240
608
  await runtime.close();
241
609
  }
@@ -250,19 +618,18 @@ try {
250
618
  if (error?.code === "usage") {
251
619
  process.stderr.write(error.message + "\n");
252
620
  process.exitCode = 2;
253
- } else if (error?.code === "send_failed" || error?.code === "not_sent" || error instanceof GateStop) {
621
+ } else if (SEND_STOPS.includes(error?.code) || error instanceof GateStop) {
254
622
  // A script that sends needs the message_id and the outcome on standard
255
623
  // output, where it reads answers, to know what not to send again.
256
624
  const said = error instanceof GateStop
257
- ? { error: error.reason ?? error.message, error_code: "gate", fix: error.fix ?? null }
258
- : { error: error.message, error_code: error.code, outcome: error.outcome, message_id: error.message_id, ...(error.status !== undefined ? { status: error.status } : {}), ...(error.outbox_error ? { outbox_error: error.outbox_error } : {}) };
625
+ ? { error: error.reason ?? error.message, error_code: "gate", fix: error.fix ?? null, ...stopFields(error) }
626
+ : { error: error.message, error_code: error.code, ...stopFields(error), ...(error.fix ? { fix: error.fix } : {}) };
627
+ // A retry of several that stopped at one: what was sent again before it is said with the stop.
628
+ if (Array.isArray(error.retried) && error.retried.length) said.retried = error.retried;
259
629
  process.stdout.write(JSON.stringify(said) + "\n");
260
630
  process.exitCode = 1;
261
631
  } else {
262
- const known = ["home_not_initialized", "key_unreadable", "home_init_failed", "home_in_use", "lock_unavailable", "state_unreadable", "invalid_name",
263
- "invalid_key", "invalid_re", "unknown_partner", "ambiguous_partner", "unknown_address", "partner_offline", "too_large", "inbox_unavailable", "runtime_closed",
264
- "board_failed", "board_unreachable", "board_inbox_unavailable", "unknown_post", "not_saved", "unknown_channel", "receipt_failed"];
265
- process.stderr.write((known.includes(error?.code) ? error.message + (error.fix ? ` ${error.fix}` : "") : "The local Node runtime could not complete this command.") + "\n");
632
+ process.stderr.write((KNOWN.includes(error?.code) ? error.message + (error.fix ? ` ${error.fix}` : "") : "The local Node runtime could not complete this command.") + "\n");
266
633
  process.exitCode = 1;
267
634
  }
268
635
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aamio",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "mcpName": "at.aamio/aamio",
5
5
  "description": "Client for aamio, ephemeral rendezvous for agents: keys, addresses, signed and encrypted messages, presence, the open board of needs and offers, receipts and anchoring. Node, Deno, Bun and browsers, and for Node a local runtime with a command line and an MCP server on stdio.",
6
6
  "type": "module",
@@ -19,10 +19,16 @@
19
19
  "bin/aamio.js",
20
20
  "src/aamio.js",
21
21
  "src/aamio.d.ts",
22
+ "src/node-addon-process.js",
23
+ "src/node-addons.js",
24
+ "src/node-archive.js",
25
+ "src/node-doctor.js",
22
26
  "src/node-identity.js",
23
27
  "src/node-lock.js",
24
28
  "src/node-mcp.js",
25
29
  "src/node-runtime.js",
30
+ "src/node-scopes.js",
31
+ "src/node-trace.js",
26
32
  "README.md",
27
33
  "LICENSE"
28
34
  ],