@buildinternet/uploads 0.54.0 → 0.55.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
@@ -31,6 +31,8 @@ uploads put --url https://cdn.example/shot.png --pr 123
31
31
  uploads put --url http://localhost:4321/shot.png
32
32
  uploads gallery create --title "Release screenshots"
33
33
  uploads put ./after.png --gallery gal_example
34
+ uploads feed create --repo owner/repo
35
+ uploads feed create --repo owner/repo --pr 123
34
36
  # custom metadata (queryable): page URL, in-app path, which surface
35
37
  uploads put ./shot.png --meta url=https://app.example/settings --meta path=/settings --meta app=web
36
38
  uploads meta get screenshots/myapp/42/shot.webp
@@ -43,7 +45,7 @@ Inside this monorepo only, `pnpm uploads …` builds the package first so you pi
43
45
  up local source; product docs and PR “how to try it” examples should use the
44
46
  global `uploads` form above.
45
47
 
46
- Commands: `attach`, `put`, `screenshot`, `annotate`, `gallery`, `comment`, `list`, `find`, `meta`, `delete`, `usage`,
48
+ Commands: `attach`, `put`, `screenshot`, `annotate`, `gallery`, `feed`, `comment`, `list`, `find`, `meta`, `delete`, `usage`,
47
49
  `reconcile`, `purge-expired`, `setup`, `install`, `login`, `whoami` (alias `status`),
48
50
  `logout`, `invite`, `admin`, `config`, `telemetry`, `report`, `doctor`, `health`, `changelog`, `docs`, `mcp`,
49
51
  `completion`.
@@ -192,13 +194,33 @@ When adding several keys, `uploads gallery add` processes them sequentially and
192
194
  individual failures in `--json` output. Gallery item updates use the API's current version to
193
195
  avoid overwriting concurrent changes.
194
196
 
197
+ ## Change feeds
198
+
199
+ A feed is a public newest-first page of screenshots already tagged with a GitHub `owner/repo`,
200
+ or with one pull request or issue. Same product — `--pr` / `--issue` / `--github` is one extra
201
+ filter. It is a live query, not a curated gallery. Use a gallery when you pick the files; use
202
+ a repo feed for the latest shots across a repo; use a PR feed when reviewers should see only
203
+ that pull request. The API returns the canonical public URL.
204
+
205
+ ```bash
206
+ uploads feed create --repo owner/repo
207
+ uploads feed create --repo owner/repo --pr 123
208
+ uploads feed create --github owner/repo#123
209
+ uploads feed create --repo owner/repo --path /settings
210
+ ```
211
+
212
+ Creating the same scope again returns the existing URL. Anyone who knows that URL can
213
+ view the feed. Each shot also has `/c/<id>/<item>` with previous / next. Syncing a
214
+ managed PR comment creates the PR feed if needed and points image clicks at that pager.
215
+ MCP: `feed_create` (`repo`, plus `pr` / `issue` / `github` / `path`) and `feed_get`.
216
+
195
217
  Link a gallery to a GitHub issue or pull request with `gallery link --github`. Run `uploads comment --pr <number>` to refresh that target’s one managed comment with every linked gallery and loose attachment. Coordinates and strict `https://github.com/<owner>/<repo>/issues|pull/<number>` URLs are accepted; `gallery list --github` performs the authenticated reverse lookup. Links never change gallery identity, and GitHub repository visibility does not make the public gallery private.
196
218
 
197
219
  Config layers (first match wins): CLI flags → env vars → `--env-file` → `~/.config/buildinternet/config`. See `config.example` for keys.
198
220
 
199
221
  ## MCP server
200
222
 
