@buildinternet/uploads 0.2.0 → 0.3.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/dist/frame.js ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Optional device/browser frames for put/attach (default off).
3
+ *
4
+ * - `phone` / `browser` — procedural (no third-party assets)
5
+ * - `iphone-16-pro` — fetches frame+mask from device-frames-media once,
6
+ * cached under ~/.cache/uploads/frames (not bundled in the npm package)
7
+ *
8
+ * @see https://github.com/jonnyjackson26/device-frames-media
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ import sharp from "sharp";
15
+ const DEVICE_FRAMES_BASE = "https://raw.githubusercontent.com/jonnyjackson26/device-frames-media/main/device-frames-output";
16
+ export const FRAME_PRESETS = {
17
+ phone: { kind: "procedural", label: "Generic phone bezel" },
18
+ browser: { kind: "procedural", label: "Generic browser chrome" },
19
+ "iphone-16-pro": {
20
+ kind: "remote",
21
+ label: "iPhone 16 Pro (community frame art)",
22
+ assetBase: `${DEVICE_FRAMES_BASE}/Apple%20iPhone/16%20Pro/Black%20Titanium`,
23
+ },
24
+ };
25
+ export function listFramePresets() {
26
+ return Object.entries(FRAME_PRESETS).map(([id, p]) => ({
27
+ id,
28
+ label: p.label,
29
+ kind: p.kind,
30
+ }));
31
+ }
32
+ export function resolveFrameId(raw) {
33
+ if (!raw?.trim())
34
+ return undefined;
35
+ const id = raw.trim().toLowerCase();
36
+ if (!(id in FRAME_PRESETS)) {
37
+ throw new Error(`unknown frame "${raw}" (known: ${Object.keys(FRAME_PRESETS).join(", ")})`);
38
+ }
39
+ return id;
40
+ }
41
+ function cacheDirDefault() {
42
+ return join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "uploads", "frames");
43
+ }
44
+ async function cachedFetch(url, cacheDir, fetchImpl) {
45
+ const name = `${createHash("sha256").update(url).digest("hex").slice(0, 16)}-${url.split("/").pop() ?? "bin"}`;
46
+ const path = join(cacheDir, name);
47
+ if (existsSync(path))
48
+ return readFileSync(path);
49
+ mkdirSync(cacheDir, { recursive: true });
50
+ const res = await fetchImpl(url);
51
+ if (!res.ok)
52
+ throw new Error(`frame download failed (${res.status}): ${url}`);
53
+ const buf = Buffer.from(await res.arrayBuffer());
54
+ writeFileSync(path, buf);
55
+ return buf;
56
+ }
57
+ function scaleRect(r, s) {
58
+ return {
59
+ x: Math.round(r.x * s),
60
+ y: Math.round(r.y * s),
61
+ width: Math.round(r.width * s),
62
+ height: Math.round(r.height * s),
63
+ };
64
+ }
65
+ async function compositeDevice(screenshot, framePng, maskPng, screen, frameSize, fit) {
66
+ const fitted = await sharp(screenshot)
67
+ .rotate()
68
+ .resize(screen.width, screen.height, {
69
+ fit,
70
+ position: "centre",
71
+ background: { r: 0, g: 0, b: 0, alpha: 1 },
72
+ })
73
+ .ensureAlpha()
74
+ .png()
75
+ .toBuffer();
76
+ let layer = await sharp({
77
+ create: {
78
+ width: frameSize.width,
79
+ height: frameSize.height,
80
+ channels: 4,
81
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
82
+ },
83
+ })
84
+ .composite([{ input: fitted, left: screen.x, top: screen.y }])
85
+ .png()
86
+ .toBuffer();
87
+ if (maskPng) {
88
+ layer = await sharp(layer)
89
+ .composite([{ input: maskPng, blend: "dest-in" }])
90
+ .png()
91
+ .toBuffer();
92
+ }
93
+ return sharp(layer)
94
+ .composite([{ input: framePng, left: 0, top: 0 }])
95
+ .png()
96
+ .toBuffer();
97
+ }
98
+ async function proceduralPhone(screenshot, fit) {
99
+ const screenW = 390;
100
+ const screenH = 844;
101
+ const bezel = 14;
102
+ const top = 36;
103
+ const bottom = 18;
104
+ const outerW = screenW + bezel * 2;
105
+ const outerH = screenH + top + bottom;
106
+ const fitted = await sharp(screenshot)
107
+ .rotate()
108
+ .resize(screenW, screenH, {
109
+ fit,
110
+ position: "centre",
111
+ background: { r: 0, g: 0, b: 0, alpha: 1 },
112
+ })
113
+ .png()
114
+ .toBuffer();
115
+ const roundMask = await sharp(Buffer.from(`<svg width="${screenW}" height="${screenH}" xmlns="http://www.w3.org/2000/svg"><rect width="${screenW}" height="${screenH}" rx="36" fill="white"/></svg>`))
116
+ .png()
117
+ .toBuffer();
118
+ const screen = await sharp(fitted)
119
+ .ensureAlpha()
120
+ .composite([{ input: roundMask, blend: "dest-in" }])
121
+ .png()
122
+ .toBuffer();
123
+ const shell = await sharp(Buffer.from(`<svg width="${outerW}" height="${outerH}" xmlns="http://www.w3.org/2000/svg">
124
+ <rect x="1" y="1" width="${outerW - 2}" height="${outerH - 2}" rx="48" fill="#1c1c1e" stroke="#3a3a3c" stroke-width="2"/>
125
+ <rect x="${bezel + 70}" y="12" width="${screenW - 140}" height="22" rx="11" fill="#0a0a0a"/>
126
+ <circle cx="${outerW / 2}" cy="${outerH - 10}" r="4" fill="#3a3a3c"/>
127
+ </svg>`))
128
+ .png()
129
+ .toBuffer();
130
+ return sharp(shell)
131
+ .composite([{ input: screen, left: bezel, top }])
132
+ .png()
133
+ .toBuffer();
134
+ }
135
+ async function proceduralBrowser(screenshot, fit, url) {
136
+ const chromeH = 72;
137
+ const pad = 12;
138
+ const meta = await sharp(screenshot).rotate().metadata();
139
+ const srcW = meta.width ?? 800;
140
+ const srcH = meta.height ?? 600;
141
+ const scale = Math.min(1, 1200 / srcW, 800 / srcH);
142
+ const contentW = Math.max(320, Math.round(srcW * scale));
143
+ const contentH = Math.max(200, Math.round(srcH * scale));
144
+ const outerW = contentW + pad * 2;
145
+ const outerH = contentH + chromeH + pad;
146
+ const fitted = await sharp(screenshot)
147
+ .rotate()
148
+ .resize(contentW, contentH, {
149
+ fit,
150
+ position: "centre",
151
+ background: { r: 255, g: 255, b: 255, alpha: 1 },
152
+ })
153
+ .png()
154
+ .toBuffer();
155
+ const safe = url
156
+ .slice(0, 80)
157
+ .replace(/&/g, "&amp;")
158
+ .replace(/</g, "&lt;")
159
+ .replace(/>/g, "&gt;")
160
+ .replace(/"/g, "&quot;");
161
+ const chrome = await sharp(Buffer.from(`<svg width="${outerW}" height="${outerH}" xmlns="http://www.w3.org/2000/svg">
162
+ <rect x="0.5" y="0.5" width="${outerW - 1}" height="${outerH - 1}" rx="12" fill="#f0f0f2" stroke="#c7c7cc"/>
163
+ <circle cx="22" cy="22" r="6" fill="#ff5f57"/>
164
+ <circle cx="42" cy="22" r="6" fill="#febc2e"/>
165
+ <circle cx="62" cy="22" r="6" fill="#28c840"/>
166
+ <rect x="84" y="12" width="${Math.max(120, outerW - 100)}" height="28" rx="8" fill="#fff" stroke="#d1d1d6"/>
167
+ <text x="96" y="31" font-family="system-ui,sans-serif" font-size="12" fill="#6e6e73">${safe}</text>
168
+ <rect x="${pad}" y="${chromeH}" width="${contentW}" height="${contentH}" fill="#fff"/>
169
+ </svg>`))
170
+ .png()
171
+ .toBuffer();
172
+ return sharp(chrome)
173
+ .composite([{ input: fitted, left: pad, top: chromeH }])
174
+ .png()
175
+ .toBuffer();
176
+ }
177
+ function asPngName(filename) {
178
+ const base = filename.includes("/") ? filename.slice(filename.lastIndexOf("/") + 1) : filename;
179
+ const dot = base.lastIndexOf(".");
180
+ return `${dot >= 0 ? base.slice(0, dot) : base}.png`;
181
+ }
182
+ function skip(bytes, filename, frameId, reason, contentType = "application/octet-stream") {
183
+ return { bytes, filename, contentType, framed: false, frameId, skippedReason: reason };
184
+ }
185
+ /** Apply a named frame. Non-images pass through. Output PNG for the optimize step. */
186
+ export async function applyFrame(bytes, filename, opts) {
187
+ const id = opts.id.toLowerCase();
188
+ const preset = FRAME_PRESETS[id];
189
+ if (!preset)
190
+ throw new Error(`unknown frame "${opts.id}"`);
191
+ try {
192
+ const meta = await sharp(bytes, { failOn: "none" }).metadata();
193
+ if (!meta.format || meta.format === "svg")
194
+ return skip(bytes, filename, id, "not_image");
195
+ if ((meta.pages ?? 1) > 1) {
196
+ return skip(bytes, filename, id, "animated", meta.format === "gif" ? "image/gif" : "image/webp");
197
+ }
198
+ }
199
+ catch {
200
+ return skip(bytes, filename, id, "not_image");
201
+ }
202
+ const fit = opts.fit ?? "cover";
203
+ let out;
204
+ if (preset.kind === "procedural") {
205
+ out =
206
+ id === "browser"
207
+ ? await proceduralBrowser(bytes, fit, opts.browserUrl ?? "https://app.example")
208
+ : await proceduralPhone(bytes, fit);
209
+ }
210
+ else {
211
+ const fetchImpl = opts.fetchImpl ?? fetch;
212
+ const cacheDir = opts.cacheDir ?? cacheDirDefault();
213
+ const tpl = JSON.parse((await cachedFetch(`${preset.assetBase}/template.json`, cacheDir, fetchImpl)).toString("utf8"));
214
+ const framePng = await cachedFetch(`${preset.assetBase}/frame.png`, cacheDir, fetchImpl);
215
+ let maskPng;
216
+ try {
217
+ maskPng = await cachedFetch(`${preset.assetBase}/mask.png`, cacheDir, fetchImpl);
218
+ }
219
+ catch {
220
+ /* optional */
221
+ }
222
+ // Keep remote frames light for PR embeds (display width is capped separately).
223
+ const maxEdge = 1000;
224
+ const s = Math.min(1, maxEdge / Math.max(tpl.frameSize.width, tpl.frameSize.height));
225
+ const frameSize = {
226
+ width: Math.round(tpl.frameSize.width * s),
227
+ height: Math.round(tpl.frameSize.height * s),
228
+ };
229
+ const screen = scaleRect(tpl.screen, s);
230
+ const frame = s < 1
231
+ ? await sharp(framePng).resize(frameSize.width, frameSize.height).png().toBuffer()
232
+ : framePng;
233
+ const mask = maskPng && s < 1
234
+ ? await sharp(maskPng).resize(frameSize.width, frameSize.height).png().toBuffer()
235
+ : maskPng;
236
+ out = await compositeDevice(bytes, frame, mask, screen, frameSize, fit);
237
+ }
238
+ return {
239
+ bytes: new Uint8Array(out),
240
+ filename: asPngName(filename),
241
+ contentType: "image/png",
242
+ framed: true,
243
+ frameId: id,
244
+ };
245
+ }
package/dist/github.d.ts CHANGED
@@ -21,4 +21,15 @@ export interface AttachmentItem {
21
21
  key: string;
22
22
  url: string | null;
23
23
  }
24
+ /** Default max width for images in the managed attachments comment (HTML img). */
25
+ export declare const ATTACHMENT_IMAGE_WIDTH_DEFAULT = 400;
26
+ /** Portrait / device mockups — keep phones readable, not full-column. */
27
+ export declare const ATTACHMENT_IMAGE_WIDTH_PORTRAIT = 280;
28
+ /** Wide UI / browser chrome. */
29
+ export declare const ATTACHMENT_IMAGE_WIDTH_WIDE = 640;
30
+ /**
31
+ * Pick a display width for GitHub comment embeds. Filenames are a weak but
32
+ * practical signal (we don't re-fetch dimensions when rebuilding the comment).
33
+ */
34
+ export declare function attachmentImageWidth(filename: string): number;
24
35
  export declare function attachmentsCommentBody(items: AttachmentItem[]): string;
package/dist/github.js CHANGED
@@ -24,13 +24,43 @@ export function ghAttachmentKey(target, filename) {
24
24
  }
25
25
  /** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
26
26
  export const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
27
+ /** Default max width for images in the managed attachments comment (HTML img). */
28
+ export const ATTACHMENT_IMAGE_WIDTH_DEFAULT = 400;
29
+ /** Portrait / device mockups — keep phones readable, not full-column. */
30
+ export const ATTACHMENT_IMAGE_WIDTH_PORTRAIT = 280;
31
+ /** Wide UI / browser chrome. */
32
+ export const ATTACHMENT_IMAGE_WIDTH_WIDE = 640;
33
+ /**
34
+ * Pick a display width for GitHub comment embeds. Filenames are a weak but
35
+ * practical signal (we don't re-fetch dimensions when rebuilding the comment).
36
+ */
37
+ export function attachmentImageWidth(filename) {
38
+ const n = filename.toLowerCase();
39
+ if (/(?:^|[-_.])(browser|desktop|dashboard|wide)(?:[-_.]|$)/.test(n)) {
40
+ return ATTACHMENT_IMAGE_WIDTH_WIDE;
41
+ }
42
+ if (/(?:^|[-_.])(phone|iphone|ipad|pixel|android|mobile|device)(?:[-_.]|$)/.test(n) ||
43
+ /iphone|pixel-?\d/.test(n)) {
44
+ return ATTACHMENT_IMAGE_WIDTH_PORTRAIT;
45
+ }
46
+ return ATTACHMENT_IMAGE_WIDTH_DEFAULT;
47
+ }
48
+ function escapeHtmlAttr(s) {
49
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
50
+ }
27
51
  export function attachmentsCommentBody(items) {
28
52
  const sorted = items.toSorted((a, b) => a.key.localeCompare(b.key));
29
53
  const lines = [ATTACHMENTS_MARKER, "### 📎 Attachments", ""];
30
54
  for (const item of sorted) {
31
55
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
32
56
  if (item.url && inferContentType(name).startsWith("image/")) {
33
- lines.push(`![${name}](${item.url})`);
57
+ // Markdown ![]() has no width control — phone frames become full-column giants.
58
+ // Link to the asset so a click opens the full image (no "open in new tab" hunt).
59
+ const w = attachmentImageWidth(name);
60
+ const alt = escapeHtmlAttr(name);
61
+ const href = escapeHtmlAttr(item.url);
62
+ lines.push(`<a href="${href}"><img width="${w}" alt="${alt}" src="${href}"></a>`);
63
+ lines.push("");
34
64
  }
35
65
  else if (item.url) {
36
66
  lines.push(`- [${name}](${item.url})`);
@@ -39,6 +69,6 @@ export function attachmentsCommentBody(items) {
39
69
  lines.push(`- ${name}`);
40
70
  }
41
71
  }
42
- lines.push("", "<sub>Maintained by uploads.sh — re-uploading a file with the same name updates it everywhere it is embedded.</sub>");
72
+ lines.push('<sub>Maintained by <a href="https://uploads.sh">uploads.sh</a> — re-uploading a file with the same name updates it everywhere it is embedded.</sub>');
43
73
  return lines.join("\n");
44
74
  }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  export { inferContentType, buildMarkdown } from "./embed.js";
2
2
  export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey } from "./keys.js";
3
+ export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, resolveDestinationRoot, resolvePutPrefix, type BuiltinDestinationId, } from "./destinations.js";
3
4
  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";
