@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Build Internet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -4,9 +4,7 @@ CLI and client for **uploads.sh** — upload files, get public URLs, and produce
4
4
 
5
5
  ## CLI
6
6
 
7
- Binary: `uploads` (also `pnpm uploads` from repo root after `pnpm install`).
8
-
9
- Install globally or run a pinned version without installing:
7
+ Binary: **`uploads`**. Install globally (or use a pinned `npx` one-shot):
10
8
 
11
9
  ```bash
12
10
  npm install --global @buildinternet/uploads
@@ -14,29 +12,65 @@ npx @buildinternet/uploads@0.1.0 --help
14
12
  ```
15
13
 
16
14
  ```bash
17
- pnpm uploads setup --env-file .env
18
- pnpm uploads attach ./before.png ./after.png --env-file .env
19
- pnpm uploads put ./shot.png --env-file .env
20
- pnpm uploads put ./after.png --pr 123 --comment --env-file .env
21
- pnpm uploads doctor --env-file .env
15
+ uploads setup
16
+ uploads attach ./before.png ./after.png
17
+ uploads put ./shot.png
18
+ uploads put ./shot.png --destination screenshots
19
+ uploads put ./shot.png --no-optimize
20
+ uploads put ./mobile.png --frame phone
21
+ uploads put ./ui.png --frame browser --frame-url "https://app.example"
22
+ uploads put ./after.png --pr 123 --comment
23
+ uploads doctor
22
24
  ```
23
25
 
24
- Commands: `attach`, `put`, `comment`, `list`, `delete`, `setup`, `config`, `doctor`, `health`.
26
+ Inside this monorepo only, `pnpm uploads …` builds the package first so you pick
27
+ up local source; product docs and PR “how to try it” examples should use the
28
+ global `uploads` form above.
29
+
30
+ Commands: `attach`, `put`, `comment`, `list`, `delete`, `usage`, `reconcile`,
31
+ `purge-expired`, `setup`, `install`, `config`, `doctor`, `health`, `mcp`.
25
32
 
26
33
  `attach` is the agent-friendly default for GitHub media. It accepts one or more files,
27
34
  infers the pull request for the current branch via `gh`, uploads stable URLs, and creates
28
35
  or updates one managed attachments comment. Use `--pr`, `--issue`, and `--repo` to select
29
36
  the target explicitly, or `--no-comment` to upload without changing GitHub comments.
30
37
 
38
+ **Keys / destinations:** default put uses the `screenshots` layout. Typed destinations
39
+ (`--destination screenshots|gh|f`, MCP `destination`) set the root; `--pr`/`--issue`
40
+ use `gh/…`. Workspaces may restrict put/sign to those roots via
41
+ `allowedKeyPrefixes` (see [workspaces](../../docs/workspaces.md)).
42
+
43
+ **Image optimization:** by default, still images are re-encoded to WebP (long edge
44
+ capped, high quality) before upload so GitHub embeds stay small, and **EXIF is
45
+ stripped**. Pass `--keep-exif` / `UPLOADS_KEEP_EXIF=1` to preserve image metadata, or
46
+ `--no-optimize` / `UPLOADS_NO_OPTIMIZE=1` to upload originals unchanged.
47
+
48
+ **Frames (opt-in):** `--frame phone|browser|iphone-16-pro` composites chrome
49
+ **before** optimize. `phone`/`browser` are procedural; `iphone-16-pro` fetches
50
+ community art from [device-frames-media](https://github.com/jonnyjackson26/device-frames-media)
51
+ into `~/.cache/uploads/frames` (not bundled).
52
+
31
53
  Config layers (first match wins): CLI flags → env vars → `--env-file` → `~/.config/buildinternet/config`. See `config.example` for keys.
32
54
 
55
+ ## MCP server
56
+
57
+ `uploads mcp` serves the Model Context Protocol over stdio (newline-delimited JSON-RPC, no extra dependencies). Tools mirror the CLI commands one-to-one — `put`, `attach`, `list`, `delete`, `usage`, `reconcile`, `purge_expired`, `comment`, `health`, `doctor` — with the same config resolution and defaults, plus a per-call `workspace` argument. 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).
58
+
59
+ ```json
60
+ { "command": "uploads", "args": ["--env-file", "/path/to/.env", "mcp"] }
61
+ ```
62
+
63
+ Or with `UPLOADS_TOKEN`/`UPLOADS_WORKSPACE` in the environment or user config. Claude Code: `claude mcp add uploads -- uploads --env-file /path/to/.env mcp`.
64
+
65
+ For HTTP clients there's also a hosted variant at `https://agents.uploads.sh/mcp` — the workspace is inferred from the bearer token, so only the URL and token are needed (`https://agents.uploads.sh/<workspace>/mcp` and the `mcp.uploads.sh` hostname also work). Tools: put/list/delete/health, same bearer tokens as the REST API — see `apps/mcp` in the repo. `uploads install` registers it with Claude Code (and installs the agent skill) in one step. Its `put` takes no content type: the stored type is sniffed server-side from the bytes and checked against the workspace allowlist, and writes are rate limited per workspace.
66
+
33
67
  ## Programmatic use
