@homespunapps/cli 1.0.1 → 1.4.3

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 CHANGED
@@ -18,6 +18,43 @@ export class ArgvError extends Error {
18
18
  this.hint = hint;
19
19
  }
20
20
  }
21
+ // Flags that never take a value. `json` is kept here purely for forward-compat
22
+ // (JSON is currently the only output mode): accepting `--json` as a no-op bool
23
+ // means a future `--text`/`--json` toggle won't break existing invocations. It
24
+ // is intentionally undocumented in --help.
25
+ //
26
+ // `version` is deliberately NOT here: the top-level `-v` / `--version` is
27
+ // handled from rawArgv[0] before parseArgs runs, so it never needs to be a
28
+ // boolean flag, and keeping it out leaves room for a future noun-level
29
+ // `--version <n>` value flag without a collision.
30
+ //
31
+ // Exported (and colocated with the parser rather than the bin entry) so tests
32
+ // exercise the REAL parse-time set. A per-test copy of this list is how
33
+ // `deploy --check` shipped un-parsed and ran a real deploy (#827): the flag
34
+ // was in the command's KNOWN_BOOLS and the test's copy, but not here, so
35
+ // parseArgs treated it as a value flag and `bools.has("check")` stayed false.
36
+ export const BOOLEAN_FLAGS = new Set([
37
+ "json",
38
+ "once",
39
+ "help",
40
+ "print-key",
41
+ "yes",
42
+ "plain",
43
+ // `homespun deploy --force` / `homespun apps ... --force`: override a compat gate.
44
+ "force",
45
+ // `homespun deploy --check`: validate-only dry run, persists nothing (#827).
46
+ "check",
47
+ // `homespun agent register --no-device`: skip the browser device-authorization
48
+ // flow and register directly (unowned agent), the pre-device-flow behavior.
49
+ "no-device",
50
+ // `homespun data ... import --emit-effects`: opt a silent bulk import back into
51
+ // firing notify/webhooks (import defaults to silent).
52
+ "emit-effects",
53
+ // `homespun agent logout --all`: wipe every saved profile, not just the active one.
54
+ "all",
55
+ // `homespun members set-role --clear-role`: drop a custom role back to plain member.
56
+ "clear-role",
57
+ ]);
21
58
  /**
22
59
  * Parse argv tokens. `booleanFlags` lists flags that never consume a value
23
60
  * (e.g. --json, --once, --help); everything else with a `--name` form
@@ -97,10 +134,19 @@ const GLOBAL_BOOLS = ["help", "json"];
97
134
  * `args`. The thrown ArgvError carries a hint pointing at the verb's own
98
135
  * --help, so a user fixing a typo lands on the canonical list of flags.
99
136
  *
100
- * Why per-command and not at parse time: the parser is single-pass and
101
- * generic on purpose adding a new flag to one verb should not require a
102
- * shared registry. Keeping the allow-list co-located with the runner that
103
- * consumes it means the two cannot drift.
137
+ * Why per-command and not at parse time: the parser stays single-pass and
138
+ * generic, and each runner asserts only its own surface.
139
+ *
140
+ * This comment used to argue the allow-list must be co-located with the runner
141
+ * so a new flag would not require a shared registry. That reasoning assumed
142
+ * two consumers (the parser and the runner). There are now four: the runner,
143
+ * `--help`, the published CLI reference on docs.homespun.dev, and this check.
144
+ * Co-location bought nothing against the other three, and in practice the
145
+ * inline lists, the help text, and the docs page had already drifted apart.
146
+ * So the allow-list moved into help-catalog.ts, which every consumer reads via
147
+ * specFor(). A new flag is still a one-line edit, it just lands in the table
148
+ * instead of the runner, and the drift it used to cause is now impossible
149
+ * rather than merely discouraged.
104
150
  *
105
151
  * Also resolves the parser's `danglingValueFlags`: an unknown name there
106
152
  * is reported alongside other unknowns ("unknown flag(s): --bogus"); a
@@ -128,6 +174,25 @@ export function assertKnownFlags(args, knownFlags, knownBools, helpCommand) {
128
174
  if (unknown.length > 0) {
129
175
  throw new ArgvError(`unknown flag(s): ${unknown.join(", ")}`, `run \`${helpCommand} --help\` for the supported flags`);
130
176
  }
177
+ // Defense against parse-time drift (#827): a flag the command declares
178
+ // boolean must never arrive as a value flag. That happens only when the
179
+ // name is missing from the parse-time BOOLEAN_FLAGS set, so parseArgs
180
+ // consumed a value for it (`--check ./dir`, `--check=x`) or recorded it
181
+ // as dangling (trailing `--check`). Before this check, that mismatch
182
+ // passed silently, `bools.has()` stayed false, and `deploy --check` ran
183
+ // a REAL deploy. Fail loudly instead: in tests and dev this surfaces the
184
+ // missing BOOLEAN_FLAGS entry immediately, and no invocation can fall
185
+ // through to the non-boolean code path.
186
+ for (const k of args.flags.keys()) {
187
+ if (boolSet.has(k) && !flagSet.has(k)) {
188
+ throw new ArgvError(`--${k} takes no value`);
189
+ }
190
+ }
191
+ for (const k of dangling) {
192
+ if (boolSet.has(k) && !flagSet.has(k)) {
193
+ throw new ArgvError(`--${k} takes no value`);
194
+ }
195
+ }
131
196
  // No unknowns — but a known value-flag may still have been left without
132
197
  // a value. Handle the first such case with the pre-existing message
133
198
  // shape ("--name requires a value"). Reporting only the first keeps the
@@ -12,26 +12,6 @@ import { runLogout } from "./logout.js";
12
12
  import { runClaim } from "./claim.js";
13
13
  import { runSetKey } from "./set-key.js";
14
14
  import { fail } from "../output.js";
15
- export const agentHelp = `homespun agent — manage this agent's identity on the relay
16
-
17
- Usage:
18
- homespun agent <verb> [options]
19
-
20
- Verbs:
21
- register Provision an agent API key (POST /v1/register) and save it
22
- to the CLI config file. Run this once before other commands.
23
- claim <code> Bind this agent to a human via a one-shot claim code the
24
- human generated in their Settings UI (POST /v1/agents/claim).
25
- One-way; no unclaim in v1.
26
- set-key <key> Save a new API key into the CLI config file. Used after
27
- regenerating the agent's key in the relay's My-agents UI:
28
- the human pastes the new key here so subsequent commands
29
- authenticate as the same agent.
30
- logout Clear the locally-saved relay URL + API key. Does NOT
31
- revoke the key on the relay — use 'homespun key revoke' for
32
- that.
33
-
34
- Run \`homespun agent <verb> --help\` for verb-specific options.`;
35
15
  export async function runAgent(args) {
36
16
  // Strip the first positional (the verb) so each verb runner sees its
37
17
  // own arguments at positionals[0..n].
@@ -14,40 +14,18 @@
14
14
  // to reclaim verbatim.
15
15
  import { HomespunApiError, appWsUrlFromAppUrl, openAppStream, } from "@homespunapps/core";
16
16
  import { assertKnownFlags } from "../argv.js";
17
+ import { nounSpec, renderNounHelp, specFor } from "../help-catalog.js";
17
18
  import { makeClient, resolveConfig } from "../config.js";
18
19
  import { fail, failFromError, printJson, printJsonLine } from "../output.js";
19
20
  import { resolveAppId } from "../resolve-app.js";
20
- export const appsHelp = `homespun apps — app lifecycle management
21
-
22
- Usage:
23
- homespun apps list [--status active|dormant|archived|all] [--limit] [--cursor] [--slug <slug>]
24
- homespun apps show <app>
25
- homespun apps update <app> --visibility <private|link|public>
26
- homespun apps delete <app> [--yes]
27
- homespun apps wake <app>
28
- homespun apps watch <app> [--since <cursor>] [--collection <name[,name2,...]>]
29
- [--once] [--timeout <secs>]
30
-
31
- <app> accepts either the app_id or its slug (resolved via GET /v1/apps?slug=
32
- when it doesn't look like a cuid).
33
-
34
- watch streams the app's change feed as JSON-lines on stdout — one compact
35
- SerializedFeedEntry object per line, identical whether served over the live
36
- WebSocket (primary) or the long-poll fallback (used automatically when the
37
- WS upgrade fails, e.g. self-host mode has no WS support yet, or a locked-down
38
- network blocks outbound WS). A dormancy transition mid-watch emits a single
39
- {"type":"_dormant"} line and exits 0.
40
-
41
- Output (JSON; JSON-lines for watch). Errors on stderr:
42
- {"error":{"code","message"}} with non-zero exit.`;
43
21
  export async function runApps(args) {
44
22
  const verb = args.positionals[0];
45
23
  if ((verb === undefined || verb === "help") && args.bools.has("help")) {
46
- process.stdout.write(appsHelp + "\n");
24
+ process.stdout.write(renderNounHelp(nounSpec("apps")) + "\n");
47
25
  return;
48
26
  }
49
27
  if (verb === undefined) {
50
- fail("missing verb homespun apps <list|show|update|delete|wake|watch>", "invalid_args");
28
+ fail("missing verb: homespun apps <list|show|update|share-link|delete|wake|watch>", "invalid_args");
51
29
  }
52
30
  const sub = {
53
31
  positionals: args.positionals.slice(1),
@@ -64,6 +42,8 @@ export async function runApps(args) {
64
42
  return runShow(sub);
65
43
  case "update":
66
44
  return runUpdate(sub);
45
+ case "share-link":
46
+ return runShareLink(sub);
67
47
  case "delete":
68
48
  return runDelete(sub);
69
49
  case "wake":
@@ -71,14 +51,14 @@ export async function runApps(args) {
71
51
  case "watch":
72
52
  return runWatch(sub);
73
53
  default:
74
- fail(`unknown verb '${verb}' homespun apps <list|show|update|delete|wake|watch>`, "invalid_args");
54
+ fail(`unknown verb '${verb}': homespun apps <list|show|update|share-link|delete|wake|watch>`, "invalid_args");
75
55
  }
76
56
  }
77
57
  // ---------------------------------------------------------------------------
78
58
  // list
79
59
  // ---------------------------------------------------------------------------
80
60
  async function runList(args) {
81
- assertKnownFlags(args, ["status", "limit", "cursor", "slug", "url", "api-key"], ["help"], "homespun apps list");
61
+ assertKnownFlags(args, ...specFor("apps", "list"));
82
62
  const status = args.flags.get("status");
83
63
  if (status !== undefined &&
84
64
  !["active", "dormant", "archived", "all"].includes(status)) {
@@ -107,7 +87,7 @@ async function runList(args) {
107
87
  // show
108
88
  // ---------------------------------------------------------------------------
109
89
  async function runShow(args) {
110
- assertKnownFlags(args, ["url", "api-key"], ["help"], "homespun apps show");
90
+ assertKnownFlags(args, ...specFor("apps", "show"));
111
91
  const appArg = args.positionals[0];
112
92
  if (!appArg)
113
93
  fail("usage: homespun apps show <app>", "invalid_args");
@@ -124,22 +104,59 @@ async function runShow(args) {
124
104
  // update
125
105
  // ---------------------------------------------------------------------------
126
106
  async function runUpdate(args) {
127
- assertKnownFlags(args, ["visibility", "url", "api-key"], ["help"], "homespun apps update");
107
+ assertKnownFlags(args, ...specFor("apps", "update"));
128
108
  const appArg = args.positionals[0];
129
109
  if (!appArg) {
130
- fail("usage: homespun apps update <app> --visibility <private|link|public>", "invalid_args");
110
+ fail("usage: homespun apps update <app> [--visibility <private|link|public>] [--timezone <IANA zone>]", "invalid_args");
131
111
  }
132
112
  const visibility = args.flags.get("visibility");
133
- if (visibility === undefined) {
134
- fail("--visibility is required", "invalid_args");
113
+ const timezone = args.flags.get("timezone");
114
+ if (visibility === undefined && timezone === undefined) {
115
+ fail("nothing to update; pass --visibility and/or --timezone", "invalid_args");
135
116
  }
136
- if (!["private", "link", "public"].includes(visibility)) {
117
+ if (visibility !== undefined &&
118
+ !["private", "link", "public"].includes(visibility)) {
137
119
  fail("--visibility must be private|link|public", "invalid_args");
138
120
  }
121
+ if (timezone !== undefined && timezone.trim() === "") {
122
+ fail("--timezone must be an IANA zone name, e.g. Europe/Berlin", "invalid_args");
123
+ }
124
+ const client = makeClient(args);
125
+ const id = await resolveAppId(client, appArg);
126
+ try {
127
+ printJson(await client.updateApp(id, {
128
+ ...(visibility !== undefined
129
+ ? { visibility: visibility }
130
+ : {}),
131
+ ...(timezone !== undefined ? { timezone } : {}),
132
+ }));
133
+ }
134
+ catch (e) {
135
+ failFromError(e);
136
+ }
137
+ }
138
+ // ---------------------------------------------------------------------------
139
+ // share-link (rotate)
140
+ // ---------------------------------------------------------------------------
141
+ // `homespun apps share-link rotate <app>` rotates a link app's share token.
142
+ // Prints the new { share_url } (its #k= fragment carries the token, shown once).
143
+ // Rotating instantly revokes the previous share URL. Also generates a link for a
144
+ // link app that has none yet. Only `rotate` is supported (rotate-only, no
145
+ // delete): revoking a link IS rotating it.
146
+ async function runShareLink(args) {
147
+ const action = args.positionals[0];
148
+ if (action !== "rotate") {
149
+ fail("usage: homespun apps share-link rotate <app>", "invalid_args");
150
+ }
151
+ assertKnownFlags(args, ...specFor("apps", "share-link"));
152
+ const appArg = args.positionals[1];
153
+ if (!appArg) {
154
+ fail("usage: homespun apps share-link rotate <app>", "invalid_args");
155
+ }
139
156
  const client = makeClient(args);
140
157
  const id = await resolveAppId(client, appArg);
141
158
  try {
142
- printJson(await client.updateApp(id, visibility));
159
+ printJson(await client.rotateShareLink(id));
143
160
  }
144
161
  catch (e) {
145
162
  failFromError(e);
@@ -149,7 +166,7 @@ async function runUpdate(args) {
149
166
  // delete
150
167
  // ---------------------------------------------------------------------------
151
168
  async function runDelete(args) {
152
- assertKnownFlags(args, ["url", "api-key"], ["yes", "help"], "homespun apps delete");
169
+ assertKnownFlags(args, ...specFor("apps", "delete"));
153
170
  const appArg = args.positionals[0];
154
171
  if (!appArg)
155
172
  fail("usage: homespun apps delete <app> [--yes]", "invalid_args");
@@ -170,7 +187,7 @@ async function runDelete(args) {
170
187
  // wake
171
188
  // ---------------------------------------------------------------------------
172
189
  async function runWake(args) {
173
- assertKnownFlags(args, ["url", "api-key"], ["help"], "homespun apps wake");
190
+ assertKnownFlags(args, ...specFor("apps", "wake"));
174
191
  const appArg = args.positionals[0];
175
192
  if (!appArg)
176
193
  fail("usage: homespun apps wake <app>", "invalid_args");
@@ -221,7 +238,7 @@ export function isDormantConflict(err) {
221
238
  err.message === "app is dormant");
222
239
  }
223
240
  async function runWatch(args) {
224
- assertKnownFlags(args, ["since", "collection", "timeout", "url", "api-key"], ["once", "help"], "homespun apps watch");
241
+ assertKnownFlags(args, ...specFor("apps", "watch"));
225
242
  const appArg = args.positionals[0];
226
243
  if (!appArg)
227
244
  fail("usage: homespun apps watch <app>", "invalid_args");
@@ -1,27 +1,10 @@
1
1
  // `homespun attachment delete <attachment-id>` — soft-delete a attachment.
2
2
  import { assertKnownFlags } from "../argv.js";
3
+ import { specFor } from "../help-catalog.js";
3
4
  import { makeClient } from "../config.js";
4
5
  import { fail, failFromError, printJson } from "../output.js";
5
- const KNOWN_FLAGS = [];
6
- const KNOWN_BOOLS = [];
7
- export const blobDeleteHelp = `homespun attachment delete — soft-delete a attachment
8
-
9
- Usage:
10
- homespun attachment delete <attachment-id> [options]
11
-
12
- Marks the attachment as deleted (DELETE /v1/attachments/:id). Idempotent: deleting an
13
- already-deleted attachment still returns success. Tokens minted against this attachment
14
- become unusable.
15
-
16
- Options:
17
- --url <url> Relay base URL (overrides HOMESPUN_URL).
18
- --api-key <key> Agent API key (overrides HOMESPUN_API_KEY).
19
- -h, --help Show this help.
20
-
21
- Output (stdout, JSON):
22
- { attachment_id, deleted: true }`;
23
6
  export async function runBlobDelete(args) {
24
- assertKnownFlags(args, KNOWN_FLAGS, KNOWN_BOOLS, "homespun attachment delete");
7
+ assertKnownFlags(args, ...specFor("attachment", "delete"));
25
8
  const attachmentId = args.positionals[0];
26
9
  if (!attachmentId) {
27
10
  fail("missing <attachment-id> — 'homespun attachment delete <attachment-id>'", "invalid_args");
@@ -1,31 +1,11 @@
1
1
  // `homespun attachment download <attachment-id>` — fetch attachment bytes by id.
2
2
  import { writeFileSync } from "node:fs";
3
3
  import { assertKnownFlags } from "../argv.js";
4
+ import { specFor } from "../help-catalog.js";
4
5
  import { makeClient } from "../config.js";
5
6
  import { fail, failFromError, printJson } from "../output.js";
6
- const KNOWN_FLAGS = ["out"];
7
- const KNOWN_BOOLS = [];
8
- export const blobDownloadHelp = `homespun attachment download — fetch a attachment's bytes
9
-
10
- Usage:
11
- homespun attachment download <attachment-id> [--out <path>] [options]
12
-
13
- GETs the attachment bytes. With --out <path> the bytes are written to that file and
14
- a JSON summary is printed on stdout; without --out the bytes are written to
15
- stdout verbatim (useful for piping into another tool — but binary on a TTY
16
- is rarely useful).
17
-
18
- Options:
19
- --out <path> Write bytes to <path> instead of stdout.
20
- --url <url> Relay base URL (overrides HOMESPUN_URL).
21
- --api-key <key> Agent API key (overrides HOMESPUN_API_KEY).
22
- -h, --help Show this help.
23
-
24
- Output:
25
- Without --out: raw bytes to stdout.
26
- With --out: { attachment_id, written: <path>, bytes: <n> } to stdout.`;
27
7
  export async function runBlobDownload(args) {
28
- assertKnownFlags(args, KNOWN_FLAGS, KNOWN_BOOLS, "homespun attachment download");
8
+ assertKnownFlags(args, ...specFor("attachment", "download"));
29
9
  const attachmentId = args.positionals[0];
30
10
  if (!attachmentId) {
31
11
  fail("missing <attachment-id> — 'homespun attachment download <attachment-id>'", "invalid_args");
@@ -4,31 +4,11 @@
4
4
  // are excluded; tokens are not enumerated here (use 'homespun attachment token list
5
5
  // <attachment-id>' for that).
6
6
  import { assertKnownFlags } from "../argv.js";
7
+ import { specFor } from "../help-catalog.js";
7
8
  import { makeClient } from "../config.js";
8
9
  import { fail, printJson, failFromError } from "../output.js";
9
- const KNOWN_FLAGS = ["cursor", "limit"];
10
- const KNOWN_BOOLS = [];
11
- export const blobListHelp = `homespun attachment list — enumerate YOUR agent's attachments
12
-
13
- Usage:
14
- homespun attachment list [--cursor <token>] [--limit <n>] [options]
15
-
16
- Returns the agent's non-deleted attachments (newest first). Paginated via opaque
17
- cursor: when next_cursor is non-null in the response, pass it back as
18
- --cursor to get the next page.
19
-
20
- Options:
21
- --cursor <token> Opaque pagination cursor from a prior response.
22
- --limit <n> Page size (1..100). Defaults to the relay default
23
- (50).
24
- --url <url> Relay base URL (overrides HOMESPUN_URL).
25
- --api-key <key> Agent API key (overrides HOMESPUN_API_KEY).
26
- -h, --help Show this help.
27
-
28
- Output (stdout, JSON):
29
- { items: AttachmentRef[], next_cursor: string | null }`;
30
10
  export async function runBlobList(args) {
31
- assertKnownFlags(args, KNOWN_FLAGS, KNOWN_BOOLS, "homespun attachment list");
11
+ assertKnownFlags(args, ...specFor("attachment", "list"));
32
12
  const cursor = args.flags.get("cursor");
33
13
  const limitRaw = args.flags.get("limit");
34
14
  let limit;
@@ -1,27 +1,10 @@
1
1
  // `homespun attachment show <attachment-id>` — print a attachment's metadata.
2
2
  import { assertKnownFlags } from "../argv.js";
3
+ import { specFor } from "../help-catalog.js";
3
4
  import { makeClient } from "../config.js";
4
5
  import { fail, failFromError, printJson } from "../output.js";
5
- const KNOWN_FLAGS = [];
6
- const KNOWN_BOOLS = [];
7
- export const blobShowHelp = `homespun attachment show — print a attachment's metadata (no bytes)
8
-
9
- Usage:
10
- homespun attachment show <attachment-id> [options]
11
-
12
- Looks up the attachment by id and prints its AttachmentRef metadata — owner, scope,
13
- mime, size, sha256, etc. Does NOT download the bytes; use 'homespun attachment
14
- download' for that.
15
-
16
- Options:
17
- --url <url> Relay base URL (overrides HOMESPUN_URL).
18
- --api-key <key> Agent API key (overrides HOMESPUN_API_KEY).
19
- -h, --help Show this help.
20
-
21
- Output (stdout, JSON):
22
- AttachmentRef`;
23
6
  export async function runBlobShow(args) {
24
- assertKnownFlags(args, KNOWN_FLAGS, KNOWN_BOOLS, "homespun attachment show");
7
+ assertKnownFlags(args, ...specFor("attachment", "show"));
25
8
  const attachmentId = args.positionals[0];
26
9
  if (!attachmentId) {
27
10
  fail("missing <attachment-id> — 'homespun attachment show <attachment-id>'", "invalid_args");
@@ -12,51 +12,11 @@
12
12
  // positionals[2..]. Mirrors how participant.ts dispatches under `app
13
13
  // app participant`.
14
14
  import { assertKnownFlags } from "../argv.js";
15
+ import { specFor } from "../help-catalog.js";
15
16
  import { makeClient } from "../config.js";
16
17
  import { fail, failFromError, printJson } from "../output.js";
17
- const MINT_FLAGS = ["ttl"];
18
- const MINT_BOOLS = ["once"];
19
- const NO_FLAGS = [];
20
- const NO_BOOLS = [];
21
- export const blobTokenHelp = `homespun attachment token — manage a attachment's capability URLs
22
-
23
- Capability URLs let a participant (or any browser holding the URL) fetch a
24
- attachment without the agent's API key. Tokens are stored HASHED on the relay; the
25
- plaintext token is returned only ONCE from 'mint' — save the response before
26
- delivering the URL.
27
-
28
- Usage:
29
- homespun attachment token <verb> <args>
30
-
31
- Verbs:
32
- mint <attachment-id> Mint a /b/<token> capability URL for one attachment.
33
- Optional: --ttl <seconds> (defaults by scope:
34
- 30d app / 24h agent; the caller can only
35
- shorten), --once (token self-deletes on
36
- first successful GET). Returns { token, url,
37
- expires_at, ... } — ONCE.
38
-
39
- revoke <attachment-id> <token-id>
40
- Invalidate one previously-minted token by id.
41
- Idempotent: revoking twice still returns success.
42
-
43
- list <attachment-id> Enumerate the tokens minted against one attachment,
44
- including revoked rows (for audit). Returns
45
- { attachment_id, items: [...] } where each item carries
46
- { token_id, token_prefix, expires_at, once,
47
- created_at, last_used_at, use_count, revoked_at }.
48
- The token plaintext is NEVER returned.
49
-
50
- Options:
51
- --ttl <seconds> (mint) per-token TTL; clamped by scope default.
52
- --once (mint) token self-deletes on first GET.
53
- --url <url> Relay base URL (overrides HOMESPUN_URL).
54
- --api-key <key> Agent API key (overrides HOMESPUN_API_KEY).
55
- -h, --help Show this help.
56
-
57
- Output: stdout is machine-readable JSON.`;
58
18
  async function runBlobTokenMint(args) {
59
- assertKnownFlags(args, MINT_FLAGS, MINT_BOOLS, "homespun attachment token mint");
19
+ assertKnownFlags(args, ...specFor("attachment", "token mint"));
60
20
  const attachmentId = args.positionals[1];
61
21
  if (!attachmentId) {
62
22
  fail("missing <attachment-id> — 'homespun attachment token mint <attachment-id>'", "invalid_args");
@@ -79,7 +39,7 @@ async function runBlobTokenMint(args) {
79
39
  }
80
40
  }
81
41
  async function runBlobTokenRevoke(args) {
82
- assertKnownFlags(args, NO_FLAGS, NO_BOOLS, "homespun attachment token revoke");
42
+ assertKnownFlags(args, ...specFor("attachment", "token revoke"));
83
43
  const attachmentId = args.positionals[1];
84
44
  const tokenId = args.positionals[2];
85
45
  if (!attachmentId || !tokenId) {
@@ -95,7 +55,7 @@ async function runBlobTokenRevoke(args) {
95
55
  }
96
56
  }
97
57
  async function runBlobTokenList(args) {
98
- assertKnownFlags(args, NO_FLAGS, NO_BOOLS, "homespun attachment token list");
58
+ assertKnownFlags(args, ...specFor("attachment", "token list"));
99
59
  const attachmentId = args.positionals[1];
100
60
  if (!attachmentId) {
101
61
  fail("missing <attachment-id> — 'homespun attachment token list <attachment-id>'", "invalid_args");
@@ -2,34 +2,11 @@
2
2
  import { readFileSync } from "node:fs";
3
3
  import { basename } from "node:path";
4
4
  import { assertKnownFlags } from "../argv.js";
5
+ import { specFor } from "../help-catalog.js";
5
6
  import { makeClient } from "../config.js";
6
7
  import { fail, failFromError, printJson } from "../output.js";
7
- const KNOWN_FLAGS = ["file", "scope", "app-id", "filename", "mime"];
8
- const KNOWN_BOOLS = [];
9
- export const blobUploadHelp = `homespun attachment upload — upload a local file as a attachment
10
-
11
- Usage:
12
- homespun attachment upload --file <path> [options]
13
-
14
- Required:
15
- --file <path> Local file to upload.
16
-
17
- Scope (default: agent):
18
- --scope <s> "agent" | "app".
19
- --app-id <id> Required when --scope=app.
20
-
21
- Optional:
22
- --filename <name> Display filename (otherwise basename of --file).
23
- --mime <type> Declared Content-Type. The relay sniffs the bytes
24
- regardless — this is advisory.
25
- --url <url> Relay base URL (overrides HOMESPUN_URL).
26
- --api-key <key> Agent API key (overrides HOMESPUN_API_KEY).
27
- -h, --help Show this help.
28
-
29
- Output (stdout, JSON):
30
- AttachmentRef — { attachment_id, scope, mime, size, sha256, ... }`;
31
8
  export async function runBlobUpload(args) {
32
- assertKnownFlags(args, KNOWN_FLAGS, KNOWN_BOOLS, "homespun attachment upload");
9
+ assertKnownFlags(args, ...specFor("attachment", "upload"));
33
10
  const filePath = args.flags.get("file");
34
11
  if (!filePath) {
35
12
  fail("missing --file <path> — 'homespun attachment upload' requires a local file to upload", "invalid_args");
@@ -12,54 +12,14 @@
12
12
  // Most attachment verbs read their primary positional (the attachment_id) at
13
13
  // positionals[0]; we slice off our own verb before delegating so each verb
14
14
  // runner doesn't need to know it was reached through `homespun attachment`.
15
- import { runBlobUpload, blobUploadHelp } from "./attachment-upload.js";
16
- import { runBlobDownload, blobDownloadHelp } from "./attachment-download.js";
17
- import { runBlobShow, blobShowHelp } from "./attachment-show.js";
18
- import { runBlobList, blobListHelp } from "./attachment-list.js";
19
- import { runBlobDelete, blobDeleteHelp } from "./attachment-delete.js";
20
- import { runBlobToken, blobTokenHelp } from "./attachment-token.js";
15
+ import { nounSpec, renderNounHelp } from "../help-catalog.js";
16
+ import { runBlobUpload } from "./attachment-upload.js";
17
+ import { runBlobDownload } from "./attachment-download.js";
18
+ import { runBlobShow } from "./attachment-show.js";
19
+ import { runBlobList } from "./attachment-list.js";
20
+ import { runBlobDelete } from "./attachment-delete.js";
21
+ import { runBlobToken } from "./attachment-token.js";
21
22
  import { fail } from "../output.js";
22
- export const blobHelp = `homespun attachment — manage attachments (binary attachments) on the relay
23
-
24
- A attachment is a typed binary file (image, PDF, audio, video, ...) the agent has
25
- uploaded to the relay. Blobs are scoped:
26
-
27
- agent reusable across the agent's apps (default)
28
- app bound to one App; deleted with it
29
-
30
- Pages reference attachments by id (the relay's schema validates the id with
31
- \`format: homespun-attachment-id\`). For a participant-facing URL that bypasses the
32
- agent's API key, mint a token with 'homespun attachment token mint'.
33
-
34
- Usage:
35
- homespun attachment <verb> [options]
36
-
37
- Verbs:
38
- upload Upload a local file. Required: --file. Optional:
39
- --scope, --app-id, --filename, --mime. Prints
40
- { attachment_id, scope, mime, size, sha256, ... }.
41
-
42
- download <attachment-id> Download a attachment by id. Use --out <path> to write a
43
- file (default: writes to stdout — useful for piping).
44
-
45
- show <attachment-id> Print a attachment's metadata (HEAD-based — doesn't
46
- download the bytes).
47
-
48
- list Enumerate YOUR agent's non-deleted attachments (newest
49
- first). Supports --cursor + --limit for pagination.
50
-
51
- delete <attachment-id> Soft-delete a attachment. Idempotent.
52
-
53
- token <verb> Capability URLs for a attachment (mint | revoke | list).
54
- 'mint' returns a /b/<token> URL anyone can GET, with
55
- optional --ttl and --once. 'revoke' invalidates one
56
- token. 'list' enumerates a attachment's tokens (without
57
- the token plaintext, which is unrecoverable).
58
-
59
- Run \`homespun attachment <verb> --help\` for verb-specific options.
60
-
61
- Output: stdout is machine-readable JSON. Errors go to stderr as
62
- {"error":{"code","message"}} with a non-zero exit.`;
63
23
  /**
64
24
  * Build a new ParsedArgs with the leading positional (the verb) stripped.
65
25
  * The downstream verb runners read their primary positional (the attachment_id)
@@ -89,7 +49,7 @@ export async function runBlob(args) {
89
49
  if (verb === "token" &&
90
50
  args.bools.has("help") &&
91
51
  args.positionals.length === 1) {
92
- process.stdout.write(blobTokenHelp + "\n");
52
+ process.stdout.write(renderNounHelp(nounSpec("attachment")) + "\n");
93
53
  return;
94
54
  }
95
55
  // `homespun attachment list --help` — same pattern (list takes no required positional
@@ -98,7 +58,7 @@ export async function runBlob(args) {
98
58
  if (verb === "list" &&
99
59
  args.bools.has("help") &&
100
60
  args.positionals.length === 1) {
101
- process.stdout.write(blobListHelp + "\n");
61
+ process.stdout.write(renderNounHelp(nounSpec("attachment")) + "\n");
102
62
  return;
103
63
  }
104
64
  const inner = shiftPositionals(args);
@@ -128,6 +88,3 @@ export async function runBlob(args) {
128
88
  fail(`unknown attachment verb '${verb}' — expected upload|download|show|list|delete|token (run 'homespun attachment --help')`, "invalid_args");
129
89
  }
130
90
  }
131
- // Re-export per-verb helps so tests / docs can import them by canonical name
132
- // without knowing which file owns each verb.
133
- export { blobUploadHelp, blobDownloadHelp, blobShowHelp, blobListHelp, blobDeleteHelp, blobTokenHelp, };