@buildinternet/uploads 0.48.1 → 0.50.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
@@ -27,6 +27,8 @@ uploads put ./ui.png --frame browser --frame-url "https://app.example"
27
27
  uploads put ./after.png --pr 123
28
28
  uploads put ./capture-2026-…Z.png --pr 123 --name hero.png # clean leaf, stable path
29
29
  uploads put ./shot.png --pr 123 --name hero.png --dry-run --format url # preview URL, no upload
30
+ uploads put --url https://cdn.example/shot.png --pr 123
31
+ uploads put --url http://localhost:4321/shot.png
30
32
  uploads gallery create --title "Release screenshots"
31
33
  uploads put ./after.png --gallery gal_example
32
34
  # custom metadata (queryable): page URL, in-app path, which surface
@@ -196,7 +198,7 @@ Config layers (first match wins): CLI flags → env vars → `--env-file` → `~
196
198
 
197
199
  ## MCP server
198
200
 
199
- `uploads mcp` serves the Model Context Protocol over stdio (newline-delimited JSON-RPC, no extra dependencies). Tools include file operations plus public gallery workflows: `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`. Gallery tools return API-provided canonical URLs and never need GitHub credentials. The remaining stdio tools are `put`, `attach`, `list`, `delete`, `get_metadata`, `set_metadata`, `find_files`, `usage`, `reconcile`, `purge_expired`, `comment`, `health`, and `doctor` — with the same config resolution and defaults, plus a per-call `workspace` argument. `put` and `attach` accept a `metadata` param (same `gh.*` auto-injection as the CLI's `attach`); `get_metadata`, `set_metadata`, and `find_files` mirror `uploads meta get` / `meta set` / `find`. Interactive/credential commands (`setup`, `login`, `admin`, `config`) are not exposed. A token isn't required to start the server; auth errors surface per tool call (`health` needs no auth).
201
+ `uploads mcp` serves the Model Context Protocol over stdio (newline-delimited JSON-RPC, no extra dependencies). Tools include file operations plus public gallery workflows: `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`. Gallery tools return API-provided canonical URLs and never need GitHub credentials. The remaining stdio tools are `put`, `attach`, `list`, `delete`, `get_metadata`, `set_metadata`, `find_files`, `usage`, `reconcile`, `purge_expired`, `comment`, `whoami`, and `doctor` — with the same config resolution and defaults, plus a per-call `workspace` argument. `put` and `attach` accept a `metadata` param (same `gh.*` auto-injection as the CLI's `attach`); `get_metadata`, `set_metadata`, and `find_files` mirror `uploads meta get` / `meta set` / `find`. Interactive/credential commands (`setup`, `login`, `admin`, `config`) are not exposed. A token isn't required to start the server; auth errors surface per tool call (`whoami` needs no auth).
200
202
 
201
203
  ```json
202
204
  { "command": "uploads", "args": ["--env-file", "/path/to/.env", "mcp"] }
package/dist/cli-help.js CHANGED
@@ -106,6 +106,7 @@ ${section(style, "Examples:")}
106
106
  ${style.command("uploads whoami")}
107
107
  ${style.command("uploads put")} ./shot.png --pr 123 --name hero.png
108
108
  ${style.command("uploads put")} ./after.png --pr 123
109
+ ${style.command("uploads put")} --url https://cdn.example/shot.png --pr 123
109
110
  ${style.command("uploads put")} ./bug.png --issue 45
110
111
  ${style.command("uploads put")} ./shot.png --meta path=/settings --state after
111
112
  ${style.command("uploads attach")} ./before.png ./after.png
@@ -155,6 +156,7 @@ ${section(style, "Examples:")}
155
156
  ${style.command("uploads whoami")}
156
157
  ${style.command("uploads put")} ./shot.png --pr 123 --name hero.png
157
158
  ${style.command("uploads put")} ./after.png --pr 123
159
+ ${style.command("uploads put")} --url https://cdn.example/shot.png --pr 123
158
160
  ${style.command("uploads put")} ./bug.png --issue 45 --repo myorg/myapp
159
161
  ${style.command("uploads put")} ./shot.png --dry-run --format url
160
162
  ${style.command("uploads put")} ./shot.png --meta path=/settings --state after
