@buildinternet/uploads 0.45.0 → 0.46.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -204,7 +204,7 @@ Config layers (first match wins): CLI flags → env vars → `--env-file` → `~
204
204
 
205
205
  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`.
206
206
 
207
- 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: file operations (including `get_metadata` / `set_metadata` / `find_files`) plus `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`; all use the same bearer-token workspace scopes and gallery URLs come from the API — see `apps/mcp` in the repo. The hosted `put` also accepts a `metadata` param. `uploads install` registers the skills + hosted MCP + Grok/Cursor hooks (short progress; `--verbose` for underlying output). Claude and Codex use their plugins for the same pre-PR screenshot reminder (`uploads hook pre-pr-screenshot`). 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.
207
+ 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: file operations (including `get_metadata` / `set_metadata` / `find_files`) plus `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`; all use the same bearer-token workspace scopes and gallery URLs come from the API — see `apps/mcp` in the repo. The hosted `put` also accepts a `metadata` param. `uploads install` registers the skills + hosted MCP with whichever of Claude Code, Codex, and Grok are on PATH (a missing CLI is skipped) + Grok/Cursor hooks (short progress; `--verbose` for underlying output). Claude and Codex use their plugins for the same pre-PR screenshot reminder (`uploads hook pre-pr-screenshot`). 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.
208
208
 
209
209
  ## Programmatic use
210
210
 
