@buildinternet/uploads 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -91,8 +91,20 @@ attachments when one opens: automatically via the
91
91
  [GitHub App](https://uploads.sh/docs/github-app) webhook, or on the first
92
92
  `attach` after the PR exists (`--promote` forces it with no new files,
93
93
  `--no-promote` opts out). `uploads github link` inspects or claims the
94
- workspace↔repo binding the webhook path uses. Promoted staging is cleaned up
95
- server-side after ~7 days (~30 for branches that never got a PR).
94
+ workspace↔repo binding the webhook path uses. Promotion is copy-and-keep
95
+ the staged original is never deleted, so any URL already embedded keeps
96
+ serving — and staged objects follow only normal per-workspace retention and
97
+ explicit deletes. Promotion (auto or `--promote`) does skip files staged
98
+ more than 30 days before the PR opens, though; they're still there, just no
99
+ longer auto-promoted.
100
+
101
+ **Bare `put` stages too, by default (issue #403):** on a non-default git
102
+ branch, a `put` with none of
103
+ `--pr`/`--issue`/`--key`/`--ref`/`--prefix`/`--destination` set
104
+ (and not `--no-git`) stages exactly like `attach --branch` — same key,
105
+ same `gh.*` metadata. The classic dated layout
106
+ (`<prefix>/<repo>/<ref-or-date>/<name>`) remains the default branch/detached
107
+ HEAD/non-repo/`--no-git` behavior, and the explicit-flags opt-out.
96
108
 
97
109
  **Screenshot capture:** `uploads screenshot <url|file.html>` renders a page to a
98
110
  hosted image in one step — no separate browser tooling needed. `--via auto`
@@ -114,6 +114,10 @@ export const ROOT_COMMANDS = [
114
114
  summary: "Upload (+ URL + markdown for GitHub)",
115
115
  essential: true,
116
116
  },
117
+ {
118
+ name: "staged",
119
+ summary: "Show what's staged for a branch, and whether it will auto-attach",
120
+ },
117
121
  {
118
122
  name: "screenshot",
119
123
  usage: "screenshot <target>",
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { UploadsError } from "./errors.js";
4
4
  import { commandWorkspace, flagString, isHelpFlag, parseArgv, parseCommandArgs, UsageError, } from "./cli-args.js";
5
5
  import { formatRootHelp, wantsFullHelp } from "./cli-help.js";
6
6
  import { colorEnabled, createStyle } from "./cli-style.js";
7
- import { runPut, runAttach, runList, runFind, runMeta, runDelete, runHealth, runDoctor, runComment, runGithub, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
7
+ import { runPut, runAttach, runStaged, runList, runFind, runMeta, runDelete, runHealth, runDoctor, runComment, runGithub, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
8
8
  import { runConfig } from "./commands/config.js";
9
9
  import { runSetup } from "./commands/setup.js";
10
10
  import { runLogin } from "./commands/login.js";
@@ -269,6 +269,7 @@ export async function runCli(argv) {
269
269
  break;
270
270
  case "attach":
271
271
  case "put":
272
+ case "staged":
272
273
  case "screenshot":
273
274
  case "gallery":
274
275
  case "list":
@@ -289,6 +290,9 @@ export async function runCli(argv) {
289
290
  case "put":
290
291
  code = await runPut(ctx, cmdArgs, showHelp);
291
292
  break;
293
+ case "staged":
294
+ code = await runStaged(ctx, cmdArgs, showHelp);
295
+ break;
292
296
  case "screenshot":
293
297
  code = await runScreenshot(ctx, cmdArgs, showHelp);
294
298
  break;
package/dist/client.d.ts CHANGED
@@ -249,6 +249,16 @@ export interface GithubLinkUnlinkResult {
249
249
  unlinked: boolean;
250
250
  reason?: "not_linked";
251
251
  }
252
+ /**
253
+ * `GET /v1/:workspace/github/repo-link` result (issue #398). Deliberately
254
+ * minimal relative to `GithubLinkResult`: never names the owning workspace
255
+ * when it isn't this one — "self"/"other"/"none" is all the stage-time
256
+ * warning needs, and anything richer would leak cross-tenant info to a
257
+ * caller that's just probing a repo it detected from local git context.
258
+ */
259
+ export interface GithubRepoLinkResult {
260
+ binding: "self" | "other" | "none";
261
+ }
252
262
  export interface HealthResult {
253
263
  ok: boolean;
254
264
  }
@@ -528,6 +538,14 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
528
538
  * server without this route.
529
539
  */
530
540
  githubLinkClaim(repo: string): Promise<GithubLinkClaimResult>;
541
+ /**
542
+ * Tri-state binding status for `repo` relative to this workspace (issue
543
+ * #398, `attach --branch`'s stage-time warning) — never names another
544
+ * workspace, unlike `githubLinkStatus` above. Throws `UploadsError`
545
+ * (status 404) on an older/self-hosted server without this route; the
546
+ * caller (the stage warning) treats ANY failure here as "stay silent".
547
+ */
548
+ githubRepoLinkStatus(repo: string): Promise<GithubRepoLinkResult>;
531
549
  /**
532
550
  * GitHub App configuration + webhook event subscription check. Throws
533
551
  * `UploadsError` (status 404) on an older/self-hosted server without
package/dist/client.js CHANGED
@@ -516,6 +516,16 @@ export function createUploadsClient(config) {
516
516
  headers: { "Content-Type": "application/json" },
517
517
  });
518
518
  },
519
+ /**
520
+ * Tri-state binding status for `repo` relative to this workspace (issue
521
+ * #398, `attach --branch`'s stage-time warning) — never names another
522
+ * workspace, unlike `githubLinkStatus` above. Throws `UploadsError`
523
+ * (status 404) on an older/self-hosted server without this route; the
524
+ * caller (the stage warning) treats ANY failure here as "stay silent".
525
+ */
526
+ async githubRepoLinkStatus(repo) {
527
+ return request("GET", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/repo-link?repo=${encodeURIComponent(repo)}`);
528
+ },
519
529
  /**
520
530
  * GitHub App configuration + webhook event subscription check. Throws
521
531
  * `UploadsError` (status 404) on an older/self-hosted server without
@@ -82,6 +82,13 @@ export interface UploadPreparedImageOptions {
82
82
  optimize: OptimizeImageOptions;
83
83
  /** gh attachment key wins over `key` when both are set (matches every call site). */
84
84
  ghTarget?: GhTarget;
85
+ /**
86
+ * Branch-staged key (issue #403): wins over `key`, loses to `ghTarget` (the
87
+ * two are mutually exclusive at every call site — a PR/issue target always
88
+ * implies staging is moot). Produces the exact same key as `attach
89
+ * --branch` for the same filename via `ghBranchAttachmentKey`.
90
+ */
91
+ ghBranchTarget?: BranchTarget;
85
92
  key?: string;
86
93
  prefix?: string;
87
94
  repo?: string;
@@ -258,6 +265,8 @@ export declare function uploadPuts(opts: {
258
265
  /** Single-file --key. */
259
266
  explicitKey?: string;
260
267
  ghTarget?: GhTarget;
268
+ /** Branch-staged key (issue #403) — see UploadPreparedImageOptions.ghBranchTarget. */
269
+ ghBranchTarget?: BranchTarget;
261
270
  prefix?: string;
262
271
  repo?: string;
263
272
  ref?: string;
@@ -290,6 +299,106 @@ export declare function uploadPuts(opts: {
290
299
  firstError?: unknown;
291
300
  }>;
292
301
  export declare function runAttach(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
302
+ /**
303
+ * One source of truth for the "staged, but not going to auto-attach" advisory
304
+ * text (issue #398), shared by the `attach --branch`/bare-`put` stage-time
305
+ * warning below and the `uploads staged` view (issue #405) — both surfaces
306
+ * must say the exact same thing for the same binding state, verified by
307
+ * tests on both call sites. Returns undefined for `"self"` (the happy path —
308
+ * callers each phrase that themselves) and any unrecognized value.
309
+ */
310
+ export declare function stagingBindingAdvisory(binding: string, repo: string): string | undefined;
311
+ /**
312
+ * Best-effort stage-time binding warning (issue #398): after `attach
313
+ * --branch` stages files, checks whether `repo` is bound to THIS workspace —
314
+ * webhook auto-promotion at PR open only fires for a repo already bound
315
+ * (#297), and staging alone never binds one. Fires only for `binding: "none"`
316
+ * (unbound) or `"other"` (bound elsewhere); `"self"` and any failure
317
+ * (network, non-200, older server without the route, `binding: "unknown"`)
318
+ * are silent — this is advisory only and must never make staging look like
319
+ * it failed. Same suppression as the #393 put nudge: `--quiet`,
320
+ * `UPLOADS_NO_NUDGE=1` (env or config).
321
+ */
322
+ export declare function resolveStageBindingWarning(opts: {
323
+ ctx: CliContext;
324
+ defaults: PutDefaults;
325
+ repo: string;
326
+ }): Promise<string | undefined>;
327
+ /**
328
+ * Bare-put branch-staging trigger (issue #403): put on a non-default git
329
+ * branch stages to the branch prefix by default — the branch becomes the
330
+ * organizing unit instead of the date, superseding the #393 CLI nudge for
331
+ * this case (see `putStagingNoteText`). Reuses the same detection stack as
332
+ * `resolvePutNudge` (`deriveRepoFromGit` / `resolveCurrentBranch` /
333
+ * `resolveDefaultBranch` / main-master fallback) plus a `resolveRepo` lookup
334
+ * (needed for the "owner/name" staging key) and an explicit-flag opt-out:
335
+ * `ghTarget`/`keyHint`/`refArg`/`prefixArg`/`destinationArg` set, or `noGit`,
336
+ * forces the classic dated (or typed-destination) layout. Never throws — any
337
+ * failure (not a repo, detached HEAD, gh missing/unauthenticated/timed out,
338
+ * unresolvable repo) degrades to "no staging", leaving the caller to fall
339
+ * back to the dated path.
340
+ *
341
+ * Plain-params (not CLI `flags`) so both `runPut` and the local stdio MCP
342
+ * `put` tool — same staging default, issue #403's scope — can call this
343
+ * without either depending on the other's argument shape.
344
+ */
345
+ export declare function resolvePutStagingTarget(opts: {
346
+ ghTarget: GhTarget | undefined;
347
+ keyHint: string | undefined;
348
+ refArg: string | undefined;
349
+ prefixArg: string | undefined;
350
+ /** Explicit `--destination` (CLI) / `destination` (MCP) also opts out — it
351
+ * resolves to its own prefix via `resolvePutPrefix`, which staging would
352
+ * otherwise silently override. */
353
+ destinationArg: string | undefined;
354
+ noGit: boolean;
355
+ repoArg: string | undefined;
356
+ run: CommandRunner;
357
+ }): BranchTarget | undefined;
358
+ /**
359
+ * The bare-put staging note's wording (issue #403): replaces the #393 nudge
360
+ * for the (now default) case where a bare put on a non-default branch stages
361
+ * to the branch prefix instead of landing on the dated layout. Used verbatim
362
+ * for both the human-mode stderr line and the JSON `hint` field.
363
+ */
364
+ export declare function putStagingNoteText(branch: string): string;
365
+ /** One file currently staged for a branch (issue #405). */
366
+ export interface StagedFile {
367
+ /** Full object key (`gh/<owner>/<repo>/branch/<branch>/<filename>`). */
368
+ key: string;
369
+ /** `key` with the staging prefix stripped. */
370
+ filename: string;
371
+ size?: number;
372
+ /** `gh.staged-at` metadata (ISO 8601 UTC), when present. */
373
+ stagedAt?: string;
374
+ url: string | null;
375
+ }
376
+ /** Tri-state binding, folded into a ready-to-render advisory (issue #405/#398). */
377
+ export interface StagedBinding {
378
+ state: "self" | "other" | "none" | "unknown";
379
+ /** True only for "self" — the only state where staged files actually auto-attach. */
380
+ autoAttach: boolean;
381
+ message: string;
382
+ }
383
+ export interface StagedResult {
384
+ repo: string;
385
+ branch: string;
386
+ files: StagedFile[];
387
+ binding: StagedBinding;
388
+ }
389
+ /**
390
+ * Shared core for `uploads staged` (CLI) and the `staged` MCP tool (issue
391
+ * #405): one `list` call against the branch staging prefix
392
+ * (`ghBranchKeyPrefix` — never hand-built) plus the #398 binding check. Never
393
+ * throws on the binding check (see `resolveStagedBinding`); a failed `list`
394
+ * call still propagates, same as every other read command.
395
+ */
396
+ export declare function resolveStaged(opts: {
397
+ client: UploadsClient;
398
+ repo: string;
399
+ branch: string;
400
+ }): Promise<StagedResult>;
401
+ export declare function runStaged(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
293
402
  export declare function runPut(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
294
403
  export declare function runGallery(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
295
404
  export declare function runList(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
package/dist/commands.js CHANGED
@@ -11,7 +11,7 @@ import { writeJson, writeStdout } from "./io.js";
11
11
  import { imageFactsFromBytes } from "./image-facts.js";
12
12
  import { parseMetaFlags, validateMetaMap } from "./metadata.js";
13
13
  import { mergeDerivedMeta, nearMissMetaWarnings, validateStateValue } from "./metadata-vocab.js";
14
- import { ghAttachmentKey, ghBranchAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, normalizeGithubCoordinate, } from "./github.js";
14
+ import { ghAttachmentKey, ghBranchAttachmentKey, ghBranchKeyPrefix, ghKeyPrefix, ghMetadataFromTarget, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, normalizeGithubCoordinate, } from "./github.js";
15
15
  import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
16
16
  import { deriveRepoFromGit } from "./keys.js";
17
17
  import { resolvePutPrefix } from "./destinations.js";
@@ -373,7 +373,11 @@ export async function uploadPreparedImage(client, bytes, sourceName, opts) {
373
373
  frameFit: opts.frame.frameFit,
374
374
  optimize: opts.optimize,
375
375
  });
376
- let key = opts.ghTarget ? ghAttachmentKey(opts.ghTarget, prepared.filename) : opts.key;
376
+ let key = opts.ghTarget
377
+ ? ghAttachmentKey(opts.ghTarget, prepared.filename)
378
+ : opts.ghBranchTarget
379
+ ? ghBranchAttachmentKey(opts.ghBranchTarget.repo, opts.ghBranchTarget.branch, prepared.filename)
380
+ : opts.key;
377
381
  if (key && prepared.optimized)
378
382
  key = rewriteKeyExtension(key, prepared.filename);
379
383
  const result = await client.put(prepared.bytes, {
@@ -743,6 +747,7 @@ export async function uploadPuts(opts) {
743
747
  frame: opts.frame,
744
748
  optimize: opts.optimize,
745
749
  ghTarget: opts.ghTarget,
750
+ ghBranchTarget: opts.ghBranchTarget,
746
751
  key: opts.explicitKey,
747
752
  prefix: opts.prefix,
748
753
  repo: opts.repo,
@@ -1009,8 +1014,17 @@ async function runAttachBranch(ctx, parsed, branch, run) {
1009
1014
  if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
1010
1015
  throw firstError instanceof Error ? firstError : new Error(String(firstError));
1011
1016
  }
1017
+ // Stage-time binding warning (issue #398): only worth checking once staging
1018
+ // actually produced something to warn about. Best-effort — see
1019
+ // resolveStageBindingWarning; never affects exit code or the upload itself.
1020
+ const bindingWarning = uploads.length > 0 ? await resolveStageBindingWarning({ ctx, defaults, repo }) : undefined;
1012
1021
  if (ctx.json) {
1013
- await writeJson({ target, uploads, failures });
1022
+ await writeJson({
1023
+ target,
1024
+ uploads,
1025
+ failures,
1026
+ ...(bindingWarning ? { hint: bindingWarning } : {}),
1027
+ });
1014
1028
  }
1015
1029
  else {
1016
1030
  for (const result of uploads) {
@@ -1034,9 +1048,58 @@ async function runAttachBranch(ctx, parsed, branch, run) {
1034
1048
  process.stderr.write(`>> staged: these auto-attach to this branch's PR when it opens ` +
1035
1049
  `(or run \`uploads attach --promote\` after opening)\n`);
1036
1050
  }
1051
+ if (bindingWarning)
1052
+ process.stderr.write(`${bindingWarning}\n`);
1037
1053
  }
1038
1054
  return failures.length === 0 ? 0 : 1;
1039
1055
  }
1056
+ /**
1057
+ * One source of truth for the "staged, but not going to auto-attach" advisory
1058
+ * text (issue #398), shared by the `attach --branch`/bare-`put` stage-time
1059
+ * warning below and the `uploads staged` view (issue #405) — both surfaces
1060
+ * must say the exact same thing for the same binding state, verified by
1061
+ * tests on both call sites. Returns undefined for `"self"` (the happy path —
1062
+ * callers each phrase that themselves) and any unrecognized value.
1063
+ */
1064
+ export function stagingBindingAdvisory(binding, repo) {
1065
+ switch (binding) {
1066
+ case "none":
1067
+ return (`staged, but ${repo} isn't linked to your workspace yet — staged files only ` +
1068
+ `auto-attach on PR open for linked repos. Link it once with: uploads attach <file> ` +
1069
+ `(on any PR) or uploads github link. After the PR opens: uploads attach --promote`);
1070
+ case "other":
1071
+ return (`staged, but ${repo} is linked to a different workspace — these files won't ` +
1072
+ `auto-attach from here.`);
1073
+ default:
1074
+ return undefined; // "self", or any unrecognized value
1075
+ }
1076
+ }
1077
+ /**
1078
+ * Best-effort stage-time binding warning (issue #398): after `attach
1079
+ * --branch` stages files, checks whether `repo` is bound to THIS workspace —
1080
+ * webhook auto-promotion at PR open only fires for a repo already bound
1081
+ * (#297), and staging alone never binds one. Fires only for `binding: "none"`
1082
+ * (unbound) or `"other"` (bound elsewhere); `"self"` and any failure
1083
+ * (network, non-200, older server without the route, `binding: "unknown"`)
1084
+ * are silent — this is advisory only and must never make staging look like
1085
+ * it failed. Same suppression as the #393 put nudge: `--quiet`,
1086
+ * `UPLOADS_NO_NUDGE=1` (env or config).
1087
+ */
1088
+ export async function resolveStageBindingWarning(opts) {
1089
+ const { ctx, defaults, repo } = opts;
1090
+ if (ctx.quiet)
1091
+ return undefined;
1092
+ if (defaults.noNudge)
1093
+ return undefined;
1094
+ try {
1095
+ const { binding } = await ctx.client.githubRepoLinkStatus(repo);
1096
+ const advisory = stagingBindingAdvisory(binding, repo);
1097
+ return advisory ? `note: ${advisory}` : undefined;
1098
+ }
1099
+ catch {
1100
+ return undefined; // any failure (network, non-200, older server) — stay silent
1101
+ }
1102
+ }
1040
1103
  /**
1041
1104
  * `attach --promote` with zero file arguments: resolve the PR target (same
1042
1105
  * resolution as the default `runAttach` path), promote this workspace's
@@ -1156,6 +1219,191 @@ function resolvePutNudge(opts) {
1156
1219
  return undefined;
1157
1220
  }
1158
1221
  }
1222
+ /**
1223
+ * Bare-put branch-staging trigger (issue #403): put on a non-default git
1224
+ * branch stages to the branch prefix by default — the branch becomes the
1225
+ * organizing unit instead of the date, superseding the #393 CLI nudge for
1226
+ * this case (see `putStagingNoteText`). Reuses the same detection stack as
1227
+ * `resolvePutNudge` (`deriveRepoFromGit` / `resolveCurrentBranch` /
1228
+ * `resolveDefaultBranch` / main-master fallback) plus a `resolveRepo` lookup
1229
+ * (needed for the "owner/name" staging key) and an explicit-flag opt-out:
1230
+ * `ghTarget`/`keyHint`/`refArg`/`prefixArg`/`destinationArg` set, or `noGit`,
1231
+ * forces the classic dated (or typed-destination) layout. Never throws — any
1232
+ * failure (not a repo, detached HEAD, gh missing/unauthenticated/timed out,
1233
+ * unresolvable repo) degrades to "no staging", leaving the caller to fall
1234
+ * back to the dated path.
1235
+ *
1236
+ * Plain-params (not CLI `flags`) so both `runPut` and the local stdio MCP
1237
+ * `put` tool — same staging default, issue #403's scope — can call this
1238
+ * without either depending on the other's argument shape.
1239
+ */
1240
+ export function resolvePutStagingTarget(opts) {
1241
+ const { ghTarget, keyHint, refArg, prefixArg, destinationArg, noGit, repoArg, run } = opts;
1242
+ if (ghTarget || keyHint || noGit)
1243
+ return undefined;
1244
+ if (refArg || prefixArg || destinationArg)
1245
+ return undefined;
1246
+ try {
1247
+ if (deriveRepoFromGit(run) === undefined)
1248
+ return undefined; // not a (usable) git repo
1249
+ let branch;
1250
+ try {
1251
+ branch = resolveCurrentBranch(run);
1252
+ }
1253
+ catch {
1254
+ return undefined; // detached HEAD, or git unavailable
1255
+ }
1256
+ const defaultBranch = resolveDefaultBranch(run);
1257
+ const onDefaultBranch = defaultBranch
1258
+ ? branch === defaultBranch
1259
+ : branch === "main" || branch === "master"; // undetermined: err toward the dated layout
1260
+ if (onDefaultBranch)
1261
+ return undefined;
1262
+ // Same bounded-timeout treatment as the #393 nudge's `gh pr view` call —
1263
+ // this is best-effort, and must never be felt as a hang.
1264
+ const timed = run === execRunner ? timedExecRunner(PUT_NUDGE_GH_TIMEOUT_MS) : run;
1265
+ const repo = resolveRepo(repoArg, timed);
1266
+ return { repo, branch };
1267
+ }
1268
+ catch {
1269
+ return undefined; // gh/git unavailable, or repo unresolvable — dated layout
1270
+ }
1271
+ }
1272
+ /**
1273
+ * The bare-put staging note's wording (issue #403): replaces the #393 nudge
1274
+ * for the (now default) case where a bare put on a non-default branch stages
1275
+ * to the branch prefix instead of landing on the dated layout. Used verbatim
1276
+ * for both the human-mode stderr line and the JSON `hint` field.
1277
+ */
1278
+ export function putStagingNoteText(branch) {
1279
+ return (`note: staged for branch ${branch} — auto-attaches to this branch's PR when it opens ` +
1280
+ `(or run: uploads attach --promote once it exists). Use --ref/--prefix for a plain dated upload.`);
1281
+ }
1282
+ /**
1283
+ * Binding lookup for `uploads staged`, folded into a renderable `StagedBinding`.
1284
+ * `"none"`/`"other"` reuse `stagingBindingAdvisory` — the exact #398 wording,
1285
+ * one source of truth. `"self"` gets its own message (the #398 warning stays
1286
+ * silent on "self"; this view is the one place that names the happy path
1287
+ * explicitly). Any failure (network, non-200, older server without the
1288
+ * route) degrades to `"unknown"` rather than throwing — this is a read-only
1289
+ * view and a binding check failing must never make it fail outright.
1290
+ */
1291
+ async function resolveStagedBinding(client, repo) {
1292
+ try {
1293
+ const { binding } = await client.githubRepoLinkStatus(repo);
1294
+ switch (binding) {
1295
+ case "self":
1296
+ return {
1297
+ state: "self",
1298
+ autoAttach: true,
1299
+ message: "these auto-attach when this branch's PR opens",
1300
+ };
1301
+ case "none":
1302
+ case "other":
1303
+ return {
1304
+ state: binding,
1305
+ autoAttach: false,
1306
+ // stagingBindingAdvisory is total for "none"/"other" — never undefined here.
1307
+ message: stagingBindingAdvisory(binding, repo),
1308
+ };
1309
+ default:
1310
+ return { state: "unknown", autoAttach: false, message: "binding status unrecognized" };
1311
+ }
1312
+ }
1313
+ catch {
1314
+ return {
1315
+ state: "unknown",
1316
+ autoAttach: false,
1317
+ message: "could not check binding status (offline, or an older server without this route)",
1318
+ };
1319
+ }
1320
+ }
1321
+ /**
1322
+ * Shared core for `uploads staged` (CLI) and the `staged` MCP tool (issue
1323
+ * #405): one `list` call against the branch staging prefix
1324
+ * (`ghBranchKeyPrefix` — never hand-built) plus the #398 binding check. Never
1325
+ * throws on the binding check (see `resolveStagedBinding`); a failed `list`
1326
+ * call still propagates, same as every other read command.
1327
+ */
1328
+ export async function resolveStaged(opts) {
1329
+ const { client, repo, branch } = opts;
1330
+ const prefix = ghBranchKeyPrefix(repo, branch);
1331
+ const [list, binding] = await Promise.all([
1332
+ client.list({ prefix, metadata: true }),
1333
+ resolveStagedBinding(client, repo),
1334
+ ]);
1335
+ const files = list.items.map((item) => ({
1336
+ key: item.key,
1337
+ filename: item.key.slice(prefix.length),
1338
+ size: item.size,
1339
+ stagedAt: item.metadata?.["gh.staged-at"],
1340
+ url: item.url,
1341
+ }));
1342
+ return { repo, branch, files, binding };
1343
+ }
1344
+ const STAGED_HELP = `uploads staged [--branch <name>] [--repo <owner/name>] [--format json] [--workspace <name>]
1345
+
1346
+ Read-only view of what's staged for a branch (\`attach --branch\` / bare
1347
+ \`put\` on a non-default branch, issue #403) and whether it will auto-attach
1348
+ once a PR opens. One \`list\` call against the branch staging prefix
1349
+ (gh/<owner>/<repo>/branch/<branch>/) plus a binding check — files:read only,
1350
+ no new server surface.
1351
+
1352
+ Defaults: current git branch (same resolution as \`attach --branch\`, worktree-
1353
+ safe), repo from --repo / gh / git remote (same as every other command).
1354
+
1355
+ Binding: self means these files auto-attach when this branch's PR opens; none
1356
+ or other means they won't (repo unlinked, or linked to a different
1357
+ workspace) — same advisory as the attach --branch stage-time warning.
1358
+
1359
+ Examples:
1360
+ uploads staged
1361
+ uploads staged --branch feature/thing --repo owner/name
1362
+ uploads staged --format json
1363
+ `;
1364
+ export async function runStaged(ctx, args, help = false, run = execRunner) {
1365
+ const parsed = parseCommandArgs(args);
1366
+ if (help || parsed.help) {
1367
+ writeCommandHelp(STAGED_HELP);
1368
+ return 0;
1369
+ }
1370
+ const format = ctx.json
1371
+ ? "json"
1372
+ : (() => {
1373
+ const raw = flagString(parsed.flags, "--format");
1374
+ if (!raw || raw === "human")
1375
+ return "human";
1376
+ if (raw === "json")
1377
+ return "json";
1378
+ throw new UsageError(`invalid --format: ${raw} (expected: json)`);
1379
+ })();
1380
+ const repo = resolveRepo(flagString(parsed.flags, "--repo"), run);
1381
+ const branch = flagString(parsed.flags, "--branch") ?? resolveCurrentBranch(run);
1382
+ const result = await resolveStaged({ client: ctx.client, repo, branch });
1383
+ if (format === "json") {
1384
+ // Always a valid JSON document, even with zero files — never empty
1385
+ // stdout (issue #405 explicitly calls out find --format json's empty-
1386
+ // stdout-on-no-matches wart as a wrong pattern to avoid here).
1387
+ await writeJson(result);
1388
+ return 0;
1389
+ }
1390
+ if (result.files.length === 0) {
1391
+ await writeStdout(`nothing staged for ${branch} in ${repo}\n`);
1392
+ return 0;
1393
+ }
1394
+ for (const file of result.files) {
1395
+ const size = file.size !== undefined ? formatByteSize(file.size) : "? B";
1396
+ const staged = file.stagedAt ? ` staged ${file.stagedAt}` : "";
1397
+ await writeStdout(`${file.filename} ${size}${staged} ${file.url ?? "(no url)"}\n`);
1398
+ }
1399
+ process.stderr.write(`binding: ${result.binding.state} — ${result.binding.message}\n`);
1400
+ // Promote is pointless advice when the repo belongs to another workspace —
1401
+ // the cross-tenant gate (#297) would reject it from here.
1402
+ if (result.binding.state !== "other") {
1403
+ process.stderr.write(`once the PR exists: uploads attach --promote\n`);
1404
+ }
1405
+ return 0;
1406
+ }
1159
1407
  export async function runPut(ctx, args, help = false, run = execRunner) {
1160
1408
  if (help) {
1161
1409
  writeCommandHelp(PUT_HELP);
@@ -1274,7 +1522,25 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1274
1522
  })()
1275
1523
  : defaults.width;
1276
1524
  const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true;
1277
- // gh.* metadata: explicit --pr/--issue target wins over --meta; otherwise
1525
+ // Bare-put branch staging (issue #403): a bare put (no --pr/--issue/--key/
1526
+ // --ref/--prefix/--destination, not --no-git) on a non-default git branch
1527
+ // stages to the branch prefix — identical key/metadata to `attach
1528
+ // --branch` — instead of the dated layout. Computed before gh.* metadata
1529
+ // resolution below since it takes over that resolution entirely (branch
1530
+ // metadata, not PR/issue metadata) and supersedes the #393 nudge for this
1531
+ // case.
1532
+ const stagingTarget = resolvePutStagingTarget({
1533
+ ghTarget,
1534
+ keyHint,
1535
+ refArg: flagString(parsed.flags, "--ref"),
1536
+ prefixArg: prefixFlag,
1537
+ destinationArg: destFlag,
1538
+ noGit,
1539
+ repoArg: flagString(parsed.flags, "--repo") ?? defaults.repo,
1540
+ run,
1541
+ });
1542
+ // gh.* metadata: explicit --pr/--issue target wins over --meta; staging
1543
+ // wins over --meta the same way (matches attach --branch); otherwise
1278
1544
  // best-effort auto resolution (on by default) where --meta wins. --no-git,
1279
1545
  // --no-auto, or UPLOADS_NO_AUTO_META disable auto; --auto forces past the
1280
1546
  // config default but never past --no-git (no repo to resolve).
@@ -1286,6 +1552,14 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1286
1552
  metadata = merged;
1287
1553
  attachedRef = merged["gh.ref"];
1288
1554
  }
1555
+ else if (stagingTarget) {
1556
+ const merged = {
1557
+ ...userMeta,
1558
+ ...ghMetadataForBranch(stagingTarget.repo, stagingTarget.branch),
1559
+ };
1560
+ validateMetaMap(merged); // matches attach --branch's unwrapped call — same builder, same contract
1561
+ metadata = merged;
1562
+ }
1289
1563
  else {
1290
1564
  // gh.* additionally needs git, which the shared derived gate ignores.
1291
1565
  if (!noGit && derivedMetaEnabled(parsed.flags, defaults)) {
@@ -1306,18 +1580,31 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1306
1580
  }
1307
1581
  }
1308
1582
  }
1309
- // Bare-put nudge (issue #393): computed once, used for both the trailing
1310
- // stderr line (human mode) and the JSON `hint` field below. Best-effort —
1311
- // see resolvePutNudge; never affects exit code, stdout, or the upload.
1312
- const nudge = resolvePutNudge({
1313
- ctx,
1314
- flags: parsed.flags,
1315
- ghTarget,
1316
- keyHint,
1317
- noGit,
1318
- defaults,
1319
- run,
1320
- });
1583
+ // Bare-put nudge (issue #393): only relevant when staging didn't take over
1584
+ // once `stagingTarget` resolves, staging IS the upgrade the nudge used to
1585
+ // point at, so this is skipped entirely rather than firing redundantly.
1586
+ // Still fires as before for a bare put that lands on the dated layout with
1587
+ // a detectable PR (e.g. an explicit --ref/--prefix opts out of staging).
1588
+ // Computed once, used for both the trailing stderr line (human mode) and
1589
+ // the JSON `hint` field below. Best-effort — see resolvePutNudge; never
1590
+ // affects exit code, stdout, or the upload.
1591
+ const nudge = stagingTarget
1592
+ ? undefined
1593
+ : resolvePutNudge({
1594
+ ctx,
1595
+ flags: parsed.flags,
1596
+ ghTarget,
1597
+ keyHint,
1598
+ noGit,
1599
+ defaults,
1600
+ run,
1601
+ });
1602
+ // Staging note (issue #403): same suppression as the #393 nudge
1603
+ // (--quiet, UPLOADS_NO_NUDGE=1 env/config); staging itself is NOT gated by
1604
+ // either — only whether the note is printed/hinted.
1605
+ const stagingNote = stagingTarget && !ctx.quiet && !defaults.noNudge
1606
+ ? putStagingNoteText(stagingTarget.branch)
1607
+ : undefined;
1321
1608
  const logHuman = !ctx.quiet && format === "human";
1322
1609
  if (logHuman) {
1323
1610
  if (multi) {
@@ -1336,6 +1623,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1336
1623
  nameOverride: nameFlag,
1337
1624
  explicitKey: keyHint,
1338
1625
  ghTarget,
1626
+ ghBranchTarget: stagingTarget,
1339
1627
  prefix: resolvedPrefix ?? defaults.prefix,
1340
1628
  repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
1341
1629
  ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
@@ -1354,6 +1642,19 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1354
1642
  if (uploads.length === 0 && failures.length > 0 && !multi) {
1355
1643
  throw firstError instanceof Error ? firstError : new Error(String(firstError));
1356
1644
  }
1645
+ // Stage-time binding warning (issue #398/#400): same check `attach
1646
+ // --branch` runs, now also on the bare-put staging path. Best-effort — see
1647
+ // resolveStageBindingWarning; never affects exit code or the upload.
1648
+ const bindingWarning = stagingTarget && uploads.length > 0
1649
+ ? await resolveStageBindingWarning({ ctx, defaults, repo: stagingTarget.repo })
1650
+ : undefined;
1651
+ // One JSON `hint` slot, shared with the #393 nudge (mutually exclusive with
1652
+ // it — nudge is undefined whenever staging took over). When staging fires,
1653
+ // prefer the more actionable binding warning over the generic staging note
1654
+ // (mirrors attach --branch, whose only JSON hint content IS the binding
1655
+ // warning); stderr prints the nudge/staging-note and binding-warning lines
1656
+ // independently, below.
1657
+ const jsonHint = nudge ?? bindingWarning ?? stagingNote;
1357
1658
  const galleriesByKey = new Map();
1358
1659
  let galleryHadError = false;
1359
1660
  if (galleryId && uploads.length > 0) {
@@ -1399,7 +1700,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1399
1700
  failures,
1400
1701
  comment,
1401
1702
  commentError,
1402
- ...(nudge ? { hint: nudge } : {}),
1703
+ ...(jsonHint ? { hint: jsonHint } : {}),
1403
1704
  });
1404
1705
  }
1405
1706
  else {
@@ -1432,6 +1733,10 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1432
1733
  }
1433
1734
  if (nudge)
1434
1735
  process.stderr.write(`${nudge}\n`);
1736
+ if (stagingNote)
1737
+ process.stderr.write(`${stagingNote}\n`);
1738
+ if (bindingWarning)
1739
+ process.stderr.write(`${bindingWarning}\n`);
1435
1740
  }
1436
1741
  return failures.length === 0 && !galleryHadError ? 0 : 1;
1437
1742
  }
@@ -1463,7 +1768,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1463
1768
  frame: result.frame,
1464
1769
  gallery,
1465
1770
  ...(dryRun ? { dryRun: true } : {}),
1466
- ...(nudge ? { hint: nudge } : {}),
1771
+ ...(jsonHint ? { hint: jsonHint } : {}),
1467
1772
  });