201
- `uploads mcp` serves the Model Context Protocol over stdio (newline-delimited JSON-RPC, no extra dependencies). Tools include file operations plus public gallery workflows: `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`. Gallery tools return API-provided canonical URLs and never need GitHub credentials. The remaining stdio tools are `put`, `attach`, `list`, `delete`, `get_metadata`, `set_metadata`, `find_files`, `usage`, `reconcile`, `purge_expired`, `comment`, `whoami`, and `doctor` — with the same config resolution and defaults, plus a per-call `workspace` argument. `put` and `attach` accept a `metadata` param (same `gh.*` auto-injection as the CLI's `attach`); `get_metadata`, `set_metadata`, and `find_files` mirror `uploads meta get` / `meta set` / `find`. 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 (`whoami` needs no auth).
223
+ `uploads mcp` serves the Model Context Protocol over stdio (newline-delimited JSON-RPC, no extra dependencies). Tools include file operations plus public gallery workflows (`gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, `gallery_find_by_reference`) and change feeds (`feed_create`, `feed_get` — pass `pr` or `github` to scope a feed to one pull request). Gallery and feed tools return API-provided canonical URLs and never need GitHub credentials. The remaining stdio tools are `put`, `attach`, `list`, `delete`, `get_metadata`, `set_metadata`, `find_files`, `usage`, `reconcile`, `purge_expired`, `comment`, `whoami`, and `doctor` — with the same config resolution and defaults, plus a per-call `workspace` argument. `put` and `attach` accept a `metadata` param (same `gh.*` auto-injection as the CLI's `attach`); `get_metadata`, `set_metadata`, and `find_files` mirror `uploads meta get` / `meta set` / `find`. 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 (`whoami` needs no auth).
202
224
 
203
225
  ```json
204
226
  { "command": "uploads", "args": ["--env-file", "/path/to/.env", "mcp"] }
@@ -208,7 +230,7 @@ Or with `UPLOADS_TOKEN`/`UPLOADS_WORKSPACE` in the environment or user config. C
208
230
 
209
231
  The MCP Registry lists this server as `sh.uploads/mcp`.
210
232
 
211
- 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.
233
+ 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`, `gallery_find_by_reference`, `feed_create`, and `feed_get`; all use the same bearer-token workspace scopes and gallery/feed 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.
212
234
 
213
235
  ## Programmatic use
214
236
 
@@ -156,6 +156,16 @@ export const ROOT_COMMANDS = [
156
156
  { name: "unlink", summary: "Unlink a gallery from GitHub" },
157
157
  ],
158
158
  },
159
+ {
160
+ name: "feed",
161
+ summary: "Create a public newest-first screenshot feed for a GitHub repo or pull request",
162
+ subcommands: [
163
+ { name: "create", summary: "Create or reuse a repo or PR change feed" },
164
+ { name: "show", summary: "Show a feed" },
165
+ { name: "list", summary: "List feeds" },
166
+ { name: "delete", summary: "Delete a feed record" },
167
+ ],
168
+ },
159
169
  {
160
170
  name: "comment",
161
171
  summary: "Create/update a PR/issue attachments comment (via gh)",
package/dist/cli-help.js CHANGED
@@ -165,6 +165,7 @@ ${section(style, "Examples:")}
165
165
  ${style.command("uploads attach")} ./artifact.zip --issue 45 --no-comment
166
166
  ${style.command("uploads attach")} ./shot.png --meta path=/settings --state after
167
167
  ${style.command("uploads gallery")} create --title "Release screenshots"
168
+ ${style.command("uploads feed")} create --repo owner/repo
168
169
  ${style.command("uploads doctor")}
169
170
  ${style.command("uploads logout")}
170
171
  ${style.command("uploads --version")}
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import { commandSummary, suggestCommand } from "./cli-suggest.js";
7
7
  import { writeJson } from "./io.js";
8
8
  import { colorEnabled, createStyle } from "./cli-style.js";
9
9
  import { runPut, runAttach, runStaged, runList, runFind, runMeta, runDelete, runHealth, runDoctor, runComment, runGithub, runIngest, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
10
+ import { runFeed } from "./commands/feed.js";
10
11
  import { runConfig } from "./commands/config.js";
11
12
  import { runSetup } from "./commands/setup.js";
12
13
  import { runLogin } from "./commands/login.js";
@@ -309,6 +310,7 @@ export async function runCli(argv) {
309
310
  case "staged":
310
311
  case "screenshot":
311
312
  case "gallery":
313
+ case "feed":
312
314
  case "list":
313
315
  case "find":
314
316
  case "meta":
@@ -337,6 +339,9 @@ export async function runCli(argv) {
337
339
  case "gallery":
338
340
  code = await runGallery(ctx, cmdArgs, showHelp);
339
341
  break;
342
+ case "feed":
343
+ code = await runFeed(ctx, cmdArgs, showHelp);
344
+ break;
340
345
  case "comment":
341
346
  code = await runComment(ctx, cmdArgs, showHelp);
342
347
  break;
@@ -397,7 +402,7 @@ export async function runCli(argv) {
397
402
  return 2;
398
403
  }
399
404
  }
400
- // Best-effort; skipped for mcp, --quiet/--json, and opt-out env vars.
405
+ // Best-effort; skipped for mcp, update, --quiet/--json, and opt-out env vars.
401
406
  if (code === 0 && !showHelp) {
402
407
  await maybeHintUpdate({ quiet: quiet || json, command: parsed.command });
403
408
  }
package/dist/client.d.ts CHANGED
@@ -257,6 +257,59 @@ export interface FindGalleriesByReferenceOptions {
257
257
  limit?: number;
258
258
  cursor?: string;
259
259
  }
260
+ /** A workspace-owned, query-backed public repo change feed. */
261
+ export interface FeedItem {
262
+ id: string;
263
+ objectKey: string;
264
+ filename: string;
265
+ status: "available" | "missing" | "withheld";
266
+ url: string | null;
267
+ embedUrl: string | null;
268
+ /** Canonical `/c/<id>/<item>` page when the API computed one. */
269
+ pageUrl?: string;
270
+ contentType: string | null;
271
+ size: number | null;
272
+ uploaded: string | null;
273
+ modified: string | null;
274
+ path: string | null;
275
+ state: string | null;
276
+ posterUrl?: string;
277
+ videoDimensions?: {
278
+ width: number;
279
+ height: number;
280
+ };
281
+ }
282
+ export interface Feed {
283
+ id: string;
284
+ /** Canonical public URL returned by the API; clients must not construct it. */
285
+ url: string;
286
+ workspace: string;
287
+ repo: string;
288
+ path: string | null;
289
+ number: number | null;
290
+ kind: "pull" | "issue" | null;
291
+ title: string;
292
+ createdAt: string;
293
+ updatedAt: string;
294
+ items: FeedItem[];
295
+ }
296
+ export type FeedSummary = Omit<Feed, "items">;
297
+ export interface FeedListOptions {
298
+ limit?: number;
299
+ cursor?: string;
300
+ }
301
+ export interface FeedListResult {
302
+ feeds: FeedSummary[];
303
+ nextCursor: string | null;
304
+ }
305
+ export interface CreateFeedOptions {
306
+ repo: string;
307
+ path?: string | null;
308
+ number?: number | null;
309
+ kind?: "pull" | "issue" | null;
310
+ pr?: number | null;
311
+ issue?: number | null;
312
+ }
260
313
  /**
261
314
  * Reasons the bot did not post. The CLI falls back to the local `gh` path
262
315
  * for all of these except `not_authorized` (issue #297 baseline control):
@@ -734,6 +787,13 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
734
787
  id: string;
735
788
  }>;
736
789
  findGalleriesByReference(opts: FindGalleriesByReferenceOptions): Promise<GalleryListResult>;
790
+ createFeed(opts: CreateFeedOptions): Promise<Feed>;
791
+ getFeed(id: string): Promise<Feed>;
792
+ listFeeds(opts?: FeedListOptions): Promise<FeedListResult>;
793
+ deleteFeed(id: string): Promise<{
794
+ deleted: boolean;
795
+ id: string;
796
+ }>;
737
797
  /**
738
798
  * Upsert the managed attachments comment. `resync: true` marks an
739
799
  * explicit "make the comment state correct" call (`uploads comment`), so
package/dist/client.js CHANGED
@@ -307,6 +307,9 @@ function usageBase(config) {
307
307
  function galleriesBase(config) {
308
308
  return `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/galleries`;
309
309
  }
310
+ function feedsBase(config) {
311
+ return `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/feeds`;
312
+ }
310
313
  function mapApiError(status, error, code, requiredScope, existingUrl) {
311
314
  const normalized = error.toLowerCase();
312
315
  if (status === 401 || code === "unauthorized" || normalized === "unauthorized") {
@@ -652,6 +655,27 @@ export function createUploadsClient(config) {
652
655
  params.set("cursor", opts.cursor);
653
656
  return request("GET", galleriesBase(config) + "/by-reference?" + params);
654
657
  },
658
+ async createFeed(opts) {
659
+ return request("POST", feedsBase(config), {
660
+ body: new TextEncoder().encode(JSON.stringify(opts)),
661
+ headers: { "Content-Type": "application/json" },
662
+ });
663
+ },
664
+ async getFeed(id) {
665
+ return request("GET", `${feedsBase(config)}/${encodeURIComponent(id)}`);
666
+ },
667
+ async listFeeds(opts = {}) {
668
+ const params = new URLSearchParams();
669
+ if (opts.limit != null)
670
+ params.set("limit", String(opts.limit));
671
+ if (opts.cursor)
672
+ params.set("cursor", opts.cursor);
673
+ const qs = params.toString();
674
+ return request("GET", `${feedsBase(config)}${qs ? `?${qs}` : ""}`);
675
+ },
676
+ async deleteFeed(id) {
677
+ return request("DELETE", `${feedsBase(config)}/${encodeURIComponent(id)}`);
678
+ },
655
679
  /**
656
680
  * Upsert the managed attachments comment. `resync: true` marks an
657
681
  * explicit "make the comment state correct" call (`uploads comment`), so
@@ -0,0 +1,2 @@
1
+ import type { CliContext } from "../commands.js";
2
+ export declare function runFeed(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
@@ -0,0 +1,133 @@
1
+ import { flagInt, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
2
+ import { writeCommandHelp } from "../cli-style.js";
3
+ import { parseGithubIssueRef } from "../github.js";
4
+ import { resolveRepo } from "../github-gh.js";
5
+ import { writeJson, writeStdout } from "../io.js";
6
+ const FEED_HELP = `uploads feed <command> [args]
7
+
8
+ A public newest-first feed of screenshots tagged with a GitHub owner/repo,
9
+ or one pull request / issue. Same product — pass --pr, --issue, or --github
10
+ to scope it. A gallery is a hand-picked list; a feed is live. Anyone who
11
+ knows the URL can view it. Creating the same scope again returns the
12
+ existing feed.
13
+
14
+ Commands:
15
+ create [--repo <owner/repo>] [--pr <n> | --issue <n> | --github <ref>] [--path <page-path>]
16
+ show <feed-id>
17
+ list [--limit <n>] [--cursor <c>]
18
+ delete <feed-id>
19
+
20
+ Examples:
21
+ uploads feed create
22
+ uploads feed create --repo acme/app
23
+ uploads feed create --repo acme/app --pr 123
24
+ uploads feed create --github acme/app#123
25
+ uploads feed create --repo acme/app --path /settings
26
+ uploads feed show feed_example
27
+ `;
28
+ function resolveCreateOptions(parsed) {
29
+ const github = flagString(parsed.flags, "--github");
30
+ const pr = flagInt(parsed.flags, "--pr", "--pr");
31
+ const issue = flagInt(parsed.flags, "--issue", "--issue");
32
+ const path = flagString(parsed.flags, "--path");
33
+ const repoFlag = flagString(parsed.flags, "--repo");
34
+ if (pr != null && issue != null) {
35
+ throw new UsageError("--pr and --issue are mutually exclusive", {
36
+ example: "uploads feed create --repo owner/repo --pr 123",
37
+ });
38
+ }
39
+ if (github && (pr != null || issue != null)) {
40
+ throw new UsageError("--github cannot be combined with --pr or --issue", {
41
+ example: "uploads feed create --github owner/repo#123",
42
+ });
43
+ }
44
+ if (github) {
45
+ const ref = parseGithubIssueRef(github);
46
+ if (!ref) {
47
+ throw new UsageError("--github must be owner/repo#number or a GitHub issue/PR URL", {
48
+ example: "uploads feed create --github owner/repo#123",
49
+ });
50
+ }
51
+ if (repoFlag && repoFlag.trim().toLowerCase() !== ref.repo) {
52
+ throw new UsageError("--repo does not match --github", {
53
+ example: "uploads feed create --github owner/repo#123",
54
+ });
55
+ }
56
+ return {
57
+ repo: ref.repo,
58
+ path,
59
+ number: ref.number,
60
+ kind: ref.kind,
61
+ };
62
+ }
63
+ return {
64
+ repo: resolveRepo(repoFlag).toLowerCase(),
65
+ path,
66
+ ...(pr != null ? { number: pr, kind: "pull" } : {}),
67
+ ...(issue != null ? { number: issue, kind: "issue" } : {}),
68
+ };
69
+ }
70
+ export async function runFeed(ctx, args, help = false) {
71
+ const parsed = parseCommandArgs(args);
72
+ const action = parsed.positionals[0];
73
+ if (help || parsed.help) {
74
+ writeCommandHelp(FEED_HELP);
75
+ return 0;
76
+ }
77
+ if (!action) {
78
+ throw new UsageError("feed requires a subcommand: create, show, list, or delete", {
79
+ example: "uploads feed create --repo owner/repo --pr 123",
80
+ });
81
+ }
82
+ switch (action) {
83
+ case "create": {
84
+ const feed = await ctx.client.createFeed(resolveCreateOptions(parsed));
85
+ if (ctx.json)
86
+ await writeJson(feed);
87
+ else
88
+ await writeStdout(`${feed.url}\n`);
89
+ if (!ctx.quiet && !ctx.json)
90
+ process.stderr.write("warning: feeds are public to anyone with the URL\n");
91
+ return 0;
92
+ }
93
+ case "show": {
94
+ const id = parsed.positionals[1];
95
+ if (!id)
96
+ throw new UsageError("feed show requires a feed ID");
97
+ const feed = await ctx.client.getFeed(id);
98
+ if (ctx.json)
99
+ await writeJson(feed);
100
+ else
101
+ await writeStdout(`${feed.url}\n`);
102
+ return 0;
103
+ }
104
+ case "list": {
105
+ const page = await ctx.client.listFeeds({
106
+ limit: flagInt(parsed.flags, "--limit", "--limit"),
107
+ cursor: flagString(parsed.flags, "--cursor"),
108
+ });
109
+ if (ctx.json)
110
+ await writeJson(page);
111
+ else {
112
+ for (const feed of page.feeds)
113
+ await writeStdout(`${feed.id} ${feed.url} ${feed.title}\n`);
114
+ if (page.nextCursor)
115
+ process.stderr.write(`cursor: ${page.nextCursor}\n`);
116
+ }
117
+ return 0;
118
+ }
119
+ case "delete": {
120
+ const id = parsed.positionals[1];
121
+ if (!id)
122
+ throw new UsageError("feed delete requires a feed ID");
123
+ const result = await ctx.client.deleteFeed(id);
124
+ if (ctx.json)
125
+ await writeJson(result);
126
+ else if (!ctx.quiet)
127
+ process.stderr.write(`deleted feed ${result.id}\n`);
128
+ return 0;
129
+ }
130
+ default:
131
+ throw new UsageError(`unknown feed command: ${action} (expected create, show, list, or delete)`, { example: "uploads feed create --repo owner/repo --pr 123" });
132
+ }
133
+ }
@@ -31,6 +31,12 @@ export declare function npmTooOldHint(version: string): string;
31
31
  */
32
32
  export declare function probeSkillTooling(run: CommandRunner): string | undefined;
33
33
  export declare function runStep(run: CommandRunner, command: string[]): StepResult;
34
+ /**
35
+ * True when `mcp list` output already names this server.
36
+ * Matches a name token (Claude's `uploads: url` form, tables, JSON) and not
37
+ * a substring of a host like `agents.uploads.sh`.
38
+ */
39
+ export declare function mcpListIncludesName(output: string, name: string): boolean;
34
40
  export declare function runInstall(args: string[], opts: {
35
41
  globals: GlobalFlags;
36
42
  json?: boolean;
@@ -1,6 +1,6 @@
1
1
  import { flagBool, flagString, parseCommandArgs, UsageError, } from "../cli-args.js";
2
2
  import { resolveConfig } from "../config.js";
3
- import { execRunner } from "../github-gh.js";
3
+ import { execRunner, timedExecRunner } from "../github-gh.js";
4
4
  import { writeCommandHelp } from "../cli-style.js";
5
5
  import { HOOK_COMMAND, HOOK_INVOCATION, installHookManifests, } from "../hooks-install.js";
6
6
  export const DEFAULT_MCP_URL = "https://agents.uploads.sh/mcp";
@@ -36,7 +36,9 @@ export const MCP_CLIENTS = [
36
36
  // Codex HTTP MCP has no --header; auth is OAuth on first use (same as the
37
37
  // plugin's .mcp.json). Passing --bearer-token-env-var UPLOADS_TOKEN would
38
38
  // break machines that signed in via `uploads login` (token lives in the
39
- // config file, not the environment).
39
+ // config file, not the environment). A second `mcp add` re-opens Codex's
40
+ // localhost callback ("Authentication complete. You may close this
41
+ // window.") — probe `mcp list` first so a refresh does not do that.
40
42
  command: (name, url) => ["codex", "mcp", "add", name, "--url", url],
41
43
  },
42
44
  {
@@ -58,9 +60,10 @@ workspace from the bearer token, so only the token is needed.
58
60
  Claude Code and Codex ship the same reminder via their plugins (same command:
59
61
  \`${HOOK_INVOCATION}\`) — install those plugins instead of relying on this step.
60
62
 
61
- Safe to re-run. An MCP server already registered under this name is reported
62
- as \`already configured\` and left as-is — including the token it was created
63
- with. To point it at a new token: \`<cli> mcp remove <name>\` first
63
+ Safe to re-run. Before \`mcp add\`, install checks \`mcp list\`. A server that is
64
+ already registered is reported as \`already configured\` and left as-is —
65
+ including the token it was created with — so a refresh does not open a browser
66
+ for OAuth. To point it at a new token: \`<cli> mcp remove <name>\` first
64
67
  (e.g. \`claude mcp remove uploads\`).
65
68
 
66
69
  Usage:
@@ -187,6 +190,28 @@ function skillCommand(skill) {
187
190
  function isAlreadyConfigured(error) {
188
191
  return /already exists|already (configured|registered|present)|duplicate/i.test(error);
189
192
  }
193
+ /**
194
+ * True when `mcp list` output already names this server.
195
+ * Matches a name token (Claude's `uploads: url` form, tables, JSON) and not
196
+ * a substring of a host like `agents.uploads.sh`.
197
+ */
198
+ export function mcpListIncludesName(output, name) {
199
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
200
+ const token = new RegExp(`(?:^|[\\s"'\\[{,])${escaped}(?:[:\\s"',\\]}]|$)`, "m");
201
+ return token.test(output);
202
+ }
203
+ /** Best-effort `mcp list` probe. Never opens a browser; times out on the default runner. */
204
+ const MCP_LIST_TIMEOUT_MS = 8_000;
205
+ function probeMcpAlreadyConfigured(run, client, name) {
206
+ const probe = run === execRunner ? timedExecRunner(MCP_LIST_TIMEOUT_MS) : run;
207
+ try {
208
+ const output = probe(client.id, ["mcp", "list"]);
209
+ return mcpListIncludesName(output, name) ? "yes" : "no";
210
+ }
211
+ catch (err) {
212
+ return isEnoent(err) ? "missing" : "no";
213
+ }
214
+ }
190
215
  function mcpStepKey(client) {
191
216
  return `mcp:${client.id}`;
192
217
  }
@@ -215,8 +240,25 @@ function partitionSteps(results) {
215
240
  }
216
241
  return { skills, mcp, other };
217
242
  }
218
- /** Run one client's `mcp add`; missing binaries skip, duplicates are already-configured. */
219
- function runMcpClientStep(run, command) {
243
+ /**
244
+ * Run one client's `mcp add`. Probe `mcp list` first so a refresh does not
245
+ * re-run add — Codex (and some other clients) treat a second add as success
246
+ * and open a localhost OAuth callback even when the server is already there.
247
+ */
248
+ function runMcpClientStep(run, client, name, url, bearer) {
249
+ const command = client.command(name, url, bearer);
250
+ const listed = probeMcpAlreadyConfigured(run, client, name);
251
+ if (listed === "yes") {
252
+ return { command, ok: true, skipped: "already-configured" };
253
+ }
254
+ if (listed === "missing") {
255
+ return {
256
+ command,
257
+ ok: true,
258
+ skipped: "missing-cli",
259
+ error: `${client.id} not found on PATH`,
260
+ };
261
+ }
220
262
  try {
221
263
  const output = run(command[0], command.slice(1)).trim();
222
264
  return { command, ok: true, output: output || undefined };
@@ -412,7 +454,7 @@ export async function runInstall(args, opts, help = false) {
412
454
  results[key] = { command, ok: true, skipped: "dry-run" };
413
455
  }
414
456
  else {
415
- results[key] = runMcpClientStep(run, command);
457
+ results[key] = runMcpClientStep(run, client, name, url, bearer);
416
458
  }
417
459
  }
418
460
  }
@@ -11,9 +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\` (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.
14
+ registered is left as-is (\`already configured\`) — install checks \`mcp list\`
15
+ first so \`mcp add\` (and Codex's OAuth browser) does not run again. A missing
16
+ agent CLI is skipped so it does not fail the rest of the refresh.
17
17
 
18
18
  Usage:
19
19
  uploads update [options]
@@ -90,7 +90,7 @@ export interface AttachmentItem {
90
90
  url: string | null;
91
91
  /** Prefer for `<img src>` on GitHub (Camo-friendly host). Falls back to `url`. */
92
92
  embedUrl?: string | null;
93
- /** Canonical `/f/` file-page URL (server-computed). Preferred click-through target; falls back to `url`. */
93
+ /** Preferred click-through (PR feed item, `/f/` file page, or equivalent). Falls back to `url`. */
94
94
  pageUrl?: string | null;
95
95
  /**
96
96
  * The only canonical metadata the managed comment renders (issue #365).
package/dist/github.d.ts CHANGED
@@ -5,9 +5,21 @@ export interface GithubCoordinate {
5
5
  coordinate: string;
6
6
  canonicalUrl: string;
7
7
  }
8
+ /** Parsed `owner/repo#number` or a strict GitHub issue/PR URL. */
9
+ export interface GithubIssueRef {
10
+ repo: string;
11
+ number: number;
12
+ kind?: "pull" | "issue";
13
+ }
8
14
  export declare function isValidRepo(repo: string): boolean;
9
15
  /** Parse "owner/name" from a git remote URL (SSH or HTTPS), else undefined. */
10
16
  export declare function parseRepoFromRemoteUrl(url: string): string | undefined;
17
+ /**
18
+ * Parse `owner/repo#number` or a strict
19
+ * `https://github.com/<owner>/<repo>/issues|pull/<number>` URL. Kind is set
20
+ * only when the URL path names `/pull/` or `/issues/`.
21
+ */
22
+ export declare function parseGithubIssueRef(value: string): GithubIssueRef | undefined;
11
23
  /** Normalize a GitHub issue or pull-request coordinate for gallery linking. */
12
24
  export declare function normalizeGithubCoordinate(value: string): GithubCoordinate | undefined;
13
25
  /**
package/dist/github.js CHANGED
@@ -13,6 +13,46 @@ export function parseRepoFromRemoteUrl(url) {
13
13
  const repo = match?.[1];
14
14
  return repo && isValidRepo(repo) ? repo : undefined;
15
15
  }
16
+ /**
17
+ * Parse `owner/repo#number` or a strict
18
+ * `https://github.com/<owner>/<repo>/issues|pull/<number>` URL. Kind is set
19
+ * only when the URL path names `/pull/` or `/issues/`.
20
+ */
21
+ export function parseGithubIssueRef(value) {
22
+ const input = value.trim();
23
+ const coordinate = /^([^/\s#]+)\/([^/\s#]+)#([1-9][0-9]*)$/.exec(input);
24
+ if (coordinate)
25
+ return githubIssueRef(coordinate[1], coordinate[2], coordinate[3]);
26
+ try {
27
+ const url = new URL(input);
28
+ if (url.protocol !== "https:" ||
29
+ url.hostname.toLowerCase() !== "github.com" ||
30
+ url.port ||
31
+ url.username ||
32
+ url.password ||
33
+ url.search ||
34
+ url.hash)
35
+ return undefined;
36
+ const match = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/([1-9][0-9]*)\/?$/.exec(url.pathname);
37
+ if (!match)
38
+ return undefined;
39
+ return githubIssueRef(match[1], match[2], match[4], match[3] === "issues" ? "issue" : "pull");
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ function githubIssueRef(ownerRaw, repositoryRaw, numberRaw, kind) {
46
+ const repo = ownerRaw + "/" + repositoryRaw;
47
+ const number = Number(numberRaw);
48
+ if (!isValidRepo(repo) || !Number.isSafeInteger(number))
49
+ return undefined;
50
+ return {
51
+ repo: ownerRaw.toLowerCase() + "/" + repositoryRaw.toLowerCase(),
52
+ number,
53
+ ...(kind ? { kind } : {}),
54
+ };
55
+ }
16
56
  /** Normalize a GitHub issue or pull-request coordinate for gallery linking. */
17
57
  export function normalizeGithubCoordinate(value) {
18
58
  const input = value.trim();
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export { assertFetchableUploadUrl, fetchUploadSource, filenameFromUploadUrl, res
10
10
  export { createUploadsClient, type UploadsClient, type PutOptions, type ProvenanceInput, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type GalleryItem, type Gallery, type GallerySummary, type GalleryListOptions, type GalleryListResult, type CreateGalleryOptions, type AddGalleryItemOptions, type DeleteGalleryOptions, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, type FindFilesOptions, type FindFilesItem, type FindFilesResult, type MetadataKeysResult, type MetadataValuesResult, type GetMetadataResult, type PatchMetadataOptions, type ResolveGhPrefixOptions, type ResolveGhPrefixResult, } from "./client.js";
11
11
  export { buildCliProvenance } from "./provenance.js";
12
12
  export { META_KEY_RE, META_VALUE_MAX, META_MAX_KEYS, META_MAX_TOTAL_BYTES, validateMetaEntry, parseMetaPair, parseMetaFlags, } from "./metadata.js";
13
- export { ATTACHMENTS_MARKER, attachmentsCommentBody, GH_FALLBACK_AUTHOR_NOTE, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghBranchAttachmentKey, ghBranchKeyPrefix, ghMetadataForBranch, isValidRepo, parseRepoFromRemoteUrl, GH_PRIVATE_ROOT, ghPrivateKeyPrefix, ghPrivateAttachmentKey, ghPrivateBranchKeyPrefix, ghPrivateBranchAttachmentKey, ghAttachmentKeyForMode, ghBranchAttachmentKeyForMode, parseGhKey, parseGhPrivateKey, type AttachmentItem, type GhTarget, type GhTargetKind, type GhKeyMode, } from "./github.js";
13
+ export { ATTACHMENTS_MARKER, attachmentsCommentBody, GH_FALLBACK_AUTHOR_NOTE, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghBranchAttachmentKey, ghBranchKeyPrefix, ghMetadataForBranch, isValidRepo, parseGithubIssueRef, parseRepoFromRemoteUrl, GH_PRIVATE_ROOT, ghPrivateKeyPrefix, ghPrivateAttachmentKey, ghPrivateBranchKeyPrefix, ghPrivateBranchAttachmentKey, ghAttachmentKeyForMode, ghBranchAttachmentKeyForMode, parseGhKey, parseGhPrivateKey, type AttachmentItem, type GithubIssueRef, type GhTarget, type GhTargetKind, type GhKeyMode, } from "./github.js";
14
14
  export { DEFAULT_OPTIMIZE_MAX_EDGE, DEFAULT_OPTIMIZE_QUALITY, optimizeImageForUpload, rewriteKeyExtension, withImageExtension, type OptimizeImageOptions, type OptimizeImageResult, type OptimizeOutputFormat, } from "./optimize.js";
15
15
  export { FRAME_PRESETS, applyFrame, listFramePresets, resolveFrameId, type FrameFit, type FrameOptions, type FrameResult, } from "./frame.js";
16
16
  export { execRunner, resolveRepo, upsertAttachmentsComment, type CommandRunner, } from "./github-gh.js";
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ export { assertFetchableUploadUrl, fetchUploadSource, filenameFromUploadUrl, res
10
10
  export { createUploadsClient, } from "./client.js";
11
11
  export { buildCliProvenance } from "./provenance.js";
12
12
  export { META_KEY_RE, META_VALUE_MAX, META_MAX_KEYS, META_MAX_TOTAL_BYTES, validateMetaEntry, parseMetaPair, parseMetaFlags, } from "./metadata.js";
13
- export { ATTACHMENTS_MARKER, attachmentsCommentBody, GH_FALLBACK_AUTHOR_NOTE, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghBranchAttachmentKey, ghBranchKeyPrefix, ghMetadataForBranch, isValidRepo, parseRepoFromRemoteUrl,
13
+ export { ATTACHMENTS_MARKER, attachmentsCommentBody, GH_FALLBACK_AUTHOR_NOTE, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghBranchAttachmentKey, ghBranchKeyPrefix, ghMetadataForBranch, isValidRepo, parseGithubIssueRef, parseRepoFromRemoteUrl,
14
14
  // Private-repo randomized-prefix builders (issue #631) — needed by the
15
15
  // hosted MCP (apps/mcp), which builds keys in-process rather than via
16
16
  // the CLI's own commands.ts.
@@ -26,6 +26,7 @@ export declare const searchDocsResultSchema: JsonSchema;
26
26
  export declare const promoteToolResultSchema: JsonSchema;
27
27
  export declare const galleryResultSchema: JsonSchema;
28
28
  export declare const galleryFindResultSchema: JsonSchema;
29
+ export declare const feedResultSchema: JsonSchema;
29
30
  /** Hosted catalog — every tool must have an entry. */
30
31
  export declare const hostedOutputSchemas: Record<string, JsonSchema>;
31
32
  /** Shared-shape stdio tools. Hosted-only tools (`promote`, `repo_link_status`) omitted. */
@@ -337,6 +337,39 @@ export const galleryFindResultSchema = objectSchema({
337
337
  galleries: { type: "array", items: galleryResultSchema },
338
338
  nextCursor: nullableString,
339
339
  });
340
+ const feedItemSchema = objectSchema({
341
+ id: { type: "string" },
342
+ objectKey: { type: "string" },
343
+ filename: { type: "string" },
344
+ status: { type: "string" },
345
+ url: nullableString,
346
+ embedUrl: nullableString,
347
+ pageUrl: { type: "string" },
348
+ contentType: nullableString,
349
+ size: { type: ["number", "null"] },
350
+ uploaded: nullableString,
351
+ modified: nullableString,
352
+ path: nullableString,
353
+ state: nullableString,
354
+ posterUrl: { type: "string" },
355
+ videoDimensions: {
356
+ type: "object",
357
+ additionalProperties: true,
358
+ },
359
+ });
360
+ export const feedResultSchema = objectSchema({
361
+ id: { type: "string" },
362
+ url: { type: "string" },
363
+ workspace: { type: "string" },
364
+ repo: { type: "string" },
365
+ path: nullableString,
366
+ number: { type: ["number", "null"] },
367
+ kind: nullableString,
368
+ title: { type: "string" },
369
+ createdAt: { type: "string" },
370
+ updatedAt: { type: "string" },
371
+ items: { type: "array", items: feedItemSchema },
372
+ });
340
373
  /** Hosted catalog — every tool must have an entry. */
341
374
  export const hostedOutputSchemas = {
342
375
  gallery_create: galleryResultSchema,
@@ -344,6 +377,8 @@ export const hostedOutputSchemas = {
344
377
  gallery_add: galleryItemSchema,
345
378
  gallery_link: galleryReferenceSchema,
346
379
  gallery_find_by_reference: galleryFindResultSchema,
380
+ feed_create: feedResultSchema,
381
+ feed_get: feedResultSchema,
347
382
  put: putResultSchema,
348
383
  list: listResultSchema,
349
384
  delete: deleteResultSchema,
@@ -368,6 +403,8 @@ export const stdioOutputSchemas = {
368
403
  gallery_add: galleryItemSchema,
369
404
  gallery_link: galleryReferenceSchema,
370
405
  gallery_find_by_reference: galleryFindResultSchema,
406
+ feed_create: feedResultSchema,
407
+ feed_get: feedResultSchema,
371
408
  put: putResultSchema,
372
409
  list: listResultSchema,
373
410
  delete: deleteResultSchema,
package/dist/mcp/tools.js CHANGED
@@ -4,7 +4,7 @@ import { resolveFrameId } from "../frame.js";
4
4
  import { resolveConfig, resolvePutDefaults, } from "../config.js";
5
5
  import { resolvePutPrefix } from "../destinations.js";
6
6
  import { fetchUploadSource, resolveUploadFilename } from "../fetch-upload-source.js";
7
- import { ghKeyPrefix, ghPrivateKeyPrefix } from "../github.js";
7
+ import { ghKeyPrefix, ghPrivateKeyPrefix, parseGithubIssueRef } from "../github.js";
8
8
  import { safeCaptureFacts } from "../capture-facts.js";
9
9
  import { deriveRepoSlugFromGit } from "../keys.js";
10
10
  import { validateMetaMap } from "../metadata.js";
@@ -75,6 +75,12 @@ function galleryId(args) {
75
75
  usage("galleryId is required");
76
76
  return id;
77
77
  }
78
+ function feedId(args) {
79
+ const id = optString(args, "feedId");
80
+ if (!id)
81
+ usage("feedId is required");
82
+ return id;
83
+ }
78
84
  function galleryReference(args) {
79
85
  const provider = optString(args, "provider");
80
86
  const coordinate = optString(args, "coordinate");
@@ -86,6 +92,33 @@ function galleryReference(args) {
86
92
  usage("provider must be github");
87
93
  return { provider, coordinate };
88
94
  }
95
+ function feedCreateOptions(args) {
96
+ const github = optString(args, "github");
97
+ const repo = optString(args, "repo");
98
+ const path = optString(args, "path");
99
+ const pr = optPosInt(args, "pr");
100
+ const issue = optPosInt(args, "issue");
101
+ if (pr != null && issue != null)
102
+ usage("pr and issue are mutually exclusive");
103
+ if (github && (pr != null || issue != null))
104
+ usage("github cannot be combined with pr or issue");
105
+ if (github) {
106
+ const ref = parseGithubIssueRef(github);
107
+ if (!ref)
108
+ usage("github must be owner/repo#number or a GitHub issue/PR URL");
109
+ if (repo && repo.trim().toLowerCase() !== ref.repo)
110
+ usage("repo does not match github");
111
+ return { repo: ref.repo, path, number: ref.number, kind: ref.kind };
112
+ }
113
+ if (!repo)
114
+ usage("repo is required");
115
+ return {
116
+ repo,
117
+ path,
118
+ ...(pr != null ? { number: pr, kind: "pull" } : {}),
119
+ ...(issue != null ? { number: issue, kind: "issue" } : {}),
120
+ };
121
+ }
89
122
  const workspaceProp = {
90
123
  type: "string",
91
124
  description: "Override the workspace for this call (like the CLI's --workspace flag).",
@@ -289,6 +322,66 @@ export function createUploadsMcpTools(opts) {
289
322
  });
290
323
  },
291
324
  },
325
+ {
326
+ name: "feed_create",
327
+ title: "Create change feed",
328
+ annotations: mcpWritePublic,
329
+ securitySchemes: mcpOAuthWrite,
330
+ description: "Create a public newest-first screenshot feed (same product as CLI `uploads feed create`). Pass `repo` alone for the whole GitHub repo. Pass `pr` plus `repo` — or `github` as owner/repo#123 / a GitHub PR URL — to scope that same feed to one pull request. `issue` does the same for an issue. Optional `path` filters by page-path metadata. Creating the same scope again returns the existing URL. This is a live query, not a curated gallery. Anyone who knows the URL can view it.",
331
+ inputSchema: {
332
+ type: "object",
333
+ properties: {
334
+ repo: {
335
+ type: "string",
336
+ description: "GitHub owner/repo, for example acme/app. Required unless github is set.",
337
+ },
338
+ pr: {
339
+ type: "integer",
340
+ minimum: 1,
341
+ description: "Scope the feed to this pull request. Same product as a repo feed — one extra filter. Mutually exclusive with issue and github.",
342
+ },
343
+ issue: {
344
+ type: "integer",
345
+ minimum: 1,
346
+ description: "Scope the feed to this issue. Mutually exclusive with pr and github.",
347
+ },
348
+ github: {
349
+ type: "string",
350
+ description: "Scope via owner/repo#number or a GitHub issue/PR URL. Supplies repo when omitted. Mutually exclusive with pr and issue.",
351
+ },
352
+ path: {
353
+ type: "string",
354
+ description: "Optional exact page-path metadata filter, for example /settings.",
355
+ },
356
+ workspace: workspaceProp,
357
+ },
358
+ additionalProperties: false,
359
+ },
360
+ async handler(args) {
361
+ const { client } = await clientFor(args);
362
+ return client.createFeed(feedCreateOptions(args));
363
+ },
364
+ },
365
+ {
366
+ name: "feed_get",
367
+ title: "Get change feed",
368
+ annotations: mcpRead,
369
+ securitySchemes: mcpOAuthRead,
370
+ description: "Get a workspace-owned change feed by ID, including its current newest-first screenshots and the canonical public /c/<id> URL. Each item includes pageUrl (`/c/<id>/<item>`) for the pager. The feed may be repo-wide or scoped to one pull request / issue (see `number` and `kind` on the result). Anyone with the URL can view the media.",
371
+ inputSchema: {
372
+ type: "object",
373
+ properties: {
374
+ feedId: { type: "string", description: "Opaque feed ID." },
375
+ workspace: workspaceProp,
376
+ },
377
+ required: ["feedId"],
378
+ additionalProperties: false,
379
+ },
380
+ async handler(args) {
381
+ const { client } = await clientFor(args);
382
+ return client.getFeed(feedId(args));
383
+ },
384
+ },
292
385
  {
293
386
  name: "put",
294
387
  title: "Upload file",
@@ -6,7 +6,10 @@ export interface UpdateCache {
6
6
  }
7
7
  export interface UpdateCheckOptions {
8
8
  quiet?: boolean;
9
- /** mcp is always skipped (stdio purity). */
9
+ /**
10
+ * `mcp` is always skipped (stdio purity). `update` is skipped so a successful
11
+ * upgrade does not immediately hint the old in-process version.
12
+ */
10
13
  command?: string;
11
14
  currentVersion?: string;
12
15
  cachePath?: string;
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Checks the registry at most once per day, never throws, never blocks longer
5
5
  * than a short timeout, and writes only to stderr. Silence with --quiet,
6
- * UPLOADS_NO_UPDATE=1, or NO_UPDATE_NOTIFIER=1.
6
+ * `uploads update`, `uploads mcp`, UPLOADS_NO_UPDATE=1, or NO_UPDATE_NOTIFIER=1.
7
7
  */
8
8
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
@@ -75,7 +75,7 @@ export function writeUpdateCache(path, cache) {
75
75
  export async function checkForUpdate(opts = {}) {
76
76
  const current = opts.currentVersion ?? packageVersion();
77
77
  try {
78
- if (opts.quiet || opts.command === "mcp") {
78
+ if (opts.quiet || opts.command === "mcp" || opts.command === "update") {
79
79
  return { current, updateAvailable: false };
80
80
  }
81
81
  if (truthyEnv("UPLOADS_NO_UPDATE") || truthyEnv("NO_UPDATE_NOTIFIER")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.54.0",
3
+ "version": "0.55.1",
4
4
  "mcpName": "sh.uploads/mcp",
5
5
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
6
6
  "type": "module",