@buildinternet/uploads 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,6 +50,13 @@ Commands: `attach`, `put`, `screenshot`, `annotate`, `gallery`, `comment`, `list
50
50
  `uploads help --all` (or `--help --all`) for the full command list. Per-command:
51
51
  `uploads <cmd> --help`.
52
52
 
53
+ **Errors:** every failure prints a short `error:` line on stderr, followed by a
54
+ runnable example when an argument is missing, then a hint — never a help dump,
55
+ so trimmed output (`| tail`) still carries the reason.
56
+ A mistyped command also suggests the closest real one: `uploads set-metadata`
57
+ answers `did you mean: uploads meta set`. With `--json`, an unknown command
58
+ returns `{ "error", "code": "USAGE", "didYouMean" }` on stdout.
59
+
53
60
  **Shell completion:** `uploads completion bash|zsh|fish` prints a script to
54
61
  stdout. Example (zsh): `uploads completion zsh > ~/.zsh/completions/_uploads`.
55
62
 
@@ -24,7 +24,16 @@ export declare function isHelpFlag(arg: string): boolean;
24
24
  */
25
25
  export declare function parseArgv(argv: string[]): ParsedArgv;
26
26
  export declare class UsageError extends Error {
27
- constructor(message: string);
27
+ /**
28
+ * A correct invocation to show under the error. Agents pattern-match on a
29
+ * runnable line far better than on prose, so every "you left out a required
30
+ * argument" error should carry one — the CLI prints it, and `--json`
31
+ * callers get it as `example`.
32
+ */
33
+ readonly example?: string;
34
+ constructor(message: string, options?: {
35
+ example?: string;
36
+ });
28
37
  }
29
38
  export interface CommandFlags {
30
39
  positionals: string[];
package/dist/cli-args.js CHANGED
@@ -69,9 +69,17 @@ export function parseArgv(argv) {
69
69
  return { globals, help, command: rest[0], rest };
70
70
  }
71
71
  export class UsageError extends Error {
72
- constructor(message) {
72
+ /**
73
+ * A correct invocation to show under the error. Agents pattern-match on a
74
+ * runnable line far better than on prose, so every "you left out a required
75
+ * argument" error should carry one — the CLI prints it, and `--json`
76
+ * callers get it as `example`.
77
+ */
78
+ example;
79
+ constructor(message, options = {}) {
73
80
  super(message);
74
81
  this.name = "UsageError";
82
+ this.example = options.example;
75
83
  }
76
84
  }
77
85
  /** Records a flag occurrence, turning a repeated string flag into an array. */
@@ -17,6 +17,24 @@ export interface RootHelpOptions {
17
17
  */
18
18
  needsAuth?: boolean;
19
19
  }
20
+ export interface UnknownCommandOptions {
21
+ /** The command the caller typed. */
22
+ command: string;
23
+ /** Suggested command phrase, from `suggestCommand`. */
24
+ suggestion?: string;
25
+ /** Catalog summary for the suggestion, shown beside it. */
26
+ summary?: string;
27
+ color?: boolean;
28
+ style?: CliStyle;
29
+ }
30
+ /**
31
+ * Unknown-command output: deliberately a handful of lines, not the root help
32
+ * dump (issue #545). Agents trim command output (`| tail -20`), and a 27-line
33
+ * banner pushed the one line that mattered — the error — out of the window.
34
+ * Short output survives truncation, and the pointers below still lead to the
35
+ * full list.
36
+ */
37
+ export declare function formatUnknownCommand(options: UnknownCommandOptions): string;
20
38
  /**
21
39
  * Root help text. Default is a short essentials view; pass `full: true` for
22
40
  * the complete command + config dump (`uploads help --all`).
package/dist/cli-help.js CHANGED
@@ -175,6 +175,24 @@ ${style.muted("Tip: uploads help essentials only")}
175
175
  ${style.muted(" uploads help --all this full listing")}
176
176
  `;
177
177
  }