34
68
 
35
69
  ```ts
36
70
  import { createUploadsClient } from "@buildinternet/uploads";
37
71
  ```
38
72
 
39
- Agent/MCP helpers: `@buildinternet/uploads/agent` (`createUploadsWorkerFileTools` for Workers).
73
+ Agent/MCP helpers: `@buildinternet/uploads/agent` (`createUploadsWorkerFileTools` for Workers); for local stdio MCP, use `uploads mcp` (above).
40
74
 
41
75
  ## Layout
42
76
 
@@ -44,6 +78,8 @@ Agent/MCP helpers: `@buildinternet/uploads/agent` (`createUploadsWorkerFileTools
44
78
  src/
45
79
  cli.ts Entry + help
46
80
  commands.ts put, list, delete, comment, …
81
+ commands/mcp.ts `mcp` command entry
82
+ mcp/ Stdio MCP server (server.ts, tools.ts)
47
83
  client.ts HTTP client for the API
48
84
  github.ts PR/issue key paths + attachment comments
49
85
  embed.ts Markdown image output
package/dist/cli.js CHANGED
@@ -2,11 +2,13 @@ import { createUploadsClient } from "./client.js";
2
2
  import { resolveApiUrl, resolveConfig } from "./config.js";
3
3
  import { UploadsError } from "./errors.js";
4
4
  import { commandWorkspace, isHelpFlag, parseArgv, parseCommandArgs, UsageError, } from "./cli-args.js";
5
- import { runPut, runAttach, runList, runDelete, runHealth, runDoctor, runComment, } from "./commands.js";
5
+ import { runPut, runAttach, runList, runDelete, runHealth, runDoctor, runComment, runUsage, runReconcile, runPurgeExpired, } from "./commands.js";
6
6
  import { runConfig } from "./commands/config.js";
7
7
  import { runSetup } from "./commands/setup.js";
8
8
  import { runLogin } from "./commands/login.js";
9
9
  import { runAdmin } from "./commands/admin-enrollment.js";
10
+ import { runMcp } from "./commands/mcp.js";
11
+ import { runInstall } from "./commands/install.js";
10
12
  const ROOT_HELP = `uploads — CLI for uploads.sh (GitHub image embeds)
11
13
 
12
14
  Usage:
@@ -37,12 +39,17 @@ Commands:
37
39
  comment Create/update a PR/issue attachments comment (via gh)
38
40
  list List objects
39
41
  delete <key> Delete object
42
+ usage Workspace storage / upload counters
43
+ reconcile Rebuild usage ledger from storage
44
+ purge-expired Delete objects past retentionDays
40
45
  setup Inspect/configure advanced CLI settings
46
+ install Install the agent skill + register the remote MCP server
41
47
  login Exchange an enrollment code and configure credentials
42
48
  admin Admin enrollment management
43
49
  config Show path, init, or set shared config
44
50
  doctor Health + auth + workspace checks
45
51
  health API liveness (no auth)
52
+ mcp Serve MCP over stdio (tools mirror the CLI)
46
53
 
47
54
  Put/list defaults (config file or env):
48
55
  UPLOADS_DEFAULT_PREFIX, UPLOADS_DEFAULT_REPO, UPLOADS_DEFAULT_REF
@@ -55,7 +62,10 @@ Examples:
55
62
  uploads put ./shot.png --ref 42
56
63
  uploads doctor
57
64
 
58
- Agent/MCP: use createUploadsWorkerFileTools() from @buildinternet/uploads/agent on the Worker.
65
+ Agent/MCP: \`uploads install\` sets up the agent skill and the hosted MCP server
66
+ (https://agents.uploads.sh/mcp, workspace inferred from the token). Run
67
+ \`uploads mcp\` for local stdio, or use createUploadsWorkerFileTools()
68
+ from @buildinternet/uploads/agent on the Worker.
59
69
  `;
60
70
  function createContext(globals, requireToken, commandArgs) {
61
71
  const cmdWorkspace = commandWorkspace(parseCommandArgs(commandArgs).flags);
@@ -84,6 +94,9 @@ function exitCode(err) {
84
94
  return 2;
85
95
  case "UNAUTHORIZED":
86
96
  case "NOT_FOUND":
97
+ case "KEY_POLICY":
98
+ case "STORAGE_QUOTA":
99
+ case "UPLOAD_BUDGET":
87
100
  return 3;
88
101
  case "NETWORK":
89
102
  return 4;
@@ -107,6 +120,17 @@ function errorOut(err, json) {
107
120
  process.stderr.write(`${msg}\n`);
108
121
  else
109
122
  process.stderr.write(`error: ${msg}\n`);
123
+ if (err instanceof UploadsError) {
124
+ if (err.code === "STORAGE_QUOTA" || err.code === "UPLOAD_BUDGET") {
125
+ process.stderr.write("hint: run `uploads usage` then delete objects or raise limits (`pnpm workspace:limits`)\n");
126
+ }
127
+ else if (err.code === "KEY_POLICY") {
128
+ process.stderr.write("hint: use a typed destination (`--destination screenshots|gh`) or an allowed prefix; operators set allowlists with `pnpm workspace:limits --allowed-prefixes`\n");
129
+ }
130
+ else if (err.status === 413 || err.message.toLowerCase().includes("too large")) {
131
+ process.stderr.write("hint: file exceeds workspace size policy (images vs video may differ); compress or raise --max-upload-bytes / --max-video-bytes\n");
132
+ }
133
+ }
110
134
  }
111
135
  }
112
136
  export async function runCli(argv) {
@@ -130,10 +154,17 @@ export async function runCli(argv) {
130
154
  return runLogin(cmdArgs, { json, apiUrl: resolveApiUrl(parsed.globals) }, showHelp);
131
155
  case "admin":
132
156
  return runAdmin(cmdArgs, { json, apiUrl: resolveApiUrl(parsed.globals) }, showHelp);
157
+ case "mcp":
158
+ return runMcp(cmdArgs, { globals: parsed.globals }, showHelp);
159
+ case "install":
160
+ return runInstall(cmdArgs, { globals: parsed.globals, json }, showHelp);
133
161
  case "attach":
134
162
  case "put":
135
163
  case "list":
136
164
  case "delete":
165
+ case "usage":
166
+ case "reconcile":
167
+ case "purge-expired":
137
168
  case "doctor":
138
169
  case "comment": {
139
170
  const ctx = createContext(parsed.globals, !showHelp, cmdArgs);
@@ -148,6 +179,12 @@ export async function runCli(argv) {
148
179
  return runList(ctx, cmdArgs, showHelp);
149
180
  case "delete":
150
181
  return runDelete(ctx, cmdArgs, showHelp);
182
+ case "usage":
183
+ return runUsage(ctx, cmdArgs, showHelp);
184
+ case "reconcile":
185
+ return runReconcile(ctx, cmdArgs, showHelp);
186
+ case "purge-expired":
187
+ return runPurgeExpired(ctx, cmdArgs, showHelp);
151
188
  case "doctor":
152
189
  return runDoctor(ctx, cmdArgs, showHelp);
153
190
  }
package/dist/client.d.ts CHANGED
@@ -43,6 +43,43 @@ export interface DeleteResult {
43
43
  export interface HealthResult {
44
44
  ok: boolean;
45
45
  }
46
+ export interface UsageResult {
47
+ workspace: string;
48
+ bytes: number;
49
+ objects: number;
50
+ uploadsInPeriod: number;
51
+ periodStart: string;
52
+ updatedAt: string;
53
+ maxStorageBytes?: number;
54
+ storageRemainingBytes?: number;
55
+ maxUploadsPerPeriod?: number;
56
+ uploadsRemaining?: number;
57
+ }
58
+ export interface ReconcileResult {
59
+ workspace: string;
60
+ bytes: number;
61
+ objects: number;
62
+ previous: {
63
+ bytes: number;
64
+ objects: number;
65
+ };
66
+ changed: boolean;
67
+ usage: UsageResult;
68
+ }
69
+ export interface PurgeExpiredResult {
70
+ workspace: string;
71
+ retentionDays: number;
72
+ cutoff: string;
73
+ deleted: number;
74
+ freedBytes: number;
75
+ keys: string[];
76
+ keysTruncated: boolean;
77
+ reconcile: ReconcileResult;
78
+ }
79
+ export type PurgeExpiredResponse = PurgeExpiredResult | {
80
+ skipped: true;
81
+ reason: string;
82
+ };
46
83
  export interface EnrollmentExchangeResult {
47
84
  apiUrl?: string;
48
85
  workspace: string;
@@ -67,9 +104,19 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
67
104
  put(body: Uint8Array, opts: PutOptions & {
68
105
  filename: string;
69
106
  }): Promise<PutResult>;
70
- list(opts?: ListOptions): Promise<ListResult>;
107
+ list: (opts?: ListOptions) => Promise<ListResult>;
108
+ /** Follow cursors (optionally starting from one) and return every remaining item. */
109
+ listAll(opts?: Omit<ListOptions, "cursor"> & {
110
+ cursor?: string;
111
+ }): Promise<ListItem[]>;
71
112
  delete(key: string): Promise<DeleteResult>;
72
113
  head(key: string): Promise<HeadResult>;
73
114
  health(): Promise<HealthResult>;
115
+ /** Workspace storage / upload counters (+ limits when configured). */
116
+ usage(): Promise<UsageResult>;
117
+ /** Rebuild ledger bytes/objects from storage (source of truth). */
118
+ reconcile(): Promise<ReconcileResult>;
119
+ /** Delete objects past retentionDays (if set), then reconcile. */
120
+ purgeExpired(): Promise<PurgeExpiredResponse>;
74
121
  };
75
122
  export type UploadsClient = ReturnType<typeof createUploadsClient>;
package/dist/client.js CHANGED
@@ -33,7 +33,10 @@ function encodeKeyPath(key) {
33
33
  function filesBase(config) {
34
34
  return `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/files`;
35
35
  }
36
- function mapApiError(status, error) {
36
+ function usageBase(config) {
37
+ return `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/usage`;
38
+ }
39
+ function mapApiError(status, error, code) {
37
40
  const normalized = error.toLowerCase();
38
41
  if (status === 401 || normalized === "unauthorized") {
39
42
  return new UploadsError(error, "UNAUTHORIZED", status);
@@ -44,6 +47,16 @@ function mapApiError(status, error) {
44
47
  if (status === 400 && normalized === "invalid key") {
45
48
  return new UploadsError(error, "INVALID_KEY", status);
46
49
  }
50
+ if (code === "key_prefix_not_allowed" || code === "key_too_deep") {
51
+ return new UploadsError(error, "KEY_POLICY", status);
52
+ }
53
+ // Prefer stable body.code — bare 429 is also used for write rate limits.
54
+ if (status === 507 || code === "storage_quota_exceeded") {
55
+ return new UploadsError(error, "STORAGE_QUOTA", status);
56
+ }
57
+ if (code === "upload_budget_exceeded") {
58
+ return new UploadsError(error, "UPLOAD_BUDGET", status);
59
+ }
47
60
  return new UploadsError(error, "API_ERROR", status);
48
61
  }
49
62
  async function parseErrorResponse(res) {
@@ -51,7 +64,10 @@ async function parseErrorResponse(res) {
51
64
  const message = typeof body === "object" && body && "error" in body && typeof body.error === "string"
52
65
  ? body.error
53
66
  : res.statusText || "request failed";
54
- return mapApiError(res.status, message);
67
+ const code = typeof body === "object" && body && "code" in body && typeof body.code === "string"
68
+ ? body.code
69
+ : undefined;
70
+ return mapApiError(res.status, message, code);
55
71
  }
56
72
  export function createUploadsClient(config) {
57
73
  async function request(method, path, opts) {
@@ -78,6 +94,17 @@ export function createUploadsClient(config) {
78
94
  return undefined;
79
95
  return (await res.json());
80
96
  }
97
+ async function list(opts = {}) {
98
+ const params = new URLSearchParams();
99
+ if (opts.prefix)
100
+ params.set("prefix", opts.prefix);
101
+ if (opts.limit != null)
102
+ params.set("limit", String(opts.limit));
103
+ if (opts.cursor)
104
+ params.set("cursor", opts.cursor);
105
+ const qs = params.toString();
106
+ return request("GET", `${filesBase(config)}${qs ? `?${qs}` : ""}`);
107
+ }
81
108
  return {
82
109
  async put(body, opts) {
83
110
  const key = opts.key ??
@@ -99,16 +126,17 @@ export function createUploadsClient(config) {
99
126
  }
100
127
  return { ...result, url: result.url };
101
128
  },
102
- async list(opts = {}) {
103
- const params = new URLSearchParams();
104
- if (opts.prefix)
105
- params.set("prefix", opts.prefix);
106
- if (opts.limit != null)
107
- params.set("limit", String(opts.limit));
108
- if (opts.cursor)
109
- params.set("cursor", opts.cursor);
110
- const qs = params.toString();
111
- return request("GET", `${filesBase(config)}${qs ? `?${qs}` : ""}`);
129
+ list,
130
+ /** Follow cursors (optionally starting from one) and return every remaining item. */
131
+ async listAll(opts = {}) {
132
+ const items = [];
133
+ let cursor = opts.cursor;
134
+ do {
135
+ const page = await list({ ...opts, cursor });
136
+ items.push(...page.items);
137
+ cursor = page.cursor ?? undefined;
138
+ } while (cursor);
139
+ return items;
112
140
  },
113
141
  async delete(key) {
114
142
  return request("DELETE", `${filesBase(config)}/${encodeKeyPath(key)}`);
@@ -119,5 +147,17 @@ export function createUploadsClient(config) {
119
147
  async health() {
120
148
  return request("GET", `${config.apiUrl}/health`, { auth: false });
121
149
  },
150
+ /** Workspace storage / upload counters (+ limits when configured). */
151
+ async usage() {
152
+ return request("GET", usageBase(config));
153
+ },
154
+ /** Rebuild ledger bytes/objects from storage (source of truth). */
155
+ async reconcile() {
156
+ return request("POST", `${usageBase(config)}/reconcile`);
157
+ },
158
+ /** Delete objects past retentionDays (if set), then reconcile. */
159
+ async purgeExpired() {
160
+ return request("POST", `${usageBase(config)}/purge-expired`);
161
+ },
122
162
  };
123
163
  }
@@ -0,0 +1,8 @@
1
+ import { type GlobalFlags } from "../cli-args.js";
2
+ import { type CommandRunner } from "../github-gh.js";
3
+ export declare const DEFAULT_MCP_URL = "https://agents.uploads.sh/mcp";
4
+ export declare function runInstall(args: string[], opts: {
5
+ globals: GlobalFlags;
6
+ json?: boolean;
7
+ runner?: CommandRunner;
8
+ }, help?: boolean): Promise<number>;
@@ -0,0 +1,133 @@
1
+ import { flagBool, flagString, parseCommandArgs, UsageError, } from "../cli-args.js";
2
+ import { resolveConfig } from "../config.js";
3
+ import { execRunner } from "../github-gh.js";
4
+ export const DEFAULT_MCP_URL = "https://agents.uploads.sh/mcp";
5
+ const SKILL_SOURCE = "buildinternet/uploads";
6
+ const SKILL_NAME = "uploads-cli";
7
+ const INSTALL_HELP = `uploads install — set up agent integrations (skill + remote MCP)
8
+
9
+ Installs the uploads-cli agent skill and registers the hosted MCP server
10
+ with Claude Code. The remote MCP endpoint infers your workspace from the
11
+ bearer token, so only the token is needed.
12
+
13
+ Usage:
14
+ uploads install [skill|mcp|all] (default: all)
15
+
16
+ What runs:
17
+ skill npx -y skills add ${SKILL_SOURCE} --skill ${SKILL_NAME}
18
+ mcp claude mcp add --transport http uploads ${DEFAULT_MCP_URL} \\
19
+ --header "Authorization: Bearer <token>"
20
+
21
+ Options:
22
+ --url <endpoint> Remote MCP endpoint (default: ${DEFAULT_MCP_URL})
23
+ --name <name> MCP server name in the client (default: uploads)
24
+ --dry-run Print the commands without running them
25
+
26
+ Examples:
27
+ uploads install
28
+ uploads install skill
29
+ uploads install mcp --dry-run
30
+ `;
31
+ /**
32
+ * Masks the configured token (and any Bearer credential) in text destined
33
+ * for stdout/stderr/JSON — command echoes, child-process output, and error
34
+ * messages can all embed it.
35
+ */
36
+ function redactor(token) {
37
+ return (text) => {
38
+ let out = text.replace(/Bearer \S+/g, "Bearer ***");
39
+ if (token)
40
+ out = out.split(token).join("***");
41
+ return out;
42
+ };
43
+ }
44
+ function runStep(run, command) {
45
+ try {
46
+ const output = run(command[0], command.slice(1)).trim();
47
+ return { command, ok: true, output: output || undefined };
48
+ }
49
+ catch (err) {
50
+ const message = err instanceof Error ? err.message : String(err);
51
+ // execFileSync's ENOENT means the binary itself is missing.
52
+ const hint = err.code === "ENOENT"
53
+ ? `${command[0]} not found on PATH — run manually: ${command.join(" ")}`
54
+ : message;
55
+ return { command, ok: false, error: hint };
56
+ }
57
+ }
58
+ export async function runInstall(args, opts, help = false) {
59
+ const parsed = parseCommandArgs(args);
60
+ if (help || parsed.help) {
61
+ process.stderr.write(INSTALL_HELP);
62
+ return 0;
63
+ }
64
+ const target = parsed.positionals[0] ?? "all";
65
+ if (!["skill", "mcp", "all"].includes(target)) {
66
+ throw new UsageError(`unknown install target: ${target} (expected skill, mcp, or all)`);
67
+ }
68
+ const url = flagString(parsed.flags, "--url") ?? DEFAULT_MCP_URL;
69
+ const name = flagString(parsed.flags, "--name") ?? "uploads";
70
+ const dryRun = flagBool(parsed.flags, "--dry-run");
71
+ const run = opts.runner ?? execRunner;
72
+ const results = {};
73
+ let redact = redactor(undefined);
74
+ if (target === "skill" || target === "all") {
75
+ const command = ["npx", "-y", "skills", "add", SKILL_SOURCE, "--skill", SKILL_NAME];
76
+ results.skill = dryRun ? { command, ok: true, skipped: "dry-run" } : runStep(run, command);
77
+ }
78
+ if (target === "mcp" || target === "all") {
79
+ const config = resolveConfig({
80
+ apiUrl: opts.globals.apiUrl,
81
+ workspace: opts.globals.workspace,
82
+ token: opts.globals.token,
83
+ envFile: opts.globals.envFile,
84
+ requireToken: !dryRun,
85
+ });
86
+ const bearer = config.token || "<token>";
87
+ redact = redactor(config.token || undefined);
88
+ const command = [
89
+ "claude",
90
+ "mcp",
91
+ "add",
92
+ "--transport",
93
+ "http",
94
+ name,
95
+ url,
96
+ "--header",
97
+ `Authorization: Bearer ${bearer}`,
98
+ ];
99
+ results.mcp = dryRun ? { command, ok: true, skipped: "dry-run" } : runStep(run, command);
100
+ }
101
+ const failed = Object.values(results).some((r) => !r.ok);
102
+ if (opts.json) {
103
+ // Never echo the token in structured output — commands, child output,
104
+ // and error text can all embed it.
105
+ const redacted = Object.fromEntries(Object.entries(results).map(([key, r]) => [
106
+ key,
107
+ {
108
+ ...r,
109
+ command: r.command.map(redact),
110
+ output: r.output === undefined ? undefined : redact(r.output),
111
+ error: r.error === undefined ? undefined : redact(r.error),
112
+ },
113
+ ]));
114
+ process.stdout.write(JSON.stringify({ ok: !failed, steps: redacted }, null, 2) + "\n");
115
+ return failed ? 1 : 0;
116
+ }
117
+ for (const [step, r] of Object.entries(results)) {
118
+ const shown = redact(r.command.join(" "));
119
+ if (r.skipped)
120
+ process.stdout.write(`${step}: would run — ${shown}\n`);
121
+ else if (r.ok) {
122
+ process.stdout.write(`${step}: ok — ${shown}\n`);
123
+ if (r.output)
124
+ process.stdout.write(` ${redact(r.output).split("\n").join("\n ")}\n`);
125
+ }
126
+ else
127
+ process.stderr.write(`${step}: failed — ${redact(r.error ?? "")}\n`);
128
+ }
129
+ if (!failed && !dryRun) {
130
+ process.stderr.write("hint: restart your agent session to pick up the new skill/server\n");
131
+ }
132
+ return failed ? 1 : 0;
133
+ }
@@ -0,0 +1,4 @@
1
+ import { type GlobalFlags } from "../cli-args.js";
2
+ export declare function runMcp(args: string[], opts: {
3
+ globals: GlobalFlags;
4
+ }, help?: boolean): Promise<number>;
@@ -0,0 +1,39 @@
1
+ import { createRequire } from "node:module";
2
+ import { parseCommandArgs } from "../cli-args.js";
3
+ import { createMcpServer } from "../mcp/server.js";
4
+ import { serveStdio } from "../mcp/stdio.js";
5
+ import { createUploadsMcpTools } from "../mcp/tools.js";
6
+ const MCP_HELP = `uploads [globals] mcp
7
+
8
+ Serve the Model Context Protocol (MCP) over stdio for agent clients. Tools
9
+ mirror the CLI commands: put, attach, list, delete, usage, reconcile,
10
+ purge_expired, comment, health, doctor.
11
+ Global flags before "mcp" (--api-url, --token, --workspace, --env-file)
12
+ configure every tool call; a per-call "workspace" argument overrides
13
+ --workspace, like the CLI's per-command flag.
14
+
15
+ Example MCP client config:
16
+ {
17
+ "command": "uploads",
18
+ "args": ["--env-file", "/path/.env", "mcp"]
19
+ }
20
+
21
+ Examples:
22
+ uploads --env-file .env mcp
23
+ uploads --token up_default_… mcp
24
+ `;
25
+ // Same relative depth from src/commands/ and dist/commands/, so this works
26
+ // both under vitest (src) and at runtime (dist).
27
+ const { version } = createRequire(import.meta.url)("../../package.json");
28
+ export async function runMcp(args, opts, help = false) {
29
+ if (help || parseCommandArgs(args).help) {
30
+ process.stderr.write(MCP_HELP);
31
+ return 0;
32
+ }
33
+ const server = createMcpServer({
34
+ serverInfo: { name: "uploads", version },
35
+ tools: createUploadsMcpTools({ globals: opts.globals }),
36
+ });
37
+ await serveStdio(server);
38
+ return 0;
39
+ }
@@ -1,6 +1,11 @@
1
1
  import { type UploadsClient } from "./client.js";
2
+ import { type CommandFlags } from "./cli-args.js";
2
3
  import { type ResolvedConfig } from "./config.js";
4
+ import { type GhTarget } from "./github.js";
3
5
  import { type CommandRunner } from "./github-gh.js";
6
+ import { type OptimizeImageOptions, type OptimizeImageResult } from "./optimize.js";
7
+ import { type FrameResult } from "./frame.js";
8
+ import type { PutDefaults } from "./config-file.js";
4
9
  export interface CliContext {
5
10
  config: ResolvedConfig;
6
11
  client: UploadsClient;
@@ -8,12 +13,70 @@ export interface CliContext {
8
13
  quiet: boolean;
9
14
  envFile?: string;
10
15
  }
16
+ /**
17
+ * Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
18
+ * neither is present. Shared by the CLI flags and the MCP tool arguments.
19
+ */
20
+ export declare function makeGhTarget(pr: number | undefined, issue: number | undefined, repoArg: string | undefined, run: CommandRunner): GhTarget | undefined;
21
+ /** Shared put/attach optimize flags + UPLOADS_NO_OPTIMIZE default. */
22
+ export declare function optimizeOptionsFromFlags(flags: CommandFlags["flags"], defaults: PutDefaults): OptimizeImageOptions;
23
+ export type PreparedUpload = OptimizeImageResult & {
24
+ frame?: Pick<FrameResult, "framed" | "frameId" | "skippedReason">;
25
+ };
26
+ /** Frame (optional) then optimize — shared by put/attach/MCP. */
27
+ export declare function prepareImageForUpload(bytes: Uint8Array, filename: string, opts: {
28
+ frameId?: string;
29
+ frameUrl?: string;
30
+ frameFit?: "cover" | "contain";
31
+ optimize: OptimizeImageOptions;
32
+ }): Promise<PreparedUpload>;
33
+ /**
34
+ * List every attachment under the target's prefix and create/update the
35
+ * managed comment. Throws on gh failure — callers decide whether that is
36
+ * fatal (`comment` command) or a warning (`put --comment`).
37
+ */
38
+ export declare function syncAttachmentsComment(client: UploadsClient, target: GhTarget, run: CommandRunner): Promise<{
39
+ action: "created" | "updated" | "skipped";
40
+ count: number;
41
+ }>;
11
42
  export declare function runAttach(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
12
43
  export declare function runPut(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
13
44
  export declare function runList(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
14
45
  export declare function runDelete(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
15
46
  export declare function runComment(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
47
+ export declare function runUsage(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
48
+ export declare function runReconcile(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
49
+ export declare function runPurgeExpired(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
16
50
  export declare function runHealth(ctx: Pick<CliContext, "json"> & {
17
51
  apiUrl: string;
18
52
  }, args: string[], help?: boolean): Promise<number>;
53
+ export interface DoctorReport {
54
+ ok: boolean;
55
+ apiUrl: string;
56
+ workspace: string;
57
+ workspaceSource: ResolvedConfig["workspaceSource"];
58
+ workspaceFromToken: string | undefined;
59
+ configPath: string;
60
+ configExists: boolean;
61
+ health: {
62
+ ok: boolean;
63
+ };
64
+ auth: {
65
+ ok: boolean;
66
+ error: string | undefined;
67
+ };
68
+ /** Usage snapshot when auth works (optional fields when the endpoint fails). */
69
+ usage?: {
70
+ ok: boolean;
71
+ bytes?: number;
72
+ objects?: number;
73
+ uploadsInPeriod?: number;
74
+ error?: string;
75
+ };
76
+ /** Workspace/token mismatch warning (also present in hints). */
77
+ warning?: string;
78
+ hints: string[];
79
+ }
80
+ /** Doctor's health + auth + workspace checks, shared by the CLI and the MCP tool. */
81
+ export declare function buildDoctorReport(config: ResolvedConfig, client: UploadsClient): Promise<DoctorReport>;
19
82
  export declare function runDoctor(ctx: CliContext, args: string[], help?: boolean): Promise<number>;