4
5
  export { UploadsError, type UploadsErrorCode } from "./errors.js";
5
6
  export { createUploadsClient, type UploadsClient, type PutOptions, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, } from "./client.js";
6
7
  export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, isValidRepo, parseRepoFromRemoteUrl, type AttachmentItem, type GhTarget, type GhTargetKind, } from "./github.js";
8
+ export { DEFAULT_OPTIMIZE_MAX_EDGE, DEFAULT_OPTIMIZE_QUALITY, optimizeImageForUpload, rewriteKeyExtension, withImageExtension, type OptimizeImageOptions, type OptimizeImageResult, type OptimizeOutputFormat, } from "./optimize.js";
9
+ export { FRAME_PRESETS, applyFrame, listFramePresets, resolveFrameId, type FrameFit, type FrameOptions, type FrameResult, } from "./frame.js";
7
10
  export { execRunner, resolveRepo, upsertAttachmentsComment, type CommandRunner, } from "./github-gh.js";
package/dist/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  export { inferContentType, buildMarkdown } from "./embed.js";
2
2
  export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey } from "./keys.js";
3
+ export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, resolveDestinationRoot, resolvePutPrefix, } from "./destinations.js";
3
4
  export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, } from "./config.js";
4
5
  export { UploadsError } from "./errors.js";