@@ -212,7 +212,7 @@ export const ROOT_COMMANDS = [
212
212
  essential: true,
213
213
  subcommands: [
214
214
  { name: "skill", summary: "Install the agent skills only" },
215
- { name: "mcp", summary: "Register the remote MCP server only" },
215
+ { name: "mcp", summary: "Register the remote MCP server (skips missing agent CLIs)" },
216
216
  { name: "hooks", summary: "Install PR screenshot hooks for Grok/Cursor" },
217
217
  { name: "all", summary: "Install skills, MCP, and hooks (default)" },
218
218
  ],
package/dist/cli-help.js CHANGED
@@ -167,9 +167,10 @@ ${section(style, "Examples:")}
167
167
  ${style.command("uploads logout")}
168
168
  ${style.command("uploads --version")}
169
169
 
170
- ${section(style, "Agent/MCP:")} ${style.body("`uploads install` sets up skills, hosted MCP, and hooks for")}
171
- ${style.body("Grok/Cursor. Claude and Codex use their plugins for the same PR-screenshot")}
172
- ${style.body("hook. Run `uploads mcp` for local stdio.")}
170
+ ${section(style, "Agent/MCP:")} ${style.body("`uploads install` sets up skills, hosted MCP (Claude/Codex/Grok;")}
171
+ ${style.body("skips any CLI that is not on PATH), and hooks for Grok/Cursor. Claude and")}
172
+ ${style.body("Codex use their plugins for the same PR-screenshot hook. Run `uploads mcp`")}
173
+ ${style.body("for local stdio.")}
173
174
 
174
175
  ${style.muted("Tip: uploads help essentials only")}
175
176
  ${style.muted(" uploads help --all this full listing")}
@@ -1,10 +1,21 @@
1
1
  import { type GlobalFlags } from "../cli-args.js";
2
2
  import { type CommandRunner } from "../github-gh.js";
3
3
  export declare const DEFAULT_MCP_URL = "https://agents.uploads.sh/mcp";
4
+ type McpClientId = "claude" | "codex" | "grok";
5
+ interface McpClient {
6
+ id: McpClientId;
7
+ label: string;
8
+ command: (name: string, url: string, bearer: string) => string[];
9
+ }
10
+ /**
11
+ * Agent CLIs that can register the hosted MCP server. Each is attempted
12
+ * independently; a missing binary is skipped so the others can still install.
13
+ */
14
+ export declare const MCP_CLIENTS: readonly McpClient[];
4
15
  export interface StepResult {
5
16
  command: string[];
6
17
  ok: boolean;
7
- skipped?: "dry-run" | "sign-in" | "already-configured";
18
+ skipped?: "dry-run" | "sign-in" | "already-configured" | "missing-cli";
8
19
  error?: string;
9
20
  output?: string;
10
21
  }
@@ -27,3 +38,4 @@ export declare function runInstall(args: string[], opts: {
27
38
  /** Override home for hook installs (tests). */
28
39
  home?: string;
29
40
  }, help?: boolean): Promise<number>;
41
+ export {};
@@ -6,20 +6,62 @@ import { HOOK_COMMAND, HOOK_INVOCATION, installHookManifests, } from "../hooks-i
6
6
  export const DEFAULT_MCP_URL = "https://agents.uploads.sh/mcp";
7
7
  const SKILL_SOURCE = "buildinternet/uploads";
8
8
  const SKILL_NAMES = ["uploads-cli", "github-screenshots", "annotate-screenshots"];
9
+ /** `mcp add --transport http` with an Authorization header. */
10
+ function httpMcpAdd(binary, name, url, bearer) {
11
+ return [
12
+ binary,
13
+ "mcp",
14
+ "add",
15
+ "--transport",
16
+ "http",
17
+ name,
18
+ url,
19
+ "--header",
20
+ `Authorization: Bearer ${bearer}`,
21
+ ];
22
+ }
23
+ /**
24
+ * Agent CLIs that can register the hosted MCP server. Each is attempted
25
+ * independently; a missing binary is skipped so the others can still install.
26
+ */
27
+ export const MCP_CLIENTS = [
28
+ {
29
+ id: "claude",
30
+ label: "Claude Code",
31
+ command: (name, url, bearer) => httpMcpAdd("claude", name, url, bearer),
32
+ },
33
+ {
34
+ id: "codex",
35
+ label: "Codex",
36
+ // Codex HTTP MCP has no --header; auth is OAuth on first use (same as the
37
+ // plugin's .mcp.json). Passing --bearer-token-env-var UPLOADS_TOKEN would
38
+ // break machines that signed in via `uploads login` (token lives in the
39
+ // config file, not the environment).
40
+ command: (name, url) => ["codex", "mcp", "add", name, "--url", url],
41
+ },
42
+ {
43
+ id: "grok",
44
+ label: "Grok",
45
+ command: (name, url, bearer) => httpMcpAdd("grok", name, url, bearer),
46
+ },
47
+ ];
48
+ const MCP_CLIENT_BINARIES = MCP_CLIENTS.map((c) => c.id).join(", ");
9
49
  const INSTALL_HELP = `uploads install — set up agent integrations (skills + remote MCP + hooks)
10
50
 
11
51
  Installs the github-screenshots, uploads-cli, and annotate-screenshots agent
12
- skills, registers the
13
- hosted MCP server with Claude Code, and installs the PR screenshot reminder
14
- hook for Grok / Cursor when those tools are present. The remote MCP endpoint
15
- infers your workspace from the bearer token, so only the token is needed.
52
+ skills, registers the hosted MCP server with whichever of Claude Code, Codex,
53
+ and Grok are on PATH, and installs the PR screenshot reminder hook for
54
+ Grok / Cursor when those tools are present. A missing agent CLI is skipped —
55
+ it does not fail the rest of the install. The remote MCP endpoint infers your
56
+ workspace from the bearer token, so only the token is needed.
16
57
 
17
58
  Claude Code and Codex ship the same reminder via their plugins (same command:
18
59
  \`${HOOK_INVOCATION}\`) — install those plugins instead of relying on this step.
19
60
 
20
61
  Safe to re-run. An MCP server already registered under this name is reported
21
62
  as \`already configured\` and left as-is — including the token it was created
22
- with. To point it at a new token: \`claude mcp remove <name>\` first.
63
+ with. To point it at a new token: \`<cli> mcp remove <name>\` first
64
+ (e.g. \`claude mcp remove uploads\`).
23
65
 
24
66
  Usage:
25
67
  uploads install [skill|mcp|hooks|all] (default: all)
@@ -28,7 +70,8 @@ What it does:
28
70
  skill Agent skills (via npx skills) — github-screenshots: visuals into
29
71
  PRs/issues; uploads-cli: full CLI reference; annotate-screenshots:
30
72
  hand-drawn callouts and redaction on screenshots
31
- mcp Hosted MCP server in Claude Code — put, list, attach, galleries
73
+ mcp Hosted MCP server in Claude Code, Codex, and Grok each CLI that
74
+ is installed is registered; missing ones are skipped
32
75
  hooks PR screenshot reminder for Grok / Cursor (user-global manifests)
33
76
 
34
77
  What runs under the hood:
@@ -37,7 +80,10 @@ What runs under the hood:
37
80
  with npx on PATH — missing tooling fails once with install guidance)
38
81
  mcp claude mcp add --transport http uploads ${DEFAULT_MCP_URL} \\
39
82
  --header "Authorization: Bearer <token>"
40
- (needs the Claude Code CLI on PATH)
83
+ codex mcp add uploads --url ${DEFAULT_MCP_URL}
84
+ grok mcp add --transport http uploads ${DEFAULT_MCP_URL} \\
85
+ --header "Authorization: Bearer <token>"
86
+ (each is skipped when that CLI is not on PATH)
41
87
  hooks write/merge ~/.grok/hooks/… and ~/.cursor/hooks.json when present
42
88
 
43
89
  Options:
@@ -76,9 +122,15 @@ export function missingBinaryHint(binary) {
76
122
  `Install Node 22+ from https://nodejs.org (or your package manager), open a new shell, ` +
77
123
  `confirm \`${binary} --version\` works, then re-run \`uploads install skill\`.`);