package/dist/client.d.ts CHANGED
@@ -441,6 +441,8 @@ export interface UsageResult {
441
441
  uploadsRemaining?: number;
442
442
  /** Bytes still on hosted storage (shared-lane residue). */
443
443
  sharedBytes?: number;
444
+ /** Objects still on hosted storage (shared-lane residue). */
445
+ sharedObjects?: number;
444
446
  /** "shared" = BYO bucket active: the storage cap meters only hosted
445
447
  * residue; the customer's own bucket is unmetered. */
446
448
  storageBudgetBasis?: "total" | "shared";
@@ -124,7 +124,10 @@ ${subMaps.join("\n")}
124
124
 
125
125
  if [[ "$cur" == -* ]]; then
126
126
  case "$cmd" in
127
- put|attach)
127
+ put)
128
+ COMPREPLY=( $(compgen -W "\${put_flags[*]} --url" -- "$cur") )
129
+ ;;
130
+ attach)
128
131
  COMPREPLY=( $(compgen -W "\${put_flags[*]}" -- "$cur") )
129
132
  ;;
130
133
  screenshot)
@@ -284,6 +287,7 @@ function fishScript() {
284
287
  continue;
285
288
  lines.push(`complete -c uploads -n '__fish_seen_subcommand_from put attach' -l ${flag.slice(2)}`);
286
289
  }
290
+ lines.push(`complete -c uploads -n '__fish_seen_subcommand_from put' -l url -r`);
287
291
  for (const flag of SCREENSHOT_FLAGS) {
288
292
  if (!flag.startsWith("--"))
289
293
  continue;
@@ -10,7 +10,7 @@ const MCP_HELP = `uploads [globals] mcp
10
10
 
11
11
  Serve the Model Context Protocol (MCP) over stdio for agent clients. Tools
12
12
  mirror the CLI commands: put, attach, list, delete, usage, reconcile,
13
- purge_expired, comment, health, doctor.
13
+ purge_expired, comment, whoami, doctor.
14
14
  Global flags before "mcp" (--api-url, --token, --workspace, --env-file)
15
15
  configure every tool call; a per-call "workspace" argument overrides
16
16
  --workspace, like the CLI's per-command flag.
@@ -350,7 +350,16 @@ export type PutUploadItem = PutResult & {
350
350
  */
351
351
  export declare function uploadPuts(opts: {
352
352
  client: UploadsClient;
353
- files: readonly string[];
353
+ files?: readonly string[];
354
+ /**
355
+ * In-memory bodies (CLI `--url`, MCP `contentUrl`). Mutually exclusive
356
+ * with `files`. `source` is the failure/progress label (the URL).
357
+ */
358
+ byteSources?: readonly {
359
+ bytes: Uint8Array;
360
+ filename: string;
361
+ source: string;
362
+ }[];
354
363
  /** Single-file --name leaf override. */
355
364
  nameOverride?: string;
356
365
  /** Single-file --key. */
package/dist/commands.js CHANGED
@@ -8,6 +8,7 @@ import { buildUploadMarkdown } from "./embed.js";
8
8
  import { readLocalRepoCommentConfig, resolveCommentOptions } from "./comment-config.js";
9
9
  import { urlForGithubEmbed } from "./public-urls.js";
10
10
  import { UploadsError } from "./errors.js";
11
+ import { fetchUploadSource, resolveUploadFilename } from "./fetch-upload-source.js";
11
12
  import { writeJson, writeStdout } from "./io.js";
12
13
  import { imageFactsFromBytes } from "./image-facts.js";
13
14
  import { parseMetaFlags, validateMetaMap } from "./metadata.js";
@@ -94,8 +95,12 @@ export function readFileArg(fileArg) {
94
95
  }
95
96
  // --- put ---
96
97
  const PUT_HELP = `uploads put <file...> [options]
98
+ uploads put --url <url> [options]
97
99
 
98
100
  Upload one or more images for GitHub embeds. Use "-" for stdin (single file only).
101
+ Pass --url (repeatable) to fetch a file instead of a local path. Public HTTPS,
102
+ or http://localhost / 127.0.0.1 / *.localhost on this machine. Other private
103
+ hosts are rejected. The filename comes from the URL path, or --name.
99
104
 
100
105
  Multiple files upload in parallel (bounded concurrency). One bad file does not
101
106
  block the rest; multi-file JSON is { uploads, failures } (exit 1 when any failed).
@@ -135,6 +140,7 @@ MARKDOWN prefers embedUrl for GitHub. Override: UPLOADS_EMBED_PUBLIC_BASE_URL.
135
140
  Options:
136
141
  --key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>). Single file only