5
6
  export { createUploadsClient, } from "./client.js";
6
7
  export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, isValidRepo, parseRepoFromRemoteUrl, } from "./github.js";
8
+ export { DEFAULT_OPTIMIZE_MAX_EDGE, DEFAULT_OPTIMIZE_QUALITY, optimizeImageForUpload, rewriteKeyExtension, withImageExtension, } from "./optimize.js";
9
+ export { FRAME_PRESETS, applyFrame, listFramePresets, resolveFrameId, } from "./frame.js";
7
10
  export { execRunner, resolveRepo, upsertAttachmentsComment, } from "./github-gh.js";
package/dist/mcp/tools.js CHANGED
@@ -8,10 +8,13 @@
8
8
  import { readFileSync } from "node:fs";
9
9
  import { basename } from "node:path";
10
10
  import { createUploadsClient } from "../client.js";
11
- import { buildDoctorReport, makeGhTarget, syncAttachmentsComment } from "../commands.js";
11
+ import { buildDoctorReport, makeGhTarget, prepareImageForUpload, syncAttachmentsComment, } from "../commands.js";
12
+ import { resolveFrameId } from "../frame.js";
12
13
  import { resolveConfig, resolvePutDefaults, } from "../config.js";
13
14
  import { buildMarkdown } from "../embed.js";