78
124
  case "claude":
79
- return (`claude not found on PATH — MCP install needs the Claude Code CLI. ` +
125
+ return (`claude not found on PATH — MCP install for Claude Code needs the Claude Code CLI. ` +
80
126
  `Install it from https://docs.anthropic.com/en/docs/claude-code, ensure \`claude\` is on PATH, ` +
81
- `then re-run \`uploads install mcp\`. Skills and hooks still work without Claude Code.`);
127
+ `then re-run \`uploads install mcp\`. Other agent CLIs (and skills/hooks) still work without it.`);
128
+ case "codex":
129
+ return (`codex not found on PATH — MCP install for Codex needs the Codex CLI. ` +
130
+ `Install it, ensure \`codex\` is on PATH, then re-run \`uploads install mcp\`.`);
131
+ case "grok":
132
+ return (`grok not found on PATH — MCP install for Grok needs the Grok CLI. ` +
133
+ `Install it, ensure \`grok\` is on PATH, then re-run \`uploads install mcp\`.`);
82
134
  default:
83
135
  return `${binary} not found on PATH — install it and ensure it is available in this shell.`;
84
136
  }
@@ -131,28 +183,59 @@ function skillCommand(skill) {
131
183
  // -g global, -y non-interactive, -a '*' every agent (skips the multi-select TUI)
132
184
  return ["npx", "-y", "skills", "add", SKILL_SOURCE, "--skill", skill, "-g", "-y", "-a", "*"];
133
185
  }
