@homespunapps/cli 1.0.1 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/argv.js +69 -4
- package/dist/commands/agent.js +0 -20
- package/dist/commands/apps.js +54 -37
- package/dist/commands/attachment-delete.js +2 -19
- package/dist/commands/attachment-download.js +2 -22
- package/dist/commands/attachment-list.js +2 -22
- package/dist/commands/attachment-show.js +2 -19
- package/dist/commands/attachment-token.js +4 -44
- package/dist/commands/attachment-upload.js +2 -25
- package/dist/commands/attachment.js +9 -52
- package/dist/commands/claim.js +2 -30
- package/dist/commands/config.js +6 -35
- package/dist/commands/data.js +211 -24
- package/dist/commands/deploy.js +23 -28
- package/dist/commands/feedback.js +3 -44
- package/dist/commands/grant.js +158 -0
- package/dist/commands/ingest.js +85 -0
- package/dist/commands/key.js +20 -31
- package/dist/commands/logout.js +2 -28
- package/dist/commands/members.js +64 -32
- package/dist/commands/register.js +2 -49
- package/dist/commands/set-key.js +2 -32
- package/dist/commands/skill.js +3 -39
- package/dist/commands/taste.js +4 -49
- package/dist/help-catalog.js +1087 -0
- package/dist/index.js +28 -103
- package/package.json +11 -6
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
// The single source of truth for the CLI's command surface.
|
|
2
|
+
//
|
|
3
|
+
// One table describes every noun, verb, and flag. Three consumers read it and
|
|
4
|
+
// nothing else:
|
|
5
|
+
//
|
|
6
|
+
// 1. assertKnownFlags(), via specFor(), so the flags a command ACCEPTS are
|
|
7
|
+
// the flags this table declares. There is no second list to drift from.
|
|
8
|
+
// 2. `homespun <noun> --help` and `homespun --help`, rendered by
|
|
9
|
+
// renderNounHelp() / renderRootHelp() below.
|
|
10
|
+
// 3. docs-site, which bundles this file with esbuild and generates
|
|
11
|
+
// /agents/cli-reference from it.
|
|
12
|
+
//
|
|
13
|
+
// Adding a flag is a one-line edit here. Previously it meant editing an inline
|
|
14
|
+
// allow-list in the runner AND a usage line in a help template, with nothing
|
|
15
|
+
// checking the two against each other, and the published CLI reference was a
|
|
16
|
+
// third hand-written copy that matched neither.
|
|
17
|
+
//
|
|
18
|
+
// This file MUST stay pure: no imports, no I/O, no env. docs-site executes it
|
|
19
|
+
// at build time with none of the CLI's runtime available. Keep it a data table
|
|
20
|
+
// plus pure string building.
|
|
21
|
+
//
|
|
22
|
+
// Note on globals: url, api-key and profile (value) and help and json
|
|
23
|
+
// (boolean) are added to every command by assertKnownFlags itself, so they are
|
|
24
|
+
// deliberately NOT repeated per verb here. Before this table, 25 of 50 call
|
|
25
|
+
// sites listed some of them redundantly and the rest did not.
|
|
26
|
+
//
|
|
27
|
+
// House style: no em or en dashes anywhere in this file. The docs generator
|
|
28
|
+
// scans for them and names the offending entry.
|
|
29
|
+
const DEFAULT_OUTPUT_NOTE = 'Output is JSON on stdout. Errors go to stderr as {"error":{"code","message"}} with a non-zero exit.';
|
|
30
|
+
const APPS = {
|
|
31
|
+
noun: "apps",
|
|
32
|
+
tagline: "app lifecycle management",
|
|
33
|
+
group: "app",
|
|
34
|
+
rootSummary: "App lifecycle: list, show, update, delete, wake, watch (stream the app's change feed as JSON-lines).",
|
|
35
|
+
verbs: [
|
|
36
|
+
{
|
|
37
|
+
verb: "list",
|
|
38
|
+
summary: "Lists your apps.",
|
|
39
|
+
flags: [
|
|
40
|
+
{
|
|
41
|
+
name: "status",
|
|
42
|
+
value: "<active|dormant|archived|all>",
|
|
43
|
+
description: "Filter by lifecycle status",
|
|
44
|
+
},
|
|
45
|
+
{ name: "limit", value: "<n>", description: "Page size" },
|
|
46
|
+
{ name: "cursor", value: "<cursor>", description: "Page cursor" },
|
|
47
|
+
{
|
|
48
|
+
name: "slug",
|
|
49
|
+
value: "<slug>",
|
|
50
|
+
description: "Look up one app by slug",
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
verb: "show",
|
|
56
|
+
positionals: "<app>",
|
|
57
|
+
summary: "Shows one app's detail record.",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
verb: "update",
|
|
61
|
+
positionals: "<app>",
|
|
62
|
+
summary: "Changes an app's visibility or timezone.",
|
|
63
|
+
flags: [
|
|
64
|
+
{
|
|
65
|
+
name: "visibility",
|
|
66
|
+
value: "<private|link|public>",
|
|
67
|
+
description: "Who can open the app",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "timezone",
|
|
71
|
+
value: "<IANA zone>",
|
|
72
|
+
description: "Timezone used for the app's day boundaries",
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
verb: "share-link",
|
|
78
|
+
positionals: "rotate <app>",
|
|
79
|
+
summary: "Rotates a link-visibility app's share token, invalidating the old URL.",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
verb: "delete",
|
|
83
|
+
positionals: "<app>",
|
|
84
|
+
summary: "Soft-deletes an app.",
|
|
85
|
+
bools: [{ name: "yes", description: "Skip the confirmation prompt" }],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
verb: "wake",
|
|
89
|
+
positionals: "<app>",
|
|
90
|
+
summary: "Wakes a dormant app.",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
verb: "watch",
|
|
94
|
+
positionals: "<app>",
|
|
95
|
+
summary: "Streams the app's change feed as JSON-lines.",
|
|
96
|
+
flags: [
|
|
97
|
+
{
|
|
98
|
+
name: "since",
|
|
99
|
+
value: "<cursor>",
|
|
100
|
+
description: "Resume from a feed cursor",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "collection",
|
|
104
|
+
value: "<name[,name2,...]>",
|
|
105
|
+
description: "Only stream these collections",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "timeout",
|
|
109
|
+
value: "<secs>",
|
|
110
|
+
description: "Give up after this long",
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
bools: [
|
|
114
|
+
{
|
|
115
|
+
name: "once",
|
|
116
|
+
description: "Print one batch and exit instead of streaming",
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
notes: [
|
|
122
|
+
"<app> accepts either the app_id or its slug (resolved via GET /v1/apps?slug= when it does not look like a cuid).",
|
|
123
|
+
'watch streams the app\'s change feed as JSON-lines on stdout, one compact SerializedFeedEntry object per line, identical whether served over the live WebSocket (primary) or the long-poll fallback (used automatically when the WS upgrade fails, for example self-host mode has no WS support yet, or a locked-down network blocks outbound WS). A dormancy transition mid-watch emits a single {"type":"_dormant"} line and exits 0.',
|
|
124
|
+
],
|
|
125
|
+
outputNote: 'Output is JSON, and JSON-lines for watch. Errors go to stderr as {"error":{"code","message"}} with a non-zero exit.',
|
|
126
|
+
};
|
|
127
|
+
const DATA = {
|
|
128
|
+
noun: "data",
|
|
129
|
+
tagline: "collection row CRUD for an app",
|
|
130
|
+
group: "app",
|
|
131
|
+
rootSummary: "Collection row CRUD for an app: list, get, upsert, update, delete, purge, import.",
|
|
132
|
+
verbs: [
|
|
133
|
+
{
|
|
134
|
+
verb: "list",
|
|
135
|
+
positionals: "<app> <collection>",
|
|
136
|
+
summary: "Lists rows in a collection.",
|
|
137
|
+
flags: [
|
|
138
|
+
{
|
|
139
|
+
name: "since",
|
|
140
|
+
value: "<cursor>",
|
|
141
|
+
description: "Page from this feed cursor",
|
|
142
|
+
},
|
|
143
|
+
{ name: "limit", value: "<n>", description: "Page size, 1 to 1000" },
|
|
144
|
+
{
|
|
145
|
+
name: "where",
|
|
146
|
+
value: "<json>",
|
|
147
|
+
description: "JSON array of {field, op, value} conditions, ANDed together",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: "sort",
|
|
151
|
+
value: "<json>",
|
|
152
|
+
description: "JSON array of {field, dir} sort specs, dir asc or desc",
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
verb: "get",
|
|
158
|
+
positionals: "<app> <collection> <key>",
|
|
159
|
+
summary: "Shows one row by key.",
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
verb: "upsert",
|
|
163
|
+
positionals: "<app> <collection>",
|
|
164
|
+
summary: "Creates a row, or ensures one exists at a key or unique field.",
|
|
165
|
+
flags: [
|
|
166
|
+
{
|
|
167
|
+
name: "data",
|
|
168
|
+
value: "<path|json>",
|
|
169
|
+
description: "Row data as a path to a JSON file, or inline JSON (required)",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "key",
|
|
173
|
+
value: "<key>",
|
|
174
|
+
description: "Ensure a row exists at this key instead of server-generating one",
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: "on",
|
|
178
|
+
value: "<field>",
|
|
179
|
+
description: "Upsert on this manifest-declared UNIQUE field instead of the key",
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
verb: "update",
|
|
185
|
+
positionals: "<app> <collection> <key>",
|
|
186
|
+
summary: "Replaces an existing row's data.",
|
|
187
|
+
flags: [
|
|
188
|
+
{
|
|
189
|
+
name: "data",
|
|
190
|
+
value: "<path|json>",
|
|
191
|
+
description: "Row data as a path to a JSON file, or inline JSON (required)",
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: "if-match",
|
|
195
|
+
value: "<version>",
|
|
196
|
+
description: "Only write if the row is still at this version",
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
verb: "delete",
|
|
202
|
+
positionals: "<app> <collection> <key>",
|
|
203
|
+
summary: "Deletes one row by key.",
|
|
204
|
+
flags: [
|
|
205
|
+
{
|
|
206
|
+
name: "if-match",
|
|
207
|
+
value: "<version>",
|
|
208
|
+
description: "Only delete if the row is still at this version",
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
bools: [{ name: "yes", description: "Skip the confirmation prompt" }],
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
verb: "purge",
|
|
215
|
+
positionals: "<app> <collection>",
|
|
216
|
+
summary: "Removes one row even from an append-only collection.",
|
|
217
|
+
flags: [
|
|
218
|
+
{
|
|
219
|
+
name: "key",
|
|
220
|
+
value: "<key>",
|
|
221
|
+
description: "Key of the row to purge (required)",
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
bools: [{ name: "yes", description: "Skip the confirmation prompt" }],
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
verb: "import",
|
|
228
|
+
positionals: "<app> <collection>",
|
|
229
|
+
summary: "Bulk-writes rows from a file in chunks via the batch API.",
|
|
230
|
+
flags: [
|
|
231
|
+
{
|
|
232
|
+
name: "file",
|
|
233
|
+
value: "<path>",
|
|
234
|
+
description: "NDJSON or JSON-array file to import (required)",
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: "chunk",
|
|
238
|
+
value: "<n>",
|
|
239
|
+
description: "Rows per batch call, default 100",
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: "key-field",
|
|
243
|
+
value: "<field>",
|
|
244
|
+
description: "Derive each row key from this field, create-or-skip by id",
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: "on",
|
|
248
|
+
value: "<field>",
|
|
249
|
+
description: "Upsert on this manifest-declared UNIQUE field",
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
bools: [
|
|
253
|
+
{
|
|
254
|
+
name: "emit-effects",
|
|
255
|
+
description: "Fire notify and webhooks instead of importing silently",
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
notes: [
|
|
261
|
+
"<app> accepts either the app_id or its slug. upsert is the ONLY create-shaped verb: omit --key to add a new row (the server generates the key); pass --key to ensure a row exists at that key (returns the existing row with deduped:true on a collision, never errors). Pass --on <field> to upsert on a manifest-declared UNIQUE field instead of the key: the row whose <field> value matches is updated in place (idempotent re-import), else created.",
|
|
262
|
+
"list --where takes a JSON array of {field, op, value} conditions (ANDed), op one of eq, neq, in, notIn, gt, lt, gte, lte (in and notIn take an array value). --sort takes a JSON array of {field, dir} (dir asc or desc). Filtering is applied AFTER the read permission and author scoping, so a filtered list is always a subset of what you could already read. Comparisons are same-type only (no coercion); dates compare as ISO-8601 strings. A custom --sort cannot be combined with --since.",
|
|
263
|
+
"purge removes ONE row by --key even in an append-only collection. Owner and agent only (never members or anyone); it bypasses append-only and the collection delete list on purpose, and writes an audited delete feed entry.",
|
|
264
|
+
"import reads NDJSON (one JSON object per line) OR a JSON array from --file and bulk-writes it in chunks via the batch API, in ONE process. Each object is a row's data. Pass --key-field to derive the row key from a field: an existing row at that key is LEFT UNCHANGED, so this is create-or-skip-by-id, not overwrite, and re-importing changed data for a known key does not update it. Import DEFAULTS TO SILENT (it suppresses notify and webhooks, since a bulk import is a migration); pass --emit-effects to fire them. A per-row failure is listed in the summary WITHOUT aborting the import.",
|
|
265
|
+
],
|
|
266
|
+
outputNote: 'Output is a single JSON object on stdout, and import additionally writes per-chunk progress lines to stderr. Errors go to stderr as {"error":{"code","message"}} with a non-zero exit.',
|
|
267
|
+
};
|
|
268
|
+
const MEMBERS = {
|
|
269
|
+
noun: "members",
|
|
270
|
+
tagline: "app membership management",
|
|
271
|
+
group: "app",
|
|
272
|
+
rootSummary: "App membership management: add, list, set-role, remove, roles. Invite or attach a member by email, list the app's owner and members, re-role or remove someone, or summarize the app's declared roles.",
|
|
273
|
+
verbs: [
|
|
274
|
+
{
|
|
275
|
+
verb: "add",
|
|
276
|
+
summary: "Invites or attaches a member to the app by email.",
|
|
277
|
+
flags: [
|
|
278
|
+
{
|
|
279
|
+
name: "app",
|
|
280
|
+
value: "<idOrSlug>",
|
|
281
|
+
description: "App to add the member to (required)",
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
name: "email",
|
|
285
|
+
value: "<email>",
|
|
286
|
+
description: "Email address of the human to add (required)",
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: "role",
|
|
290
|
+
value: "<member>",
|
|
291
|
+
description: 'Role to grant; only "member" is valid, and it is the default',
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
verb: "list",
|
|
297
|
+
summary: "Lists the app's owner and every attached member.",
|
|
298
|
+
flags: [
|
|
299
|
+
{
|
|
300
|
+
name: "app",
|
|
301
|
+
value: "<idOrSlug>",
|
|
302
|
+
description: "App to list members of (required)",
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
verb: "set-role",
|
|
308
|
+
summary: "Changes an existing member's custom role in place.",
|
|
309
|
+
flags: [
|
|
310
|
+
{
|
|
311
|
+
name: "app",
|
|
312
|
+
value: "<idOrSlug>",
|
|
313
|
+
description: "App the member belongs to (required)",
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
name: "human",
|
|
317
|
+
value: "<humanId>",
|
|
318
|
+
description: "Human whose role changes (required)",
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
name: "custom-role",
|
|
322
|
+
value: "<name>",
|
|
323
|
+
description: "Custom role to assign; must be declared in the app's manifest",
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
bools: [
|
|
327
|
+
{
|
|
328
|
+
name: "clear-role",
|
|
329
|
+
description: "Drop the member back to a plain member",
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
verb: "remove",
|
|
335
|
+
summary: "Removes a member from the app and revokes their sessions.",
|
|
336
|
+
flags: [
|
|
337
|
+
{
|
|
338
|
+
name: "app",
|
|
339
|
+
value: "<idOrSlug>",
|
|
340
|
+
description: "App to remove the member from (required)",
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
name: "human",
|
|
344
|
+
value: "<humanId>",
|
|
345
|
+
description: "Human to remove (required)",
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
verb: "roles",
|
|
351
|
+
summary: "Summarizes the roles the app declares and their effective access.",
|
|
352
|
+
flags: [
|
|
353
|
+
{
|
|
354
|
+
name: "app",
|
|
355
|
+
value: "<idOrSlug>",
|
|
356
|
+
description: "App to summarize roles for (required)",
|
|
357
|
+
},
|
|
358
|
+
],
|
|
359
|
+
},
|
|
360
|
+
],
|
|
361
|
+
notes: [
|
|
362
|
+
"--app accepts either the app_id or its slug (resolved via GET /v1/apps?slug= when it does not look like a cuid).",
|
|
363
|
+
'add: if a Human already exists for --email, the member row is attached immediately and the response is { member: { humanId, email, role, createdAt } }. Otherwise the relay mints a signed invite and emails a magic link, responding { ok: true, invited, expires_at }. Only "member" is a valid --role (the default); ownership transfer is not available here. Fails with a relay error (503 auth_provider_unavailable) if the relay has no email provider configured.',
|
|
364
|
+
"set-role changes an existing member's custom role in place. --custom-role must name a role the app's manifest declares (a built-in role, or one that is not declared, is rejected); --clear-role drops back to a plain member. This does NOT revoke the member's sessions, so re-roling someone never signs them out, which makes it preferable to remove-then-add. The app owner cannot be re-roled.",
|
|
365
|
+
"remove is idempotent, and also revokes the human's live sessions on this app. The app owner cannot be removed (the relay refuses with a 409 conflict).",
|
|
366
|
+
'roles returns the derived summary { roles: [{ name, label, description, collections, member_count, active_grant_count }] }. Each collection entry reports EFFECTIVE access (what a holder can actually do, floors included) per population: member_access for a signed-in member holding the role, grant_access for a grant-link holder of it (no member floor, so the two can differ). Each of read, update and delete is "all", "own" (only rows the holder authored) or "none"; create is "all" or "none". An app that declares no custom roles returns an empty list.',
|
|
367
|
+
],
|
|
368
|
+
};
|
|
369
|
+
const INGEST = {
|
|
370
|
+
noun: "ingest",
|
|
371
|
+
tagline: "inbound catch-hook read surface",
|
|
372
|
+
group: "app",
|
|
373
|
+
rootSummary: "Inbound catch-hook management: list, rotate. Read back an app's declared inbound hooks with their full secret URL so you can tell the owner where an external system posts, or rotate a leaked secret. Hooks themselves are declared in the app manifest (x-homespun-manifest.ingest).",
|
|
374
|
+
verbs: [
|
|
375
|
+
{
|
|
376
|
+
verb: "list",
|
|
377
|
+
summary: "Lists the app's inbound catch-hooks with their full secret URL and delivery counts.",
|
|
378
|
+
flags: [
|
|
379
|
+
{
|
|
380
|
+
name: "app",
|
|
381
|
+
value: "<idOrSlug>",
|
|
382
|
+
description: "App to list inbound hooks for (required)",
|
|
383
|
+
},
|
|
384
|
+
],
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
verb: "rotate",
|
|
388
|
+
summary: "Rotates one inbound catch-hook's secret and returns the new URL.",
|
|
389
|
+
flags: [
|
|
390
|
+
{
|
|
391
|
+
name: "app",
|
|
392
|
+
value: "<idOrSlug>",
|
|
393
|
+
description: "App the hook belongs to (required)",
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
name: "name",
|
|
397
|
+
value: "<hookName>",
|
|
398
|
+
description: "Name of the manifest ingest hook to rotate (required)",
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
},
|
|
402
|
+
],
|
|
403
|
+
notes: [
|
|
404
|
+
"--app accepts either the app_id or its slug (resolved via GET /v1/apps?slug= when it does not look like a cuid).",
|
|
405
|
+
"list returns { hooks: [{ name, url, collection, mode, wake, handshake, disabledAt, createdAt, deliveries: { accepted, failed, dropped_duplicate } }] }. The url is the full secret POST URL an external system posts JSON to; hand it to the app owner to paste into Stripe, Zapier, Make, Home Assistant, or any system that can POST a webhook. A hook whose rule left the manifest has disabledAt set and null rule fields.",
|
|
406
|
+
"rotate mints a fresh secret for the named hook and returns { hook: { name, url } } with the NEW url once. The old url stops working immediately; no redeploy is needed. Use it when a url leaks.",
|
|
407
|
+
"Hooks are declared in the app manifest (x-homespun-manifest.ingest) and materialized at deploy, so there is no create or delete verb here: add or remove a hook by editing the manifest and redeploying.",
|
|
408
|
+
],
|
|
409
|
+
};
|
|
410
|
+
const GRANTS = {
|
|
411
|
+
noun: "grants",
|
|
412
|
+
tagline: "grant-link management",
|
|
413
|
+
group: "app",
|
|
414
|
+
rootSummary: "App grant-link management: mint, list, revoke. Mint a capability URL carrying a declared custom role, list an app's links, or revoke one.",
|
|
415
|
+
verbs: [
|
|
416
|
+
{
|
|
417
|
+
verb: "mint",
|
|
418
|
+
summary: "Mints a grant link carrying a declared custom role.",
|
|
419
|
+
flags: [
|
|
420
|
+
{
|
|
421
|
+
name: "app",
|
|
422
|
+
value: "<idOrSlug>",
|
|
423
|
+
description: "App to mint the link for (required)",
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
name: "role",
|
|
427
|
+
value: "<customRole>",
|
|
428
|
+
description: "Declared custom role the link confers (required)",
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
name: "mode",
|
|
432
|
+
value: "<once|multi>",
|
|
433
|
+
description: "One-time link, or a shared link (multi is the default)",
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
name: "max-uses",
|
|
437
|
+
value: "<n>",
|
|
438
|
+
description: "Cap on how many times a multi link can be claimed",
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
name: "label",
|
|
442
|
+
value: "<text>",
|
|
443
|
+
description: "Human-readable label for the link",
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
name: "ttl",
|
|
447
|
+
value: "<seconds>",
|
|
448
|
+
description: "Lifetime in seconds, default 30 days and clamped to the server max",
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
name: "pin-row",
|
|
452
|
+
value: "<rowKey>",
|
|
453
|
+
description: "Narrow the holder to a single row",
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
name: "pin-where",
|
|
457
|
+
value: "<json>",
|
|
458
|
+
description: "Narrow the holder to rows matching a JSON where array",
|
|
459
|
+
},
|
|
460
|
+
],
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
verb: "list",
|
|
464
|
+
summary: "Lists the app's grant links.",
|
|
465
|
+
flags: [
|
|
466
|
+
{
|
|
467
|
+
name: "app",
|
|
468
|
+
value: "<idOrSlug>",
|
|
469
|
+
description: "App to list grant links for (required)",
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
verb: "revoke",
|
|
475
|
+
summary: "Revokes one grant link.",
|
|
476
|
+
flags: [
|
|
477
|
+
{
|
|
478
|
+
name: "app",
|
|
479
|
+
value: "<idOrSlug>",
|
|
480
|
+
description: "App the grant link belongs to (required)",
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
name: "grant",
|
|
484
|
+
value: "<grantId>",
|
|
485
|
+
description: "Grant link to revoke (required)",
|
|
486
|
+
},
|
|
487
|
+
],
|
|
488
|
+
},
|
|
489
|
+
],
|
|
490
|
+
notes: [
|
|
491
|
+
"--app accepts either the app_id or its slug (resolved via GET /v1/apps?slug= when it does not look like a cuid).",
|
|
492
|
+
"mint creates a grant link carrying a DECLARED custom role (a key under x-homespun-manifest.roles). A built-in role (owner, member, agent, anyone) is rejected. --mode once is a one-time link, claimed by the first browser that opens it, with later opens by others inert; --mode multi (the default) is a shared link, capped by --max-uses within expiry. --ttl sets the lifetime in seconds (default 30 days, clamped to the server max). An optional pin NARROWS the holder to specific rows and can never widen: --pin-row <rowKey> for a single row, or --pin-where with a JSON where array. The response carries a grant_url whose #g= fragment holds the token, shown ONCE.",
|
|
493
|
+
"list returns { grants: [...] }, the app's links, never any token material.",
|
|
494
|
+
"revoke is idempotent; a revoked link is rejected on every subsequent request.",
|
|
495
|
+
],
|
|
496
|
+
};
|
|
497
|
+
const KEY = {
|
|
498
|
+
noun: "key",
|
|
499
|
+
tagline: "your agent's API key",
|
|
500
|
+
group: "other",
|
|
501
|
+
rootSummary: "Your agent's own API key: list, mint, revoke. The relay scopes keys to the calling agent, so every verb acts on your own key.",
|
|
502
|
+
verbs: [
|
|
503
|
+
{
|
|
504
|
+
verb: "list",
|
|
505
|
+
summary: "Shows your agent's key info: agent_id, name, key_prefix, created_at, last_used_at, revoked_at.",
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
verb: "mint",
|
|
509
|
+
summary: "Mints a new sibling API key for your own agent identity and prints its raw value once.",
|
|
510
|
+
},
|
|
511
|
+
{
|
|
512
|
+
verb: "revoke",
|
|
513
|
+
summary: "Revokes your own API key, which stops working immediately.",
|
|
514
|
+
bools: [
|
|
515
|
+
{
|
|
516
|
+
name: "yes",
|
|
517
|
+
description: "Confirm the revoke, which is irreversible",
|
|
518
|
+
},
|
|
519
|
+
],
|
|
520
|
+
},
|
|
521
|
+
],
|
|
522
|
+
notes: [
|
|
523
|
+
"The relay scopes /v1/keys to the authenticated agent, so there is exactly one key per agent, your own. Every verb therefore acts only on the caller's own key: mint only ever mints a sibling of yourself (same scope and ownership), never another agent's key, and the relay only allows revoking your own key.",
|
|
524
|
+
"The raw key printed by mint is never retrievable again, so save it at once. Use it to hand a fresh process a working credential. A revoke is a self-destruct: every subsequent command fails until you run 'homespun agent register' again to provision a new key.",
|
|
525
|
+
],
|
|
526
|
+
};
|
|
527
|
+
const TASTE = {
|
|
528
|
+
noun: "taste",
|
|
529
|
+
tagline: "your agent's UI taste notes",
|
|
530
|
+
group: "other",
|
|
531
|
+
rootSummary: "Your agent's freeform UI taste notes: get, set, clear. Presentation preferences the agent has learned from human feedback and reads before generating an app.",
|
|
532
|
+
verbs: [
|
|
533
|
+
{
|
|
534
|
+
verb: "get",
|
|
535
|
+
summary: "Prints the current notes attachment as { taste, updated_at, bytes }.",
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
verb: "set",
|
|
539
|
+
summary: "Replaces the whole notes attachment with new markdown.",
|
|
540
|
+
flags: [
|
|
541
|
+
{
|
|
542
|
+
name: "file",
|
|
543
|
+
value: "<path|->",
|
|
544
|
+
description: "Markdown source: a file path, or - to read stdin explicitly",
|
|
545
|
+
},
|
|
546
|
+
],
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
verb: "clear",
|
|
550
|
+
summary: "Deletes the notes and prints { cleared: true }.",
|
|
551
|
+
bools: [{ name: "yes", description: "Confirm deleting the notes" }],
|
|
552
|
+
},
|
|
553
|
+
],
|
|
554
|
+
notes: [
|
|
555
|
+
'Taste notes are a small markdown attachment storing presentation preferences your agent has picked up from human feedback ("denser table", "no rounded corners", "use a dark header"). Read them before generating an app template so prior feedback shapes the output, and rewrite them whenever the human gives new presentation feedback. Keep entries about UI and presentation taste only, not project context, todos, or homespun state.',
|
|
556
|
+
"set is a whole-attachment replace, not an append, so send the WHOLE new attachment. Source the markdown via --file <path>, --file - to read stdin, or by piping into 'homespun taste set' with no flag. The relay rejects empty or whitespace-only payloads and caps the attachment at MAX_TASTE_BYTES (utf8). To remove the notes use 'homespun taste clear', not set with an empty body.",
|
|
557
|
+
"taste is null and bytes is 0 when notes have never been written.",
|
|
558
|
+
],
|
|
559
|
+
};
|
|
560
|
+
const FEEDBACK = {
|
|
561
|
+
noun: "feedback",
|
|
562
|
+
tagline: "feedback to the relay operator",
|
|
563
|
+
group: "other",
|
|
564
|
+
rootSummary: "One-shot feedback to the relay operator: create, list. Bug reports, feature requests, and notes.",
|
|
565
|
+
verbs: [
|
|
566
|
+
{
|
|
567
|
+
verb: "create",
|
|
568
|
+
summary: "Submits one feedback row and prints { id, type, created_at }.",
|
|
569
|
+
flags: [
|
|
570
|
+
{
|
|
571
|
+
name: "type",
|
|
572
|
+
value: "<bug|feature|note>",
|
|
573
|
+
description: "Feedback category, required",
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
name: "message",
|
|
577
|
+
value: "<text|->",
|
|
578
|
+
description: "Message body, 1 to 4000 chars after trim; pass - to read stdin",
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
name: "app-id",
|
|
582
|
+
value: "<id>",
|
|
583
|
+
description: "Optional app this feedback relates to, owned by your agent's human",
|
|
584
|
+
},
|
|
585
|
+
],
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
verb: "list",
|
|
589
|
+
summary: "Lists your agent's own submissions, newest first.",
|
|
590
|
+
flags: [
|
|
591
|
+
{
|
|
592
|
+
name: "limit",
|
|
593
|
+
value: "<n>",
|
|
594
|
+
description: "Page size (default 50, max 100)",
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
name: "before",
|
|
598
|
+
value: "<cursor>",
|
|
599
|
+
description: "Opaque cursor from a previous page's next_before",
|
|
600
|
+
},
|
|
601
|
+
],
|
|
602
|
+
},
|
|
603
|
+
],
|
|
604
|
+
notes: [
|
|
605
|
+
"Feedback is a one-shot bug report, feature request, or note from your agent to whoever runs the relay. Submissions are stored in the relay DB and the operator triages them out of band.",
|
|
606
|
+
"create does not echo the message back. list prints { items: [...], next_before } so you can pass --before <cursor> from a previous page to fetch the next one.",
|
|
607
|
+
],
|
|
608
|
+
};
|
|
609
|
+
const CONFIG = {
|
|
610
|
+
noun: "config",
|
|
611
|
+
tagline: "CLI config and profile management",
|
|
612
|
+
group: "other",
|
|
613
|
+
rootSummary: "CLI config inspection and multi-profile management: show, list, use, add, rm.",
|
|
614
|
+
verbs: [
|
|
615
|
+
{
|
|
616
|
+
verb: "show",
|
|
617
|
+
summary: "Shows the resolved relay config and where each value came from (flag, env, profile, or none).",
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
verb: "list",
|
|
621
|
+
summary: "Lists saved profiles with their URLs and masked key prefixes, marking the active one.",
|
|
622
|
+
},
|
|
623
|
+
{
|
|
624
|
+
verb: "use",
|
|
625
|
+
positionals: "<profile>",
|
|
626
|
+
summary: "Switches the active profile.",
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
verb: "add",
|
|
630
|
+
positionals: "<profile>",
|
|
631
|
+
summary: "Saves a url and api_key pair under a profile name without contacting the relay.",
|
|
632
|
+
flags: [
|
|
633
|
+
{
|
|
634
|
+
name: "api-key",
|
|
635
|
+
value: "<key>",
|
|
636
|
+
description: "Agent API key to save in the profile, required",
|
|
637
|
+
},
|
|
638
|
+
],
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
verb: "rm",
|
|
642
|
+
positionals: "<profile>",
|
|
643
|
+
summary: "Deletes a profile from the config file.",
|
|
644
|
+
},
|
|
645
|
+
],
|
|
646
|
+
notes: [
|
|
647
|
+
"A profile is one url and api_key pair under a short name (dev, staging, prod). Switch via 'homespun config use', --profile <name>, or the HOMESPUN_PROFILE env var. The active profile is what every other command sees unless overridden by --url, --api-key, HOMESPUN_URL or HOMESPUN_API_KEY.",
|
|
648
|
+
"Every verb is purely local: it inspects flags, env, and the saved config file and makes no network call. The full API key is never printed, only a short masked prefix. The config file lives at ${XDG_CONFIG_HOME:-~/.config}/homespun/config.json (mode 0600).",
|
|
649
|
+
"add requires both --url and --api-key, and overwrites the existing values if the profile already exists. Use it when an operator handed you an API key out of band, for example a closed-registration relay; for self-register and secret-mode relays prefer 'homespun agent register --profile <name>'. It does not change current_profile unless it is the first profile added, so run 'homespun config use' afterwards to switch. rm clears current_profile when it removes the active profile, and the next command falls back to env or the default URL until another profile is selected.",
|
|
650
|
+
],
|
|
651
|
+
};
|
|
652
|
+
const SKILL = {
|
|
653
|
+
noun: "skill",
|
|
654
|
+
tagline: "the relay's SKILL.md",
|
|
655
|
+
group: "other",
|
|
656
|
+
rootSummary: "The relay's SKILL.md: show, version. Auto-updating, and no API key is required.",
|
|
657
|
+
verbs: [
|
|
658
|
+
{
|
|
659
|
+
verb: "show",
|
|
660
|
+
summary: "Fetches the relay's SKILL.md and writes the raw markdown to stdout.",
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
verb: "version",
|
|
664
|
+
summary: "Prints the relay's skill version.",
|
|
665
|
+
bools: [
|
|
666
|
+
{
|
|
667
|
+
name: "plain",
|
|
668
|
+
description: "Print the bare version string instead of the JSON envelope",
|
|
669
|
+
},
|
|
670
|
+
],
|
|
671
|
+
},
|
|
672
|
+
],
|
|
673
|
+
notes: [
|
|
674
|
+
"The skill is auto-updating: the relay's deployed image owns both the body and the version, so this is always the skill that matches the relay you are talking to.",
|
|
675
|
+
"Both verbs are unauthenticated, so no API key is needed. An agent can call either form before 'homespun agent register' to bootstrap or refresh its local skill copy. Pipe show to your local skill path, and use version as the staleness probe: compare it against the skill-version comment in the local file and re-run show when they differ. --plain makes that comparison easy inline in a shell pipeline.",
|
|
676
|
+
],
|
|
677
|
+
outputNote: 'Output on stdout is raw markdown for show, and {"version":"1.0.0"} for version, or a bare version string with --plain. Errors go to stderr as {"error":{"code","message"}} with a non-zero exit.',
|
|
678
|
+
};
|
|
679
|
+
const DEPLOY = {
|
|
680
|
+
noun: "deploy",
|
|
681
|
+
tagline: "create or redeploy an app",
|
|
682
|
+
group: "app",
|
|
683
|
+
rootSummary: "Create or redeploy an app (POST /v1/apps or POST /v1/apps/:id/versions): the create then redeploy loop.",
|
|
684
|
+
verbs: [
|
|
685
|
+
{
|
|
686
|
+
verb: "",
|
|
687
|
+
positionals: "<dir|file>",
|
|
688
|
+
summary: "Creates a new app, or redeploys an existing one when --app is given.",
|
|
689
|
+
flags: [
|
|
690
|
+
{
|
|
691
|
+
name: "app",
|
|
692
|
+
value: "<id>",
|
|
693
|
+
description: "Redeploy this existing app instead of creating a new one",
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
name: "manifest",
|
|
697
|
+
value: "<path|json>",
|
|
698
|
+
description: "Manifest file path or inline JSON, for the single-file deploy",
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
name: "slug",
|
|
702
|
+
value: "<slug>",
|
|
703
|
+
description: "Requested slug when creating an app, rejected with visibility link",
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
name: "visibility",
|
|
707
|
+
value: "<private|link|public>",
|
|
708
|
+
description: "Who can open the new app, on create only",
|
|
709
|
+
},
|
|
710
|
+
],
|
|
711
|
+
bools: [
|
|
712
|
+
{ name: "force", description: "Override the redeploy compat gate" },
|
|
713
|
+
{
|
|
714
|
+
name: "check",
|
|
715
|
+
description: "Validate only and report what a deploy would do, without creating anything",
|
|
716
|
+
},
|
|
717
|
+
],
|
|
718
|
+
},
|
|
719
|
+
],
|
|
720
|
+
notes: [
|
|
721
|
+
"Packaging has one canonical shape and one escape hatch. A directory deploy (homespun deploy ./my-app) reads ./my-app/index.html and ./my-app/manifest.json: fixed filenames, no discovery heuristics, and both files are required. The single-file escape hatch (homespun deploy ./index.html --manifest ./manifest.json) takes the manifest from --manifest, which accepts a file path or inline JSON.",
|
|
722
|
+
"Create versus redeploy is decided by the presence of --app, not by two verbs. With no --app this creates an app (POST /v1/apps); new apps default to private (owner plus invited members, sign-in gated), --slug is accepted with private or public visibility including the default, and an explicit --visibility link always gets a server-generated slug and rejects --slug. With --app <id> this redeploys (POST /v1/apps/:id/versions), where --slug and --visibility are rejected because the slug is immutable and visibility changes go through 'homespun apps update'.",
|
|
723
|
+
"--check is a dry run. It runs the full manifest and asset-shape validation, the redeploy compat gate (with --app), and the schedule-timezone advisory, then prints { ok, warnings, compat, breaks } without creating a version or mutating anything. An invalid manifest fails the same way a real deploy would, and a narrowing redeploy reports the compat break instead of applying it.",
|
|
724
|
+
],
|
|
725
|
+
outputNote: 'Output is JSON: { app_id, slug, url, version, visibility, created, share_url, compat, breaks, warnings }. share_url is present only when creating a link-visibility app: it carries the app share token in its #k= fragment and is shown ONCE, it is not recoverable later, and it can be rotated with \'homespun apps share-link rotate <app>\'. warnings flags non-fatal issues, for example an app that declares schedules with no timezone set (reminders fire at 08:00 UTC until one is set). Errors go to stderr as {"error":{"code","message"}} with a non-zero exit.',
|
|
726
|
+
};
|
|
727
|
+
const ATTACHMENT = {
|
|
728
|
+
noun: "attachment",
|
|
729
|
+
tagline: "binary attachments on the relay",
|
|
730
|
+
group: "other",
|
|
731
|
+
rootSummary: "Binary attachments: upload, download, show, list, delete, and token (mint, revoke, list). Attachments are scoped to an agent or an App, and can be referenced from input_data.",
|
|
732
|
+
verbs: [
|
|
733
|
+
{
|
|
734
|
+
verb: "upload",
|
|
735
|
+
summary: "Uploads a local file as an attachment.",
|
|
736
|
+
flags: [
|
|
737
|
+
{ name: "file", value: "<path>", description: "Local file to upload" },
|
|
738
|
+
{
|
|
739
|
+
name: "scope",
|
|
740
|
+
value: "<agent|app>",
|
|
741
|
+
description: "Attachment scope: agent is reusable and the default, app binds it to one App",
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
name: "app-id",
|
|
745
|
+
value: "<id>",
|
|
746
|
+
description: "App to bind the attachment to, required when scope is app",
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
name: "filename",
|
|
750
|
+
value: "<name>",
|
|
751
|
+
description: "Display filename, defaulting to the basename of --file",
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
name: "mime",
|
|
755
|
+
value: "<type>",
|
|
756
|
+
description: "Declared Content-Type, advisory only since the relay sniffs the bytes regardless",
|
|
757
|
+
},
|
|
758
|
+
],
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
verb: "download",
|
|
762
|
+
positionals: "<attachment-id>",
|
|
763
|
+
summary: "Downloads an attachment's bytes.",
|
|
764
|
+
flags: [
|
|
765
|
+
{
|
|
766
|
+
name: "out",
|
|
767
|
+
value: "<path>",
|
|
768
|
+
description: "Write the bytes to this path instead of stdout",
|
|
769
|
+
},
|
|
770
|
+
],
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
verb: "show",
|
|
774
|
+
positionals: "<attachment-id>",
|
|
775
|
+
summary: "Prints an attachment's metadata without downloading the bytes.",
|
|
776
|
+
},
|
|
777
|
+
{
|
|
778
|
+
verb: "list",
|
|
779
|
+
summary: "Lists your agent's non-deleted attachments, newest first.",
|
|
780
|
+
flags: [
|
|
781
|
+
{
|
|
782
|
+
name: "cursor",
|
|
783
|
+
value: "<token>",
|
|
784
|
+
description: "Opaque pagination cursor from a prior response",
|
|
785
|
+
},
|
|
786
|
+
{
|
|
787
|
+
name: "limit",
|
|
788
|
+
value: "<n>",
|
|
789
|
+
description: "Page size, 1 to 100, defaulting to the relay default of 50",
|
|
790
|
+
},
|
|
791
|
+
],
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
verb: "delete",
|
|
795
|
+
positionals: "<attachment-id>",
|
|
796
|
+
summary: "Soft-deletes an attachment.",
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
verb: "token mint",
|
|
800
|
+
positionals: "<attachment-id>",
|
|
801
|
+
summary: "Mints a /b/<token> capability URL for one attachment.",
|
|
802
|
+
flags: [
|
|
803
|
+
{
|
|
804
|
+
name: "ttl",
|
|
805
|
+
value: "<seconds>",
|
|
806
|
+
description: "Per-token lifetime in seconds, clamped by the scope default",
|
|
807
|
+
},
|
|
808
|
+
],
|
|
809
|
+
bools: [
|
|
810
|
+
{
|
|
811
|
+
name: "once",
|
|
812
|
+
description: "Token self-deletes on its first successful GET",
|
|
813
|
+
},
|
|
814
|
+
],
|
|
815
|
+
},
|
|
816
|
+
{
|
|
817
|
+
verb: "token revoke",
|
|
818
|
+
positionals: "<attachment-id> <token-id>",
|
|
819
|
+
summary: "Revokes one previously minted token by id, idempotently.",
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
verb: "token list",
|
|
823
|
+
positionals: "<attachment-id>",
|
|
824
|
+
summary: "Lists the tokens minted against one attachment, including revoked rows.",
|
|
825
|
+
},
|
|
826
|
+
],
|
|
827
|
+
notes: [
|
|
828
|
+
"An attachment is a typed binary file (image, PDF, audio, video, and so on) the agent has uploaded to the relay. Attachments are scoped: agent scope is reusable across the agent's apps and is the default, while app scope binds the attachment to one App and it is deleted with that App.",
|
|
829
|
+
"Pages reference attachments by id, and the relay's schema validates that id with the homespun-attachment-id format. For a participant-facing URL that bypasses the agent's API key, mint a capability token with 'homespun attachment token mint'.",
|
|
830
|
+
"A capability URL (/b/<token>) lets a participant, or any browser holding the URL, fetch an attachment without the agent's API key. Tokens are stored hashed on the relay and the plaintext token is returned only ONCE, from mint, so save the response before delivering the URL. The TTL defaults by scope (30 days for app scope, 24 hours for agent scope) and the caller can only shorten it.",
|
|
831
|
+
"token list is for audit: it returns every token minted against the attachment, revoked rows included, each carrying token_id, token_prefix, expires_at, once, created_at, last_used_at, use_count and revoked_at. The token plaintext is never returned.",
|
|
832
|
+
"delete is a soft delete and is idempotent: deleting an already-deleted attachment still returns success. Tokens minted against a deleted attachment become unusable.",
|
|
833
|
+
],
|
|
834
|
+
outputNote: 'Output is JSON on stdout, except attachment download without --out, which writes the raw bytes to stdout for piping. Errors go to stderr as {"error":{"code","message"}} with a non-zero exit.',
|
|
835
|
+
};
|
|
836
|
+
const AGENT = {
|
|
837
|
+
noun: "agent",
|
|
838
|
+
tagline: "this agent's identity on the relay",
|
|
839
|
+
group: "other",
|
|
840
|
+
rootSummary: "Agent identity on this machine: register for an API key, claim the agent for a human, save a rotated key, and clear the saved credentials.",
|
|
841
|
+
verbs: [
|
|
842
|
+
{
|
|
843
|
+
verb: "register",
|
|
844
|
+
summary: "Registers this agent with the relay and saves the key to a local profile.",
|
|
845
|
+
flags: [
|
|
846
|
+
{
|
|
847
|
+
name: "name",
|
|
848
|
+
value: "<name>",
|
|
849
|
+
description: "Agent display name on the relay, shown on the approval screen",
|
|
850
|
+
},
|
|
851
|
+
{
|
|
852
|
+
name: "secret",
|
|
853
|
+
value: "<secret>",
|
|
854
|
+
description: "Registration secret sent as a Bearer token, for relays using REGISTRATION_MODE=secret",
|
|
855
|
+
},
|
|
856
|
+
],
|
|
857
|
+
bools: [
|
|
858
|
+
{
|
|
859
|
+
name: "print-key",
|
|
860
|
+
description: "Also echo the full api_key in the output",
|
|
861
|
+
},
|
|
862
|
+
{
|
|
863
|
+
name: "no-device",
|
|
864
|
+
description: "Skip the browser approval and register directly via POST /v1/register",
|
|
865
|
+
},
|
|
866
|
+
],
|
|
867
|
+
},
|
|
868
|
+
{
|
|
869
|
+
verb: "claim",
|
|
870
|
+
positionals: "<code>",
|
|
871
|
+
summary: "Binds this agent to the human who issued the one-shot claim code.",
|
|
872
|
+
},
|
|
873
|
+
{
|
|
874
|
+
verb: "set-key",
|
|
875
|
+
positionals: "<api-key>",
|
|
876
|
+
summary: "Saves a new API key into the local config file.",
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
verb: "logout",
|
|
880
|
+
summary: "Clears a saved profile locally, without revoking anything.",
|
|
881
|
+
bools: [
|
|
882
|
+
{
|
|
883
|
+
name: "all",
|
|
884
|
+
description: "Delete every profile, meaning the whole config file",
|
|
885
|
+
},
|
|
886
|
+
],
|
|
887
|
+
},
|
|
888
|
+
],
|
|
889
|
+
notes: [
|
|
890
|
+
"register runs the browser device-authorization flow by default: it prints a link and a short code, the account owner opens the link on any device, signs in and approves, and the agent comes out already linked to that account. Older relays without the flow fall back to plain POST /v1/register automatically, as do --no-device and a supplied registration secret; agents registered that way are unowned until 'homespun agent claim' runs.",
|
|
891
|
+
"The API key and relay URL are saved under a named profile in the CLI config file (mode 0600), so later commands work with only HOMESPUN_URL set, or with nothing set. The key is never printed unless --print-key is passed. Without --profile the key goes under the currently active profile, or under default on a fresh install; use --profile <name> to keep several environments side by side and switch with 'homespun config use <name>'.",
|
|
892
|
+
"claim is one-way. The human generates a one-shot code (it begins with cc_) in their settings UI, hands it to the agent out of band, and the relay binds the agent to that human and migrates app ownership. There is no unclaim in v1: to rotate the owner, revoke the agent with 'homespun key revoke' and register a new one.",
|
|
893
|
+
"set-key makes no relay round-trip. It is the companion to regenerating a key in the relay's my-agents UI: paste the new key here so later commands authenticate as the same agent. Setting HOMESPUN_API_KEY on the agent process instead works just as well.",
|
|
894
|
+
"logout clears the active profile only, leaving the other profiles on disk and unsetting current_profile, while --all deletes the whole config file. It is idempotent, and it touches only LOCAL config: it does NOT revoke the key on the relay, which keeps working until 'homespun key revoke' retires it.",
|
|
895
|
+
],
|
|
896
|
+
};
|
|
897
|
+
// Order here is the order in `homespun --help` and in the generated reference
|
|
898
|
+
// page: app commands first, then the rest.
|
|
899
|
+
const NOUNS = [
|
|
900
|
+
DEPLOY,
|
|
901
|
+
APPS,
|
|
902
|
+
DATA,
|
|
903
|
+
MEMBERS,
|
|
904
|
+
GRANTS,
|
|
905
|
+
INGEST,
|
|
906
|
+
KEY,
|
|
907
|
+
TASTE,
|
|
908
|
+
FEEDBACK,
|
|
909
|
+
ATTACHMENT,
|
|
910
|
+
AGENT,
|
|
911
|
+
CONFIG,
|
|
912
|
+
SKILL,
|
|
913
|
+
];
|
|
914
|
+
/** Every noun, in display order. */
|
|
915
|
+
export function allNouns() {
|
|
916
|
+
return NOUNS.map((n) => ({ ...n }));
|
|
917
|
+
}
|
|
918
|
+
/** Look up one noun, or undefined if the name is not a command. */
|
|
919
|
+
export function nounSpec(noun) {
|
|
920
|
+
return NOUNS.find((n) => n.noun === noun);
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* The argument triple for assertKnownFlags(args, ...specFor("apps", "watch")).
|
|
924
|
+
* Throws on an unknown noun or verb, which is a programming error: it means a
|
|
925
|
+
* runner exists that this table does not describe.
|
|
926
|
+
*/
|
|
927
|
+
export function specFor(noun, verb = "") {
|
|
928
|
+
const n = nounSpec(noun);
|
|
929
|
+
if (!n)
|
|
930
|
+
throw new Error(`help-catalog: no such noun "${noun}"`);
|
|
931
|
+
const v = n.verbs.find((x) => x.verb === verb);
|
|
932
|
+
if (!v) {
|
|
933
|
+
throw new Error(`help-catalog: noun "${noun}" has no verb "${verb}"`);
|
|
934
|
+
}
|
|
935
|
+
return [
|
|
936
|
+
(v.flags ?? []).map((f) => f.name),
|
|
937
|
+
(v.bools ?? []).map((f) => f.name),
|
|
938
|
+
["homespun", noun, verb].filter(Boolean).join(" "),
|
|
939
|
+
];
|
|
940
|
+
}
|
|
941
|
+
/** "homespun apps watch <app> [--since <cursor>] [--once]" */
|
|
942
|
+
export function usageLine(noun, v) {
|
|
943
|
+
const parts = ["homespun", noun.noun];
|
|
944
|
+
if (v.verb)
|
|
945
|
+
parts.push(v.verb);
|
|
946
|
+
if (v.positionals)
|
|
947
|
+
parts.push(v.positionals);
|
|
948
|
+
for (const f of v.flags ?? []) {
|
|
949
|
+
parts.push(f.value ? `[--${f.name} ${f.value}]` : `[--${f.name}]`);
|
|
950
|
+
}
|
|
951
|
+
for (const b of v.bools ?? [])
|
|
952
|
+
parts.push(`[--${b.name}]`);
|
|
953
|
+
return parts.join(" ");
|
|
954
|
+
}
|
|
955
|
+
/** Wrap a paragraph to `width`, indenting continuation lines by `indent`. */
|
|
956
|
+
function wrap(text, width, indent) {
|
|
957
|
+
const out = [];
|
|
958
|
+
let line = "";
|
|
959
|
+
for (const word of text.split(/\s+/)) {
|
|
960
|
+
if (line === "") {
|
|
961
|
+
line = word;
|
|
962
|
+
}
|
|
963
|
+
else if (line.length + 1 + word.length <= width) {
|
|
964
|
+
line += " " + word;
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
out.push(line);
|
|
968
|
+
line = word;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
if (line !== "")
|
|
972
|
+
out.push(line);
|
|
973
|
+
return out.map((l, i) => (i === 0 ? l : indent + l));
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Split a usage line into wrappable chunks, treating a bracket group as one
|
|
977
|
+
* atom. Brackets nest, e.g. `[--collection <name[,name2,...]>]`, so this
|
|
978
|
+
* tracks depth rather than matching to the first `]`.
|
|
979
|
+
*/
|
|
980
|
+
function usageChunks(line) {
|
|
981
|
+
const chunks = [];
|
|
982
|
+
let cur = "";
|
|
983
|
+
let depth = 0;
|
|
984
|
+
for (const ch of line) {
|
|
985
|
+
if (ch === "[")
|
|
986
|
+
depth++;
|
|
987
|
+
else if (ch === "]")
|
|
988
|
+
depth--;
|
|
989
|
+
if (ch === " " && depth === 0) {
|
|
990
|
+
if (cur !== "")
|
|
991
|
+
chunks.push(cur);
|
|
992
|
+
cur = "";
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
cur += ch;
|
|
996
|
+
}
|
|
997
|
+
if (cur !== "")
|
|
998
|
+
chunks.push(cur);
|
|
999
|
+
return chunks;
|
|
1000
|
+
}
|
|
1001
|
+
/** The text printed by `homespun --help`. */
|
|
1002
|
+
export function renderRootHelp() {
|
|
1003
|
+
const out = [
|
|
1004
|
+
"homespun: apps your AI builds and hosts for you and people you invite",
|
|
1005
|
+
"",
|
|
1006
|
+
"Usage:",
|
|
1007
|
+
" homespun <command> [options]",
|
|
1008
|
+
"",
|
|
1009
|
+
];
|
|
1010
|
+
const blocks = [
|
|
1011
|
+
["App commands (operate on an App, a persistent deployed web app):", "app"],
|
|
1012
|
+
["Other noun groups:", "other"],
|
|
1013
|
+
];
|
|
1014
|
+
for (const [title, group] of blocks) {
|
|
1015
|
+
out.push(title);
|
|
1016
|
+
for (const n of NOUNS.filter((x) => x.group === group)) {
|
|
1017
|
+
const pad = n.noun.padEnd(16);
|
|
1018
|
+
const lines = wrap(n.rootSummary, 60, " ".repeat(20));
|
|
1019
|
+
out.push(` ${pad} ${lines[0]}`);
|
|
1020
|
+
for (const l of lines.slice(1))
|
|
1021
|
+
out.push(l);
|
|
1022
|
+
}
|
|
1023
|
+
out.push("");
|
|
1024
|
+
}
|
|
1025
|
+
out.push("Run `homespun <command> --help` for command-specific options.", "", "Config:", " HOMESPUN_URL Relay base URL. Override: --url <url>", " HOMESPUN_API_KEY Agent API key. Override: --api-key <key>", " HOMESPUN_PROFILE Active profile name. Override: --profile <name>", " 'homespun agent register' provisions the API key and saves it (with the URL)", " to ${XDG_CONFIG_HOME:-~/.config}/homespun/config.json under a named profile,", " after which commands need no env vars. Manage multiple environments with", " 'homespun config list / use / add / rm'.", "", "Global flags:", " -h, --help Show help.", " -v, --version Print version.", " --profile <name> Pick a saved profile for this invocation.", " --url <url> Relay base URL, bypasses profile selection entirely.", " --api-key <key> Agent API key, bypasses profile selection entirely.", "", ...wrap(DEFAULT_OUTPUT_NOTE, 78, ""));
|
|
1026
|
+
return out.join("\n");
|
|
1027
|
+
}
|
|
1028
|
+
/** The text printed by `homespun <noun> --help`. */
|
|
1029
|
+
export function renderNounHelp(noun) {
|
|
1030
|
+
const WIDTH = 78;
|
|
1031
|
+
const out = [
|
|
1032
|
+
`homespun ${noun.noun}: ${noun.tagline}`,
|
|
1033
|
+
"",
|
|
1034
|
+
"Usage:",
|
|
1035
|
+
];
|
|
1036
|
+
for (const v of noun.verbs) {
|
|
1037
|
+
// Usage lines are wrapped with a hanging indent so a verb with many flags
|
|
1038
|
+
// stays readable in an 80-column terminal. Bracket groups are atomic:
|
|
1039
|
+
// breaking "[--timezone <IANA zone>]" across two lines reads as two
|
|
1040
|
+
// separate options, which is worse than a slightly long line.
|
|
1041
|
+
const chunks = usageChunks(usageLine(noun, v));
|
|
1042
|
+
const lines = [];
|
|
1043
|
+
let line = " ";
|
|
1044
|
+
for (const chunk of chunks) {
|
|
1045
|
+
const candidate = line.trimEnd() === "" ? line + chunk : `${line} ${chunk}`;
|
|
1046
|
+
if (candidate.length > WIDTH && line.trim() !== "") {
|
|
1047
|
+
lines.push(line);
|
|
1048
|
+
line = " " + chunk;
|
|
1049
|
+
}
|
|
1050
|
+
else {
|
|
1051
|
+
line = candidate;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
if (line.trim() !== "")
|
|
1055
|
+
lines.push(line);
|
|
1056
|
+
out.push(...lines);
|
|
1057
|
+
}
|
|
1058
|
+
for (const note of noun.notes ?? []) {
|
|
1059
|
+
out.push("", ...wrap(note, WIDTH, ""));
|
|
1060
|
+
}
|
|
1061
|
+
const flagged = noun.verbs.filter((v) => (v.flags ?? []).length > 0 || (v.bools ?? []).length > 0);
|
|
1062
|
+
if (flagged.length > 0) {
|
|
1063
|
+
out.push("", "Flags:");
|
|
1064
|
+
// One description column across the whole noun, so the flags read as a
|
|
1065
|
+
// table rather than ragged pairs.
|
|
1066
|
+
const spelled = (f) => f.value ? `--${f.name} ${f.value}` : `--${f.name}`;
|
|
1067
|
+
const all = flagged.flatMap((v) => [
|
|
1068
|
+
...(v.flags ?? []),
|
|
1069
|
+
...(v.bools ?? []),
|
|
1070
|
+
]);
|
|
1071
|
+
const col = Math.min(34, all.reduce((w, f) => Math.max(w, spelled(f).length), 0) + 2);
|
|
1072
|
+
for (const v of flagged) {
|
|
1073
|
+
out.push(` ${["homespun", noun.noun, v.verb].filter(Boolean).join(" ")}`);
|
|
1074
|
+
for (const f of [...(v.flags ?? []), ...(v.bools ?? [])]) {
|
|
1075
|
+
const left = ` ${spelled(f)}`;
|
|
1076
|
+
const pad = Math.max(1, col + 4 - left.length);
|
|
1077
|
+
const indent = " ".repeat(col + 5);
|
|
1078
|
+
const desc = wrap(f.description, WIDTH - col - 5, indent);
|
|
1079
|
+
out.push(left + " ".repeat(pad) + desc[0]);
|
|
1080
|
+
for (const l of desc.slice(1))
|
|
1081
|
+
out.push(l);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
out.push("", ...wrap(noun.outputNote ?? DEFAULT_OUTPUT_NOTE, WIDTH, ""));
|
|
1086
|
+
return out.join("\n");
|
|
1087
|
+
}
|