@buildinternet/uploads 0.36.0 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -173,15 +173,17 @@ export const ROOT_COMMANDS = [
173
173
  },
174
174
  {
175
175
  name: "find",
176
- usage: "find k=v...",
177
- summary: "List objects matching metadata (alias for list --meta)",
176
+ usage: "find [k=v...] [--name <term>]",
177
+ summary: "Find objects by metadata and/or filename substring",
178
178
  },
179
179
  {
180
180
  name: "meta",
181
- summary: "Get/set an object's queryable metadata",
181
+ summary: "Get/set object metadata; discover workspace keys/values",
182
182
  subcommands: [
183
183
  { name: "get", summary: "Show metadata for an object" },
184
184
  { name: "set", summary: "Merge-set and/or delete metadata pairs" },
185
+ { name: "keys", summary: "List distinct metadata keys in the workspace" },
186
+ { name: "values", summary: "List distinct values for one metadata key" },
185
187
  ],
186
188
  },
187
189
  {
package/dist/client.d.ts CHANGED
@@ -47,6 +47,11 @@ export interface ListOptions {
47
47
  export interface FindFilesOptions {
48
48
  prefix?: string;
49
49
  limit?: number;
50
+ /**
51
+ * Case-insensitive substring match on object keys (`?name=`).
52
+ * At least one of non-empty `filters` or `name` is required.
53
+ */
54
+ name?: string;
50
55
  }
51
56
  export interface FindFilesItem {
52
57
  key: string;
@@ -56,6 +61,24 @@ export interface FindFilesItem {
56
61
  export interface FindFilesResult {
57
62
  items: FindFilesItem[];
58
63
  cursor: string | null;
64
+ /** Present when a `name` term was used — true if the underlying query hit its cap. */
65
+ truncated?: boolean;
66
+ }
67
+ export interface MetadataKeysResult {
68
+ keys: Array<{
69
+ key: string;
70
+ count: number;
71
+ distinctValues: number;
72
+ }>;
73
+ truncated: boolean;
74
+ }
75
+ export interface MetadataValuesResult {
76
+ key: string;
77
+ values: Array<{
78
+ value: string;
79
+ count: number;
80
+ }>;
81
+ truncated: boolean;
59
82
  }
60
83
  export interface GetMetadataResult {
61
84
  metadata: Record<string, string>;
@@ -220,7 +243,7 @@ export interface FindGalleriesByReferenceOptions {
220
243
  * anyway, defeating the point of the server-side gate, so the CLI surfaces
221
244
  * the decline instead.
222
245
  */
223
- export type GithubCommentDeclineReason = "app_unconfigured" | "not_installed" | "forbidden" | "not_authorized" | "unavailable";
246
+ export type GithubCommentDeclineReason = "app_unconfigured" | "not_installed" | "forbidden" | "not_authorized" | "actor_not_authorized" | "unavailable";
224
247
  export type GithubCommentResult = {
225
248
  posted: true;
226
249
  action: "created" | "updated" | "skipped";
@@ -523,10 +546,16 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
523
546
  /** `PATCH /v1/:workspace/files/:key` — merge `set`/`delete`; returns the merged map. */
524
547
  patchMetadata(key: string, opts: PatchMetadataOptions): Promise<GetMetadataResult>;
525
548
  /**
526
- * `GET /v1/:workspace/files?meta.<k>=<v>&…` — ANDed equality filter over
527
- * queryable metadata. `filters` must be pre-validated (see `metadata.ts`).
549
+ * `GET /v1/:workspace/files?meta.<k>=<v>&…&name=…` — ANDed equality filter
550
+ * over queryable metadata and/or a case-insensitive filename substring.
551
+ * At least one of non-empty `filters` or `opts.name` is required.
552
+ * `filters` must be pre-validated when present (see `metadata.ts`).
528
553
  */
529
- findFiles(filters: Record<string, string>, opts?: FindFilesOptions): Promise<FindFilesResult>;
554
+ findFiles(filters?: Record<string, string>, opts?: FindFilesOptions): Promise<FindFilesResult>;
555
+ /** `GET /v1/:workspace/files/facets` — workspace metadata key vocabulary. */
556
+ listMetadataKeys(): Promise<MetadataKeysResult>;
557
+ /** `GET /v1/:workspace/files/facets?key=` — distinct values for one key. */
558
+ listMetadataValues(key: string): Promise<MetadataValuesResult>;
530
559
  head(key: string): Promise<HeadResult>;
531
560
  createGallery(opts: CreateGalleryOptions): Promise<Gallery>;
532
561
  getGallery(id: string): Promise<Gallery>;
package/dist/client.js CHANGED
@@ -405,19 +405,31 @@ export function createUploadsClient(config) {
405
405
  });
406
406
  },
407
407
  /**
408
- * `GET /v1/:workspace/files?meta.<k>=<v>&…` — ANDed equality filter over
409
- * queryable metadata. `filters` must be pre-validated (see `metadata.ts`).
408
+ * `GET /v1/:workspace/files?meta.<k>=<v>&…&name=…` — ANDed equality filter
409
+ * over queryable metadata and/or a case-insensitive filename substring.
410
+ * At least one of non-empty `filters` or `opts.name` is required.
411
+ * `filters` must be pre-validated when present (see `metadata.ts`).
410
412
  */
411
- async findFiles(filters, opts = {}) {
413
+ async findFiles(filters = {}, opts = {}) {
412
414
  const params = new URLSearchParams();
413
415
  for (const [k, v] of Object.entries(filters))
414
416
  params.append(`meta.${k}`, v);
417
+ if (opts.name)
418
+ params.set("name", opts.name);
415
419
  if (opts.prefix)
416
420
  params.set("prefix", opts.prefix);
417
421
  if (opts.limit != null)
418
422
  params.set("limit", String(opts.limit));
419
423
  return request("GET", `${filesBase(config)}?${params.toString()}`);
420
424
  },
425
+ /** `GET /v1/:workspace/files/facets` — workspace metadata key vocabulary. */
426
+ async listMetadataKeys() {
427
+ return request("GET", `${filesBase(config)}/facets`);
428
+ },
429
+ /** `GET /v1/:workspace/files/facets?key=` — distinct values for one key. */
430
+ async listMetadataValues(key) {
431
+ return request("GET", `${filesBase(config)}/facets?${new URLSearchParams({ key })}`);
432
+ },
421
433
  async head(key) {
422
434
  const result = await request("GET", `${filesBase(config)}/${encodeKeyPath(key)}`);
423
435
  return { ...result, embedUrl: resolveEmbedUrl(result.url, result.embedUrl) };
@@ -165,8 +165,10 @@ export interface AttachmentsCommentResult {
165
165
  export declare function commentViaSuffix(via: AttachmentsCommentResult["via"]): string;
166
166
  /**
167
167
  * Thrown by `syncAttachmentsComment` when the server declines with
168
- * `not_authorized` (issue #297 baseline control) — this repo is bound to a
169
- * different workspace. Deliberately not caught by the generic "bot endpoint
168
+ * `not_authorized` (issue #297 baseline control — this repo is bound to a
169
+ * different workspace) or `actor_not_authorized` (issue #297 control 2 the
170
+ * workspace requires the caller to be on the target PR/issue thread).
171
+ * Deliberately not caught by the generic "bot endpoint
170
172
  * unreachable" fallback below: falling back to gh here would let the
171
173
  * human's own credentials post anyway, defeating the point of the
172
174
  * server-side gate.
@@ -490,6 +492,19 @@ export interface DoctorReport {
490
492
  scopes?: string[];
491
493
  /** Workspace/token mismatch warning (also present in hints). */
492
494
  warning?: string;
495
+ /**
496
+ * Bring-your-own-bucket storage status (issue #583 Phase 3). `GET
497
+ * /me/workspaces/:name/storage` is session-gated (Better Auth cookie or
498
+ * bearer via the AUTH service — see `session-auth.ts`); the CLI only ever
499
+ * holds a minted `up_<workspace>_…` workspace token, never a session
500
+ * bearer, so doctor cannot reach that route today. Until a token-authed
501
+ * read path exists, this is an honest "can't check from here" rather than
502
+ * a fabricated mode.
503
+ */
504
+ storage: {
505
+ checked: false;
506
+ note: string;
507
+ };
493
508
  hints: string[];
494
509
  /** `screenshot`'s local-browser detection (fs scans only — never launches a browser). */
495
510
  browser: {
package/dist/commands.js CHANGED
@@ -447,8 +447,10 @@ export function commentViaSuffix(via) {
447
447
  }
448
448
  /**
449
449
  * Thrown by `syncAttachmentsComment` when the server declines with
450
- * `not_authorized` (issue #297 baseline control) — this repo is bound to a
451
- * different workspace. Deliberately not caught by the generic "bot endpoint
450
+ * `not_authorized` (issue #297 baseline control — this repo is bound to a
451
+ * different workspace) or `actor_not_authorized` (issue #297 control 2 the
452
+ * workspace requires the caller to be on the target PR/issue thread).
453
+ * Deliberately not caught by the generic "bot endpoint
452
454
  * unreachable" fallback below: falling back to gh here would let the
453
455
  * human's own credentials post anyway, defeating the point of the
454
456
  * server-side gate.
@@ -485,6 +487,14 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
485
487
  `Run \`uploads github link --status --repo ${target.repo}\` to see who owns the ` +
486
488
  `binding, use that workspace instead, or post the comment manually with gh.`);
487
489
  }
490
+ // Actor-on-PR gate (issue #297 control 2, workspace opt-in): same
491
+ // no-gh-fallback rule as not_authorized — the workspace explicitly asked
492
+ // the server to hold this line, so the CLI shouldn't route around it.
493
+ if (bot.reason === "actor_not_authorized") {
494
+ throw new GithubCommentAuthorizationError(`${bot.message ?? `You are not an actor on ${target.repo}#${target.num}.`}\n` +
495
+ `Ask an authorized thread participant to run this, or post the ` +
496
+ `comment manually with gh.`);
497
+ }
488
498
  // Installed-but-unapproved is a fixable misconfiguration, not a silent
489
499
  // degrade: tell the user (and how to fix it) before falling back to gh.
490
500
  if (bot.reason === "forbidden" && bot.message) {
@@ -1384,7 +1394,7 @@ export function mergeStagingMeta(base, target) {
1384
1394
  * for both the human-mode stderr line and the JSON `hint` field.
1385
1395
  */
1386
1396
  export function putStagingNoteText(branch) {
1387
- return (`note: staged for branch ${branch} — auto-attaches to this branch's PR when it opens ` +
1397
+ return (`note: staged for branch ${branch} — auto-comments to pull request when opened ` +
1388
1398
  `(or run: uploads attach --promote once it exists). Use --ref/--prefix for a plain dated upload.`);
1389
1399
  }
1390
1400
  /**
@@ -2134,31 +2144,45 @@ export async function runGallery(ctx, args, help = false) {
2134
2144
  }
2135
2145
  }
2136
2146
  // --- list ---
2137
- const LIST_HELP = `uploads list [--prefix <p>] [--pr <num> | --issue <num>] [--repo <owner/name>] [--limit <n>] [--cursor <c>] [--all] [--meta <k=v>]... [--workspace <name>]
2147
+ const LIST_HELP = `uploads list [--prefix <p>] [--pr <num> | --issue <num>] [--repo <owner/name>] [--limit <n>] [--cursor <c>] [--all] [--meta <k=v>]... [--name <term>] [--workspace <name>]
2138
2148
 
2139
2149
  Default prefix: UPLOADS_DEFAULT_PREFIX (screenshots if unset).
2140
2150
 
2141
- --meta <k=v> (repeatable, ANDed) switches to the metadata filter endpoint
2142
- returned items include their matched metadata. Combines with --prefix, not
2143
- with --pr/--issue/--all. See also: uploads find (positional-pair alias).
2151
+ --meta <k=v> (repeatable, ANDed) and/or --name <term> switch to the search
2152
+ endpoint — returned items include their matched metadata. Combines with
2153
+ --prefix, not with --pr/--issue/--all. --name is a case-insensitive
2154
+ substring match on object keys. See also: uploads find.
2144
2155
 
2145
2156
  Examples:
2146
2157
  uploads list --prefix screenshots/
2147
2158
  uploads list --pr 123
2148
2159
  uploads list --all --json
2149
2160
  uploads list --meta gh.repo=buildinternet/uploads --meta gh.number=123
2161
+ uploads list --name hero --meta app=web
2150
2162
  `;
2151
- /** `--meta k=v` (repeatable) filter path, shared by `runList` and `runFind`. */
2152
- async function runFindFiles(ctx, filters, flags) {
2163
+ /** Human-mode stderr note when a paged API response was capped server-side. */
2164
+ function writeTruncatedNotice(truncated, quiet, detail) {
2165
+ if (truncated && !quiet) {
2166
+ process.stderr.write(`truncated: true (${detail})\n`);
2167
+ }
2168
+ }
2169
+ /** `--meta` / `--name` search path, shared by `runList` and `runFind`. */
2170
+ async function runFindFiles(ctx, filters, flags, name) {
2153
2171
  if (flagString(flags, "--cursor") !== undefined) {
2154
- throw new UsageError("--cursor is not supported with metadata filters");
2172
+ throw new UsageError("--cursor is not supported with metadata or name filters");
2155
2173
  }
2156
2174
  const prefix = flagString(flags, "--prefix");
2157
2175
  const limit = flagInt(flags, "--limit", "--limit");
2158
- const result = await ctx.client.findFiles(filters, { prefix, limit });
2176
+ const nameTerm = name ?? flagString(flags, "--name");
2177
+ if (Object.keys(filters).length === 0 && !nameTerm) {
2178
+ throw new UsageError("find requires at least one k=v pair, --meta k=v, or --name <term>", {
2179
+ example: "uploads find path=/settings state=after",
2180
+ });
2181
+ }
2182
+ const result = await ctx.client.findFiles(filters, { prefix, limit, name: nameTerm });
2159
2183
  if (ctx.json)
2160
2184
  await writeJson(result);
2161
- else
2185
+ else {
2162
2186
  for (const item of result.items) {
2163
2187
  // LIST_HELP promises matched metadata in the output; render it inline
2164
2188
  // (sorted for stable output) so human mode honors that, not just --json.
@@ -2168,6 +2192,8 @@ async function runFindFiles(ctx, filters, flags) {
2168
2192
  .join(" ");
2169
2193
  await writeStdout(`${item.key}${item.url ? ` ${item.url}` : ""}${meta ? ` ${meta}` : ""}\n`);
2170
2194
  }
2195
+ writeTruncatedNotice(result.truncated, ctx.quiet, "more matches may exist beyond this page");
2196
+ }
2171
2197
  return 0;
2172
2198
  }
2173
2199
  export async function runList(ctx, args, help = false, run = execRunner) {
@@ -2177,14 +2203,15 @@ export async function runList(ctx, args, help = false, run = execRunner) {
2177
2203
  return 0;
2178
2204
  }
2179
2205
  const metaPairs = flagValues(parsed.flags, "--meta");
2180
- if (metaPairs.length > 0) {
2206
+ const nameFlag = flagString(parsed.flags, "--name");
2207
+ if (metaPairs.length > 0 || nameFlag !== undefined) {
2181
2208
  if (ghTargetFromFlags(parsed.flags, run)) {
2182
- throw new UsageError("--meta cannot be combined with --pr/--issue");
2209
+ throw new UsageError("--meta/--name cannot be combined with --pr/--issue");
2183
2210
  }
2184
2211
  if (flagBool(parsed.flags, "--all")) {
2185
- throw new UsageError("--meta cannot be combined with --all");
2212
+ throw new UsageError("--meta/--name cannot be combined with --all");
2186
2213
  }
2187
- return runFindFiles(ctx, parseMetaFlags(metaPairs), parsed.flags);
2214
+ return runFindFiles(ctx, metaPairs.length > 0 ? parseMetaFlags(metaPairs) : {}, parsed.flags, nameFlag);
2188
2215
  }
2189
2216
  const defaults = resolvePutDefaults({ envFile: ctx.envFile });
2190
2217
  const prefixFlag = flagString(parsed.flags, "--prefix");
@@ -2219,15 +2246,21 @@ export async function runList(ctx, args, help = false, run = execRunner) {
2219
2246
  return 0;
2220
2247
  }
2221
2248
  // --- find ---
2222
- const FIND_HELP = `uploads find k=v [k=v...] [--prefix <p>] [--limit <n>] [--workspace <name>]
2249
+ const FIND_HELP = `uploads find [k=v...] [--meta k=v]... [--name <term>] [--prefix <p>] [--limit <n>] [--workspace <name>]
2250
+
2251
+ Find objects by queryable metadata (ANDed equality) and/or a case-insensitive
2252
+ filename substring. Same output as \`uploads list --meta\` / \`--name\`.
2223
2253
 
2224
- Human-friendly alias for \`uploads list --meta k=v...\` same metadata filter
2225
- (ANDed equality), same output; pairs are positional, or spelled --meta k=v.
2254
+ Pairs are positional k=v, or spelled --meta k=v. A bare positional without
2255
+ \`=\` is treated as --name (e.g. \`uploads find hero\`). At least one of a
2256
+ meta pair or a name term is required.
2226
2257
 
2227
2258
  Examples:
2228
2259
  uploads find gh.repo=buildinternet/uploads gh.number=123
2229
2260
  uploads find path=/settings state=after --prefix screenshots/
2230
2261
  uploads find --meta path=/settings
2262
+ uploads find hero
2263
+ uploads find --name hero --meta app=web
2231
2264
  `;
2232
2265
  export async function runFind(ctx, args, help = false) {
2233
2266
  const parsed = parseCommandArgs(args);
@@ -2237,24 +2270,43 @@ export async function runFind(ctx, args, help = false) {
2237
2270
  }
2238
2271
  // Same flag/positional symmetry as `meta set` (issue #545): `find` is the
2239
2272
  // 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",
2273
+ // Bare positionals without `=` are the filename term (issue #528) so
2274
+ // `uploads find hero` works without a flag. Empty input is rejected inside
2275
+ // `runFindFiles` (shared with `list --name` / `--meta`).
2276
+ const nameFlag = flagString(parsed.flags, "--name");
2277
+ const kvPositionals = [];
2278
+ const bareNames = [];
2279
+ for (const pos of parsed.positionals) {
2280
+ if (pos.includes("="))
2281
+ kvPositionals.push(pos);
2282
+ else
2283
+ bareNames.push(pos);
2284
+ }
2285
+ if (bareNames.length > 1) {
2286
+ throw new UsageError("find accepts at most one bare name term (or use --name)", {
2287
+ example: "uploads find hero --meta app=web",
2288
+ });
2289
+ }
2290
+ if (bareNames.length === 1 && nameFlag !== undefined) {
2291
+ throw new UsageError("pass the name term either as a bare positional or --name, not both", {
2292
+ example: "uploads find hero",
2244
2293
  });
2245
2294
  }
2246
- const filters = parseMetaFlags(pairs);
2247
- return runFindFiles(ctx, filters, parsed.flags);
2295
+ const pairs = [...kvPositionals, ...flagValues(parsed.flags, "--meta")];
2296
+ return runFindFiles(ctx, pairs.length > 0 ? parseMetaFlags(pairs) : {}, parsed.flags, nameFlag ?? bareNames[0]);
2248
2297
  }
2249
2298
  // --- meta ---
2250
2299
  const META_HELP = `uploads meta <command> [args]
2251
2300
 
2252
2301
  Read/write an object's queryable custom metadata (D1-backed key-value pairs;
2253
- distinct from the R2 provenance headers put on upload).
2302
+ distinct from the R2 provenance headers put on upload). Discover which keys
2303
+ and values exist in the workspace before filtering with find/list.
2254
2304
 
2255
2305
  Commands:
2256
2306
  get <key> Show metadata for an object
2257
2307
  set <key> k=v [k=v...] [--delete k]... Merge-set and/or delete pairs
2308
+ keys List distinct metadata keys (with counts)
2309
+ values <meta-key> List distinct values for one key
2258
2310
 
2259
2311
  Pairs take either form: positional k=v, or --meta k=v (same spelling as
2260
2312
  put/screenshot/list). Both can appear in one call.
@@ -2264,6 +2316,8 @@ Examples:
2264
2316
  uploads meta set screenshots/myapp/42/shot.png path=/settings state=after
2265
2317
  uploads meta set screenshots/myapp/42/shot.png --meta path=/settings
2266
2318
  uploads meta set screenshots/myapp/42/shot.png --delete path --delete state
2319
+ uploads meta keys
2320
+ uploads meta values app
2267
2321
  `;
2268
2322
  export async function runMeta(ctx, args, help = false) {
2269
2323
  const parsed = parseCommandArgs(args);
@@ -2273,7 +2327,7 @@ export async function runMeta(ctx, args, help = false) {
2273
2327
  return 0;
2274
2328
  }
2275
2329
  if (!action) {
2276
- throw new UsageError("meta requires a subcommand: get or set", {
2330
+ throw new UsageError("meta requires a subcommand: get, set, keys, or values", {
2277
2331
  example: "uploads meta set screenshots/myapp/42/shot.png --meta path=/settings",
2278
2332
  });
2279
2333
  }
@@ -2330,8 +2384,40 @@ export async function runMeta(ctx, args, help = false) {
2330
2384
  await resyncCommentAfterMetaSet(ctx, key, [...Object.keys(set ?? {}), ...del]);
2331
2385
  return 0;
2332
2386
  }
2387
+ case "keys": {
2388
+ // Workspace vocabulary discovery (issue #528) — which meta keys exist
2389
+ // and how common they are, before agents guess at find filters.
2390
+ const result = await ctx.client.listMetadataKeys();
2391
+ if (ctx.json)
2392
+ await writeJson(result);
2393
+ else {
2394
+ for (const row of result.keys) {
2395
+ await writeStdout(`${row.key} count=${row.count} distinct=${row.distinctValues}\n`);
2396
+ }
2397
+ writeTruncatedNotice(result.truncated, ctx.quiet, "more keys may exist beyond this page");
2398
+ }
2399
+ return 0;
2400
+ }
2401
+ case "values": {
2402
+ const key = parsed.positionals[1];
2403
+ if (!key) {
2404
+ throw new UsageError("meta values requires a metadata key", {
2405
+ example: "uploads meta values app",
2406
+ });
2407
+ }
2408
+ const result = await ctx.client.listMetadataValues(key);
2409
+ if (ctx.json)
2410
+ await writeJson(result);
2411
+ else {
2412
+ for (const row of result.values) {
2413
+ await writeStdout(`${row.value} count=${row.count}\n`);
2414
+ }
2415
+ writeTruncatedNotice(result.truncated, ctx.quiet, "more values may exist beyond this page");
2416
+ }
2417
+ return 0;
2418
+ }
2333
2419
  default:
2334
- throw new UsageError(`unknown meta command: ${action} (expected get or set)`, {
2420
+ throw new UsageError(`unknown meta command: ${action} (expected get, set, keys, or values)`, {
2335
2421
  example: "uploads meta get screenshots/myapp/42/shot.png",
2336
2422
  });
2337
2423
  }
@@ -2845,6 +2931,10 @@ export async function buildDoctorReport(config, client, detectRoots) {
2845
2931
  usage,
2846
2932
  scopes,
2847
2933
  warning: mismatch,
2934
+ storage: {
2935
+ checked: false,
2936
+ note: "not checked from the CLI — storage settings (shared vs. bring-your-own-bucket) live behind a signed-in session; sign in on the web (Account → workspace → Settings) to view mode and verification status",
2937
+ },
2848
2938
  hints,
2849
2939
  browser,
2850
2940
  };
@@ -2881,6 +2971,7 @@ export async function runDoctor(ctx, args, help = false) {
2881
2971
  else {
2882
2972
  lines.push(`browser: ${report.browser.note ?? "not supported in this runtime"}`);
2883
2973
  }
2974
+ lines.push(`storage: ${report.storage.note}`);
2884
2975
  if (report.warning)
2885
2976
  lines.push(`warning: ${report.warning}`);
2886
2977
  for (const h of report.hints)
package/dist/github.d.ts CHANGED
@@ -76,8 +76,8 @@ export declare function attachmentsMarker(workspace?: string): string;
76
76
  export declare const MAX_INLINE_ATTACHMENT_IMAGES = 16;
77
77
  /**
78
78
  * Per-render knobs for the managed comment (issue #307), sourced from repo
79
- * comment config. `imageWidth: "auto"` preserves today's per-item width
80
- * heuristics (`attachmentImageWidth`/`posterImageWidth`/pair cap); `"full"`
79
+ * comment config. `imageWidth: "auto"` uses per-item filename heuristics plus
80
+ * density-aware sizing (solo/sparse/dense from the inlined count); `"full"`
81
81
  * omits the `width` attribute entirely; a number overrides every width site.
82
82
  */
83
83
  export interface CommentRenderOptions {
@@ -134,21 +134,29 @@ export interface GalleryCommentItem {
134
134
  itemUrl?: string;
135
135
  }[];
136
136
  }
137
- /** Default max width for images in the managed attachments comment (HTML img). */
137
+ /**
138
+ * How crowded the managed comment is. Sparse comments (one shot, a single
139
+ * before/after) get larger embeds; dense comments keep compact historical sizes.
140
+ */
141
+ export type AttachmentDensity = "solo" | "sparse" | "dense";
142
+ /** Dense (historical) default max width for images in the managed comment. */
138
143
  export declare const ATTACHMENT_IMAGE_WIDTH_DEFAULT = 400;
139
144
  /** Portrait / device mockups — keep phones readable, not full-column. */
140
145
  export declare const ATTACHMENT_IMAGE_WIDTH_PORTRAIT = 280;
141
146
  /** Wide UI / browser chrome. */
142
147
  export declare const ATTACHMENT_IMAGE_WIDTH_WIDE = 640;
148
+ /** Dense pair-cell cap (side-by-side before/after). */
149
+ export declare const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
150
+ /** Map an inlined-media count onto a density tier. */
151
+ export declare function attachmentDensityForCount(inlinedCount: number): AttachmentDensity;
152
+ /** Pair-cell cap for the given density. */
153
+ export declare function attachmentPairWidth(density?: AttachmentDensity): number;
143
154
  /**
144
- * Pick a display width for GitHub comment embeds. Filenames are a weak but
145
- * practical signal (we don't re-fetch dimensions when rebuilding the comment).
155
+ * Display width for a GitHub comment embed. Filenames are a weak but practical
156
+ * signal (we don't re-fetch dimensions when rebuilding the comment). `density`
157
+ * only affects managed-comment auto layout; other callers leave it `"dense"`.
146
158
  */
147
- export declare function attachmentImageWidth(filename: string): number;
148
- /** Max display width for one image inside a before/after pair row — smaller
149
- * than a standalone image so two side by side stay under GitHub's comment
150
- * column width (and don't overflow on mobile). */
151
- export declare const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
159
+ export declare function attachmentImageWidth(filename: string, density?: AttachmentDensity): number;
152
160
  /**
153
161
  * Render the one marker-owned GitHub comment. When there are no galleries this
154
162
  * intentionally preserves the legacy attachment-only body byte-for-byte.
package/dist/github.js CHANGED
@@ -171,26 +171,52 @@ export const AUTO_RENDER_OPTIONS = {
171
171
  metaState: true,
172
172
  note: null,
173
173
  };
174
- /** Default max width for images in the managed attachments comment (HTML img). */
174
+ /** Dense (historical) default max width for images in the managed comment. */
175
175
  export const ATTACHMENT_IMAGE_WIDTH_DEFAULT = 400;
176
176
  /** Portrait / device mockups — keep phones readable, not full-column. */
177
177
  export const ATTACHMENT_IMAGE_WIDTH_PORTRAIT = 280;
178
178
  /** Wide UI / browser chrome. */
179
179
  export const ATTACHMENT_IMAGE_WIDTH_WIDE = 640;
180
+ /** Dense pair-cell cap (side-by-side before/after). */
181
+ export const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
182
+ /** Per-density widths for `imageWidth: "auto"`. Dense reuses the exports above. */
183
+ const WIDTH_BY_DENSITY = {
184
+ solo: { default: 720, portrait: 360, wide: 800, pair: 400 },
185
+ sparse: { default: 560, portrait: 300, wide: 720, pair: 380 },
186
+ dense: {
187
+ default: ATTACHMENT_IMAGE_WIDTH_DEFAULT,
188
+ portrait: ATTACHMENT_IMAGE_WIDTH_PORTRAIT,
189
+ wide: ATTACHMENT_IMAGE_WIDTH_WIDE,
190
+ pair: ATTACHMENT_IMAGE_WIDTH_PAIR,
191
+ },
192
+ };
193
+ /** Map an inlined-media count onto a density tier. */
194
+ export function attachmentDensityForCount(inlinedCount) {
195
+ if (inlinedCount <= 1)
196
+ return "solo";
197
+ if (inlinedCount <= 3)
198
+ return "sparse";
199
+ return "dense";
200
+ }
201
+ /** Pair-cell cap for the given density. */
202
+ export function attachmentPairWidth(density = "dense") {
203
+ return WIDTH_BY_DENSITY[density].pair;
204
+ }
180
205
  /**
181
- * Pick a display width for GitHub comment embeds. Filenames are a weak but
182
- * practical signal (we don't re-fetch dimensions when rebuilding the comment).
206
+ * Display width for a GitHub comment embed. Filenames are a weak but practical
207
+ * signal (we don't re-fetch dimensions when rebuilding the comment). `density`
208
+ * only affects managed-comment auto layout; other callers leave it `"dense"`.
183
209
  */
184
- export function attachmentImageWidth(filename) {
210
+ export function attachmentImageWidth(filename, density = "dense") {
211
+ const table = WIDTH_BY_DENSITY[density];
185
212
  const n = filename.toLowerCase();
186
- if (/(?:^|[-_.])(browser|desktop|dashboard|wide)(?:[-_.]|$)/.test(n)) {
187
- return ATTACHMENT_IMAGE_WIDTH_WIDE;
188
- }
213
+ if (/(?:^|[-_.])(browser|desktop|dashboard|wide)(?:[-_.]|$)/.test(n))
214
+ return table.wide;
189
215
  if (/(?:^|[-_.])(phone|iphone|ipad|pixel|android|mobile|device)(?:[-_.]|$)/.test(n) ||
190
216
  /iphone|pixel-?\d/.test(n)) {
191
- return ATTACHMENT_IMAGE_WIDTH_PORTRAIT;
217
+ return table.portrait;
192
218
  }
193
- return ATTACHMENT_IMAGE_WIDTH_DEFAULT;
219
+ return table.default;
194
220
  }
195
221
  /** `m:ss` under an hour, `h:mm:ss` at or above one. */
196
222
  function formatDuration(seconds) {
@@ -205,19 +231,16 @@ function formatDuration(seconds) {
205
231
  }
206
232
  /**
207
233
  * Display width for a video poster. Real dimensions only *select* among the
208
- * width constants — a raw 1920 would blow out the comment column — and the
209
- * result is capped at the real width so a small clip is never upscaled.
234
+ * density table's tiers — a raw 1920 would blow out the comment column — and
235
+ * the result is capped at the real width so a small clip is never upscaled.
210
236
  */
211
- function posterImageWidth(videoMeta, filename) {
237
+ function posterImageWidth(videoMeta, filename, density = "dense") {
212
238
  const w = videoMeta?.width ?? 0;
213
239
  const h = videoMeta?.height ?? 0;
214
240
  if (w <= 0 || h <= 0)
215
- return attachmentImageWidth(filename);
216
- const chosen = h > w
217
- ? ATTACHMENT_IMAGE_WIDTH_PORTRAIT
218
- : w / h >= 16 / 9
219
- ? ATTACHMENT_IMAGE_WIDTH_WIDE
220
- : ATTACHMENT_IMAGE_WIDTH_DEFAULT;
241
+ return attachmentImageWidth(filename, density);
242
+ const table = WIDTH_BY_DENSITY[density];
243
+ const chosen = h > w ? table.portrait : w / h >= 16 / 9 ? table.wide : table.default;
221
244
  return Math.min(chosen, w);
222
245
  }
223
246
  function escapeHtmlAttr(s) {
@@ -236,45 +259,37 @@ function escapeMarkdownText(s) {
236
259
  return s.replace(/([\\`*_[\]~])/g, "\\$1");
237
260
  }
238
261
  /**
239
- * An attachment's caption parts `path`, then `state` (issue #365). Empty
240
- * when neither is usable, so callers emit nothing at all and a body with no
241
- * metadata stays byte-identical to the pre-#365 render.
242
- *
243
- * Neither value is pre-sanitized: metadata values are printable ASCII up to
244
- * 512 chars, and while the CLI validates `--state` against a closed enum,
245
- * `PATCH /v1/:workspace/files/:key` can set any valid metadata value. A
246
- * whitespace-only value passes that validation (length-1 printable ASCII), so
247
- * treat it as absent rather than rendering a dangling separator.
248
- *
249
- * Bare `/` is stored/searchable but omitted from captions (issue #375) —
250
- * alone it is a stray character, and as a prefix next to `state` it is
251
- * noise. Only exact `/` after trim is suppressed.
262
+ * Collect path then state for a caption (issue #365). Bare `/` and
263
+ * whitespace-only values are omitted (issue #375). Empty when nothing usable.
252
264
  */
253
- function metaCaptionParts(meta, options) {
254
- const parts = [];
265
+ function metaCaptionValues(meta, options) {
266
+ const values = [];
255
267
  const path = meta?.path?.trim();
256
268
  if (options.metaPath && path && path !== "/")
257
- parts.push(path);
269
+ values.push(path);
258
270
  const state = meta?.state?.trim();
259
271
  if (options.metaState && state)
260
- parts.push(state);
261
- return parts;
262
- }
263
- /** `<sub>` caption body for an inline image, or null when there is nothing to say. */
264
- function metaCaptionHtml(meta, options) {
265
- const parts = metaCaptionParts(meta, options);
266
- return parts.length > 0 ? parts.map(escapeHtmlText).join(" · ") : null;
272
+ values.push(state);
273
+ return values;
267
274
  }
268
275
  /**
269
- * ` · …` suffix for a markdown list row, or `""` when there is nothing to add.
270
- * HTML-escapes first, then markdown-escapes: HTML escaping introduces no
271
- * backslashes or brackets, so the markdown pass cannot corrupt its entities.
276
+ * Format path/state as code tokens. HTML `<code>…</code>`; markdown
277
+ * `` `…` `` (backslash-escape if the value itself contains a backtick).
278
+ * Returns `""` when there is nothing to say.
272
279
  */
273
- function metaCaptionMarkdown(meta, options) {
274
- const parts = metaCaptionParts(meta, options);
275
- if (parts.length === 0)
280
+ function formatMetaCaption(meta, options, mode) {
281
+ const values = metaCaptionValues(meta, options);
282
+ if (values.length === 0)
276
283
  return "";
277
- return ` · ${parts.map((p) => escapeMarkdownText(escapeHtmlText(p))).join(" · ")}`;
284
+ if (mode === "html") {
285
+ return values.map((v) => `<code>${escapeHtmlText(v)}</code>`).join(" · ");
286
+ }
287
+ return values
288
+ .map((v) => {
289
+ const esc = escapeHtmlText(v);
290
+ return esc.includes("`") ? escapeMarkdownText(esc) : `\`${esc}\``;
291
+ })
292
+ .join(" · ");
278
293
  }
279
294
  /** Resolved pixel width for an image site, or `null` meaning "omit the width
280
295
  * attribute". `"auto"` defers to the caller's per-item heuristic (`autoPx`);
@@ -388,28 +403,37 @@ function pairAttachments(items, isImageAt) {
388
403
  }
389
404
  return { partnerOf, roleOf };
390
405
  }
391
- /** Max display width for one image inside a before/after pair row — smaller
392
- * than a standalone image so two side by side stay under GitHub's comment
393
- * column width (and don't overflow on mobile). */
394
- export const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
395
- function renderPairCell(item, label, options) {
406
+ function renderPairCell(item, label, options, density) {
396
407
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
397
408
  const src = item.embedUrl ?? item.url;
398
409
  const link = item.pageUrl ?? item.url;
399
- const autoPx = Math.min(attachmentImageWidth(name), ATTACHMENT_IMAGE_WIDTH_PAIR);
410
+ const autoPx = Math.min(attachmentImageWidth(name, density), attachmentPairWidth(density));
400
411
  const w = resolvedWidth(autoPx, options);
401
412
  const alt = escapeHtmlAttr(name);
402
413
  const href = escapeHtmlAttr((link ?? src));
403
414
  const imgSrc = escapeHtmlAttr(src);
404
- const caption = metaCaptionHtml(item.meta, options);
415
+ const caption = formatMetaCaption(item.meta, options, "html");
405
416
  const captionHtml = caption ? `<br><sub>${caption}</sub>` : "";
406
417
  return `<td align="center"><sub><strong>${label}</strong></sub><br><a href="${href}">${imgTag(w, alt, imgSrc)}</a>${captionHtml}</td>`;
407
418
  }
408
- /** One side-by-side before/after row (issue #419): a single HTML table so
409
- * GitHub renders both images on one line, with `Before`/`After` labels and
410
- * each side's usual path/state caption preserved underneath. */
411
- function renderPairRow(beforeItem, afterItem, options) {
412
- return `<table><tr>${renderPairCell(beforeItem, "Before", options)}${renderPairCell(afterItem, "After", options)}</tr></table>`;
419
+ /** One side-by-side before/after row (issue #419). */
420
+ function renderPairRow(beforeItem, afterItem, options, density) {
421
+ return `<table><tr>${renderPairCell(beforeItem, "Before", options, density)}${renderPairCell(afterItem, "After", options, density)}</tr></table>`;
422
+ }
423
+ /** How many image/poster items will fit under `maxInlineImages` (for density). */
424
+ function countInlinableMedia(sorted, maxInlineImages) {
425
+ let count = 0;
426
+ for (const item of sorted) {
427
+ if (count >= maxInlineImages)
428
+ break;
429
+ const name = item.key.slice(item.key.lastIndexOf("/") + 1);
430
+ const src = item.embedUrl ?? item.url;
431
+ const isImage = Boolean(src) && inferContentType(name).startsWith("image/");
432
+ const isPoster = Boolean(item.posterUrl) && inferContentType(name).startsWith("video/");
433
+ if (isImage || isPoster)
434
+ count++;
435
+ }
436
+ return count;
413
437
  }
414
438
  /**
415
439
  * Render the one marker-owned GitHub comment. When there are no galleries this
@@ -444,6 +468,10 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
444
468
  return Boolean(src) && inferContentType(name).startsWith("image/");
445
469
  });
446
470
  const { partnerOf, roleOf } = pairAttachments(sorted, isImageAt);
471
+ // One screenshot → large; a wall of shots → compact historical sizes.
472
+ const density = options.imageWidth === "auto"
473
+ ? attachmentDensityForCount(countInlinableMedia(sorted, options.maxInlineImages))
474
+ : "dense";
447
475
  const consumedByPair = new Set();
448
476
  let inlinedImages = 0;
449
477
  const overflowImages = [];
@@ -459,7 +487,7 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
459
487
  consumedByPair.add(partnerIdx);
460
488
  const beforeItem = roleOf.get(idx) === "before" ? item : partner;
461
489
  const afterItem = roleOf.get(idx) === "before" ? partner : item;
462
- lines.push(renderPairRow(beforeItem, afterItem, options), "");
490
+ lines.push(renderPairRow(beforeItem, afterItem, options, density), "");
463
491
  continue;
464
492
  }
465
493
  // Cap already full for a two-image row — degrade this pair to two
@@ -483,7 +511,7 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
483
511
  }
484
512
  if (isPosterVideo) {
485
513
  inlinedImages++;
486
- const autoPx = posterImageWidth(item.videoMeta, name);
514
+ const autoPx = posterImageWidth(item.videoMeta, name, density);
487
515
  const w = resolvedWidth(autoPx, options);
488
516
  const href = escapeHtmlAttr(link ?? item.posterUrl);
489
517
  lines.push(`<a href="${href}">${imgTag(w, escapeHtmlAttr(name), escapeHtmlAttr(item.posterUrl))}</a>`);
@@ -493,29 +521,33 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
493
521
  if (item.videoMeta?.durationSeconds != null) {
494
522
  parts.push(formatDuration(item.videoMeta.durationSeconds));
495
523
  }
496
- parts.push(...metaCaptionParts(item.meta, options).map(escapeHtmlText));
524
+ const metaCap = formatMetaCaption(item.meta, options, "html");
525
+ if (metaCap)
526
+ parts.push(metaCap);
497
527
  lines.push(`<sub>${parts.join(" · ")}</sub>`, "");
498
528
  }
499
529
  else if (isImage) {
500
530
  inlinedImages++;
501
531
  // Markdown ![]() has no width control — phone frames become full-column giants.
502
532
  // img src uses embed host when available (Camo revalidates); click-through prefers the file page.
503
- const autoPx = attachmentImageWidth(name);
533
+ const autoPx = attachmentImageWidth(name, density);
504
534
  const w = resolvedWidth(autoPx, options);
505
535
  const alt = escapeHtmlAttr(name);
506
536
  const href = escapeHtmlAttr(link ?? src);
507
537
  const imgSrc = escapeHtmlAttr(src);
508
538
  lines.push(`<a href="${href}">${imgTag(w, alt, imgSrc)}</a>`);
509
- const caption = metaCaptionHtml(item.meta, options);
539
+ const caption = formatMetaCaption(item.meta, options, "html");
510
540
  if (caption)
511
541
  lines.push(`<sub>${caption}</sub>`);
512
542
  lines.push("");
513
543
  }
514
544
  else if (link) {
515
- lines.push(`- [${name}](${link})${metaCaptionMarkdown(item.meta, options)}`);
545
+ const cap = formatMetaCaption(item.meta, options, "markdown");
546
+ lines.push(`- [${name}](${link})${cap ? ` · ${cap}` : ""}`);
516
547
  }
517
548
  else {
518
- lines.push(`- ${name}${metaCaptionMarkdown(item.meta, options)}`);
549
+ const cap = formatMetaCaption(item.meta, options, "markdown");
550
+ lines.push(`- ${name}${cap ? ` · ${cap}` : ""}`);
519
551
  }
520
552
  }
521
553
  if (overflowImages.length > 0) {
@@ -524,7 +556,8 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
524
556
  for (const item of overflowImages) {
525
557
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
526
558
  const link = item.pageUrl ?? item.url;
527
- const suffix = metaCaptionMarkdown(item.meta, options);
559
+ const cap = formatMetaCaption(item.meta, options, "markdown");
560
+ const suffix = cap ? ` · ${cap}` : "";
528
561
  lines.push(link ? `- [${name}](${link})${suffix}` : `- ${name}${suffix}`);
529
562
  }
530
563
  lines.push("", "</details>", "");
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey
4
4
  export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, resolveDestinationRoot, resolvePutPrefix, type BuiltinDestinationId, } from "./destinations.js";
5
5
  export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, type UploadsClientConfig, type ResolvedConfig, type WorkspaceSource, type ConfigValueSource, type ConfigSources, type UploadsConfigKey, type UploadsConfigValues, type PutDefaults, } from "./config.js";
6
6
  export { UploadsError, type UploadsErrorCode } from "./errors.js";
7
- export { createUploadsClient, type UploadsClient, type PutOptions, type ProvenanceInput, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type GalleryItem, type Gallery, type GallerySummary, type GalleryListOptions, type GalleryListResult, type CreateGalleryOptions, type AddGalleryItemOptions, type DeleteGalleryOptions, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, type FindFilesOptions, type FindFilesItem, type FindFilesResult, type GetMetadataResult, type PatchMetadataOptions, } from "./client.js";
7
+ export { createUploadsClient, type UploadsClient, type PutOptions, type ProvenanceInput, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type GalleryItem, type Gallery, type GallerySummary, type GalleryListOptions, type GalleryListResult, type CreateGalleryOptions, type AddGalleryItemOptions, type DeleteGalleryOptions, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, type FindFilesOptions, type FindFilesItem, type FindFilesResult, type MetadataKeysResult, type MetadataValuesResult, type GetMetadataResult, type PatchMetadataOptions, } from "./client.js";
8
8
  export { buildCliProvenance } from "./provenance.js";
9
9
  export { META_KEY_RE, META_VALUE_MAX, META_MAX_KEYS, META_MAX_TOTAL_BYTES, validateMetaEntry, parseMetaPair, parseMetaFlags, } from "./metadata.js";
10
10
  export { ATTACHMENTS_MARKER, attachmentsCommentBody, GH_FALLBACK_AUTHOR_NOTE, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghBranchAttachmentKey, ghBranchKeyPrefix, ghMetadataForBranch, isValidRepo, parseRepoFromRemoteUrl, type AttachmentItem, type GhTarget, type GhTargetKind, } from "./github.js";
package/dist/mcp/tools.js CHANGED
@@ -1075,34 +1075,64 @@ export function createUploadsMcpTools(opts) {
1075
1075
  },
1076
1076
  {
1077
1077
  name: "find_files",
1078
- description: "Find objects in the workspace whose queryable custom metadata matches ALL of `filters` (ANDed equality). Returns each match's key, public URL, and full metadata map. Same as `uploads find k=v...` / `uploads list --meta k=v`.",
1078
+ description: "Find objects whose queryable custom metadata matches ALL of `filters` (ANDed equality) and/or whose key contains `name` (case-insensitive substring). At least one of `filters` or `name` is required. Returns each match's key, public URL, full metadata map, and optional `truncated`. Same as `uploads find k=v...` / `uploads find --name <term>`.",
1079
1079
  inputSchema: {
1080
1080
  type: "object",
1081
1081
  properties: {
1082
1082
  filters: {
1083
1083
  ...metadataProp,
1084
- description: "Metadata equality filters (at least one pair). " + METADATA_DESCRIPTION,
1084
+ description: "Metadata equality filters (optional when `name` is set). " + METADATA_DESCRIPTION,
1085
+ },
1086
+ name: {
1087
+ type: "string",
1088
+ description: "Case-insensitive substring match on object keys (1–128 chars). Optional when `filters` is non-empty.",
1089
+ },
1090
+ prefix: {
1091
+ type: "string",
1092
+ description: "Key prefix filter, combinable with filters/name.",
1085
1093
  },
1086
- prefix: { type: "string", description: "Key prefix filter, combinable with filters." },
1087
1094
  limit: { type: "number", description: "Page size (default 50, max 500)." },
1088
1095
  workspace: workspaceProp,
1089
1096
  },
1090
- required: ["filters"],
1091
1097
  additionalProperties: false,
1092
1098
  },
1093
1099
  async handler(args) {
1094
- const filters = optStringRecord(args, "filters");
1095
- if (!filters || Object.keys(filters).length === 0) {
1096
- usage("filters must have at least one key");
1100
+ const filters = optStringRecord(args, "filters") ?? {};
1101
+ const name = optString(args, "name");
1102
+ const hasMeta = Object.keys(filters).length > 0;
1103
+ if (!hasMeta && !name) {
1104
+ usage("find_files requires filters and/or name");
1097
1105
  }
1098
- validateMetaMap(filters);
1106
+ if (hasMeta)
1107
+ validateMetaMap(filters);
1099
1108
  const { client } = clientFor(args);
1100
1109
  return client.findFiles(filters, {
1110
+ name,
1101
1111
  prefix: optString(args, "prefix"),
1102
1112
  limit: optPosInt(args, "limit"),
1103
1113
  });
1104
1114
  },
1105
1115
  },
1116
+ {
1117
+ name: "list_metadata_keys",
1118
+ description: "List the distinct queryable metadata keys present in the workspace, with file counts and distinct-value counts. Use this to discover what is filterable before calling find_files — keys are user/agent-defined, not a fixed schema. Same as `uploads meta keys`. Pass optional `key` to list that key's values instead (`uploads meta values <key>`).",
1119
+ inputSchema: {
1120
+ type: "object",
1121
+ properties: {
1122
+ key: {
1123
+ type: "string",
1124
+ description: "When set, return distinct values for this metadata key (with counts) instead of the key list.",
1125
+ },
1126
+ workspace: workspaceProp,
1127
+ },
1128
+ additionalProperties: false,
1129
+ },
1130
+ async handler(args) {
1131
+ const { client } = clientFor(args);
1132
+ const key = optString(args, "key");
1133
+ return key ? client.listMetadataValues(key) : client.listMetadataKeys();
1134
+ },
1135
+ },
1106
1136
  {
1107
1137
  name: "usage",
1108
1138
  description: "Workspace storage and monthly upload counters (and remaining headroom when budgets are configured). Same as `uploads usage`.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.36.0",
3
+ "version": "0.37.1",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,