134
- /**
135
- * `claude mcp add` refuses to overwrite an existing entry and exits non-zero
136
- * ("MCP server uploads already exists in local config"). That is the steady
137
- * state for anyone re-running `uploads install`, not a failure — recognize it
138
- * so the run stays green and the footer tells them how to re-add if they want
139
- * a fresh token in the header.
140
- */
141
- function alreadyConfigured(result) {
142
- return !result.ok && /already exists/i.test(result.error ?? "");
186
+ /** `mcp add` exits non-zero when the name is already registered; treat that as success. */
187
+ function isAlreadyConfigured(error) {
188
+ return /already exists|already (configured|registered|present)|duplicate/i.test(error);
143
189
  }
144
- function mcpCommand(name, url, bearer) {
145
- return [
146
- "claude",
147
- "mcp",
148
- "add",
149
- "--transport",
150
- "http",
151
- name,
152
- url,
153
- "--header",
154
- `Authorization: Bearer ${bearer}`,
155
- ];
190
+ function mcpStepKey(client) {
191
+ return `mcp:${client.id}`;
192
+ }
193
+ function mcpClientForStep(step) {
194
+ return MCP_CLIENTS.find((c) => mcpStepKey(c) === step);
195
+ }
196
+ function stepFamily(key) {
197
+ if (key.startsWith("skill:"))
198
+ return "skills";
199
+ if (key.startsWith("mcp:"))
200
+ return "mcp";
201
+ return key;
202
+ }
203
+ function partitionSteps(results) {
204
+ const skills = [];
205
+ const mcp = [];
206
+ const other = [];
207
+ for (const entry of Object.entries(results)) {
208
+ const key = entry[0];
209
+ if (key.startsWith("skill:"))
210
+ skills.push(entry);
211
+ else if (key.startsWith("mcp:"))
212
+ mcp.push(entry);
213
+ else
214
+ other.push(entry);
215
+ }
216
+ return { skills, mcp, other };
217
+ }
218
+ /** Run one client's `mcp add`; missing binaries skip, duplicates are already-configured. */
219
+ function runMcpClientStep(run, command) {
220
+ try {
221
+ const output = run(command[0], command.slice(1)).trim();
222
+ return { command, ok: true, output: output || undefined };
223
+ }
224
+ catch (err) {
225
+ if (isEnoent(err)) {
226
+ return {
227
+ command,
228
+ ok: true,
229
+ skipped: "missing-cli",
230
+ error: `${command[0]} not found on PATH`,
231
+ };
232
+ }
233
+ const message = err instanceof Error ? err.message : String(err);
234
+ if (isAlreadyConfigured(message)) {
235
+ return { command, ok: true, skipped: "already-configured", output: message };
236
+ }
237
+ return { command, ok: false, error: message };
238
+ }
156
239
  }
157
240
  function peekToken(globals) {
158
241
  try {
@@ -171,27 +254,35 @@ function peekToken(globals) {
171
254
  }
172
255
  function printOneHumanStep(step, r, redact, verbose, mcpName) {
173
256
  const cmd = redact(r.command.join(" "));
174
- if (r.skipped === "dry-run") {
175
- process.stdout.write(`${step}: would run — ${cmd}\n`);
176
- }
177
- else if (r.skipped === "sign-in") {
178
- process.stdout.write(`${step}: skipped — ${redact(r.error ?? "needs sign-in")}\n`);
179
- }
180
- else if (r.skipped === "already-configured") {
181
- process.stdout.write(`${step}: already configured — "${mcpName}" is registered in Claude Code (nothing to do)\n` +
182
- ` To re-register (e.g. with a new token): claude mcp remove ${mcpName} && uploads install mcp\n`);
257
+ switch (r.skipped) {
258
+ case "dry-run":
259
+ process.stdout.write(`${step}: would run — ${cmd}\n`);
260
+ return;
261
+ case "sign-in":
262
+ process.stdout.write(`${step}: skipped — ${redact(r.error ?? "needs sign-in")}\n`);
263
+ return;
264
+ case "missing-cli":
265
+ process.stdout.write(`${step}: skipped ${r.error ?? `${r.command[0]} not found on PATH`}\n`);
266
+ return;
267
+ case "already-configured": {
268
+ const client = mcpClientForStep(step);
269
+ const label = client?.label ?? "the client";
270
+ const remove = `${client?.id ?? "<cli>"} mcp remove ${mcpName}`;
271
+ process.stdout.write(`${step}: already configured — "${mcpName}" is registered in ${label} (nothing to do)\n` +
272
+ ` To re-register (e.g. with a new token): ${remove} && uploads install mcp\n`);
273
+ return;
274
+ }
183
275
  }
184
- else if (r.ok) {
276
+ if (r.ok) {
185
277
  process.stdout.write(`${step}: ok\n`);
186
278
  if (verbose && r.output) {
187
279
  process.stdout.write(` ${redact(r.output).split("\n").join("\n ")}\n`);
188
280
  }
281
+ return;
189
282
  }
190
- else {
191
- process.stderr.write(`${step}: failed — ${redact(r.error ?? "")}\n`);
192
- if (verbose)
193
- process.stderr.write(` command: ${cmd}\n`);
194
- }
283
+ process.stderr.write(`${step}: failed — ${redact(r.error ?? "")}\n`);
284
+ if (verbose)
285
+ process.stderr.write(` command: ${cmd}\n`);
195
286
  }
196
287
  /** Shared error when every skill step failed identically; otherwise undefined. */
197
288
  function identicalSkillFailure(skillEntries) {
@@ -203,27 +294,41 @@ function identicalSkillFailure(skillEntries) {
203
294
  const allSame = skillEntries.every(([, r]) => !r.ok && !r.skipped && r.error === error);
204
295
  return allSame ? error : undefined;
205
296
  }
297
+ function printMcpHumanSteps(mcp, redact, verbose, mcpName) {
298
+ const first = mcp[0];
299
+ if (!first)
300
+ return;
301
+ if (mcp.every(([, r]) => r.skipped === "sign-in")) {
302
+ printOneHumanStep("mcp", first[1], redact, verbose, mcpName);
303
+ return;
304
+ }
305
+ if (mcp.every(([, r]) => r.skipped === "missing-cli")) {
306
+ process.stdout.write(`mcp: skipped — no agent CLI on PATH (${MCP_CLIENT_BINARIES}). Skills and hooks still work.\n`);
307
+ return;
308
+ }
309
+ for (const [step, r] of mcp) {
310
+ printOneHumanStep(step, r, redact, verbose, mcpName);
311
+ }
312
+ }
206
313
  /** Collapse identical skill failures to one `skills:` line (missing npx, old npm, …). */
207
314
  function printHumanSteps(results, redact, verbose, mcpName) {
208
- const entries = Object.entries(results);
209
- const skillEntries = entries.filter(([step]) => step.startsWith("skill:"));
210
- const sharedError = identicalSkillFailure(skillEntries);
315
+ const { skills, mcp, other } = partitionSteps(results);
316
+ const sharedError = identicalSkillFailure(skills);
211
317
  if (sharedError !== undefined) {
212
318
  process.stderr.write(`skills: failed — ${redact(sharedError)}\n`);
213
319
  if (verbose) {
214
- for (const [step, r] of skillEntries) {
320
+ for (const [step, r] of skills) {
215
321
  process.stderr.write(` ${step}: ${redact(r.command.join(" "))}\n`);
216
322
  }
217
323
  }
218
324
  }
219
325
  else {
220
- for (const [step, r] of skillEntries) {
326
+ for (const [step, r] of skills) {
221
327
  printOneHumanStep(step, r, redact, verbose, mcpName);
222
328
  }
223
329
  }
224
- for (const [step, r] of entries) {
225
- if (step.startsWith("skill:"))
226
- continue;
330
+ printMcpHumanSteps(mcp, redact, verbose, mcpName);
331
+ for (const [step, r] of other) {
227
332
  printOneHumanStep(step, r, redact, verbose, mcpName);
228
333
  }
229
334
  }
@@ -288,24 +393,27 @@ export async function runInstall(args, opts, help = false) {
288
393
  }
289
394
  }
290
395
  if (target === "mcp" || target === "all") {
291
- if (!dryRun && !token) {
292
- results.mcp = {
293
- command: mcpCommand(name, url, "<token>"),
294
- ok: false,
295
- skipped: "sign-in",
296
- error: "needs sign-in run `uploads login`, then `uploads install mcp`",
297
- };
298
- }
299
- else {
300
- const command = mcpCommand(name, url, token || "<token>");
301
- if (human)
302
- process.stdout.write("Installing MCP server…\n");
303
- const step = dryRun
304
- ? { command, ok: true, skipped: "dry-run" }
305
- : runStep(run, command);
306
- results.mcp = alreadyConfigured(step)
307
- ? { command, ok: true, skipped: "already-configured", output: step.error }
308
- : step;
396
+ const bearer = token || "<token>";
397
+ const skipSignIn = !dryRun && !token;
398
+ if (human && !skipSignIn)
399
+ process.stdout.write("Installing MCP server…\n");
400
+ for (const client of MCP_CLIENTS) {
401
+ const command = client.command(name, url, bearer);
402
+ const key = mcpStepKey(client);
403
+ if (skipSignIn) {
404
+ results[key] = {
405
+ command,
406
+ ok: false,
407
+ skipped: "sign-in",
408
+ error: "needs sign-in — run `uploads login`, then `uploads install mcp`",
409
+ };
410
+ }
411
+ else if (dryRun) {
412
+ results[key] = { command, ok: true, skipped: "dry-run" };
413
+ }
414
+ else {
415
+ results[key] = runMcpClientStep(run, command);
416
+ }
309
417
  }
310
418
  }
311
419
  if (target === "hooks" || target === "all") {
@@ -345,28 +453,25 @@ export async function runInstall(args, opts, help = false) {
345
453
  (dryRun || (human && (verbose || hookWrites.some((w) => w.action !== "skipped"))))) {
346
454
  printHookResults(hookWrites);
347
455
  }
348
- const skillResults = Object.entries(results)
349
- .filter(([step]) => step.startsWith("skill:"))
350
- .map(([, r]) => r);
456
+ const { skills: skillEntries, mcp: mcpEntries } = partitionSteps(results);
457
+ const skillResults = skillEntries.map(([, r]) => r);
351
458
  const skillsOk = skillResults.length > 0 && skillResults.every((r) => r.ok);
352
459
  const skillsFailed = skillResults.some((r) => !r.ok);
460
+ const mcpResults = mcpEntries.map(([, r]) => r);
461
+ const mcpFailed = mcpResults.some((r) => !r.ok);
353
462
  if (!failed && !dryRun) {
354
463
  const stepLabels = [
355
- ...new Set(Object.keys(results).map((k) => (k.startsWith("skill:") ? "skills" : k))),
464
+ ...new Set(Object.entries(results)
465
+ .filter(([, r]) => r.ok && r.skipped !== "missing-cli" && r.skipped !== "sign-in")
466
+ .map(([k]) => stepFamily(k))),
356
467
  ];
357
- printSuccessFooter(stepLabels, signedIn);
468
+ if (stepLabels.length > 0)
469
+ printSuccessFooter(stepLabels, signedIn);
358
470
  }
359
- else if (failed && !dryRun && skillsOk && results.mcp && !results.mcp.ok) {
360
- let next;
361
- if (results.mcp.skipped === "sign-in") {
362
- next = "Sign in with `uploads login`, then re-run `uploads install mcp`.";
363
- }
364
- else if (results.mcp.error?.includes("not found on PATH")) {
365
- next = "Install the Claude Code CLI (or skip MCP), then re-run `uploads install mcp`.";
366
- }
367
- else {
368
- next = "Fix the MCP step above, then re-run `uploads install mcp`.";
369
- }
471
+ else if (failed && !dryRun && skillsOk && mcpFailed) {
472
+ const next = mcpResults.every((r) => r.skipped === "sign-in")
473
+ ? "Sign in with `uploads login`, then re-run `uploads install mcp`."
474
+ : "Fix the MCP step above, then re-run `uploads install mcp`.";
370
475
  process.stdout.write(`\nSkills are installed. ${next}\n`);
371
476
  }
372
477
  else if (failed && !dryRun && skillsFailed) {
@@ -459,7 +459,7 @@ export async function runLogin(args, opts, help = false, deviceIo = defaultDevic
459
459
  process.stdout.write(`saved credentials to ${path}\napi: ${savedApiUrl}\nworkspace: ${result.workspace}\ntoken: ${redactToken(result.token)}\n`);
460
460
  process[doctor.ok ? "stdout" : "stderr"].write(`doctor: ${checked ? (doctor.ok ? "ok" : `failed — ${doctor.error}`) : "skipped"}\n`);
461
461
  if (doctor.ok)
462
- process.stdout.write("\nusing a coding agent? run `uploads install` to add the uploads skill + MCP server to Claude Code\n");
462
+ process.stdout.write("\nusing a coding agent? run `uploads install` to add the uploads skill + MCP server\n");
463
463
  }
464
464
  return doctor.ok ? 0 : 1;
465
465
  }
@@ -82,7 +82,16 @@ Options:
82
82
  --no-hide-dev-tools Don't auto-hide framework dev toolbars (auto-hidden on localhost/private)
83
83
  --reduced-motion Emulate prefers-reduced-motion: reduce so animations settle (best-effort
84
84
  on --via remote — neutralizes animations via injected CSS)
85
- --eval <js> Run JS in the page after settle, before capture (--via local only)
85
+ --wait-for <js> Poll this JS expression in the page until truthy before --eval and
86
+ capture (--via local only). Bridges framework hydration: load/
87
+ networkidle settle before React/Next attach handlers, so a synthetic
88
+ click in --eval hits the inert server-rendered DOM. Express the app's
89
+ own "interactive" signal, e.g. --wait-for 'window.__hydrated===true' or
90
+ --wait-for 'document.querySelector("[data-hydrated]")'. Times out with
91
+ the capture timeout if it never becomes truthy.
92
+ --eval <js> Run JS in the page after settle, before capture (--via local only).
93
+ Note: synthetic events (el.click()) won't reach framework handlers
94
+ until the app hydrates — pair with --wait-for on React/Next apps.
86
95
  --init-script <file> Inject a JS file before navigation (--via local only)
87
96
  --annotate <file|-> Bake hand-drawn boxes, arrows, labels, and redactions from a JSON
88
97
  annotation spec onto the capture before upload (file path or - for
@@ -216,6 +225,7 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
216
225
  // lets captureScreenshot apply its localhost-aware default.
217
226
  const hideDevTools = flagBool(parsed.flags, "--no-hide-dev-tools") ? false : undefined;
218
227
  const reducedMotion = flagBool(parsed.flags, "--reduced-motion");
228
+ const waitForExpr = flagString(parsed.flags, "--wait-for");
219
229
  const evalJs = flagString(parsed.flags, "--eval");
220
230
  const initScriptPath = flagString(parsed.flags, "--init-script");
221
231
  let initScript;
@@ -445,6 +455,7 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
445
455
  hide,
446
456
  hideDevTools,
447
457
  reducedMotion,
458
+ waitForExpr,
448
459
  evalJs,
449
460
  initScript,
450
461
  // Skip folding when an explicit --key was given — --key sets the whole
@@ -11,8 +11,9 @@ const UPDATE_HELP = `uploads update — update the CLI and refresh agent integra
11
11
  Upgrades the globally installed npm package, then re-runs \`uploads install\` so
12
12
  the agent skills match the new version. Skills drift on their own, so this
13
13
  refreshes them even when the CLI is already current. An MCP server already
14
- registered is left as-is (\`already configured\`) — \`claude mcp add\` never
15
- overwrites an existing entry.
14
+ registered is left as-is (\`already configured\`) — \`claude mcp add\` (and the
15
+ Codex/Grok equivalents) never overwrite an existing entry. A missing agent CLI
16
+ is skipped so it does not fail the rest of the refresh.
16
17
 
17
18
  Usage:
18
19
  uploads update [options]
@@ -62,6 +62,15 @@ export interface LocalCaptureOptions {
62
62
  hide?: string[];
63
63
  /** Emulate prefers-reduced-motion: reduce so animations settle deterministically. */
64
64
  reducedMotion?: boolean;
65
+ /**
66
+ * JS expression polled in the page (page.waitForFunction) after settle and
67
+ * before `evalJs`/capture — the caller's "app is interactive" signal. Lets a
68
+ * synthetic click in `evalJs` land after a framework (React/Next/…) has
69
+ * hydrated and attached its handlers, instead of firing on the still-inert
70
+ * server-rendered DOM. Throws `RENDER_FAILED` if it never becomes truthy
71
+ * within the capture timeout.
72
+ */
73
+ waitForExpr?: string;
65
74
  /** JS run via page.evaluate after settle, before capture. */
66
75
  evalJs?: string;
67
76
  /** JS injected via addInitScript before navigation. */
@@ -346,9 +346,28 @@ export async function captureLocal(opts) {
346
346
  }
347
347
  }
348
348
  const waitUntil = typeof opts.waitUntil === "string" ? opts.waitUntil : "load";
349
- await page.goto(opts.url, { waitUntil, timeout: opts.timeoutMs ?? 30_000 });
349
+ const timeoutMs = opts.timeoutMs ?? 30_000;
350
+ await page.goto(opts.url, { waitUntil, timeout: timeoutMs });
350
351
  if (typeof opts.waitUntil === "number")
351
352
  await page.waitForTimeout(opts.waitUntil);
353
+ // Hydration-aware gate (issue #715): poll the caller's "app is interactive"
354
+ // predicate before running any eval or capturing. The load/networkidle
355
+ // settle strategies fire before a framework hydrates, so a synthetic
356
+ // click in `evalJs` would hit the inert server-rendered DOM with no
357
+ // handler attached. Waiting for the caller's own signal (e.g.
358
+ // `window.__hydrated === true`, or a class/attribute the app sets once
359
+ // interactive) closes that gap. A timeout means the predicate never
360
+ // became truthy — surface it clearly rather than capturing the un-ready
361
+ // page silently.
362
+ if (opts.waitForExpr) {
363
+ try {
364
+ await page.waitForFunction(opts.waitForExpr, undefined, { timeout: timeoutMs });
365
+ }
366
+ catch (err) {
367
+ throw new UploadsError(`--wait-for expression never became truthy within ${timeoutMs}ms: ${opts.waitForExpr}` +
368
+ ` (${err instanceof Error ? err.message : String(err)})`, "RENDER_FAILED");
369
+ }
370
+ }
352
371
  // Hide overlays first, then run any user eval (which may depend on, or
353
372
  // deliberately override, the hidden state).
354
373
  if (opts.hide && opts.hide.length > 0) {
@@ -99,6 +99,13 @@ export interface CaptureScreenshotOptions {
99
99
  hideDevTools?: boolean;
100
100
  /** Emulate prefers-reduced-motion: reduce so CSS/JS animations settle. */
101
101
  reducedMotion?: boolean;
102
+ /**
103
+ * JS expression polled in the page until truthy after settle, before
104
+ * `evalJs` and capture — the caller's "app is interactive" signal so a
105
+ * synthetic click in `evalJs` lands after framework hydration (issue #715).
106
+ * Local backend only — throws if the resolved backend is remote.
107
+ */
108
+ waitForExpr?: string;
102
109
  /** Run this JS in the page after settle, before capture (local backend only). */
103
110
  evalJs?: string;
104
111
  /** Inject this JS as an init script before navigation (local backend only). */
@@ -127,6 +134,7 @@ export interface CaptureScreenshotOptions {
127
134
  waitUntil: WaitUntil;
128
135
  hide?: string[];
129
136
  reducedMotion?: boolean;
137
+ waitForExpr?: string;
130
138
  evalJs?: string;
131
139
  initScript?: string;
132
140
  measureSelectors?: string[];
@@ -290,6 +290,12 @@ export async function captureScreenshot(opts) {
290
290
  if (backend === "remote" && (opts.evalJs !== undefined || opts.initScript !== undefined)) {
291
291
  throw new UploadsError("--eval and --init-script are local-only — use --via local", "USAGE");
292
292
  }
293
+ // --wait-for polls a JS predicate via the live local page (page.waitForFunction);
294
+ // the remote renderer has no eval escape hatch to evaluate it. Fail fast
295
+ // rather than silently ignore the caller's readiness signal.
296
+ if (backend === "remote" && opts.waitForExpr !== undefined) {
297
+ throw new UploadsError("--wait-for is local-only — use --via local", "USAGE");
298
+ }
293
299
  // Selector-based annotation measurement needs a live local page — the
294
300
  // remote render endpoint has no eval escape hatch to run
295
301
  // getBoundingClientRect. Covers both explicit --via remote and auto
@@ -315,6 +321,7 @@ export async function captureScreenshot(opts) {
315
321
  waitUntil,
316
322
  hide,
317
323
  reducedMotion: opts.reducedMotion,
324
+ waitForExpr: opts.waitForExpr,
318
325
  evalJs: opts.evalJs,
319
326
  initScript: opts.initScript,
320
327
  measureSelectors: opts.measureSelectors,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.45.0",
3
+ "version": "0.46.1",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,