@buildinternet/uploads 0.1.1 → 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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Minimal, dependency-free MCP (Model Context Protocol) server core.
3
+ *
4
+ * Transport is one JSON-RPC 2.0 message per line/request. This module is
5
+ * transport- and runtime-agnostic (usable from Workers as well as Node) —
6
+ * `handleLine` takes a raw message string and returns the serialized response
7
+ * (or undefined when no response is due), so it is directly testable. The
8
+ * stdio transport lives in ./stdio.ts; logs must never go to stdout.
9
+ */
10
+ import { UploadsError } from "../errors.js";
11
+ export { optPosInt, optString, usage } from "./args.js";
12
+ const SUPPORTED_PROTOCOL_VERSIONS = new Set(["2025-06-18", "2025-03-26", "2024-11-05"]);
13
+ const LATEST_PROTOCOL_VERSION = "2025-06-18";
14
+ function response(id, result) {
15
+ return JSON.stringify({ jsonrpc: "2.0", id, result });
16
+ }
17
+ function errorResponse(id, code, message) {
18
+ return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
19
+ }
20
+ /** Tool failures become tool results (isError), never JSON-RPC errors. */
21
+ function toolErrorText(err) {
22
+ if (err instanceof UploadsError)
23
+ return `${err.message} (${err.code})`;
24
+ return err instanceof Error ? err.message : String(err);
25
+ }
26
+ export function createMcpServer(opts) {
27
+ const { serverInfo, tools } = opts;
28
+ async function callTool(id, params) {
29
+ const name = params.name;
30
+ const tool = typeof name === "string" ? tools.find((t) => t.name === name) : undefined;
31
+ if (!tool)
32
+ return errorResponse(id, -32602, `unknown tool: ${String(name ?? "(missing)")}`);
33
+ const args = params.arguments ?? {};
34
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
35
+ return errorResponse(id, -32602, "tool arguments must be an object");
36
+ }
37
+ try {
38
+ const result = await tool.handler(args);
39
+ return response(id, {
40
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
41
+ structuredContent: result,
42
+ isError: false,
43
+ });
44
+ }
45
+ catch (err) {
46
+ return response(id, {
47
+ content: [{ type: "text", text: toolErrorText(err) }],
48
+ isError: true,
49
+ });
50
+ }
51
+ }
52
+ return {
53
+ async handleLine(line) {
54
+ let msg;
55
+ try {
56
+ msg = JSON.parse(line);
57
+ }
58
+ catch {
59
+ return errorResponse(null, -32700, "Parse error");
60
+ }
61
+ // JSON-RPC batching was removed from MCP: arrays are invalid requests.
62
+ if (typeof msg !== "object" || msg === null || Array.isArray(msg)) {
63
+ return errorResponse(null, -32600, "Invalid Request");
64
+ }
65
+ const record = msg;
66
+ const { method, params } = record;
67
+ // A response from the client (has result/error, no method): ignore.
68
+ if (method === undefined && ("result" in record || "error" in record))
69
+ return undefined;
70
+ const id = typeof record.id === "string" || typeof record.id === "number" ? record.id : null;
71
+ if (typeof method !== "string")
72
+ return errorResponse(id, -32600, "Invalid Request");
73
+ if (method.startsWith("notifications/"))
74
+ return undefined;
75
+ // A request without an id is a notification — never respond.
76
+ if (!("id" in record))
77
+ return undefined;
78
+ const p = (typeof params === "object" && params !== null && !Array.isArray(params) ? params : {});
79
+ try {
80
+ switch (method) {
81
+ case "initialize": {
82
+ const requested = typeof p.protocolVersion === "string" ? p.protocolVersion : "";
83
+ const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.has(requested)
84
+ ? requested
85
+ : LATEST_PROTOCOL_VERSION;
86
+ return response(id, { protocolVersion, capabilities: { tools: {} }, serverInfo });
87
+ }
88
+ case "ping":
89
+ return response(id, {});
90
+ case "tools/list":
91
+ return response(id, {
92
+ tools: tools.map(({ name, description, inputSchema }) => ({
93
+ name,
94
+ description,
95
+ inputSchema,
96
+ })),
97
+ });
98
+ case "tools/call":
99
+ return await callTool(id, p);
100
+ default:
101
+ return errorResponse(id, -32601, `method not found: ${method}`);
102
+ }
103
+ }
104
+ catch (err) {
105
+ return errorResponse(id, -32603, err instanceof Error ? err.message : String(err));
106
+ }
107
+ },
108
+ };
109
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "./server.js";
2
+ /** Serve the MCP protocol on stdin/stdout; resolves when stdin ends. */
3
+ export declare function serveStdio(server: McpServer): Promise<void>;
@@ -0,0 +1,14 @@
1
+ /** Stdio transport for the MCP server core (Node-only; the core itself is runtime-agnostic). */
2
+ import { createInterface } from "node:readline";
3
+ import { writeStdout } from "../io.js";
4
+ /** Serve the MCP protocol on stdin/stdout; resolves when stdin ends. */
5
+ export async function serveStdio(server) {
6
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
7
+ for await (const line of rl) {
8
+ if (!line.trim())
9
+ continue;
10
+ const out = await server.handleLine(line);
11
+ if (out !== undefined)
12
+ await writeStdout(out + "\n");
13
+ }
14
+ }
@@ -0,0 +1,10 @@
1
+ import type { GlobalFlags } from "../cli-args.js";
2
+ import { type UploadsClient } from "../client.js";
3
+ import { type UploadsClientConfig } from "../config.js";
4
+ import { type CommandRunner } from "../github-gh.js";
5
+ import type { McpTool } from "./server.js";
6
+ export declare function createUploadsMcpTools(opts: {
7
+ globals: GlobalFlags;
8
+ runner?: CommandRunner;
9
+ clientFactory?: (config: UploadsClientConfig) => UploadsClient;
10
+ }): McpTool[];
@@ -0,0 +1,522 @@
1
+ /**
2
+ * MCP tool set mirroring the CLI commands (put, attach, list, delete,
3
+ * usage, reconcile, purge_expired, comment, health, doctor). Config is
4
+ * resolved fresh per tool call so a
5
+ * per-call `workspace` argument behaves like the CLI's --workspace flag, and
6
+ * a missing token surfaces as a tool error rather than a startup failure.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { basename } from "node:path";
10
+ import { createUploadsClient } from "../client.js";
11
+ import { buildDoctorReport, makeGhTarget, prepareImageForUpload, syncAttachmentsComment, } from "../commands.js";
12
+ import { resolveFrameId } from "../frame.js";
13
+ import { resolveConfig, resolvePutDefaults, } from "../config.js";
14
+ import { buildMarkdown } from "../embed.js";
15
+ import { resolvePutPrefix } from "../destinations.js";
16
+ import { ghAttachmentKey, ghKeyPrefix } from "../github.js";
17
+ import { rewriteKeyExtension } from "../optimize.js";
18
+ import { execRunner, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
19
+ import { optPosInt, optString, usage } from "./args.js";
20
+ function optBool(args, name) {
21
+ const v = args[name];
22
+ if (v === undefined || v === null)
23
+ return false;
24
+ if (typeof v !== "boolean")
25
+ usage(`${name} must be a boolean`);
26
+ return v;
27
+ }
28
+ function optStringArray(args, name) {
29
+ const v = args[name];
30
+ if (v === undefined || v === null)
31
+ return undefined;
32
+ if (!Array.isArray(v) || v.some((item) => typeof item !== "string")) {
33
+ usage(`${name} must be an array of strings`);
34
+ }
35
+ return v;
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
+ };
86
+ /** Reads pr/issue (+ repo) into a GhTarget; undefined when neither is present. */
87
+ function ghTargetFromArgs(args, run) {
88
+ return makeGhTarget(optPosInt(args, "pr"), optPosInt(args, "issue"), optString(args, "repo"), run);
89
+ }
90
+ const workspaceProp = {
91
+ type: "string",
92
+ description: "Override the workspace for this call (like the CLI's --workspace flag).",
93
+ };
94
+ /** pr/issue/repo schema properties shared by the tools that resolve a GhTarget. */
95
+ function ghTargetProps(action) {
96
+ return {
97
+ pr: {
98
+ type: "number",
99
+ description: `${action} this pull request. Mutually exclusive with issue.`,
100
+ },
101
+ issue: {
102
+ type: "number",
103
+ description: `${action} this issue. Mutually exclusive with pr.`,
104
+ },
105
+ repo: {
106
+ type: "string",
107
+ description: "owner/name repository (default: gh/git inference).",
108
+ },
109
+ };
110
+ }
111
+ export function createUploadsMcpTools(opts) {
112
+ const { globals } = opts;
113
+ const run = opts.runner ?? execRunner;
114
+ const clientFactory = opts.clientFactory ?? createUploadsClient;
115
+ function clientFor(args, requireToken = true) {
116
+ const config = resolveConfig({
117
+ apiUrl: globals.apiUrl,
118
+ token: globals.token,
119
+ envFile: globals.envFile,
120
+ workspace: optString(args, "workspace") ?? globals.workspace,
121
+ requireToken,
122
+ });
123
+ return { config, client: clientFactory(config) };
124
+ }
125
+ const syncComment = async (client, target) => {
126
+ let comment;
127
+ let commentError;
128
+ try {
129
+ comment = await syncAttachmentsComment(client, target, run);
130
+ }
131
+ catch (err) {
132
+ // Uploads already succeeded; the comment is best-effort by design.
133
+ commentError = err instanceof Error ? err.message : String(err);
134
+ }
135
+ return { comment, commentError };
136
+ };
137
+ return [
138
+ {
139
+ name: "put",
140
+ description: "Upload a file to uploads.sh and get a public URL plus GitHub-ready embed markdown (the returned `markdown` is ready to paste into a PR or issue). Pass `file` (a local path) or `contentBase64` + `filename` for in-memory content; with `pr`/`issue` the key is stable (same filename → same URL) and `comment` syncs the managed attachments comment.",
141
+ inputSchema: {
142
+ type: "object",
143
+ properties: {
144
+ file: {
145
+ type: "string",
146
+ description: "Path of the file to upload. Exactly one of file or contentBase64 is required.",
147
+ },
148
+ contentBase64: {
149
+ type: "string",
150
+ description: "Base64-encoded file content for in-memory uploads; requires filename.",
151
+ },
152
+ filename: {
153
+ type: "string",
154
+ description: "Filename for contentBase64 content (drives the key and content type).",
155
+ },
156
+ key: {
157
+ type: "string",
158
+ description: "Explicit object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>). Cannot be combined with pr/issue.",
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
+ },
164
+ prefix: {
165
+ type: "string",
166
+ description: "Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX). Cannot be combined with pr/issue.",
167
+ },
168
+ ...ghTargetProps("Attach to"),
169
+ // put's repo doubles as the default key layout's repo segment.
170
+ repo: {
171
+ type: "string",
172
+ description: "owner/name repo segment (default: git remote, or UPLOADS_DEFAULT_REPO).",
173
+ },
174
+ ref: {
175
+ type: "string",
176
+ description: "PR/issue/branch key segment (default: today, or UPLOADS_DEFAULT_REF). Cannot be combined with pr/issue.",
177
+ },
178
+ alt: { type: "string", description: "Alt text for the markdown (default: filename)." },
179
+ width: {
180
+ type: "number",
181
+ description: "Emit <img width=…> markdown instead of a plain image embed.",
182
+ },
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,
204
+ noGit: { type: "boolean", description: "Don't derive the repo segment from git." },
205
+ comment: {
206
+ type: "boolean",
207
+ description: "With pr/issue: create or update the managed attachments comment via local gh auth (best-effort).",
208
+ },
209
+ workspace: workspaceProp,
210
+ },
211
+ additionalProperties: false,
212
+ },
213
+ async handler(args) {
214
+ const file = optString(args, "file");
215
+ const contentBase64 = optString(args, "contentBase64");
216
+ if ((file === undefined) === (contentBase64 === undefined)) {
217
+ usage("exactly one of file or contentBase64 is required");
218
+ }
219
+ const filenameArg = optString(args, "filename");
220
+ if (contentBase64 !== undefined && !filenameArg) {
221
+ usage("filename is required with contentBase64");
222
+ }
223
+ const target = ghTargetFromArgs(args, run);
224
+ const wantComment = optBool(args, "comment");
225
+ const keyArg = optString(args, "key");
226
+ const destArg = optString(args, "destination");
227
+ const prefixArg = optString(args, "prefix");
228
+ const refArg = optString(args, "ref");
229
+ if (wantComment && !target)
230
+ usage("comment requires pr or issue");
231
+ if (target) {
232
+ if (keyArg)
233
+ usage("key cannot be combined with pr/issue");
234
+ if (refArg)
235
+ usage("ref cannot be combined with pr/issue");
236
+ if (prefixArg)
237
+ usage("prefix cannot be combined with pr/issue");
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
+ }
251
+ const { client } = clientFor(args);
252
+ const bytes = file !== undefined
253
+ ? new Uint8Array(readFileSync(file))
254
+ : new Uint8Array(Buffer.from(contentBase64, "base64"));
255
+ const sourceName = file !== undefined ? (filenameArg ?? basename(file)) : filenameArg;
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);
265
+ const noGit = optBool(args, "noGit") || defaults.noGit === true;
266
+ const result = await client.put(prepared.bytes, {
267
+ filename,
268
+ key,
269
+ prefix: resolvedPrefix ?? defaults.prefix,
270
+ repo: optString(args, "repo") ?? defaults.repo,
271
+ ref: refArg ?? defaults.ref,
272
+ contentType: prepared.optimized ? prepared.contentType : optString(args, "contentType"),
273
+ deriveRepoFromGit: !noGit,
274
+ });
275
+ const markdown = buildMarkdown(result.url, {
276
+ alt: optString(args, "alt") ?? sourceName,
277
+ width: optPosInt(args, "width") ?? defaults.width,
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
+ };
286
+ if (wantComment && target) {
287
+ const { comment, commentError } = await syncComment(client, target);
288
+ return { ...result, markdown, optimize, frame: prepared.frame, comment, commentError };
289
+ }
290
+ return { ...result, markdown, optimize, frame: prepared.frame };
291
+ },
292
+ },
293
+ {
294
+ name: "attach",
295
+ description: "Upload one or more files as stable PR/issue attachments and maintain a single managed GitHub comment listing them (each upload's `markdown` is ready to paste into GitHub). With no pr/issue, targets the pull request for the current branch.",
296
+ inputSchema: {
297
+ type: "object",
298
+ properties: {
299
+ files: {
300
+ type: "array",
301
+ items: { type: "string" },
302
+ description: "Paths of the files to upload (at least one).",
303
+ },
304
+ ...ghTargetProps("Attach to"),
305
+ noComment: {
306
+ type: "boolean",
307
+ description: "Upload only; don't create/update the managed comment.",
308
+ },
309
+ contentType: {
310
+ type: "string",
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).",
324
+ },
325
+ keepExif: {
326
+ type: "boolean",
327
+ description: "Keep EXIF/XMP/ICC when optimizing (default: strip for privacy on public embeds).",
328
+ },
329
+ ...frameProps,
330
+ workspace: workspaceProp,
331
+ },
332
+ required: ["files"],
333
+ additionalProperties: false,
334
+ },
335
+ async handler(args) {
336
+ const files = optStringArray(args, "files");
337
+ if (!files || files.length === 0)
338
+ usage("files must be a non-empty array of paths");
339
+ const explicitTarget = ghTargetFromArgs(args, run);
340
+ const target = explicitTarget ??
341
+ resolveCurrentPullRequest(resolveRepo(optString(args, "repo"), run), run);
342
+ const { client } = clientFor(args);
343
+ const contentType = optString(args, "contentType");
344
+ const defaults = resolvePutDefaults({ envFile: globals.envFile });
345
+ const frameOpts = mcpFrameOptions(args);
346
+ const optimizeOpts = mcpOptimizeOptions(args, defaults);
347
+ const uploads = [];
348
+ for (const file of files) {
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
+ },
367
+ });
368
+ }
369
+ if (optBool(args, "noComment"))
370
+ return { target, uploads };
371
+ const { comment, commentError } = await syncComment(client, target);
372
+ return { target, uploads, comment, commentError };
373
+ },
374
+ },
375
+ {
376
+ name: "list",
377
+ description: "List uploaded objects in the workspace, filtered by key prefix or by a PR/issue's attachments. Paginate with cursor, or set all to fetch every page.",
378
+ inputSchema: {
379
+ type: "object",
380
+ properties: {
381
+ prefix: {
382
+ type: "string",
383
+ description: "Key prefix filter (default: UPLOADS_DEFAULT_PREFIX + '/'). Cannot be combined with pr/issue.",
384
+ },
385
+ ...ghTargetProps("List attachments for"),
386
+ limit: { type: "number", description: "Page size." },
387
+ cursor: { type: "string", description: "Pagination cursor from a previous call." },
388
+ all: { type: "boolean", description: "Follow cursors and return every page." },
389
+ workspace: workspaceProp,
390
+ },
391
+ additionalProperties: false,
392
+ },
393
+ async handler(args) {
394
+ const defaults = resolvePutDefaults({ envFile: globals.envFile });
395
+ const prefixArg = optString(args, "prefix");
396
+ let prefix = prefixArg ?? (defaults.prefix ? `${defaults.prefix}/` : undefined);
397
+ const target = ghTargetFromArgs(args, run);
398
+ if (target) {
399
+ if (prefixArg)
400
+ usage("prefix cannot be combined with pr/issue");
401
+ prefix = ghKeyPrefix(target);
402
+ }
403
+ const limit = optPosInt(args, "limit");
404
+ const cursor = optString(args, "cursor");
405
+ const { client } = clientFor(args);
406
+ if (optBool(args, "all")) {
407
+ const items = await client.listAll({ prefix, limit, cursor });
408
+ return { items, cursor: null };
409
+ }
410
+ return client.list({ prefix, limit, cursor });
411
+ },
412
+ },
413
+ {
414
+ name: "delete",
415
+ description: "Delete an uploaded object by key. Set dryRun to preview without deleting.",
416
+ inputSchema: {
417
+ type: "object",
418
+ properties: {
419
+ key: { type: "string", description: "Object key to delete." },
420
+ dryRun: {
421
+ type: "boolean",
422
+ description: "Report what would be deleted without deleting.",
423
+ },
424
+ workspace: workspaceProp,
425
+ },
426
+ required: ["key"],
427
+ additionalProperties: false,
428
+ },
429
+ async handler(args) {
430
+ const key = optString(args, "key");
431
+ if (!key)
432
+ usage("key is required");
433
+ if (optBool(args, "dryRun"))
434
+ return { key, deleted: false, dryRun: true };
435
+ const { client } = clientFor(args);
436
+ return client.delete(key);
437
+ },
438
+ },
439
+ {
440
+ name: "usage",
441
+ description: "Workspace storage and monthly upload counters (and remaining headroom when budgets are configured). Same as `uploads usage`.",
442
+ inputSchema: {
443
+ type: "object",
444
+ properties: { workspace: workspaceProp },
445
+ additionalProperties: false,
446
+ },
447
+ async handler(args) {
448
+ const { client } = clientFor(args);
449
+ return client.usage();
450
+ },
451
+ },
452
+ {
453
+ name: "reconcile",
454
+ description: "Rebuild usage ledger bytes/objects from storage (source of truth). Preserves the monthly upload counter. Requires files:write. Same as `uploads reconcile`.",
455
+ inputSchema: {
456
+ type: "object",
457
+ properties: { workspace: workspaceProp },
458
+ additionalProperties: false,
459
+ },
460
+ async handler(args) {
461
+ const { client } = clientFor(args);
462
+ return client.reconcile();
463
+ },
464
+ },
465
+ {
466
+ name: "purge_expired",
467
+ description: "Delete objects older than the workspace retentionDays setting, then reconcile. Skips if retention is unset. Requires files:delete. Same as `uploads purge-expired`.",
468
+ inputSchema: {
469
+ type: "object",
470
+ properties: { workspace: workspaceProp },
471
+ additionalProperties: false,
472
+ },
473
+ async handler(args) {
474
+ const { client } = clientFor(args);
475
+ return client.purgeExpired();
476
+ },
477
+ },
478
+ {
479
+ name: "comment",
480
+ description: "Create or update the managed attachments comment on a GitHub PR or issue, listing everything uploaded for it. Uses local gh auth; edits its own prior comment in place and never touches other comments.",
481
+ inputSchema: {
482
+ type: "object",
483
+ properties: {
484
+ ...ghTargetProps("Comment on"),
485
+ workspace: workspaceProp,
486
+ },
487
+ additionalProperties: false,
488
+ },
489
+ async handler(args) {
490
+ const target = ghTargetFromArgs(args, run);
491
+ if (!target)
492
+ usage("comment requires pr or issue");
493
+ const { client } = clientFor(args);
494
+ const result = await syncAttachmentsComment(client, target, run);
495
+ return { ...target, ...result };
496
+ },
497
+ },
498
+ {
499
+ name: "health",
500
+ description: "Check uploads.sh API liveness. No auth or arguments required.",
501
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
502
+ async handler(args) {
503
+ const { config, client } = clientFor(args, false);
504
+ const result = await client.health();
505
+ return { ...result, apiUrl: config.apiUrl };
506
+ },
507
+ },
508
+ {
509
+ name: "doctor",
510
+ description: "Diagnose the configuration: API health, token auth, and workspace/token alignment. Returns the same report as `uploads doctor --json`, including hints.",
511
+ inputSchema: {
512
+ type: "object",
513
+ properties: { workspace: workspaceProp },
514
+ additionalProperties: false,
515
+ },
516
+ async handler(args) {
517
+ const { config, client } = clientFor(args);
518
+ return buildDoctorReport(config, client);
519
+ },
520
+ },
521
+ ];
522
+ }