15
+ import { resolvePutPrefix } from "../destinations.js";
14
16
  import { ghAttachmentKey, ghKeyPrefix } from "../github.js";
17
+ import { rewriteKeyExtension } from "../optimize.js";
15
18
  import { execRunner, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
16
19
  import { optPosInt, optString, usage } from "./args.js";
17
20
  function optBool(args, name) {
@@ -31,6 +34,55 @@ function optStringArray(args, name) {
31
34
  }
32
35
  return v;
33
36
  }
37
+ function mcpOptimizeOptions(args, defaults) {
38
+ const quality = optPosInt(args, "optimizeQuality");
39
+ if (quality !== undefined && quality > 100)
40
+ usage("optimizeQuality must be 1–100");
41
+ return {
42
+ enabled: !(optBool(args, "noOptimize") || defaults.noOptimize === true),
43
+ maxEdge: optPosInt(args, "optimizeMaxEdge"),
44
+ quality,
45
+ keepExif: optBool(args, "keepExif") || defaults.keepExif === true,
46
+ };
47
+ }
48
+ function mcpFrameOptions(args) {
49
+ const raw = optString(args, "frame");
50
+ let frameId;
51
+ try {
52
+ frameId = resolveFrameId(raw);
53
+ }
54
+ catch (err) {
55
+ usage(err instanceof Error ? err.message : String(err));
56
+ }
57
+ const fitRaw = optString(args, "frameFit");
58
+ let frameFit;
59
+ if (fitRaw) {
60
+ if (fitRaw !== "cover" && fitRaw !== "contain") {
61
+ usage("frameFit must be cover or contain");
62
+ }
63
+ frameFit = fitRaw;
64
+ }
65
+ if (frameFit && !frameId)
66
+ usage("frameFit requires frame");
67
+ const frameUrl = optString(args, "frameUrl");
68
+ if (frameUrl && !frameId)
69
+ usage("frameUrl requires frame");
70
+ return { frameId, frameUrl, frameFit };
71
+ }
72
+ const frameProps = {
73
+ frame: {
74
+ type: "string",
75
+ description: "Optional frame before optimize: phone | browser | iphone-16-pro.",
76
+ },
77
+ frameUrl: {
78
+ type: "string",
79
+ description: "Address bar text for frame=browser.",
80
+ },
81
+ frameFit: {
82
+ type: "string",
83
+ description: "cover (default) or contain.",
84
+ },
85
+ };
34
86
  /** Reads pr/issue (+ repo) into a GhTarget; undefined when neither is present. */
35
87
  function ghTargetFromArgs(args, run) {
36
88
  return makeGhTarget(optPosInt(args, "pr"), optPosInt(args, "issue"), optString(args, "repo"), run);
@@ -105,6 +157,10 @@ export function createUploadsMcpTools(opts) {
105
157
  type: "string",
106
158
  description: "Explicit object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>). Cannot be combined with pr/issue.",
107
159
  },
160
+ destination: {
161
+ type: "string",
162
+ description: "Typed destination root: screenshots | gh | f. Sets the key prefix; first-class alternative to prefix. With pr/issue must be gh or omitted.",
163
+ },
108
164
  prefix: {
109
165
  type: "string",
110
166
  description: "Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX). Cannot be combined with pr/issue.",
@@ -124,7 +180,27 @@ export function createUploadsMcpTools(opts) {
124
180
  type: "number",
125
181
  description: "Emit <img width=…> markdown instead of a plain image embed.",
126
182
  },
127
- contentType: { type: "string", description: "Override the Content-Type." },
183
+ contentType: {
184
+ type: "string",
185
+ description: "Override the Content-Type (ignored when optimize rewrites the body).",
186
+ },
187
+ noOptimize: {
188
+ type: "boolean",
189
+ description: "Skip client-side image optimization (default: optimize still images to WebP).",
190
+ },
191
+ optimizeMaxEdge: {
192
+ type: "number",
193
+ description: "Max long edge in pixels when optimizing (default: 2400).",
194
+ },
195
+ optimizeQuality: {
196
+ type: "number",
197
+ description: "WebP quality 1–100 when optimizing (default: 85).",
198
+ },
199
+ keepExif: {
200
+ type: "boolean",
201
+ description: "Keep EXIF/XMP/ICC when optimizing (default: strip for privacy on public embeds).",
202
+ },
203
+ ...frameProps,
128
204
  noGit: { type: "boolean", description: "Don't derive the repo segment from git." },
129
205
  comment: {
130
206
  type: "boolean",
@@ -146,44 +222,72 @@ export function createUploadsMcpTools(opts) {
146
222
  }
147
223
  const target = ghTargetFromArgs(args, run);
148
224
  const wantComment = optBool(args, "comment");
149
- const key = optString(args, "key");
225
+ const keyArg = optString(args, "key");
226
+ const destArg = optString(args, "destination");
150
227
  const prefixArg = optString(args, "prefix");
151
228
  const refArg = optString(args, "ref");
152
229
  if (wantComment && !target)
153
230
  usage("comment requires pr or issue");
154
231
  if (target) {
155
- if (key)
232
+ if (keyArg)
156
233
  usage("key cannot be combined with pr/issue");
157
234
  if (refArg)
158
235
  usage("ref cannot be combined with pr/issue");
159
236
  if (prefixArg)
160
237
  usage("prefix cannot be combined with pr/issue");
161
238
  }
239
+ let resolvedPrefix;
240
+ try {
241
+ resolvedPrefix = resolvePutPrefix({
242
+ destination: destArg,
243
+ prefix: prefixArg,
244
+ key: keyArg,
245
+ ghAttachment: Boolean(target),
246
+ });
247
+ }
248
+ catch (err) {
249
+ usage(err instanceof Error ? err.message : String(err));
250
+ }
162
251
  const { client } = clientFor(args);
163
252
  const bytes = file !== undefined
164
253
  ? new Uint8Array(readFileSync(file))
165
254
  : new Uint8Array(Buffer.from(contentBase64, "base64"));
166
- const filename = file !== undefined ? (filenameArg ?? basename(file)) : filenameArg;
255
+ const sourceName = file !== undefined ? (filenameArg ?? basename(file)) : filenameArg;
167
256
  const defaults = resolvePutDefaults({ envFile: globals.envFile });
257
+ const prepared = await prepareImageForUpload(bytes, sourceName, {
258
+ ...mcpFrameOptions(args),
259
+ optimize: mcpOptimizeOptions(args, defaults),
260
+ });
261
+ const filename = prepared.filename;
262
+ let key = target ? ghAttachmentKey(target, filename) : keyArg;
263
+ if (key && prepared.optimized)
264
+ key = rewriteKeyExtension(key, filename);
168
265
  const noGit = optBool(args, "noGit") || defaults.noGit === true;
169
- const result = await client.put(bytes, {
266
+ const result = await client.put(prepared.bytes, {
170
267
  filename,
171
- key: target ? ghAttachmentKey(target, filename) : key,
172
- prefix: prefixArg ?? defaults.prefix,
268
+ key,
269
+ prefix: resolvedPrefix ?? defaults.prefix,
173
270
  repo: optString(args, "repo") ?? defaults.repo,
174
271
  ref: refArg ?? defaults.ref,
175
- contentType: optString(args, "contentType"),
272
+ contentType: prepared.optimized ? prepared.contentType : optString(args, "contentType"),
176
273
  deriveRepoFromGit: !noGit,
177
274
  });
178
275
  const markdown = buildMarkdown(result.url, {
179
- alt: optString(args, "alt") ?? filename,
276
+ alt: optString(args, "alt") ?? sourceName,
180
277
  width: optPosInt(args, "width") ?? defaults.width,
181
278
  });
279
+ const optimize = {
280
+ optimized: prepared.optimized,
281
+ skippedReason: prepared.skippedReason,
282
+ originalBytes: prepared.originalBytes,
283
+ outputBytes: prepared.outputBytes,
284
+ filename: prepared.filename,
285
+ };
182
286
  if (wantComment && target) {
183
287
  const { comment, commentError } = await syncComment(client, target);
184
- return { ...result, markdown, comment, commentError };
288
+ return { ...result, markdown, optimize, frame: prepared.frame, comment, commentError };
185
289
  }
186
- return { ...result, markdown };
290
+ return { ...result, markdown, optimize, frame: prepared.frame };
187
291
  },
188
292
  },
189
293
  {
@@ -204,8 +308,25 @@ export function createUploadsMcpTools(opts) {
204
308
  },
205
309
  contentType: {
206
310
  type: "string",
207
- description: "Override the Content-Type (applied to every file).",
311
+ description: "Override the Content-Type (applied to every file; ignored when optimize rewrites).",
312
+ },
313
+ noOptimize: {
314
+ type: "boolean",
315
+ description: "Skip client-side image optimization (default: optimize still images to WebP).",
316
+ },
317
+ optimizeMaxEdge: {
318
+ type: "number",
319
+ description: "Max long edge in pixels when optimizing (default: 2400).",
320
+ },
321
+ optimizeQuality: {
322
+ type: "number",
323
+ description: "WebP quality 1–100 when optimizing (default: 85).",
208
324
  },
325
+ keepExif: {
326
+ type: "boolean",
327
+ description: "Keep EXIF/XMP/ICC when optimizing (default: strip for privacy on public embeds).",
328
+ },
329
+ ...frameProps,
209
330
  workspace: workspaceProp,
210
331
  },
211
332
  required: ["files"],
@@ -220,15 +341,30 @@ export function createUploadsMcpTools(opts) {
220
341
  resolveCurrentPullRequest(resolveRepo(optString(args, "repo"), run), run);
221
342
  const { client } = clientFor(args);
222
343
  const contentType = optString(args, "contentType");
344
+ const defaults = resolvePutDefaults({ envFile: globals.envFile });
345
+ const frameOpts = mcpFrameOptions(args);
346
+ const optimizeOpts = mcpOptimizeOptions(args, defaults);
223
347
  const uploads = [];
224
348
  for (const file of files) {
225
- const filename = basename(file);
226
- const result = await client.put(new Uint8Array(readFileSync(file)), {
227
- filename,
228
- key: ghAttachmentKey(target, filename),
229
- contentType,
349
+ const sourceName = basename(file);
350
+ const prepared = await prepareImageForUpload(new Uint8Array(readFileSync(file)), sourceName, { ...frameOpts, optimize: optimizeOpts });
351
+ const result = await client.put(prepared.bytes, {
352
+ filename: prepared.filename,
353
+ key: ghAttachmentKey(target, prepared.filename),
354
+ contentType: prepared.optimized ? prepared.contentType : contentType,
355
+ });
356
+ uploads.push({
357
+ ...result,
358
+ markdown: buildMarkdown(result.url, { alt: sourceName }),
359
+ frame: prepared.frame,
360
+ optimize: {
361
+ optimized: prepared.optimized,
362
+ skippedReason: prepared.skippedReason,
363
+ originalBytes: prepared.originalBytes,
364
+ outputBytes: prepared.outputBytes,
365
+ filename: prepared.filename,
366
+ },
230
367
  });
231
- uploads.push({ ...result, markdown: buildMarkdown(result.url, { alt: filename }) });
232
368
  }
233
369
  if (optBool(args, "noComment"))
234
370
  return { target, uploads };
@@ -0,0 +1,38 @@
1
+ /** Longest edge in pixels (screenshots beyond this rarely help PR review). */
2
+ export declare const DEFAULT_OPTIMIZE_MAX_EDGE = 2400;
3
+ /** WebP quality tuned for UI screenshots (text/chrome stay sharp enough). */
4
+ export declare const DEFAULT_OPTIMIZE_QUALITY = 85;
5
+ export type OptimizeOutputFormat = "webp" | "jpeg";
6
+ export interface OptimizeImageOptions {
7
+ /** When false, returns the input unchanged. Default true. */
8
+ enabled?: boolean;
9
+ format?: OptimizeOutputFormat;
10
+ maxEdge?: number;
11
+ quality?: number;
12
+ /**
13
+ * When true, preserve EXIF/XMP/ICC (and related) from the input via sharp
14
+ * `withMetadata()`. Default false — strip for privacy and smaller embeds.
15
+ * Orientation is still applied so pixels match what the user saw.
16
+ */
17
+ keepExif?: boolean;
18
+ }
19
+ export interface OptimizeImageResult {
20
+ bytes: Uint8Array;
21
+ filename: string;
22
+ /** Suggested Content-Type for the body (API still sniffs magic bytes). */
23
+ contentType: string;
24
+ optimized: boolean;
25
+ /** Why bytes were left as-is when optimized is false. */
26
+ skippedReason?: string;
27
+ originalBytes: number;
28
+ outputBytes: number;
29
+ }
30
+ /** Replace a trailing image-looking extension, or append when missing. */
31
+ export declare function withImageExtension(name: string, ext: string): string;
32
+ /**
33
+ * Optimize still images for public embeds. Safe to call on any payload:
34
+ * non-images and unsupported types pass through.
35
+ */
36
+ export declare function optimizeImageForUpload(bytes: Uint8Array, filename: string, opts?: OptimizeImageOptions): Promise<OptimizeImageResult>;
37
+ /** Rewrite an object key's trailing image extension to match optimized output. */
38
+ export declare function rewriteKeyExtension(key: string, filename: string): string;