@ours.network/cli 0.1.0 → 0.1.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.
@@ -0,0 +1,379 @@
1
+ import {
2
+ CliUsageError,
3
+ parseBoolean
4
+ } from "./chunk-6NFATG6P.js";
5
+
6
+ // src/operations.ts
7
+ var str = (help, required = false, values, valueName = "TEXT", allowEmpty = false) => ({ kind: "string", help, valueName, required, values, allowEmpty });
8
+ var bool = (help, required = false) => ({ kind: "boolean", help, valueName: "true|false", required });
9
+ var integer = (help, required = false, positive = false) => ({ kind: "integer", help, valueName: "N", required, positive });
10
+ var strings = (help, required = false) => ({ kind: "string[]", help, valueName: "VALUE,...", required });
11
+ var integers = (help, required = false) => ({ kind: "integer[]", help, valueName: "N,...", required });
12
+ var none = {};
13
+ var OPERATION_SPECS = {
14
+ "create-identity": {
15
+ method: "createIdentity",
16
+ fields: {
17
+ name: str("Public display name for the new identity.", true, void 0, "NAME"),
18
+ bio: str("Public profile text shared with contacts.", true),
19
+ exposeLocal: bool("Publish the identity in the host-local contact book.", true),
20
+ localAutoAccept: bool("Automatically approve verified local introductions.", true)
21
+ },
22
+ summary: "Create a persistent identity and bind this CLI session to it.",
23
+ effects: "Writes identity state. With a Human identity, the new identity becomes its role; otherwise the first persistent identity becomes the Human identity.",
24
+ exampleInput: { name: "BuildBot", bio: "Build coordinator", exposeLocal: true, localAutoAccept: true }
25
+ },
26
+ "create-temporary-identity": {
27
+ method: "createTemporaryIdentity",
28
+ fields: {
29
+ name: str("Optional public name; omit it to generate a random tmp-\u2026 name.", false, void 0, "NAME"),
30
+ bio: str("Public profile text shared with contacts.", true),
31
+ exposeLocal: bool("Publish the temporary identity in the host-local contact book.", true),
32
+ localAutoAccept: bool("Automatically approve verified local introductions.", true)
33
+ },
34
+ summary: "Create and bind a session-owned temporary identity.",
35
+ effects: "Writes temporary identity state. Session teardown or lease release closes it, sends best-effort remove-me notices, and permanently deletes its local state.",
36
+ exampleInput: { name: "ScratchBot", bio: "Short-lived test operator", exposeLocal: false, localAutoAccept: true }
37
+ },
38
+ "close-temporary-identity": {
39
+ method: "closeTemporaryIdentityOp",
40
+ fields: { name: str("Temporary identity to close; omit it to close the one bound by this lease.", false, void 0, "NAME") },
41
+ summary: "Close a temporary identity owned by this session, or reclaim one whose owner is stale.",
42
+ effects: "Destructive: queues best-effort remove-me notices and permanently deletes local identity state. A live owner cannot be evicted.",
43
+ exampleInput: { name: "ScratchBot" }
44
+ },
45
+ "create-root-identity": {
46
+ method: "createRootIdentity",
47
+ fields: {
48
+ name: str("Public Human identity name.", true, void 0, "NAME"),
49
+ bio: str("Public profile text shared through the verified identity hierarchy.", true),
50
+ exposeLocal: bool("Publish the identity in the host-local contact book.", true),
51
+ localAutoAccept: bool("Automatically approve verified local introductions.", true),
52
+ skipIfRootExists: bool("Return the existing-root result instead of trying to create another Human identity.", true)
53
+ },
54
+ summary: "Create the host Human identity and bind this CLI session to it.",
55
+ effects: "Writes identity state and may adopt existing persistent identities as roles. If a Human identity already exists, a non-skipped creation becomes a role under it.",
56
+ exampleInput: { name: "Alice@laptop", bio: "Alice on laptop", exposeLocal: true, localAutoAccept: true, skipIfRootExists: true }
57
+ },
58
+ "define-local-identity-file": {
59
+ method: "defineLocalIdentityFile",
60
+ fields: {
61
+ name: str("Identity name to suggest for this workspace.", true, void 0, "NAME"),
62
+ path: str("Absolute workspace directory in which to write .ours-identity.", true, void 0, "PATH"),
63
+ force: bool("Record that a future binder may request forced adoption; the file itself does not bind.", true),
64
+ exposeLocal: bool("Record the local contact-book exposure preference.", true),
65
+ localAutoAccept: bool("Record the local introduction approval preference.", true),
66
+ overwrite: bool("Replace an existing .ours-identity file.", true)
67
+ },
68
+ summary: "Write an advisory .ours-identity workspace pin.",
69
+ effects: "Writes only the workspace pin file. It does not create or bind an identity.",
70
+ exampleInput: { name: "BuildBot", path: "/srv/project", force: false, exposeLocal: true, localAutoAccept: true, overwrite: false }
71
+ },
72
+ "choose-identity": {
73
+ method: "chooseIdentity",
74
+ fields: {
75
+ name: str("Existing identity to bind.", true, void 0, "NAME"),
76
+ force: bool("Evict another live session that holds the identity.", true)
77
+ },
78
+ summary: "Bind an existing identity to this CLI lease.",
79
+ effects: "Changes lease ownership. Force can evict another live session; temporary identities still enforce their session owner.",
80
+ exampleInput: { name: "BuildBot", force: false }
81
+ },
82
+ "list-identities": { method: "listIdentities", fields: none, summary: "List identities, hierarchy, temporary ownership, and lease state.", effects: "Read-only." },
83
+ "current-identity": { method: "currentIdentity", fields: none, summary: "Show the identity bound to this CLI lease, including its profile and hierarchy.", effects: "Read-only." },
84
+ "remove-identity": {
85
+ method: "removeIdentity",
86
+ fields: { name: str("Persistent or temporary identity to remove.", true, void 0, "NAME") },
87
+ summary: "Permanently remove an identity.",
88
+ effects: "Destructive: deletes local identity state. A Human identity cannot be removed while it still has roles; temporary identities follow their close lifecycle.",
89
+ exampleInput: { name: "OldBot" }
90
+ },
91
+ "release-lease": {
92
+ method: "releaseLease",
93
+ fields: none,
94
+ summary: "Release every identity lease held by this CLI client token.",
95
+ effects: "Changes lease state. Owned temporary identities begin asynchronous permanent closure and best-effort contact notification."
96
+ },
97
+ "generate-invite": {
98
+ method: "generateInvite",
99
+ fields: {
100
+ name: str("Optional local contact name reserved for the one-time redeemer.", false, void 0, "NAME"),
101
+ mode: str("Invite kind: one_time is consumed once; public is reusable until revoked or daemon restart.", false, ["one_time", "public"], "MODE")
102
+ },
103
+ summary: "Generate encrypted contact-introduction material for out-of-band sharing.",
104
+ effects: "Creates an outstanding invite. Omitted mode defaults to one_time; public invites cannot pre-assign a name.",
105
+ exampleInput: { name: "Peer", mode: "one_time" }
106
+ },
107
+ "list-invites": { method: "listInvites", fields: none, summary: "List outstanding one-time and public invites.", effects: "Read-only." },
108
+ "revoke-invite": {
109
+ method: "revokeInvite",
110
+ fields: { invite_id: str("Invite identifier returned by invite creation or listing.", true, void 0, "ID") },
111
+ summary: "Revoke an outstanding invite.",
112
+ effects: "Destructive and idempotent. It prevents future redemption but does not remove existing contacts.",
113
+ exampleInput: { invite_id: "INVITE_ID" }
114
+ },
115
+ "add-contact": {
116
+ method: "addContact",
117
+ fields: {
118
+ invite: str("Invite material received out of band.", true, void 0, "INVITE"),
119
+ name: str("Optional local display name for the inviter.", false, void 0, "NAME")
120
+ },
121
+ summary: "Redeem an invite and begin an encrypted contact handshake.",
122
+ effects: "Sends the first handshake leg. The contact remains pending until the inviter verifies and replies.",
123
+ exampleInput: { invite: "<invite-from-peer>", name: "Peer" }
124
+ },
125
+ "list-contacts": { method: "listContacts", fields: none, summary: "List established contacts and pending local introductions.", effects: "Read-only." },
126
+ "list-local-contact-book": { method: "listLocalContactBook", fields: none, summary: "List same-host identities published for invite-free introduction.", effects: "Read-only." },
127
+ "set-local-book-policy": {
128
+ method: "setLocalBookPolicy",
129
+ fields: {
130
+ expose: bool("Publish or unpublish the bound identity in the host-local contact book."),
131
+ auto_accept: bool("Automatically approve or queue verified local introductions.")
132
+ },
133
+ summary: "Change local contact-book exposure and/or introduction approval policy.",
134
+ effects: "Writes identity-local policy. At least one of expose or auto_accept is required.",
135
+ exampleInput: { expose: true, auto_accept: false },
136
+ refine: (a) => {
137
+ if (a.expose === void 0 && a.auto_accept === void 0) throw new CliUsageError("set-local-book-policy requires expose and/or auto_accept");
138
+ }
139
+ },
140
+ "remove-contact": {
141
+ method: "removeContact",
142
+ fields: { contact: str("Contact name or container ID to forget.", true, void 0, "CONTACT") },
143
+ summary: "Forget a contact locally.",
144
+ effects: "Destructive: removes the local contact and queues one best-effort remove-me notice; remote deletion is not guaranteed.",
145
+ exampleInput: { contact: "OldPeer" }
146
+ },
147
+ "rename-contact": {
148
+ method: "renameContact",
149
+ fields: {
150
+ contact: str("Current contact name or container ID.", true, void 0, "CONTACT"),
151
+ name: str("New local display name.", true, void 0, "NAME")
152
+ },
153
+ summary: "Rename a contact locally without changing keys or the encrypted channel.",
154
+ effects: "Changes only the local display name.",
155
+ exampleInput: { contact: "Peer", name: "PeerLaptop" }
156
+ },
157
+ "respond-to-introduction": {
158
+ method: "respondToIntroduction",
159
+ fields: {
160
+ contact: str("Pending contact name or container ID.", true, void 0, "CONTACT"),
161
+ action: str("Approve to establish the contact and release queued messages, or reject to drop them.", true, ["approve", "reject"], "ACTION")
162
+ },
163
+ summary: "Approve or reject a queued local introduction.",
164
+ effects: "Approval establishes the contact and moves queued messages into the inbox; rejection drops the queue.",
165
+ exampleInput: { contact: "LocalBot", action: "approve" }
166
+ },
167
+ "set-bio": {
168
+ method: "setBio",
169
+ fields: { bio: str("New public profile text; an empty string clears it.", true, void 0, "TEXT", true) },
170
+ summary: "Replace the bound identity\u2019s public bio.",
171
+ effects: "Writes public profile state. Updating a Human identity also refreshes its profile in role delegation chains.",
172
+ exampleInput: { bio: "Build coordinator" }
173
+ },
174
+ "set-persona": {
175
+ method: "setPersona",
176
+ fields: { persona: str("New local operating contract; an empty string clears it.", true, void 0, "TEXT", true) },
177
+ summary: "Replace the bound identity\u2019s local persona.",
178
+ effects: "Writes local profile state. Personas are not shared in invites.",
179
+ exampleInput: { persona: "Handle build coordination only." }
180
+ },
181
+ "send-message": {
182
+ method: "sendMessage",
183
+ fields: {
184
+ contact: str("Recipient contact name or container ID.", true, void 0, "CONTACT"),
185
+ text: str("Message body.", true),
186
+ reply_to_wire_id: str("Optional wire ID of the message or file being answered.", false, void 0, "WIRE_ID"),
187
+ reply_to_sentence: integer("Optional 1-based sentence number; ignored unless reply_to_wire_id is also set.", false, true)
188
+ },
189
+ summary: "Send an end-to-end-encrypted text message.",
190
+ effects: "Sends data. A verified same-host identity may be introduced automatically when it is not already a contact.",
191
+ exampleInput: { contact: "Peer", text: "Build finished." }
192
+ },
193
+ "send-file": {
194
+ method: "sendFile",
195
+ fields: {
196
+ contact: str("Recipient contact name or container ID.", true, void 0, "CONTACT"),
197
+ path: str("Local file path to read and send; mutually exclusive with data_base64.", false, void 0, "PATH"),
198
+ data_base64: str("Inline base64 file bytes; mutually exclusive with path.", false, void 0, "BASE64"),
199
+ filename: str("Filename required with data_base64; optional override with path.", false, void 0, "NAME"),
200
+ mime: str("Optional MIME type; inferred from a path extension when omitted.", false, void 0, "TYPE"),
201
+ reply_to_wire_id: str("Optional wire ID of the message or file being answered.", false, void 0, "WIRE_ID"),
202
+ reply_to_sentence: integer("Optional 1-based sentence number; ignored unless reply_to_wire_id is also set.", false, true)
203
+ },
204
+ summary: "Send an end-to-end-encrypted file from a path or inline base64.",
205
+ effects: "Reads the selected local file when path is used and sends bytes. Exactly one of path or data_base64 is required; data_base64 also requires filename.",
206
+ exampleInput: { contact: "Peer", path: "/tmp/report.pdf" },
207
+ refine: (a) => {
208
+ if (a.path === void 0 === (a.data_base64 === void 0)) throw new CliUsageError("send-file requires exactly one of path or data_base64");
209
+ if (a.data_base64 !== void 0 && a.filename === void 0) throw new CliUsageError("send-file requires filename with data_base64");
210
+ }
211
+ },
212
+ "list-incoming-messages": { method: "listIncomingMessages", fields: none, summary: "List inbox metadata and status without revealing message bodies.", effects: "Read-only; message status is unchanged." },
213
+ "get-messages": { method: "getMessages", fields: none, summary: "Return every unread message body and mark those messages processed.", effects: "Mutates inbox status. This is the only message-body read and is exactly-once unless the returned message IDs are deferred." },
214
+ "defer-messages": {
215
+ method: "deferMessages",
216
+ fields: { msg_ids: integers("Comma-separated numeric message IDs to return to unread state.", true) },
217
+ summary: "Return processed messages to the unread queue.",
218
+ effects: "Mutates inbox status.",
219
+ exampleInput: { msg_ids: [12, 13] }
220
+ },
221
+ "defer-files": {
222
+ method: "deferFiles",
223
+ fields: { file_ids: integers("Comma-separated numeric file IDs\u2014not wire IDs\u2014to return to unread state.", true) },
224
+ summary: "Return processed files to the unread queue.",
225
+ effects: "Mutates inbox status; already-unread files are left unchanged.",
226
+ exampleInput: { file_ids: [4, 5] }
227
+ },
228
+ "get-conversation": {
229
+ method: "getConversation",
230
+ fields: { contact: str("Contact name or container ID.", true, void 0, "CONTACT") },
231
+ summary: "Show retained conversation history with one contact, oldest first.",
232
+ effects: "Read-only. It is empty when history retention is disabled.",
233
+ exampleInput: { contact: "Peer" }
234
+ },
235
+ "get-receipts": {
236
+ method: "getReceipts",
237
+ fields: { contact: str("Contact name or container ID.", true, void 0, "CONTACT") },
238
+ summary: "Show delivered/read receipts received for sent messages.",
239
+ effects: "Read-only. Receipts are retained only when conversation history is enabled.",
240
+ exampleInput: { contact: "Peer" }
241
+ },
242
+ "mark-read": {
243
+ method: "markRead",
244
+ fields: { contact: str("Contact name or container ID.", true, void 0, "CONTACT") },
245
+ summary: "Mark unread inbound entries in one conversation as read.",
246
+ effects: "Mutates conversation state and sends read receipts for newly marked messages.",
247
+ exampleInput: { contact: "Peer" }
248
+ },
249
+ "set-conversation-policy": {
250
+ method: "setConversationPolicy",
251
+ fields: { keep_history: bool("Enable or disable retained conversation history and receipt support.", true) },
252
+ summary: "Set conversation-history policy for the bound identity.",
253
+ effects: "Writes identity-local policy. Existing contacts learn newly enabled receipt capability on later outbound traffic.",
254
+ exampleInput: { keep_history: true }
255
+ },
256
+ "list-incoming-files": { method: "listIncomingFiles", fields: none, summary: "List incoming-file metadata for authorization.", effects: "Read-only: returns no bytes and does not change file status. Authorize using the authenticated sender ID, not its display name." },
257
+ "get-files": {
258
+ method: "getFiles",
259
+ fields: { wire_ids: strings("Comma-separated unread file wire IDs; omit to retrieve every unread file.") },
260
+ summary: "Write approved unread files into the bound identity\u2019s files directory.",
261
+ effects: "Writes received bytes and marks retrieved files processed. An explicit selection must contain 1\u201332 unique 64-hex wire IDs.",
262
+ exampleInput: { wire_ids: ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"] }
263
+ },
264
+ version: { method: "version", fields: none, summary: "Show the selected daemon\u2019s version and protocol information.", effects: "Read-only." },
265
+ "state-dir": { method: "stateDir", fields: none, summary: "Show the selected daemon\u2019s canonical state directory.", effects: "Read-only." },
266
+ identities: { method: "identities", fields: none, summary: "Show the selected daemon\u2019s identity summary.", effects: "Read-only." },
267
+ unread: { method: "unread", fields: none, summary: "Show body-free unread counts and sender metadata.", effects: "Read-only; it never reveals message bodies or changes inbox status." },
268
+ "watch-notifications": {
269
+ method: "watchNotifications",
270
+ fields: {
271
+ identity: str("Identity whose body-free notification stream to follow.", true, void 0, "NAME"),
272
+ since: { kind: "cursor", help: "Byte cursor to resume from, or tip to wait only for new events; omitted defaults to tip.", valueName: "N|tip" }
273
+ },
274
+ summary: "Stream body-free notification events until interrupted.",
275
+ effects: "Long-running read. It does not reveal message bodies or change inbox status.",
276
+ exampleInput: { identity: "BuildBot", since: "tip" }
277
+ },
278
+ "fetch-file": {
279
+ method: "fetchFile",
280
+ fields: { wire_id: str("Received file wire ID.", true, void 0, "WIRE_ID") },
281
+ summary: "Return one already-stored received file as base64.",
282
+ effects: "Read-only; file bytes are emitted in the command result, so protect redirected output.",
283
+ exampleInput: { wire_id: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" }
284
+ }
285
+ };
286
+ var operationNames = () => Object.keys(OPERATION_SPECS).sort();
287
+ var isOperationName = (value) => Object.prototype.hasOwnProperty.call(OPERATION_SPECS, value);
288
+ function validateInput(input, operation) {
289
+ const spec = OPERATION_SPECS[operation];
290
+ for (const key of Object.keys(input)) {
291
+ if (!Object.prototype.hasOwnProperty.call(spec.fields, key)) throw new CliUsageError(`${operation}: unknown input field ${JSON.stringify(key)}`);
292
+ }
293
+ for (const [name, field] of Object.entries(spec.fields)) {
294
+ const item = input[name];
295
+ if (item === void 0) {
296
+ if (field.required) throw new CliUsageError(`${operation}: missing required input field ${JSON.stringify(name)}`);
297
+ continue;
298
+ }
299
+ let valid = false;
300
+ if (field.kind === "string") valid = typeof item === "string" && (field.values === void 0 || field.values.includes(item));
301
+ else if (field.kind === "boolean") valid = typeof item === "boolean";
302
+ else if (field.kind === "integer") valid = Number.isSafeInteger(item) && (!field.positive || Number(item) >= 1);
303
+ else if (field.kind === "string[]") valid = Array.isArray(item) && item.every((v) => typeof v === "string");
304
+ else if (field.kind === "integer[]") valid = Array.isArray(item) && item.every((v) => Number.isSafeInteger(v));
305
+ else valid = item === "tip" || Number.isSafeInteger(item) && Number(item) >= 0;
306
+ if (!valid) {
307
+ const expected = field.values ? field.values.map((value) => JSON.stringify(value)).join(" or ") : field.kind;
308
+ throw new CliUsageError(`${operation}: input field ${JSON.stringify(name)} must be ${expected}`);
309
+ }
310
+ }
311
+ spec.refine?.(input);
312
+ return input;
313
+ }
314
+ function parseOperationJson(raw, operation) {
315
+ let value = {};
316
+ if (raw !== void 0) {
317
+ try {
318
+ value = JSON.parse(raw);
319
+ } catch (error) {
320
+ throw new CliUsageError(`--input is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
321
+ }
322
+ }
323
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new CliUsageError("--input must be a JSON object");
324
+ return validateInput(value, operation);
325
+ }
326
+ var fieldFlag = (name) => `--${name.replaceAll("_", "-").replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
327
+ function operationSpec(operation) {
328
+ return OPERATION_SPECS[operation];
329
+ }
330
+ function operationValueFlags(operation) {
331
+ return new Set(Object.keys(OPERATION_SPECS[operation].fields).map(fieldFlag));
332
+ }
333
+ function operationEmptyValueFlags(operation) {
334
+ return new Set(Object.entries(OPERATION_SPECS[operation].fields).filter(([, field]) => field.allowEmpty === true).map(([name]) => fieldFlag(name)));
335
+ }
336
+ function operationInputFromFlags(operation, values, defaults = {}) {
337
+ const input = { ...defaults };
338
+ for (const [name, field] of Object.entries(OPERATION_SPECS[operation].fields)) {
339
+ const flag = fieldFlag(name);
340
+ const raw = values[flag];
341
+ if (raw === void 0) continue;
342
+ if (field.kind === "boolean") input[name] = parseBoolean(raw, flag);
343
+ else if (field.kind === "integer") input[name] = parseCliInteger(raw, flag, field.positive === true);
344
+ else if (field.kind === "integer[]") input[name] = raw === "" ? [] : raw.split(",").map((v) => parseCliInteger(v, flag));
345
+ else if (field.kind === "string[]") input[name] = raw === "" ? [] : raw.split(",");
346
+ else if (field.kind === "cursor") input[name] = raw === "tip" ? "tip" : parseCliInteger(raw, flag);
347
+ else input[name] = raw;
348
+ }
349
+ return validateInput(input, operation);
350
+ }
351
+ function parseCliInteger(raw, flag, positive = false) {
352
+ if (!/^[0-9]+$/.test(raw)) throw new CliUsageError(`${flag} must be an integer`);
353
+ const value = Number(raw);
354
+ if (!Number.isSafeInteger(value) || positive && value < 1) throw new CliUsageError(`${flag} is outside the supported integer range`);
355
+ return value;
356
+ }
357
+ async function invokeOperation(client, operation, input) {
358
+ if (operation === "watch-notifications") throw new Error("watch-notifications is a streaming operation");
359
+ if (operation === "fetch-file") {
360
+ const bytes = await client.fetchFile(input.wire_id);
361
+ return { wire_id: input.wire_id, bytes: bytes.byteLength, data_base64: Buffer.from(bytes).toString("base64") };
362
+ }
363
+ const spec = OPERATION_SPECS[operation];
364
+ const fn = client[spec.method];
365
+ return Object.keys(spec.fields).length === 0 ? fn.call(client) : fn.call(client, input);
366
+ }
367
+
368
+ export {
369
+ OPERATION_SPECS,
370
+ operationNames,
371
+ isOperationName,
372
+ parseOperationJson,
373
+ fieldFlag,
374
+ operationSpec,
375
+ operationValueFlags,
376
+ operationEmptyValueFlags,
377
+ operationInputFromFlags,
378
+ invokeOperation
379
+ };
@@ -0,0 +1,174 @@
1
+ // src/commands.ts
2
+ var operation = (name, defaults) => defaults === void 0 ? { kind: "operation", operation: name } : { kind: "operation", operation: name, defaults };
3
+ var COMMAND_GROUPS = {
4
+ daemon: {
5
+ summary: "Run, inspect, and integrate the shared ours daemon.",
6
+ commands: {
7
+ serve: {
8
+ kind: "daemon",
9
+ handler: "serve",
10
+ summary: "Run the daemon in the foreground until interrupted.",
11
+ effects: "Starts a daemon in this process. --managed also writes the CLI-owned PID record used by stop and restart.",
12
+ example: "ours daemon serve --config /etc/ours/config.json"
13
+ },
14
+ start: {
15
+ kind: "daemon",
16
+ handler: "start",
17
+ summary: "Start a detached daemon managed by this CLI.",
18
+ effects: "Spawns a background process, writes a PID record and log in the selected state directory, and waits up to 20 seconds for readiness.",
19
+ example: "ours daemon start --config /etc/ours/config.json"
20
+ },
21
+ stop: {
22
+ kind: "daemon",
23
+ handler: "stop",
24
+ summary: "Stop a daemon previously started by this CLI.",
25
+ effects: "Signals only a process corroborated by the selected daemon and CLI-owned PID record; it refuses to signal externally managed daemons.",
26
+ example: "ours daemon stop --config /etc/ours/config.json"
27
+ },
28
+ restart: {
29
+ kind: "daemon",
30
+ handler: "restart",
31
+ summary: "Stop and start a CLI-managed daemon.",
32
+ effects: "Uses the same ownership checks as stop, then starts a detached replacement.",
33
+ example: "ours daemon restart --config /etc/ours/config.json"
34
+ },
35
+ status: {
36
+ kind: "daemon",
37
+ handler: "status",
38
+ summary: "Report whether the selected daemon is running and CLI-managed.",
39
+ effects: "Read-only. Exit status is 0 when running and 3 when stopped.",
40
+ example: "ours daemon status --json"
41
+ },
42
+ "install-service": {
43
+ kind: "daemon",
44
+ handler: "install-service",
45
+ summary: "Install and enable the Linux user systemd service.",
46
+ effects: "Changes ~/.config/systemd/user/ours.service and runs systemctl --user. Requires --yes unless --dry-run is used; --force may replace only an existing unowned unit.",
47
+ example: "ours daemon install-service --dry-run --json"
48
+ },
49
+ "uninstall-service": {
50
+ kind: "daemon",
51
+ handler: "uninstall-service",
52
+ summary: "Disable and remove the Linux user systemd service owned by this CLI.",
53
+ effects: "Changes the user service manager. Requires --yes unless --dry-run is used and refuses to remove an unowned unit.",
54
+ example: "ours daemon uninstall-service --dry-run --json"
55
+ },
56
+ info: operation("version"),
57
+ identities: operation("identities"),
58
+ unread: operation("unread"),
59
+ watch: operation("watch-notifications")
60
+ }
61
+ },
62
+ config: {
63
+ summary: "Inspect redacted connection configuration or update safe daemon settings.",
64
+ commands: {
65
+ show: {
66
+ kind: "config",
67
+ handler: "show",
68
+ summary: "Show the resolved daemon selection and redacted stored configuration.",
69
+ effects: "Read-only. Reports whether token/STT sections exist but never prints their secret values.",
70
+ example: "ours config show --json"
71
+ },
72
+ setup: {
73
+ kind: "config",
74
+ handler: "setup",
75
+ summary: "Write one or more safe daemon settings noninteractively.",
76
+ effects: "Writes a mode-0600 JSON config unless --dry-run is used. Unknown and secret fields already present are preserved and never printed.",
77
+ example: "ours config setup --port 3070 --state-dir /srv/ours-a --api-visibility owner"
78
+ }
79
+ }
80
+ },
81
+ identity: {
82
+ summary: "Create, bind, inspect, pin, release, and remove identities.",
83
+ commands: {
84
+ create: operation("create-identity", { bio: "", exposeLocal: true, localAutoAccept: true }),
85
+ "create-temporary": operation("create-temporary-identity", { bio: "", exposeLocal: false, localAutoAccept: true }),
86
+ "close-temporary": operation("close-temporary-identity"),
87
+ "create-root": operation("create-root-identity", { bio: "", exposeLocal: true, localAutoAccept: true, skipIfRootExists: true }),
88
+ pin: operation("define-local-identity-file", { force: false, exposeLocal: true, localAutoAccept: true, overwrite: false }),
89
+ use: operation("choose-identity", { force: false }),
90
+ list: operation("list-identities"),
91
+ show: operation("current-identity"),
92
+ remove: operation("remove-identity"),
93
+ release: operation("release-lease")
94
+ }
95
+ },
96
+ profile: {
97
+ summary: "Update the bound identity\u2019s public bio or private local persona.",
98
+ commands: { "set-bio": operation("set-bio"), "set-persona": operation("set-persona") }
99
+ },
100
+ invite: {
101
+ summary: "Create, inspect, revoke, and accept encrypted contact invitations.",
102
+ commands: {
103
+ create: operation("generate-invite"),
104
+ list: operation("list-invites"),
105
+ revoke: operation("revoke-invite"),
106
+ accept: operation("add-contact")
107
+ }
108
+ },
109
+ contact: {
110
+ summary: "Inspect contacts and manage local introductions and contact policy.",
111
+ commands: {
112
+ list: operation("list-contacts"),
113
+ local: operation("list-local-contact-book"),
114
+ policy: operation("set-local-book-policy"),
115
+ remove: operation("remove-contact"),
116
+ rename: operation("rename-contact"),
117
+ respond: operation("respond-to-introduction")
118
+ }
119
+ },
120
+ message: {
121
+ summary: "Send encrypted text and manage the incoming message queue.",
122
+ commands: {
123
+ send: operation("send-message"),
124
+ list: operation("list-incoming-messages"),
125
+ get: operation("get-messages"),
126
+ defer: operation("defer-messages")
127
+ }
128
+ },
129
+ file: {
130
+ summary: "Send encrypted files and authorize, retrieve, or defer received files.",
131
+ commands: {
132
+ send: operation("send-file"),
133
+ list: operation("list-incoming-files"),
134
+ get: operation("get-files"),
135
+ fetch: operation("fetch-file"),
136
+ defer: operation("defer-files")
137
+ }
138
+ },
139
+ conversation: {
140
+ summary: "Inspect retained conversations and receipts, mark them read, and set retention policy.",
141
+ commands: {
142
+ show: operation("get-conversation"),
143
+ receipts: operation("get-receipts"),
144
+ "mark-read": operation("mark-read"),
145
+ policy: operation("set-conversation-policy")
146
+ }
147
+ }
148
+ };
149
+ var DESTRUCTIVE_OPERATIONS = /* @__PURE__ */ new Set([
150
+ "close-temporary-identity",
151
+ "remove-identity",
152
+ "revoke-invite",
153
+ "remove-contact"
154
+ ]);
155
+ var IDENTITY_PREBIND_EXCEPTIONS = /* @__PURE__ */ new Set([
156
+ "choose-identity",
157
+ "create-identity",
158
+ "create-temporary-identity",
159
+ "create-root-identity"
160
+ ]);
161
+ function isCommandGroup(value) {
162
+ return Object.prototype.hasOwnProperty.call(COMMAND_GROUPS, value);
163
+ }
164
+ function commandSpec(group, command) {
165
+ return COMMAND_GROUPS[group].commands[command];
166
+ }
167
+
168
+ export {
169
+ COMMAND_GROUPS,
170
+ DESTRUCTIVE_OPERATIONS,
171
+ IDENTITY_PREBIND_EXCEPTIONS,
172
+ isCommandGroup,
173
+ commandSpec
174
+ };