178
+ /**
179
+ * Unknown-command output: deliberately a handful of lines, not the root help
180
+ * dump (issue #545). Agents trim command output (`| tail -20`), and a 27-line
181
+ * banner pushed the one line that mattered — the error — out of the window.
182
+ * Short output survives truncation, and the pointers below still lead to the
183
+ * full list.
184
+ */
185
+ export function formatUnknownCommand(options) {
186
+ const style = options.style ??
187
+ createStyle(options.color !== undefined ? options.color : colorEnabled(process.stderr));
188
+ const lines = [style.error(`unknown command: ${options.command}`)];
189
+ if (options.suggestion) {
190
+ const summary = options.summary ? ` ${style.body(options.summary)}` : "";
191
+ lines.push("", `did you mean: ${style.command(`uploads ${options.suggestion}`)}${summary}`);
192
+ }
193
+ lines.push("", ` ${padCmd("uploads help --all", CMD_WIDTH, style)}${style.body("Full command list")}`, ` ${padCmd("uploads <cmd> --help", CMD_WIDTH, style)}${style.body("Per-command options and examples")}`);
194
+ return `${lines.join("\n")}\n`;
195
+ }
178
196
  /**
179
197
  * Root help text. Default is a short essentials view; pass `full: true` for
180
198
  * the complete command + config dump (`uploads help --all`).
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The command phrase a mistyped command most likely meant, or undefined when
3
+ * nothing is close enough to be worth printing. Aliases win over distance so
4
+ * `metadata` resolves to `meta set` rather than the bare `meta` group.
5
+ */
6
+ export declare function suggestCommand(input: string): string | undefined;
7
+ /** Catalog summary for a suggested phrase (`meta set` → the subcommand's). */
8
+ export declare function commandSummary(phrase: string): string | undefined;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * "Did you mean" for a mistyped root command (issue #545).
3
+ *
4
+ * The unknown-command path used to answer with the essentials list, which
5
+ * can't correct a wrong guess for anything outside it — `uploads set-metadata`
6
+ * printed a help dump that never mentions `meta`. Two sources feed a
7
+ * suggestion: a table of spellings agents actually reach for, and an edit
8
+ * distance over the catalog. Same rule as the metadata vocabulary
9
+ * (metadata-vocab.ts): suggest, never silently rewrite.
10
+ */
11
+ import { ROOT_COMMANDS } from "./cli-catalog.js";
12
+ /**
13
+ * Wrong-but-natural spellings, mapped to the command phrase that does the
14
+ * job. Values are full phrases (`meta set`), not just root names, because the
15
+ * work an agent wants often lives on a subcommand.
16
+ */
17
+ const COMMAND_ALIASES = {
18
+ // metadata — the case in issue #545
19
+ setmetadata: "meta set",
20
+ setmeta: "meta set",
21
+ metadata: "meta set",
22
+ metaset: "meta set",
23
+ tag: "meta set",
24
+ tags: "meta set",
25
+ label: "meta set",
26
+ getmetadata: "meta get",
27
+ getmeta: "meta get",
28
+ showmetadata: "meta get",
29
+ metaget: "meta get",
30
+ // upload
31
+ upload: "put",
32
+ uploadfile: "put",
33
+ cp: "put",
34
+ copy: "put",
35
+ send: "put",
36
+ // listing and search
37
+ ls: "list",
38
+ files: "list",
39
+ objects: "list",
40
+ search: "find",
41
+ query: "find",
42
+ filter: "find",
43
+ // removal
44
+ rm: "delete",
45
+ remove: "delete",
46
+ del: "delete",
47
+ destroy: "delete",
48
+ // capture
49
+ capture: "screenshot",
50
+ shot: "screenshot",
51
+ snap: "screenshot",
52
+ screengrab: "screenshot",
53
+ screencapture: "screenshot",
54
+ // session
55
+ signin: "login",
56
+ auth: "login",
57
+ authenticate: "login",
58
+ signout: "logout",
59
+ // github
60
+ pr: "attach",
61
+ issue: "attach",
62
+ upsertcomment: "comment",
63
+ };
64
+ /** Compare on letters and digits only, so `set-metadata` ≡ `set_metadata`. */
65
+ function normalize(value) {
66
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
67
+ }
68
+ /** Plain Levenshtein distance (two-row); inputs here are a few characters. */
69
+ function editDistance(a, b) {
70
+ if (a === b)
71
+ return 0;
72
+ if (a.length === 0)
73
+ return b.length;
74
+ if (b.length === 0)
75
+ return a.length;
76
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
77
+ for (let i = 1; i <= a.length; i++) {
78
+ const row = [i];
79
+ for (let j = 1; j <= b.length; j++) {
80
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
81
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + cost);
82
+ }
83
+ prev = row;
84
+ }
85
+ return prev[b.length];
86
+ }
87
+ /** Crude de-pluralizer, so `galleries` still reaches `gallery`. */
88
+ function singular(value) {
89
+ if (value.endsWith("ies") && value.length > 4)
90
+ return `${value.slice(0, -3)}y`;
91
+ if (value.endsWith("es") && value.length > 3)
92
+ return value.slice(0, -2);
93
+ if (value.endsWith("s") && value.length > 3)
94
+ return value.slice(0, -1);
95
+ return value;
96
+ }
97
+ /** Short words tolerate one typo, longer ones two. */
98
+ function threshold(length) {
99
+ if (length <= 3)
100
+ return 0;
101
+ if (length <= 5)
102
+ return 1;
103
+ return 2;
104
+ }
105
+ /**
106
+ * The command phrase a mistyped command most likely meant, or undefined when
107
+ * nothing is close enough to be worth printing. Aliases win over distance so
108
+ * `metadata` resolves to `meta set` rather than the bare `meta` group.
109
+ */
110
+ export function suggestCommand(input) {
111
+ const norm = normalize(input);
112
+ if (!norm)
113
+ return undefined;
114
+ const alias = COMMAND_ALIASES[norm];
115
+ if (alias)
116
+ return alias;
117
+ let best;
118
+ const consider = (candidate, phrase, allowContainment) => {
119
+ // A plural or a trailing qualifier (`screenshots`, `put-file`) is the same
120
+ // intent, not a typo — treat containment as a very strong match.
121
+ const contained = allowContainment &&
122
+ candidate.length >= 3 &&
123
+ (norm.startsWith(candidate) || candidate.startsWith(norm));
124
+ const distance = contained
125
+ ? 0
126
+ : Math.min(editDistance(norm, candidate), editDistance(singular(norm), candidate));
127
+ if (!contained && distance > threshold(Math.max(norm.length, candidate.length)))
128
+ return;
129
+ if (!best || distance < best.distance)
130
+ best = { phrase, distance };
131
+ };
132
+ for (const cmd of ROOT_COMMANDS)
133
+ consider(normalize(cmd.name), cmd.name, true);
134
+ // Aliases join the fuzzy pass too, so a typo *of* a synonym (`uplaod`) still
135
+ // lands. No containment for these — an alias is a whole word, and `tag` as a
136
+ // prefix would swallow unrelated input.
137
+ for (const [spelling, phrase] of Object.entries(COMMAND_ALIASES)) {
138
+ consider(spelling, phrase, false);
139
+ }
140
+ return best?.phrase;
141
+ }
142
+ /** Catalog summary for a suggested phrase (`meta set` → the subcommand's). */
143
+ export function commandSummary(phrase) {
144
+ const [name, sub] = phrase.split(" ");
145
+ const cmd = ROOT_COMMANDS.find((c) => c.name === name);
146
+ if (!cmd)
147
+ return undefined;
148
+ if (!sub)
149
+ return cmd.summary;
150
+ return cmd.subcommands?.find((s) => s.name === sub)?.summary;
151
+ }
package/dist/cli.js CHANGED
@@ -2,7 +2,9 @@ import { createUploadsClient } from "./client.js";
2
2
  import { DEFAULT_API_URL, resolveApiUrl, resolveConfig } from "./config.js";