1468
1773
  break;
1469
1774
  case "url":
@@ -1485,6 +1790,10 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1485
1790
  }
1486
1791
  if (nudge && format !== "json")
1487
1792
  process.stderr.write(`${nudge}\n`);
1793
+ if (stagingNote && format !== "json")
1794
+ process.stderr.write(`${stagingNote}\n`);
1795
+ if (bindingWarning && format !== "json")
1796
+ process.stderr.write(`${bindingWarning}\n`);
1488
1797
  return gallery?.error ? 1 : 0;
1489
1798
  }
1490
1799
  // --- galleries ---
package/dist/github.d.ts CHANGED
@@ -123,6 +123,10 @@ export declare const ATTACHMENT_IMAGE_WIDTH_WIDE = 640;
123
123
  * practical signal (we don't re-fetch dimensions when rebuilding the comment).
124
124
  */
125
125
  export declare function attachmentImageWidth(filename: string): number;
126
+ /** Max display width for one image inside a before/after pair row — smaller
127
+ * than a standalone image so two side by side stay under GitHub's comment
128
+ * column width (and don't overflow on mobile). */
129
+ export declare const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
126
130
  /**
127
131
  * Render the one marker-owned GitHub comment. When there are no galleries this
128
132
  * intentionally preserves the legacy attachment-only body byte-for-byte.
package/dist/github.js CHANGED
@@ -255,6 +255,120 @@ function metaCaptionMarkdown(meta) {
255
255
  return "";
256
256
  return ` · ${parts.map((p) => escapeMarkdownText(escapeHtmlText(p))).join(" · ")}`;
257
257
  }
258
+ /** Extract the filename stem's before/after token (issue #419 fallback pairing).
259
+ * `base` is the stem lowercased with the token removed; `null` when the stem
260
+ * carries no recognizable before/after token. Requires a separator (`-`, `_`,
261
+ * or `.`) between the token and the rest of the name — except when the token
262
+ * IS the whole stem (`before.png`) — so `beforehand.png` doesn't false-match. */
263
+ // Token bounded by `-`, `_`, `.`, or stem start/end, so `hero-before.webp`
264
+ // and `paired-view-before-desktop.webp` match but `beforehand.webp` does
265
+ // not. Mirrors before-after.ts's TOKEN_RE (file page), applied to the stem.
266
+ const STEM_TOKEN_RE = /(^|[-_.])(before|after)($|[-_.])/i;
267
+ function filenameStemToken(name) {
268
+ const dot = name.lastIndexOf(".");
269
+ const stem = dot > 0 ? name.slice(0, dot) : name;
270
+ const m = STEM_TOKEN_RE.exec(stem);
271
+ if (!m)
272
+ return null;
273
+ const state = m[2].toLowerCase();
274
+ const tokenStart = m.index + m[1].length;
275
+ const tokenEnd = tokenStart + m[2].length;
276
+ // Base = stem with the token and one adjoining delimiter removed, so
277
+ // `paired-view-before-desktop` and `paired-view-after-desktop` both
278
+ // collapse to `paired-view-desktop` and group together.
279
+ const base = m[1].length > 0
280
+ ? stem.slice(0, m.index) + stem.slice(tokenEnd)
281
+ : stem.slice(tokenEnd + m[3].length);
282
+ return { base: base.toLowerCase(), state };
283
+ }
284
+ /**
285
+ * Pair up attachments for the before/after side-by-side row (issue #419).
286
+ * `isImageAt[i]` mirrors the renderer's own image test — only images pair;
287
+ * videos and non-image links render exactly as before.
288
+ *
289
+ * Priority order, checked independently per candidate item so rule 2 only
290
+ * ever claims items rule 1 left untouched:
291
+ * 1. Same `path` metadata (trimmed, not bare `/`), one item `state=before`
292
+ * and one `state=after`. Ambiguous groups (more than one of a state)
293
+ * don't pair — no way to know which side goes with which.
294
+ * 2. No usable `path` metadata: filename stems that differ only by a
295
+ * before/after token, same extension. Same ambiguity rule.
296
+ */
297
+ function pairAttachments(items, isImageAt) {
298
+ const partnerOf = new Map();
299
+ const roleOf = new Map();
300
+ const pair = (beforeIdx, afterIdx) => {
301
+ partnerOf.set(beforeIdx, afterIdx);
302
+ partnerOf.set(afterIdx, beforeIdx);
303
+ roleOf.set(beforeIdx, "before");
304
+ roleOf.set(afterIdx, "after");
305
+ };
306
+ // Priority 1: same path metadata, exactly one before + one after.
307
+ const pathGroups = new Map();
308
+ items.forEach((item, i) => {
309
+ if (!isImageAt[i])
310
+ return;
311
+ const path = item.meta?.path?.trim();
312
+ if (!path || path === "/")
313
+ return;
314
+ const state = item.meta?.state?.trim().toLowerCase();
315
+ if (state !== "before" && state !== "after")
316
+ return;
317
+ const g = pathGroups.get(path) ?? { before: [], after: [] };
318
+ g[state].push(i);
319
+ pathGroups.set(path, g);
320
+ });
321
+ for (const g of pathGroups.values()) {
322
+ if (g.before.length === 1 && g.after.length === 1)
323
+ pair(g.before[0], g.after[0]);
324
+ }
325
+ // Priority 2: no usable path metadata — filename stem token, same extension.
326
+ const stemGroups = new Map();
327
+ items.forEach((item, i) => {
328
+ if (!isImageAt[i] || partnerOf.has(i))
329
+ return;
330
+ const path = item.meta?.path?.trim();
331
+ if (path && path !== "/")
332
+ return; // usable path metadata — rule 1 owns this item
333
+ const name = item.key.slice(item.key.lastIndexOf("/") + 1);
334
+ const tok = filenameStemToken(name);
335
+ if (!tok)
336
+ return;
337
+ const dot = name.lastIndexOf(".");
338
+ const ext = dot > 0 ? name.slice(dot).toLowerCase() : "";
339
+ const key = `${tok.base}${ext}`;
340
+ const g = stemGroups.get(key) ?? { before: [], after: [] };
341
+ g[tok.state].push(i);
342
+ stemGroups.set(key, g);
343
+ });
344
+ for (const g of stemGroups.values()) {
345
+ if (g.before.length === 1 && g.after.length === 1)
346
+ pair(g.before[0], g.after[0]);
347
+ }
348
+ return { partnerOf, roleOf };
349
+ }
350
+ /** Max display width for one image inside a before/after pair row — smaller
351
+ * than a standalone image so two side by side stay under GitHub's comment
352
+ * column width (and don't overflow on mobile). */
353
+ export const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
354
+ function renderPairCell(item, label) {
355
+ const name = item.key.slice(item.key.lastIndexOf("/") + 1);
356
+ const src = item.embedUrl ?? item.url;
357
+ const link = item.pageUrl ?? item.url;
358
+ const w = Math.min(attachmentImageWidth(name), ATTACHMENT_IMAGE_WIDTH_PAIR);
359
+ const alt = escapeHtmlAttr(name);
360
+ const href = escapeHtmlAttr((link ?? src));
361
+ const imgSrc = escapeHtmlAttr(src);
362
+ const caption = metaCaptionHtml(item.meta);
363
+ const captionHtml = caption ? `<br><sub>${caption}</sub>` : "";
364
+ return `<td align="center"><sub><strong>${label}</strong></sub><br><a href="${href}"><img width="${w}" alt="${alt}" src="${imgSrc}"></a>${captionHtml}</td>`;
365
+ }
366
+ /** One side-by-side before/after row (issue #419): a single HTML table so
367
+ * GitHub renders both images on one line, with `Before`/`After` labels and
368
+ * each side's usual path/state caption preserved underneath. */
369
+ function renderPairRow(beforeItem, afterItem) {
370
+ return `<table><tr>${renderPairCell(beforeItem, "Before")}${renderPairCell(afterItem, "After")}</tr></table>`;
371
+ }
258
372
  /**
259
373
  * Render the one marker-owned GitHub comment. When there are no galleries this
260
374
  * intentionally preserves the legacy attachment-only body byte-for-byte.
@@ -279,9 +393,36 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
279
393
  }
280
394
  if (sorted.length > 0 || sortedGalleries.length === 0)
281
395
  lines.push("### 📎 Attachments", "");
396
+ const isImageAt = sorted.map((item) => {
397
+ const name = item.key.slice(item.key.lastIndexOf("/") + 1);
398
+ const src = item.embedUrl ?? item.url;
399
+ return Boolean(src) && inferContentType(name).startsWith("image/");
400
+ });
401
+ const { partnerOf, roleOf } = pairAttachments(sorted, isImageAt);
402
+ const consumedByPair = new Set();
282
403
  let inlinedImages = 0;
283
404
  const overflowImages = [];
284
- for (const item of sorted) {
405
+ for (let idx = 0; idx < sorted.length; idx++) {
406
+ if (consumedByPair.has(idx))
407
+ continue;
408
+ const item = sorted[idx];
409
+ const partnerIdx = partnerOf.get(idx);
410
+ if (partnerIdx !== undefined) {
411
+ const partner = sorted[partnerIdx];
412
+ if (inlinedImages + 2 <= MAX_INLINE_ATTACHMENT_IMAGES) {
413
+ inlinedImages += 2;
414
+ consumedByPair.add(partnerIdx);
415
+ const beforeItem = roleOf.get(idx) === "before" ? item : partner;
416
+ const afterItem = roleOf.get(idx) === "before" ? partner : item;
417
+ lines.push(renderPairRow(beforeItem, afterItem), "");
418
+ continue;
419
+ }
420
+ // Cap already full for a two-image row — degrade this pair to two
421
+ // ordinary overflow entries rather than only half-rendering the row.
422
+ overflowImages.push(item, partner);
423
+ consumedByPair.add(partnerIdx);
424
+ continue;
425
+ }
285
426
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
286
427
  const stable = item.url;
287
428
  const src = item.embedUrl ?? item.url;
package/dist/mcp/tools.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { createUploadsClient } from "../client.js";
2
- import { buildDoctorReport, makeGhTarget, syncAttachmentsComment, uploadAttachments, uploadPreparedImage, uploadPuts, } from "../commands.js";
2
+ import { buildDoctorReport, makeGhTarget, resolvePutStagingTarget, resolveStaged, syncAttachmentsComment, uploadAttachments, uploadPreparedImage, uploadPuts, } from "../commands.js";
3
3
  import { resolveFrameId } from "../frame.js";
4
4
  import { resolveConfig, resolvePutDefaults, } from "../config.js";
5
5
  import { resolvePutPrefix } from "../destinations.js";
6
- import { ghKeyPrefix } from "../github.js";
6
+ import { ghKeyPrefix, ghMetadataForBranch } from "../github.js";
7
7
  import { safeCaptureFacts } from "../capture-facts.js";
8
8
  import { validateMetaMap } from "../metadata.js";
9
9
  import { mergeDerivedMeta } from "../metadata-vocab.js";
10
- import { execRunner, ghMetadataFromTargetWithTitle, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
10
+ import { execRunner, ghMetadataFromTargetWithTitle, resolveCurrentBranch, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
11
11
  import { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, metadataArgWithCanonical, metadataProp, optBool, optPosInt, optString, optStringArray, optStringRecord, stateProp, usage, } from "./args.js";
12
12
  import { batchFailureMessage, ToolBatchError } from "./server.js";
13
13
  import { attachmentFromText, buildReportPayload, parseReportType, REPORT_TYPES, submitReport, validateReportMessage, } from "../report.js";
@@ -415,9 +415,35 @@ export function createUploadsMcpTools(opts) {
415
415
  const alt = optString(args, "alt");
416
416
  const width = optPosInt(args, "width") ?? defaults.width;
417
417
  const contentType = optString(args, "contentType");
418
+ // Bare-put branch staging (issue #403): local stdio MCP put mirrors
419
+ // the CLI default — no pr/issue/key/ref/prefix/destination, not
420
+ // noGit, on a non-default git branch stages to the branch prefix
421
+ // (identical key/metadata to `attach --branch`) instead of the
422
+ // dated layout. Never throws — see resolvePutStagingTarget.
423
+ const stagingTarget = resolvePutStagingTarget({
424
+ ghTarget: target,
425
+ keyHint: keyArg,
426
+ refArg,
427
+ prefixArg,
428
+ destinationArg: destArg,
429
+ noGit,
430
+ repoArg: optString(args, "repo") ?? defaults.repo,
431
+ run,
432
+ });
433
+ const putMetadata = stagingTarget
434
+ ? (() => {
435
+ const merged = {
436
+ ...metadata,
437
+ ...ghMetadataForBranch(stagingTarget.repo, stagingTarget.branch),
438
+ };
439
+ validateMetaMap(merged); // same builder, same contract as attach --branch
440
+ return merged;
441
+ })()
442
+ : metadata;
418
443
  const putShared = {
419
444
  client,
420
445
  ghTarget: target,
446
+ ghBranchTarget: stagingTarget,
421
447
  prefix: resolvedPrefix ?? defaults.prefix,
422
448
  repo: optString(args, "repo") ?? defaults.repo,
423
449
  ref: refArg ?? defaults.ref,
@@ -427,7 +453,7 @@ export function createUploadsMcpTools(opts) {
427
453
  replace: replaceArg,
428
454
  optimize: optimizeOpts,
429
455
  frame: frameOpts,
430
- metadata,
456
+ metadata: putMetadata,
431
457
  // The shared metadata description promises uploads.sh derives these
432
458
  // "automatically where it can" — MCP has no --no-auto, so always on.
433
459
  deriveImageFacts: true,
@@ -458,6 +484,7 @@ export function createUploadsMcpTools(opts) {
458
484
  frame: frameOpts,
459
485
  optimize: optimizeOpts,
460
486
  ghTarget: target,
487
+ ghBranchTarget: stagingTarget,
461
488
  key: keyArg,
462
489
  prefix: resolvedPrefix ?? defaults.prefix,
463
490
  repo: optString(args, "repo") ?? defaults.repo,
@@ -466,7 +493,7 @@ export function createUploadsMcpTools(opts) {
466
493
  deriveRepoFromGit: !noGit,
467
494
  dryRun,
468
495
  replace: replaceArg,
469
- metadata,
496
+ metadata: putMetadata,
470
497
  provenanceClient: "uploads-mcp",
471
498
  alt: () => alt ?? sourceName,
472
499
  width,
@@ -925,6 +952,31 @@ export function createUploadsMcpTools(opts) {
925
952
  return client.list({ prefix, limit, cursor });
926
953
  },
927
954
  },
955
+ {
956
+ name: "staged",
957
+ description: "Read-only view of what's staged for a git branch (attach --branch / bare put on a non-default branch) and whether it will auto-attach once a PR opens. One list call against the branch staging prefix plus a repo-binding check (files:read only). Returns { repo, branch, files, binding }; binding.state is self/other/none/unknown and binding.autoAttach is true only for self.",
958
+ inputSchema: {
959
+ type: "object",
960
+ properties: {
961
+ branch: {
962
+ type: "string",
963
+ description: "Branch name (default: current git branch, worktree-safe).",
964
+ },
965
+ repo: {
966
+ type: "string",
967
+ description: "owner/name repo (default: gh/git remote inference).",
968
+ },
969
+ workspace: workspaceProp,
970
+ },
971
+ additionalProperties: false,
972
+ },
973
+ async handler(args) {
974
+ const { client } = clientFor(args);
975
+ const repo = resolveRepo(optString(args, "repo"), run);
976
+ const branch = optString(args, "branch") ?? resolveCurrentBranch(run);
977
+ return resolveStaged({ client, repo, branch });
978
+ },
979
+ },
928
980
  {
929
981
  name: "delete",
930
982
  description: "Delete an uploaded object by key. Set dryRun to preview without deleting.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,