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