3
3
  import { UploadsError } from "./errors.js";
4
4
  import { commandWorkspace, flagString, isHelpFlag, parseArgv, parseCommandArgs, UsageError, } from "./cli-args.js";
5
- import { formatRootHelp, wantsFullHelp } from "./cli-help.js";
5
+ import { formatRootHelp, formatUnknownCommand, wantsFullHelp } from "./cli-help.js";
6
+ import { commandSummary, suggestCommand } from "./cli-suggest.js";
7
+ import { writeJson } from "./io.js";
6
8
  import { colorEnabled, createStyle } from "./cli-style.js";
7
9
  import { runPut, runAttach, runStaged, runList, runFind, runMeta, runDelete, runHealth, runDoctor, runComment, runGithub, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
8
10
  import { runConfig } from "./commands/config.js";
@@ -117,7 +119,7 @@ function errorOut(err, format) {
117
119
  const payload = err instanceof UploadsError
118
120
  ? { error: err.message, code: err.code, status: err.status }
119
121
  : err instanceof UsageError
120
- ? { error: err.message, code: "USAGE" }
122
+ ? { error: err.message, code: "USAGE", ...(err.example ? { example: err.example } : {}) }
121
123
  : { error: err instanceof Error ? err.message : String(err) };
122
124
  if (format === "json") {
123
125
  process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
@@ -136,6 +138,11 @@ function errorOut(err, format) {
136
138
  process.stderr.write(`${msg}\n`);
137
139
  else
138
140
  process.stderr.write(`error: ${msg}\n`);
141
+ // A runnable line beats prose for both agents and humans; the `--help`
142
+ // pointer that follows stays for the full option list.
143
+ if (err instanceof UsageError && err.example) {
144
+ process.stderr.write(` ${err.example}\n`);
145
+ }
139
146
  if (err instanceof UploadsError) {
140
147
  const hint = ERROR_HINTS[err.code];
141
148
  if (hint)
@@ -353,13 +360,25 @@ export async function runCli(argv) {
353
360
  break;
354
361
  }
355
362
  default: {
356
- const style = createStyle(colorEnabled(process.stderr));
357
- process.stderr.write(`${style.error(`unknown command: ${parsed.command}`)}\n\n`);
358
- await writeRootHelp({
359
- full: false,
360
- token: parsed.globals.token,
361
- envFile: parsed.globals.envFile,
362
- });
363
+ // Short, greppable, and suggestion-first (issue #545) — never the root
364
+ // help dump, which buried the error when agents piped through `tail`.
365
+ const suggestion = suggestCommand(parsed.command);
366
+ const message = `unknown command: ${parsed.command}`;
367
+ if (json) {
368
+ await writeJson({
369
+ error: message,
370
+ code: "USAGE",
371
+ ...(suggestion ? { didYouMean: suggestion } : {}),
372
+ });
373
+ }
374
+ else {
375
+ process.stderr.write(formatUnknownCommand({
376
+ command: parsed.command,
377
+ suggestion,
378
+ summary: suggestion ? commandSummary(suggestion) : undefined,
379
+ style: createStyle(colorEnabled(process.stderr)),
380
+ }));
381
+ }
363
382
  flushTelemetry(2);
364
383
  return 2;
365
384
  }
@@ -48,8 +48,9 @@ readStdinImpl = readStdin) {
48
48
  }
49
49
  const imagePath = parsed.positionals[0];
50
50
  if (!imagePath) {
51
- writeCommandHelp(ANNOTATE_HELP);
52
- return 2;
51
+ throw new UsageError("annotate requires an image path", {
52
+ example: "uploads annotate ./shot.png --spec ./callouts.json",
53
+ });
53
54
  }
54
55
  if (parsed.positionals.length > 1) {
55
56
  throw new UsageError("annotate takes exactly one image argument");
@@ -59,9 +59,9 @@ export async function runConfig(args, opts, help = false) {
59
59
  case "set":
60
60
  return runConfigSet(rest, subArgs, opts, help);
61
61
  default:
62
- process.stderr.write(`unknown config subcommand: ${sub}\n\n`);
63
- writeCommandHelp(CONFIG_HELP);
64
- return 2;
62
+ // Short, not a help dump: agents trim output, and a dump pushes the
63
+ // reason out of the window (issue #545).
64
+ throw new UsageError(`unknown config subcommand: ${sub} (expected path, show, init, or set)`, { example: "uploads config show" });
65
65
  }
66
66
  }
67
67
  async function runConfigPath(args, opts, help) {
@@ -191,8 +191,9 @@ Examples:
191
191
  const key = positionals[0];
192
192
  const value = positionals[1];
193
193
  if (!key || !value) {
194
- writeCommandHelp(`uploads config set <key> <value>\n`);
195
- return 2;
194
+ throw new UsageError("config set requires a key and a value", {
195
+ example: "uploads config set UPLOADS_WORKSPACE myteam",
196
+ });
196
197
  }
197
198
  if (!VALID_KEYS.has(key)) {
198
199
  throw new UsageError(`unknown key: ${key} (expected ${[...VALID_KEYS].join(", ")})`);
@@ -156,8 +156,9 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
156
156
  }
157
157
  const target = parsed.positionals[0];
158
158
  if (!target) {
159
- writeCommandHelp(SCREENSHOT_HELP);
160
- return 2;
159
+ throw new UsageError("screenshot requires a target URL or .html file", {
160
+ example: "uploads screenshot http://localhost:4321/settings --out settings.png",
161
+ });
161
162
  }
162
163
  if (parsed.positionals.length > 1) {
163
164
  throw new UsageError("screenshot takes exactly one target");
package/dist/commands.js CHANGED
@@ -941,8 +941,9 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
941
941
  // file args) would silently print help instead of a clear UsageError.
942
942
  const branchArg = branchFromFlags(parsed.flags, run);
943
943
  if (parsed.positionals.length === 0) {
944
- writeCommandHelp(ATTACH_HELP);
945
- return 2;
944
+ throw new UsageError("attach requires at least one file", {
945
+ example: "uploads attach ./shot.png --pr 123",
946
+ });
946
947
  }
947
948
  if (branchArg !== undefined) {
948
949
  if (parsed.flags.has("--pr"))
@@ -1523,8 +1524,9 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1523
1524
  }
1524
1525
  const files = parsed.positionals;
1525
1526
  if (files.length === 0) {
1526
- writeCommandHelp(PUT_HELP);
1527
- return 2;
1527
+ throw new UsageError("put requires at least one file", {
1528
+ example: "uploads put ./shot.png --pr 123",
1529
+ });
1528
1530
  }
1529
1531
  const multi = files.length > 1;
1530
1532
  const keyHint = flagString(parsed.flags, "--key");
@@ -1882,6 +1884,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1882
1884
  optimize: result.optimize,
1883
1885
  frame: result.frame,
1884
1886
  gallery,
1887
+ comment,
1888
+ commentError,
1885
1889
  ...(dryRun ? { dryRun: true } : {}),
1886
1890
  ...(jsonHint ? { hint: jsonHint } : {}),
1887
1891
  });
@@ -1948,15 +1952,21 @@ function githubCoordinateFromFlags(flags) {
1948
1952
  export async function runGallery(ctx, args, help = false) {
1949
1953
  const parsed = parseCommandArgs(args);
1950
1954
  const action = parsed.positionals[0];
1951
- if (help || parsed.help || !action) {
1955
+ if (help || parsed.help) {
1952
1956
  writeCommandHelp(GALLERY_HELP);
1953
- return help || parsed.help ? 0 : 2;
1957
+ return 0;
1958
+ }
1959
+ if (!action) {
1960
+ throw new UsageError("gallery requires a subcommand: create, show, list, delete, add, link, or unlink", { example: 'uploads gallery create --title "Release screenshots"' });
1954
1961
  }
1955
1962
  switch (action) {
1956
1963
  case "create": {
1957
1964
  const title = flagString(parsed.flags, "--title");
1958
- if (!title)
1959
- throw new UsageError("gallery create requires --title");
1965
+ if (!title) {
1966
+ throw new UsageError("gallery create requires --title", {
1967
+ example: 'uploads gallery create --title "Release screenshots"',
1968
+ });
1969
+ }
1960
1970
  const gallery = await ctx.client.createGallery({
1961
1971
  title,
1962
1972
  description: flagString(parsed.flags, "--description"),
@@ -2120,7 +2130,7 @@ export async function runGallery(ctx, args, help = false) {
2120
2130
  return failures.length === 0 ? 0 : 1;
2121
2131
  }
2122
2132
  default:
2123
- throw new UsageError(`unknown gallery command: ${action}`);
2133
+ throw new UsageError(`unknown gallery command: ${action} (expected create, show, list, delete, add, link, or unlink)`, { example: 'uploads gallery create --title "Release screenshots"' });
2124
2134
  }
2125
2135
  }
2126
2136
  // --- list ---
@@ -2212,11 +2222,12 @@ export async function runList(ctx, args, help = false, run = execRunner) {
2212
2222
  const FIND_HELP = `uploads find k=v [k=v...] [--prefix <p>] [--limit <n>] [--workspace <name>]
2213
2223
 
2214
2224
  Human-friendly alias for \`uploads list --meta k=v...\` — same metadata filter
2215
- (ANDed equality), same output; pairs are positional instead of repeated flags.
2225
+ (ANDed equality), same output; pairs are positional, or spelled --meta k=v.
2216
2226
 
2217
2227
  Examples:
2218
2228
  uploads find gh.repo=buildinternet/uploads gh.number=123
2219
2229
  uploads find path=/settings state=after --prefix screenshots/
2230
+ uploads find --meta path=/settings
2220
2231
  `;
2221
2232
  export async function runFind(ctx, args, help = false) {
2222
2233
  const parsed = parseCommandArgs(args);
@@ -2224,11 +2235,15 @@ export async function runFind(ctx, args, help = false) {
2224
2235
  writeCommandHelp(FIND_HELP);
2225
2236
  return 0;
2226
2237
  }
2227
- if (parsed.positionals.length === 0) {
2228
- writeCommandHelp(FIND_HELP);
2229
- return 2;
2238
+ // Same flag/positional symmetry as `meta set` (issue #545): `find` is the
2239
+ // alias for `list --meta`, so `find --meta k=v` must not dead-end.
2240
+ const pairs = [...parsed.positionals, ...flagValues(parsed.flags, "--meta")];
2241
+ if (pairs.length === 0) {
2242
+ throw new UsageError("find requires at least one k=v pair (or --meta k=v)", {
2243
+ example: "uploads find path=/settings state=after",
2244
+ });
2230
2245
  }
2231
- const filters = parseMetaFlags(parsed.positionals);
2246
+ const filters = parseMetaFlags(pairs);
2232
2247
  return runFindFiles(ctx, filters, parsed.flags);
2233
2248
  }
2234
2249
  // --- meta ---
@@ -2241,23 +2256,35 @@ Commands:
2241
2256
  get <key> Show metadata for an object
2242
2257
  set <key> k=v [k=v...] [--delete k]... Merge-set and/or delete pairs
2243
2258
 
2259
+ Pairs take either form: positional k=v, or --meta k=v (same spelling as
2260
+ put/screenshot/list). Both can appear in one call.
2261
+
2244
2262
  Examples:
2245
2263
  uploads meta get screenshots/myapp/42/shot.png
2246
2264
  uploads meta set screenshots/myapp/42/shot.png path=/settings state=after
2265
+ uploads meta set screenshots/myapp/42/shot.png --meta path=/settings
2247
2266
  uploads meta set screenshots/myapp/42/shot.png --delete path --delete state
2248
2267
  `;
2249
2268
  export async function runMeta(ctx, args, help = false) {
2250
2269
  const parsed = parseCommandArgs(args);
2251
2270
  const action = parsed.positionals[0];
2252
- if (help || parsed.help || !action) {
2271
+ if (help || parsed.help) {
2253
2272
  writeCommandHelp(META_HELP);
2254
- return help || parsed.help ? 0 : 2;
2273
+ return 0;
2274
+ }
2275
+ if (!action) {
2276
+ throw new UsageError("meta requires a subcommand: get or set", {
2277
+ example: "uploads meta set screenshots/myapp/42/shot.png --meta path=/settings",
2278
+ });
2255
2279
  }
2256
2280
  switch (action) {
2257
2281
  case "get": {
2258
2282
  const key = parsed.positionals[1];
2259
- if (!key)
2260
- throw new UsageError("meta get requires an object key");
2283
+ if (!key) {
2284
+ throw new UsageError("meta get requires an object key", {
2285
+ example: "uploads meta get screenshots/myapp/42/shot.png",
2286
+ });
2287
+ }
2261
2288
  const result = await ctx.client.getMetadata(key);
2262
2289
  if (ctx.json)
2263
2290
  await writeJson(result);
@@ -2273,12 +2300,22 @@ export async function runMeta(ctx, args, help = false) {
2273
2300
  }
2274
2301
  case "set": {
2275
2302
  const key = parsed.positionals[1];
2276
- if (!key)
2277
- throw new UsageError("meta set requires an object key");
2278
- const pairs = parsed.positionals.slice(2);
2303
+ if (!key) {
2304
+ throw new UsageError("meta set requires an object key", {
2305
+ example: "uploads meta set screenshots/myapp/42/shot.png --meta path=/settings",
2306
+ });
2307
+ }
2308
+ // `--meta k=v` is accepted alongside the positional form: `put`, `list`,
2309
+ // and `screenshot` all spell metadata that way, and `put`'s own success
2310
+ // tip teaches the flag, so carrying it here is the natural guess
2311
+ // (issue #545). Positionals come first so argument order still reads
2312
+ // left to right when both are used.
2313
+ const pairs = [...parsed.positionals.slice(2), ...flagValues(parsed.flags, "--meta")];
2279
2314
  const del = flagValues(parsed.flags, "--delete");
2280
2315
  if (pairs.length === 0 && del.length === 0) {
2281
- throw new UsageError("meta set requires k=v pairs and/or --delete <key>");
2316
+ throw new UsageError("meta set requires k=v pairs and/or --delete <key>", {
2317
+ example: `uploads meta set ${key} --meta path=/settings`,
2318
+ });
2282
2319
  }
2283
2320
  const set = pairs.length > 0 ? parseMetaFlags(pairs) : undefined;
2284
2321
  const result = await ctx.client.patchMetadata(key, {
@@ -2294,7 +2331,9 @@ export async function runMeta(ctx, args, help = false) {
2294
2331
  return 0;
2295
2332
  }
2296
2333
  default:
2297
- throw new UsageError(`unknown meta command: ${action}`);
2334
+ throw new UsageError(`unknown meta command: ${action} (expected get or set)`, {
2335
+ example: "uploads meta get screenshots/myapp/42/shot.png",
2336
+ });
2298
2337
  }
2299
2338
  }
2300
2339
  /** The metadata keys the managed comment renders (path/state, PR #370). */
@@ -2354,8 +2393,9 @@ export async function runDelete(ctx, args, help = false) {
2354
2393
  }
2355
2394
  const key = parsed.positionals[0];
2356
2395
  if (!key) {
2357
- writeCommandHelp(DELETE_HELP);
2358
- return 2;
2396
+ throw new UsageError("delete requires an object key", {
2397
+ example: "uploads delete screenshots/myapp/42/shot.png --dry-run",
2398
+ });
2359
2399
  }
2360
2400
  if (flagBool(parsed.flags, "--dry-run")) {
2361
2401
  if (ctx.json)
@@ -2395,8 +2435,11 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
2395
2435
  return 0;
2396
2436
  }
2397
2437
  const target = ghTargetFromFlags(parsed.flags, run);
2398
- if (!target)
2399
- throw new UsageError("comment requires --pr or --issue");
2438
+ if (!target) {
2439
+ throw new UsageError("comment requires --pr or --issue", {
2440
+ example: "uploads comment --pr 123",
2441
+ });
2442
+ }
2400
2443
  const result = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace, {
2401
2444
  resync: true,
2402
2445
  });
@@ -2566,7 +2609,9 @@ export async function runGithub(ctx, args, help = false, run = execRunner) {
2566
2609
  return help || parsed.help ? 0 : 2;
2567
2610
  }
2568
2611
  if (action !== "link" && action !== "unlink" && action !== "doctor") {
2569
- throw new UsageError(`unknown github subcommand: ${action}`);
2612
+ throw new UsageError(`unknown github subcommand: ${action} (expected link or doctor)`, {
2613
+ example: "uploads github link",
2614
+ });
2570
2615
  }
2571
2616
  if (action === "doctor")
2572
2617
  return runGithubDoctor(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,