137
142
  --name <leaf> Clean key leaf + default alt (no '/'); keeps --pr/default path. Single file only. Not with --key
143
+ --url <url> Fetch this URL and upload its body (repeatable). Public HTTPS, or http://localhost on the CLI. Not with file arguments
138
144
  --destination <id> Typed root: screenshots | gh | f (sets --prefix)
139
145
  --prefix <path> Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX)
140
146
  --repo <owner/repo> Repo segment (default: git remote, or UPLOADS_DEFAULT_REPO)
@@ -201,6 +207,8 @@ Examples:
201
207
  uploads put ./shot.png --pr 128 --name hero.webp --dry-run --format url
202
208
  uploads put ./after.png --gallery gal_example
203
209
  uploads put ./shot.png --meta path=/settings --state after --app web
210
+ uploads put --url https://cdn.example/shot.png --pr 128 --name hero.png
211
+ uploads put --url http://localhost:4321/shot.png
204
212
  `;
205
213
  /**
206
214
  * Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
@@ -641,6 +649,13 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
641
649
  // byte-identical.
642
650
  const path = metadata?.path;
643
651
  const state = metadata?.state;
652
+ // Server-derived image dimensions (parity with the bot path's
653
+ // COMMENT_META_KEYS hydration): finite positive numbers only, field
654
+ // omitted entirely when neither parses.
655
+ const imgWidth = Number(metadata?.["image.width"]);
656
+ const imgHeight = Number(metadata?.["image.height"]);
657
+ const hasImgWidth = Number.isFinite(imgWidth) && imgWidth > 0;
658
+ const hasImgHeight = Number.isFinite(imgHeight) && imgHeight > 0;
644
659
  return {
645
660
  key,
646
661
  url,
@@ -649,6 +664,14 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
649
664
  ...(path || state
650
665
  ? { meta: { ...(path ? { path } : {}), ...(state ? { state } : {}) } }
651
666
  : {}),
667
+ ...(hasImgWidth || hasImgHeight
668
+ ? {
669
+ imageMeta: {
670
+ ...(hasImgWidth ? { width: imgWidth } : {}),
671
+ ...(hasImgHeight ? { height: imgHeight } : {}),
672
+ },
673
+ }
674
+ : {}),
652
675
  };
653
676
  }));
654
677
  const galleries = [];
@@ -1027,13 +1050,21 @@ function errorDetail(err) {
1027
1050
  * Same partial-failure shape as uploadAttachments.
1028
1051
  */
1029
1052
  export async function uploadPuts(opts) {
1030
- if (opts.files.length > 1 && opts.files.some((f) => f === "-")) {
1053
+ const files = opts.files ?? [];
1054
+ const byteSources = opts.byteSources ?? [];
1055
+ if (files.length > 0 && byteSources.length > 0) {
1056
+ throw new UsageError("internal: uploadPuts files and byteSources are mutually exclusive");
1057
+ }
1058
+ const count = files.length + byteSources.length;
1059
+ if (count === 0)
1060
+ throw new UsageError("put requires at least one file");
1061
+ if (count > 1 && files.some((f) => f === "-")) {
1031
1062
  throw new UsageError("stdin (-) cannot be combined with multiple file arguments");
1032
1063
  }
1033
- if (opts.files.length > 1 && opts.explicitKey) {
1064
+ if (count > 1 && opts.explicitKey) {
1034
1065
  throw new UsageError("--key cannot be combined with multiple files");
1035
1066
  }
1036
- if (opts.files.length > 1 && opts.nameOverride) {
1067
+ if (count > 1 && opts.nameOverride) {
1037
1068
  throw new UsageError("--name cannot be combined with multiple files");
1038
1069
  }
1039
1070
  // Resolved once for the whole batch (issue #631) — never per file.
@@ -1048,18 +1079,33 @@ export async function uploadPuts(opts) {
1048
1079
  branch: opts.ghBranchTarget.branch,
1049
1080
  })
1050
1081
  : undefined;
1051
- const slots = await mapBounded(opts.files, opts.concurrency ?? UPLOAD_BATCH_CONCURRENCY, async (file) => {
1082
+ const items = byteSources.length > 0
1083
+ ? byteSources.map((s) => ({
1084
+ source: s.source,
1085
+ filename: opts.nameOverride ?? s.filename,
1086
+ bytes: s.bytes,
1087
+ }))
1088
+ : files.map((file) => ({
1089
+ source: file,
1090
+ path: file,
1091
+ }));
1092
+ const slots = await mapBounded(items, opts.concurrency ?? UPLOAD_BATCH_CONCURRENCY, async (item) => {
1093
+ const file = item.source;
1052
1094
  try {
1053
- const sourceName = opts.nameOverride ??
1095
+ const bytes = item.bytes ?? readFileArg(item.path ?? file);
1096
+ const sourceName = item.filename ??
1097
+ opts.nameOverride ??
1054
1098
  (file === "-"
1055
1099
  ? opts.explicitKey
1056
1100
  ? basename(opts.explicitKey)
1057
1101
  : "stdin.bin"
1058
1102
  : basename(file));
1059
- const bytes = readFileArg(file);
1060
1103
  // Sidecar manifest from a prior `screenshot --out` of this exact file
1061
- // (issue #469 lever 2) — see mergeSidecarMeta. Not applicable to stdin.
1062
- const metadata = file !== "-" ? mergeSidecarMeta(file, bytes, opts.metadata) : opts.metadata;
1104
+ // (issue #469 lever 2) — see mergeSidecarMeta. Not applicable to stdin
1105
+ // or URL fetches.
1106
+ const metadata = item.path && item.path !== "-"
1107
+ ? mergeSidecarMeta(item.path, bytes, opts.metadata)
1108
+ : opts.metadata;
1063
1109
  const { result, prepared, markdown, sentMetadata } = await uploadPreparedImage(opts.client, bytes, sourceName, {
1064
1110
  frame: opts.frame,
1065
1111
  optimize: opts.optimize,
@@ -1883,12 +1929,23 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1883
1929
  return 0;
1884
1930
  }
1885
1931
  const files = parsed.positionals;
1886
- if (files.length === 0) {
1887
- throw new UsageError("put requires at least one file", {
1932
+ if (parsed.flags.get("--url") === true) {
1933
+ throw new UsageError("missing value for --url", {
1934
+ example: "uploads put --url https://cdn.example/shot.png --pr 123",
1935
+ });
1936
+ }
1937
+ const urlArgs = flagValues(parsed.flags, "--url");
1938
+ if (files.length > 0 && urlArgs.length > 0) {
1939
+ throw new UsageError("--url cannot be combined with file arguments", {
1940
+ example: "uploads put --url https://cdn.example/shot.png --pr 123",
1941
+ });
1942
+ }
1943
+ if (files.length === 0 && urlArgs.length === 0) {
1944
+ throw new UsageError("put requires at least one file or --url", {
1888
1945
  example: "uploads put ./shot.png --pr 123",
1889
1946
  });
1890
1947
  }
1891
- const multi = files.length > 1;
1948
+ const multi = files.length > 1 || urlArgs.length > 1;
1892
1949
  // Resolved early (issue #700): both the auto-PR opt-out default and the
1893
1950
  // `--no-git`-gated staging/auto-PR detection below need it before the rest
1894
1951
  // of put's flag parsing.
@@ -2135,36 +2192,77 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
2135
2192
  const logHuman = !ctx.quiet && format === "human";
2136
2193
  if (logHuman) {
2137
2194
  if (multi) {
2138
- process.stderr.write(`>> ${dryRun ? "dry run for" : "uploading"} ${files.length} files\n`);
2195
+ const n = files.length > 0 ? files.length : urlArgs.length;
2196
+ process.stderr.write(`>> ${dryRun ? "dry run for" : "uploading"} ${n} files\n`);
2139
2197
  }
2140
2198
  else {
2141
- const fileArg = files[0];
2199
+ const fileArg = files[0] ?? urlArgs[0];
2142
2200
  process.stderr.write(`>> ${dryRun ? "dry run" : "uploading"} ${fileArg === "-" ? "stdin" : fileArg}\n`);
2143
2201
  }
2144
2202
  if (attachedRef)
2145
2203
  process.stderr.write(`>> attached to ${attachedRef}\n`);
2146
2204
  }
2147
- const { uploads, failures, firstError, sentMetadata } = await uploadPuts({
2148
- client: ctx.client,
2149
- files,
2150
- nameOverride: nameFlag,
2151
- explicitKey: keyHint,
2152
- ghTarget: effectiveGhTarget,
2153
- ghBranchTarget: stagingTarget,
2154
- prefix: resolvedPrefix ?? defaults.prefix,
2155
- repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
2156
- ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
2157
- deriveRepoFromGit: !noGit,
2158
- contentType: contentTypeOverride,
2159
- dryRun,
2160
- replace: replaceFlag,
2161
- optimize: optimizeOpts,
2162
- frame: frameOpts,
2163
- metadata,
2164
- deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
2165
- alt: altFlag,
2166
- width,
2167
- });
2205
+ let byteSources;
2206
+ const urlFetchFailures = [];
2207
+ let urlFetchFirstError;
2208
+ if (urlArgs.length > 0) {
2209
+ byteSources = [];
2210
+ for (const raw of urlArgs) {
2211
+ try {
2212
+ const filename = resolveUploadFilename(raw, !multi ? nameFlag : undefined, "--url", {
2213
+ allowLoopback: true,
2214
+ });
2215
+ const bytes = await fetchUploadSource(raw, {
2216
+ label: "--url",
2217
+ userAgent: "uploads.sh/cli",
2218
+ allowLoopback: true,
2219
+ });
2220
+ byteSources.push({ bytes, filename, source: raw });
2221
+ }
2222
+ catch (err) {
2223
+ urlFetchFirstError ??= err;
2224
+ urlFetchFailures.push({ file: raw, error: errorDetail(err) });
2225
+ }
2226
+ }
2227
+ }
2228
+ let uploads;
2229
+ let failures;
2230
+ let firstError;
2231
+ let sentMetadata;
2232
+ if (byteSources && byteSources.length === 0) {
2233
+ uploads = [];
2234
+ failures = urlFetchFailures;
2235
+ firstError = urlFetchFirstError;
2236
+ sentMetadata = [];
2237
+ }
2238
+ else {
2239
+ const batch = await uploadPuts({
2240
+ client: ctx.client,
2241
+ files: byteSources ? undefined : files,
2242
+ byteSources,
2243
+ nameOverride: byteSources ? undefined : nameFlag,
2244
+ explicitKey: keyHint,
2245
+ ghTarget: effectiveGhTarget,
2246
+ ghBranchTarget: stagingTarget,
2247
+ prefix: resolvedPrefix ?? defaults.prefix,
2248
+ repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
2249
+ ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
2250
+ deriveRepoFromGit: !noGit,
2251
+ contentType: contentTypeOverride,
2252
+ dryRun,
2253
+ replace: replaceFlag,
2254
+ optimize: optimizeOpts,
2255
+ frame: frameOpts,
2256
+ metadata,
2257
+ deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
2258
+ alt: altFlag,
2259
+ width,
2260
+ });
2261
+ uploads = batch.uploads;
2262
+ failures = [...urlFetchFailures, ...batch.failures];
2263
+ firstError = urlFetchFirstError ?? batch.firstError;
2264
+ sentMetadata = batch.sentMetadata;
2265
+ }
2168
2266
  // Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
2169
2267
  if (uploads.length === 0 && failures.length > 0 && !multi) {
2170
2268
  throw firstError instanceof Error ? firstError : new Error(String(firstError));
@@ -107,6 +107,11 @@ export interface AttachmentItem {
107
107
  width?: number;
108
108
  height?: number;
109
109
  };
110
+ /** Server-derived pixel dimensions for an image (never client-settable). */
111
+ imageMeta?: {
112
+ width?: number;
113
+ height?: number;
114
+ };
110
115
  }
111
116
  /** A public gallery linked to the PR or issue whose managed comment is syncing. */
112
117
  export interface GalleryCommentItem {
@@ -144,6 +149,11 @@ export declare function attachmentPairWidth(density?: AttachmentDensity): number
144
149
  * only affects managed-comment auto layout; other callers leave it `"dense"`.
145
150
  */
146
151
  export declare function attachmentImageWidth(filename: string, density?: AttachmentDensity): number;
152
+ /**
153
+ * Icons and other small assets: both dimensions known and no edge above this
154
+ * flow inline at natural size rather than each becoming a full row.
155
+ */
156
+ export declare const SMALL_ASSET_MAX_EDGE = 200;
147
157
  /**
148
158
  * Render the one marker-owned GitHub comment. When there are no galleries this
149
159
  * intentionally preserves the legacy attachment-only body byte-for-byte.
@@ -183,19 +183,32 @@ function formatDuration(seconds) {
183
183
  return `${h}:${String(m).padStart(2, "0")}:${ss}`;
184
184
  }
185
185
  /**
186
- * Display width for a video poster. Real dimensions only *select* among the
187
- * density table's tiers — a raw 1920 would blow out the comment column — and
188
- * the result is capped at the real width so a small clip is never upscaled.
186
+ * Display width for an image or video-poster embed. Real dimensions only
187
+ * *select* among the density table's tiers — a raw 1920 would blow out the
188
+ * comment column — and the result is capped at the real width so a small
189
+ * asset is never upscaled into a blurry tile. Without dims (either missing),
190
+ * falls back to the filename heuristic exactly, keeping dimension-less
191
+ * renders byte-identical to before dims existed.
189
192
  */
190
- function posterImageWidth(videoMeta, filename, density = "dense") {
191
- const w = videoMeta?.width ?? 0;
192
- const h = videoMeta?.height ?? 0;
193
+ function naturalMediaWidth(dims, filename, density = "dense") {
194
+ const w = dims?.width ?? 0;
195
+ const h = dims?.height ?? 0;
193
196
  if (w <= 0 || h <= 0)
194
197
  return attachmentImageWidth(filename, density);
195
198
  const table = WIDTH_BY_DENSITY[density];
196
199
  const chosen = h > w ? table.portrait : w / h >= 16 / 9 ? table.wide : table.default;
197
200
  return Math.min(chosen, w);
198
201
  }
202
+ /**
203
+ * Icons and other small assets: both dimensions known and no edge above this
204
+ * flow inline at natural size rather than each becoming a full row.
205
+ */
206
+ export const SMALL_ASSET_MAX_EDGE = 200;
207
+ function isSmallAsset(dims) {
208
+ const w = dims?.width ?? 0;
209
+ const h = dims?.height ?? 0;
210
+ return w > 0 && h > 0 && Math.max(w, h) <= SMALL_ASSET_MAX_EDGE;
211
+ }
199
212
  function escapeHtmlAttr(s) {
200
213
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
201
214
  }
@@ -360,7 +373,7 @@ function renderPairCell(item, label, options, density) {
360
373
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
361
374
  const src = item.embedUrl ?? item.url;
362
375
  const link = item.pageUrl ?? item.url;
363
- const autoPx = Math.min(attachmentImageWidth(name, density), attachmentPairWidth(density));
376
+ const autoPx = Math.min(naturalMediaWidth(item.imageMeta, name, density), attachmentPairWidth(density));
364
377
  const w = resolvedWidth(autoPx, options);
365
378
  const alt = escapeHtmlAttr(name);
366
379
  const href = escapeHtmlAttr((link ?? src));
@@ -480,7 +493,7 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
480
493
  }
481
494
  if (isPosterVideo) {
482
495
  inlinedImages++;
483
- const autoPx = posterImageWidth(item.videoMeta, name, density);
496
+ const autoPx = naturalMediaWidth(item.videoMeta, name, density);
484
497
  const w = resolvedWidth(autoPx, options);
485
498
  const href = escapeHtmlAttr(link ?? item.posterUrl);
486
499
  lines.push(`<a href="${href}">${imgTag(w, escapeHtmlAttr(name), escapeHtmlAttr(item.posterUrl))}</a>`);
@@ -496,10 +509,53 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
496
509
  lines.push(parts.join(" · "), "");
497
510
  }
498
511
  else if (isImage) {
512
+ // Small-asset flow layout: consecutive captionless small images (icons)
513
+ // join onto one line so GitHub flows them horizontally at natural size
514
+ // instead of stacking a giant column per icon. Auto mode only — a
515
+ // numeric imageWidth override or "full" keeps today's one-per-row form.
516
+ const smallGroupable = (candidate, i) => {
517
+ if (options.imageWidth !== "auto")
518
+ return false;
519
+ if (consumedByPair.has(i) || partnerOf.has(i))
520
+ return false;
521
+ if (!isSmallAsset(candidate.imageMeta))
522
+ return false;
523
+ if (metaCaptionValues(candidate.meta, options).length > 0)
524
+ return false;
525
+ const n = candidate.key.slice(candidate.key.lastIndexOf("/") + 1);
526
+ const s = candidate.embedUrl ?? candidate.url;
527
+ return Boolean(s) && inferContentType(n).startsWith("image/");
528
+ };
529
+ if (smallGroupable(item, idx)) {
530
+ const group = [item];
531
+ let j = idx + 1;
532
+ // Each grouped image still counts toward maxInlineImages.
533
+ while (j < sorted.length &&
534
+ inlinedImages + group.length < options.maxInlineImages &&
535
+ smallGroupable(sorted[j], j)) {
536
+ group.push(sorted[j]);
537
+ j++;
538
+ }
539
+ if (group.length > 1) {
540
+ inlinedImages += group.length;
541
+ lines.push(group
542
+ .map((g) => {
543
+ const gName = g.key.slice(g.key.lastIndexOf("/") + 1);
544
+ const gSrc = (g.embedUrl ?? g.url);
545
+ const gHref = escapeHtmlAttr((g.pageUrl ?? g.url ?? gSrc));
546
+ // Natural width — a small asset is never upscaled.
547
+ const gW = g.imageMeta?.width;
548
+ return `<a href="${gHref}">${imgTag(gW, escapeHtmlAttr(gName), escapeHtmlAttr(gSrc))}</a>`;
549
+ })
550
+ .join(" "), "");
551
+ idx = j - 1;
552
+ continue;
553
+ }
554
+ }
499
555
  inlinedImages++;
500
556
  // Markdown ![]() has no width control — phone frames become full-column giants.
501
557
  // img src uses embed host when available (Camo revalidates); click-through prefers the file page.
502
- const autoPx = attachmentImageWidth(name, density);
558
+ const autoPx = naturalMediaWidth(item.imageMeta, name, density);
503
559
  const w = resolvedWidth(autoPx, options);
504
560
  const alt = escapeHtmlAttr(name);
505
561
  const href = escapeHtmlAttr(link ?? src);
@@ -0,0 +1,35 @@
1
+ export declare const FETCH_UPLOAD_SOURCE_TIMEOUT_MS = 15000;
2
+ export declare const FETCH_UPLOAD_SOURCE_MAX_REDIRECTS = 5;
3
+ /** Client-side cap when the caller does not pass a workspace policy ceiling. */
4
+ export declare const FETCH_UPLOAD_SOURCE_DEFAULT_MAX_BYTES: number;
5
+ export interface FetchableUploadUrlOptions {
6
+ /**
7
+ * CLI / stdio MCP only. Permit loopback (`localhost`, `*.localhost`,
8
+ * `127.0.0.0/8`, `::1`) and `http` on those hosts. LAN, link-local, and
9
+ * `.internal` stay rejected. Hosted MCP must not set this.
10
+ */
11
+ allowLoopback?: boolean;
12
+ }
13
+ export interface FetchUploadSourceOptions extends FetchableUploadUrlOptions {
14
+ maxBytes?: number;
15
+ fetch?: typeof fetch;
16
+ timeoutMs?: number;
17
+ signal?: AbortSignal;
18
+ /** Human label in errors (`--url`, `contentUrl`). */
19
+ label?: string;
20
+ userAgent?: string;
21
+ }
22
+ /** Parse and reject URLs we will not fetch. Used on the original URL and every redirect. */
23
+ export declare function assertFetchableUploadUrl(raw: string, label?: string, opts?: FetchableUploadUrlOptions): URL;
24
+ /** Filename leaf from a URL path (`https://cdn.example/a/shot.png?x=1` → `shot.png`). */
25
+ export declare function filenameFromUploadUrl(url: URL): string | undefined;
26
+ /** `filename` if given, else the URL path leaf. Throws USAGE when neither works. */
27
+ export declare function resolveUploadFilename(rawUrl: string, filename: string | undefined, label?: string, opts?: FetchableUploadUrlOptions): string;
28
+ /**
29
+ * GET `url` and return the body bytes, capped at `maxBytes`.
30
+ *
31
+ * Redirects are followed manually so each hop is re-validated (scheme, no
32
+ * credentials, host policy). A public origin cannot redirect onto loopback
33
+ * even when `allowLoopback` is set. Auth headers are never forwarded.
34
+ */
35
+ export declare function fetchUploadSource(raw: string, opts?: FetchUploadSourceOptions): Promise<Uint8Array>;