@buildinternet/uploads 0.53.0 → 0.54.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
@@ -45,7 +45,7 @@ global `uploads` form above.
45
45
 
46
46
  Commands: `attach`, `put`, `screenshot`, `annotate`, `gallery`, `comment`, `list`, `find`, `meta`, `delete`, `usage`,
47
47
  `reconcile`, `purge-expired`, `setup`, `install`, `login`, `whoami` (alias `status`),
48
- `logout`, `invite`, `admin`, `config`, `telemetry`, `report`, `doctor`, `health`, `changelog`, `mcp`,
48
+ `logout`, `invite`, `admin`, `config`, `telemetry`, `report`, `doctor`, `health`, `changelog`, `docs`, `mcp`,
49
49
  `completion`.
50
50
 
51
51
  **Help:** bare `uploads` / `uploads help` / `--help` shows essentials; use
@@ -237,6 +237,11 @@ export const ROOT_COMMANDS = [
237
237
  name: "changelog",
238
238
  summary: "Show recent product updates (and a link to the full changelog)",
239
239
  },
240
+ {
241
+ name: "docs",
242
+ summary: "Search public uploads.sh docs (or fetch one page as markdown)",
243
+ usage: "docs [query]",
244
+ },
240
245
  {
241
246
  name: "login",
242
247
  summary: "Sign in via browser (or an enrollment code) and save credentials",
package/dist/cli.js CHANGED
@@ -17,6 +17,7 @@ import { runInstall } from "./commands/install.js";
17
17
  import { runHook } from "./commands/hook.js";
18
18
  import { runUpdate } from "./commands/update.js";
19
19
  import { runChangelog } from "./commands/changelog.js";
20
+ import { runDocs } from "./commands/docs.js";
20
21
  import { runCompletion } from "./commands/completion.js";
21
22
  import { runLogout, runWhoami } from "./commands/session.js";
22
23
  import { runTelemetry } from "./commands/telemetry.js";
@@ -243,6 +244,10 @@ export async function runCli(argv) {
243
244
  // Public feed — no token, independent of the API origin.
244
245
  code = await runChangelog(cmdArgs, { json }, showHelp);
245
246
  break;
247
+ case "docs":
248
+ // Public catalog — no token, independent of the API origin.
249
+ code = await runDocs(cmdArgs, { json }, showHelp);
250
+ break;
246
251
  case "config":
247
252
  code = await runConfig(cmdArgs, { json, envFile: parsed.globals.envFile }, showHelp);
248
253
  break;
@@ -0,0 +1,8 @@
1
+ import { type SearchDocsOptions } from "../docs.js";
2
+ export interface RunDocsOptions {
3
+ json?: boolean;
4
+ fetch?: SearchDocsOptions["fetchImpl"];
5
+ url?: string;
6
+ catalog?: SearchDocsOptions["catalog"];
7
+ }
8
+ export declare function runDocs(args: string[], opts?: RunDocsOptions, help?: boolean): Promise<number>;
@@ -0,0 +1,67 @@
1
+ import { flagBool, flagInt, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
2
+ import { UploadsError } from "../errors.js";
3
+ import { DEFAULT_DOCS_LIMIT, MAX_DOCS_LIMIT, formatDocsHuman, searchDocs, } from "../docs.js";
4
+ import { writeCommandHelp } from "../cli-style.js";
5
+ import { writeJson, writeStdout } from "../io.js";
6
+ const DOCS_HELP = `uploads docs — search public uploads.sh documentation
7
+
8
+ Lists the docs catalog, searches by topic, or fetches one page as markdown.
9
+
10
+ Usage:
11
+ uploads docs [query…] [options]
12
+
13
+ Options:
14
+ --page <slug> Fetch the full markdown of one page (slug, path, or URL)
15
+ --limit <n> Number of search hits to show (default ${DEFAULT_DOCS_LIMIT}, max ${MAX_DOCS_LIMIT})
16
+ --json JSON on stdout (also accepts global --json)
17
+
18
+ Examples:
19
+ uploads docs
20
+ uploads docs stage before a PR
21
+ uploads docs attach
22
+ uploads docs --page agents
23
+ uploads docs --json "github app"
24
+ `;
25
+ export async function runDocs(args, opts = {}, help = false) {
26
+ const parsed = parseCommandArgs(args);
27
+ if (help || parsed.help) {
28
+ writeCommandHelp(DOCS_HELP);
29
+ return 0;
30
+ }
31
+ const json = Boolean(opts.json) || flagBool(parsed.flags, "--json");
32
+ const page = flagString(parsed.flags, "--page");
33
+ const limit = flagInt(parsed.flags, "--limit", "--limit");
34
+ if (limit !== undefined && limit > MAX_DOCS_LIMIT) {
35
+ throw new UsageError(`--limit must be ${MAX_DOCS_LIMIT} or less (got ${limit})`, {
36
+ example: `uploads docs --limit ${MAX_DOCS_LIMIT}`,
37
+ });
38
+ }
39
+ const query = parsed.positionals.join(" ").trim();
40
+ if (page && query) {
41
+ throw new UsageError("pass a search query or --page, not both", {
42
+ example: "uploads docs --page attach",
43
+ });
44
+ }
45
+ let doc;
46
+ try {
47
+ doc = await searchDocs({
48
+ query: query || undefined,
49
+ page: page || undefined,
50
+ limit,
51
+ fetchImpl: opts.fetch,
52
+ url: opts.url,
53
+ catalog: opts.catalog,
54
+ });
55
+ }
56
+ catch (err) {
57
+ if (err instanceof UploadsError && err.code === "USAGE") {
58
+ throw new UsageError(err.message, { example: "uploads docs --page attach" });
59
+ }
60
+ throw err;
61
+ }
62
+ if (json)
63
+ await writeJson(doc);
64
+ else
65
+ await writeStdout(formatDocsHuman(doc));
66
+ return 0;
67
+ }
package/dist/docs.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ export declare const DOCS_HUB_URL = "https://uploads.sh/docs";
2
+ export declare const DOCS_JSON_URL = "https://uploads.sh/docs.json";
3
+ export declare const DEFAULT_DOCS_LIMIT = 5;
4
+ export declare const MAX_DOCS_LIMIT = 50;
5
+ export declare const DOCS_SNIPPET_MAX_CHARS = 1500;
6
+ export declare const DOCS_PAGE_MAX_CHARS = 50000;
7
+ export type DocsCatalogPage = {
8
+ page: string;
9
+ title: string;
10
+ url: string;
11
+ summary: string;
12
+ aliases: string[];
13
+ };
14
+ export type DocsCatalog = {
15
+ url: string;
16
+ pages: DocsCatalogPage[];
17
+ };
18
+ export type DocsSearchHit = {
19
+ title: string;
20
+ url: string;
21
+ page: string;
22
+ snippet: string;
23
+ body?: string;
24
+ truncated?: boolean;
25
+ };
26
+ export type DocsSearchDocument = {
27
+ url: string;
28
+ query?: string;
29
+ results: DocsSearchHit[];
30
+ total: number;
31
+ };
32
+ export type SearchDocsOptions = {
33
+ query?: string;
34
+ page?: string;
35
+ limit?: number;
36
+ url?: string;
37
+ fetchImpl?: typeof fetch;
38
+ timeoutMs?: number;
39
+ userAgent?: string;
40
+ /** Skip the network catalog fetch (tests). */
41
+ catalog?: DocsCatalog;
42
+ };
43
+ /** Built-in catalog used when /docs.json is unreachable. */
44
+ export declare const FALLBACK_DOCS_CATALOG: DocsCatalog;
45
+ export declare function parseDocsCatalog(raw: unknown): DocsCatalog;
46
+ export declare function resolveDocsPage(catalog: DocsCatalog, raw: string): DocsCatalogPage | undefined;
47
+ export declare function rankDocsPages(catalog: DocsCatalog, query: string): DocsCatalogPage[];
48
+ export declare function truncateDocsSnippet(text: string, maxChars?: number): string;
49
+ /** Human terminal output for catalog/search hits. A fetched page prints its body. */
50
+ export declare function formatDocsHuman(doc: DocsSearchDocument): string;
51
+ export declare function loadDocsCatalog(opts?: SearchDocsOptions): Promise<DocsCatalog>;
52
+ export declare function searchDocs(opts?: SearchDocsOptions): Promise<DocsSearchDocument>;
package/dist/docs.js ADDED
@@ -0,0 +1,439 @@
1
+ /**
2
+ * Fetch and search the public uploads.sh docs catalog for `uploads docs`
3
+ * and the MCP `search_docs` tool.
4
+ */
5
+ import { UploadsError } from "./errors.js";
6
+ import { packageVersion } from "./package-version.js";
7
+ export const DOCS_HUB_URL = "https://uploads.sh/docs";
8
+ export const DOCS_JSON_URL = "https://uploads.sh/docs.json";
9
+ export const DEFAULT_DOCS_LIMIT = 5;
10
+ export const MAX_DOCS_LIMIT = 50;
11
+ export const DOCS_SNIPPET_MAX_CHARS = 1500;
12
+ export const DOCS_PAGE_MAX_CHARS = 50_000;
13
+ const FETCH_TIMEOUT_MS = 8000;
14
+ const SITE_ORIGIN = "https://uploads.sh";
15
+ const STOP_WORDS = new Set([
16
+ "a",
17
+ "an",
18
+ "and",
19
+ "at",
20
+ "do",
21
+ "for",
22
+ "how",
23
+ "i",
24
+ "in",
25
+ "is",
26
+ "my",
27
+ "of",
28
+ "on",
29
+ "or",
30
+ "the",
31
+ "to",
32
+ "we",
33
+ "your",
34
+ ]);
35
+ /** Built-in catalog used when /docs.json is unreachable. */
36
+ export const FALLBACK_DOCS_CATALOG = {
37
+ url: DOCS_HUB_URL,
38
+ pages: [
39
+ {
40
+ page: "docs",
41
+ title: "Docs",
42
+ url: "https://uploads.sh/docs",
43
+ summary: "Install the uploads CLI, attach screenshots and video to GitHub PRs, issues, and code reviews, and set up your agent.",
44
+ aliases: ["overview", "hub", "install"],
45
+ },
46
+ {
47
+ page: "attach-pull-request-images",
48
+ title: "Attach & share",
49
+ url: "https://uploads.sh/docs/attach-pull-request-images",
50
+ summary: "Attach screenshots and video to GitHub PRs and issues, get a public URL for any accepted file, capture a screenshot, and bake callouts or redactions onto it with the uploads CLI.",
51
+ aliases: [
52
+ "attach",
53
+ "stage",
54
+ "staging",
55
+ "screenshot",
56
+ "annotate",
57
+ "pr",
58
+ "issue",
59
+ "before",
60
+ "after",
61
+ "put",
62
+ "share",
63
+ ],
64
+ },
65
+ {
66
+ page: "galleries",
67
+ title: "Galleries",
68
+ url: "https://uploads.sh/docs/galleries",
69
+ summary: "Create an ordered set of media behind one public link at /g/<id>, add files with uploads put --gallery, and link the gallery to a PR or issue.",
70
+ aliases: ["gallery", "collection"],
71
+ },
72
+ {
73
+ page: "github-app",
74
+ title: "GitHub App",
75
+ url: "https://uploads.sh/docs/github-app",
76
+ summary: "Install the uploads-sh GitHub App so attachment comments post as the bot, private-repo PR and issue titles show on file pages, and titles stay current.",
77
+ aliases: ["bot", "webhook", "promote", "app", "ingest"],
78
+ },
79
+ {
80
+ page: "comment-config",
81
+ title: "Comment config",
82
+ url: "https://uploads.sh/docs/comment-config",
83
+ summary: "Reference for .uploads.yml — a repo-committed file that controls image width, inline-image caps, caption metadata, and an optional note on the managed PR/issue comment.",
84
+ aliases: ["uploads.yml", "comment", "yaml", "yml", "width"],
85
+ },
86
+ {
87
+ page: "agents",
88
+ title: "Set up your agent",
89
+ url: "https://uploads.sh/docs/agents",
90
+ summary: "Install the uploads agent skill and MCP server so your coding agent can attach screenshots to GitHub on its own.",
91
+ aliases: ["mcp", "skill", "plugin", "claude", "codex"],
92
+ },
93
+ {
94
+ page: "reference",
95
+ title: "Reference",
96
+ url: "https://uploads.sh/docs/reference",
97
+ summary: "Manage uploaded files with the uploads CLI (list, delete, usage), plus what to know about visibility, access, and stability.",
98
+ aliases: ["list", "delete", "usage", "visibility"],
99
+ },
100
+ {
101
+ page: "limits",
102
+ title: "Plans & limits",
103
+ url: "https://uploads.sh/docs/limits",
104
+ summary: "Storage, file size, and member limits for uploads.sh free and pro workspaces, and how they compare with GitHub's own attachment caps.",
105
+ aliases: ["plan", "plans", "pricing", "quota", "storage", "pro", "free"],
106
+ },
107
+ {
108
+ page: "byo-bucket",
109
+ title: "Bring your own bucket",
110
+ url: "https://uploads.sh/docs/byo-bucket",
111
+ summary: "Point a workspace at your own Cloudflare R2 or S3-compatible bucket instead of hosted storage on storage.uploads.sh.",
112
+ aliases: ["r2", "s3", "byo", "bucket", "bring-your-own"],
113
+ },
114
+ {
115
+ page: "github-screenshots",
116
+ title: "How to get agents to upload screenshots & video to GitHub",
117
+ url: "https://uploads.sh/github-screenshots",
118
+ summary: "One command for Claude Code, CI jobs, and scripts that captures, hosts, and posts screenshots and video to PRs and issues, before or after the PR exists.",
119
+ aliases: ["walkthrough", "agent-guide", "how-to"],
120
+ },
121
+ {
122
+ page: "changelog",
123
+ title: "Changelog",
124
+ url: "https://uploads.sh/changelog",
125
+ summary: "Platform updates and CLI releases, newest first.",
126
+ aliases: ["updates", "release", "whats-new"],
127
+ },
128
+ {
129
+ page: "auth",
130
+ title: "Auth for agents",
131
+ url: "https://uploads.sh/auth.md",
132
+ summary: "Device sign-in, workspace bearer tokens, and hosted MCP OAuth.",
133
+ aliases: ["login", "token", "oauth", "bearer"],
134
+ },
135
+ ],
136
+ };
137
+ function asString(value) {
138
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
139
+ }
140
+ function asHttpUrl(value) {
141
+ const raw = asString(value);
142
+ if (!raw)
143
+ return undefined;
144
+ try {
145
+ const parsed = new URL(raw);
146
+ if (parsed.protocol === "https:" || parsed.protocol === "http:")
147
+ return raw;
148
+ }
149
+ catch {
150
+ return undefined;
151
+ }
152
+ return undefined;
153
+ }
154
+ function asStringArray(value) {
155
+ if (!Array.isArray(value))
156
+ return [];
157
+ return value.filter((item) => typeof item === "string" && item.trim().length > 0);
158
+ }
159
+ function parsePage(raw) {
160
+ if (!raw || typeof raw !== "object")
161
+ return undefined;
162
+ const rec = raw;
163
+ const page = asString(rec.page);
164
+ const title = asString(rec.title);
165
+ const url = asHttpUrl(rec.url);
166
+ const summary = asString(rec.summary);
167
+ if (!page || !title || !url || !summary)
168
+ return undefined;
169
+ return { page, title, url, summary, aliases: asStringArray(rec.aliases) };
170
+ }
171
+ export function parseDocsCatalog(raw) {
172
+ if (!raw || typeof raw !== "object") {
173
+ throw new UploadsError("docs catalog was not a JSON object", "API_ERROR");
174
+ }
175
+ const rec = raw;
176
+ if (!Array.isArray(rec.pages)) {
177
+ throw new UploadsError("docs catalog is missing a pages array", "API_ERROR");
178
+ }
179
+ const pages = rec.pages.map(parsePage).filter((p) => p !== undefined);
180
+ if (pages.length === 0) {
181
+ throw new UploadsError("docs catalog had no usable pages", "API_ERROR");
182
+ }
183
+ return { url: asHttpUrl(rec.url) ?? DOCS_HUB_URL, pages };
184
+ }
185
+ function clampDocsLimit(limit, fallback = DEFAULT_DOCS_LIMIT) {
186
+ const n = limit ?? fallback;
187
+ if (n > MAX_DOCS_LIMIT)
188
+ return MAX_DOCS_LIMIT;
189
+ if (n < 1)
190
+ return fallback;
191
+ return n;
192
+ }
193
+ function tokenizeDocsQuery(query) {
194
+ return query
195
+ .toLowerCase()
196
+ .split(/[^a-z0-9]+/)
197
+ .filter((token) => token.length > 1 && !STOP_WORDS.has(token));
198
+ }
199
+ function normalizeDocsKey(value) {
200
+ return value
201
+ .trim()
202
+ .toLowerCase()
203
+ .replace(/^https:\/\/uploads\.sh/i, "")
204
+ .replace(/^\/+/, "")
205
+ .replace(/\/+$/, "");
206
+ }
207
+ function pageKeys(page) {
208
+ const keys = [page.page, ...page.aliases];
209
+ try {
210
+ const path = new URL(page.url).pathname.replace(/^\/+/, "").replace(/\/+$/, "");
211
+ keys.push(path);
212
+ const leaf = path.split("/").pop();
213
+ if (leaf)
214
+ keys.push(leaf);
215
+ if (path.startsWith("docs/"))
216
+ keys.push(path.slice("docs/".length));
217
+ }
218
+ catch {
219
+ // Catalog URLs are validated on parse; ignore a malformed fallback entry.
220
+ }
221
+ return keys.map(normalizeDocsKey).filter(Boolean);
222
+ }
223
+ export function resolveDocsPage(catalog, raw) {
224
+ const query = normalizeDocsKey(raw);
225
+ if (!query)
226
+ return undefined;
227
+ const stripped = query.startsWith("docs/") ? query.slice("docs/".length) : query;
228
+ for (const page of catalog.pages) {
229
+ const keys = pageKeys(page);
230
+ if (keys.includes(query) || keys.includes(stripped))
231
+ return page;
232
+ }
233
+ return undefined;
234
+ }
235
+ function searchableText(page) {
236
+ return `${page.page} ${page.title} ${page.summary} ${page.aliases.join(" ")}`.toLowerCase();
237
+ }
238
+ function scoreDocsPage(page, tokens) {
239
+ if (tokens.length === 0)
240
+ return 0;
241
+ const text = searchableText(page);
242
+ let matched = 0;
243
+ for (const token of tokens) {
244
+ if (text.includes(token))
245
+ matched += 1;
246
+ }
247
+ if (matched === 0)
248
+ return 0;
249
+ return matched * 10 + (matched === tokens.length ? 40 : 0);
250
+ }
251
+ export function rankDocsPages(catalog, query) {
252
+ const tokens = tokenizeDocsQuery(query);
253
+ const needle = query.trim().toLowerCase();
254
+ const scored = catalog.pages
255
+ .map((page) => {
256
+ const tokenScore = tokens.length > 0 ? scoreDocsPage(page, tokens) : 0;
257
+ const substringScore = tokenScore === 0 && needle.length > 0 && searchableText(page).includes(needle) ? 5 : 0;
258
+ return { page, score: tokenScore + substringScore };
259
+ })
260
+ .filter((row) => row.score > 0)
261
+ .sort((a, b) => b.score - a.score || a.page.title.localeCompare(b.page.title));
262
+ return scored.map((row) => row.page);
263
+ }
264
+ export function truncateDocsSnippet(text, maxChars = DOCS_SNIPPET_MAX_CHARS) {
265
+ const trimmed = text.trim();
266
+ if (trimmed.length <= maxChars)
267
+ return trimmed;
268
+ const cut = trimmed.slice(0, maxChars - 1);
269
+ const atSpace = cut.lastIndexOf(" ");
270
+ return `${atSpace > 40 ? cut.slice(0, atSpace) : cut}…`;
271
+ }
272
+ function hitFromPage(page, body) {
273
+ if (body === undefined) {
274
+ return {
275
+ title: page.title,
276
+ url: page.url,
277
+ page: page.page,
278
+ snippet: page.summary,
279
+ };
280
+ }
281
+ const clipped = body.length > DOCS_PAGE_MAX_CHARS ? body.slice(0, DOCS_PAGE_MAX_CHARS) : body;
282
+ const snippet = truncateDocsSnippet(clipped);
283
+ return {
284
+ title: page.title,
285
+ url: page.url,
286
+ page: page.page,
287
+ snippet,
288
+ body: clipped,
289
+ ...(snippet.length < clipped.trim().length || body.length > DOCS_PAGE_MAX_CHARS
290
+ ? { truncated: true }
291
+ : {}),
292
+ };
293
+ }
294
+ function indent(text, prefix = " ") {
295
+ return text
296
+ .split("\n")
297
+ .map((line) => (line.length === 0 ? prefix.trimEnd() : `${prefix}${line}`))
298
+ .join("\n");
299
+ }
300
+ /** Human terminal output for catalog/search hits. A fetched page prints its body. */
301
+ export function formatDocsHuman(doc) {
302
+ const lines = [];
303
+ const only = doc.results.length === 1 ? doc.results[0] : undefined;
304
+ if (only?.body) {
305
+ lines.push(only.title);
306
+ lines.push(only.url);
307
+ lines.push("");
308
+ lines.push(only.body.trimEnd());
309
+ return `${lines.join("\n")}\n`;
310
+ }
311
+ lines.push("Docs", "");
312
+ if (doc.results.length === 0) {
313
+ lines.push("No matching docs.");
314
+ }
315
+ else {
316
+ for (const hit of doc.results) {
317
+ lines.push(hit.title);
318
+ lines.push(hit.url);
319
+ lines.push("");
320
+ lines.push(indent(hit.snippet));
321
+ lines.push("");
322
+ }
323
+ }
324
+ lines.push(`See all docs: ${doc.url}`);
325
+ return `${lines.join("\n")}\n`;
326
+ }
327
+ function isUploadsDocsUrl(url) {
328
+ try {
329
+ const parsed = new URL(url);
330
+ return parsed.origin === SITE_ORIGIN;
331
+ }
332
+ catch {
333
+ return false;
334
+ }
335
+ }
336
+ async function fetchUrl(fetchImpl, url, headers, timeoutMs) {
337
+ const controller = new AbortController();
338
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
339
+ try {
340
+ return await fetchImpl(url, { signal: controller.signal, headers });
341
+ }
342
+ catch (err) {
343
+ const aborted = err instanceof Error && err.name === "AbortError";
344
+ throw new UploadsError(aborted
345
+ ? `timed out fetching docs (${timeoutMs}ms)`
346
+ : `couldn't reach docs (${err instanceof Error ? err.message : String(err)})`, "NETWORK");
347
+ }
348
+ finally {
349
+ clearTimeout(timer);
350
+ }
351
+ }
352
+ export async function loadDocsCatalog(opts = {}) {
353
+ if (opts.catalog)
354
+ return opts.catalog;
355
+ const fetchImpl = opts.fetchImpl ?? fetch;
356
+ const timeoutMs = opts.timeoutMs ?? FETCH_TIMEOUT_MS;
357
+ const userAgent = opts.userAgent ?? `uploads/${packageVersion()}`;
358
+ const jsonUrl = opts.url ?? DOCS_JSON_URL;
359
+ try {
360
+ const res = await fetchUrl(fetchImpl, jsonUrl, { "user-agent": userAgent, accept: "application/json" }, timeoutMs);
361
+ if (res.ok) {
362
+ try {
363
+ const payload = await res.json();
364
+ return parseDocsCatalog(payload);
365
+ }
366
+ catch (err) {
367
+ if (opts.url) {
368
+ throw err instanceof UploadsError
369
+ ? err
370
+ : new UploadsError("docs catalog was not valid JSON", "API_ERROR", res.status);
371
+ }
372
+ }
373
+ }
374
+ else if (opts.url) {
375
+ throw new UploadsError(`docs catalog returned HTTP ${res.status}; see ${DOCS_HUB_URL}`, "API_ERROR", res.status);
376
+ }
377
+ }
378
+ catch (err) {
379
+ if (opts.url)
380
+ throw err;
381
+ }
382
+ return FALLBACK_DOCS_CATALOG;
383
+ }
384
+ async function fetchPageMarkdown(page, opts) {
385
+ if (!isUploadsDocsUrl(page.url)) {
386
+ throw new UploadsError(`refusing to fetch docs outside ${SITE_ORIGIN}`, "USAGE");
387
+ }
388
+ const fetchImpl = opts.fetchImpl ?? fetch;
389
+ const timeoutMs = opts.timeoutMs ?? FETCH_TIMEOUT_MS;
390
+ const userAgent = opts.userAgent ?? `uploads/${packageVersion()}`;
391
+ const res = await fetchUrl(fetchImpl, page.url, {
392
+ "user-agent": userAgent,
393
+ accept: "text/markdown, text/plain;q=0.9",
394
+ }, timeoutMs);
395
+ if (!res.ok) {
396
+ throw new UploadsError(`docs page returned HTTP ${res.status}; see ${page.url}`, "API_ERROR", res.status);
397
+ }
398
+ const body = await res.text();
399
+ return hitFromPage(page, body);
400
+ }
401
+ export async function searchDocs(opts = {}) {
402
+ const catalog = await loadDocsCatalog(opts);
403
+ const pageArg = opts.page?.trim();
404
+ const query = opts.query?.trim();
405
+ if (pageArg) {
406
+ const page = resolveDocsPage(catalog, pageArg);
407
+ if (!page) {
408
+ throw new UploadsError(`unknown docs page: ${pageArg}`, "USAGE");
409
+ }
410
+ const hit = await fetchPageMarkdown(page, opts);
411
+ return {
412
+ url: catalog.url,
413
+ query: pageArg,
414
+ results: [hit],
415
+ total: 1,
416
+ };
417
+ }
418
+ if (query) {
419
+ const exact = resolveDocsPage(catalog, query);
420
+ if (exact && !/\s/.test(query)) {
421
+ const hit = await fetchPageMarkdown(exact, opts);
422
+ return { url: catalog.url, query, results: [hit], total: 1 };
423
+ }
424
+ const ranked = rankDocsPages(catalog, query);
425
+ const sliced = ranked.slice(0, clampDocsLimit(opts.limit));
426
+ return {
427
+ url: catalog.url,
428
+ query,
429
+ results: sliced.map((page) => hitFromPage(page)),
430
+ total: ranked.length,
431
+ };
432
+ }
433
+ const sliced = opts.limit === undefined ? catalog.pages : catalog.pages.slice(0, clampDocsLimit(opts.limit));
434
+ return {
435
+ url: catalog.url,
436
+ results: sliced.map((page) => hitFromPage(page)),
437
+ total: catalog.pages.length,
438
+ };
439
+ }
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, reso
5
5
  export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, type UploadsClientConfig, type ResolvedConfig, type WorkspaceSource, type ConfigValueSource, type ConfigSources, type UploadsConfigKey, type UploadsConfigValues, type PutDefaults, } from "./config.js";
6
6
  export { UploadsError, type UploadsErrorCode } from "./errors.js";
7
7
  export { CHANGELOG_JSON_URL, CHANGELOG_PAGE_URL, CHANGELOG_XML_URL, DEFAULT_CHANGELOG_LIMIT, MAX_CHANGELOG_LIMIT, fetchChangelog, formatChangelogHuman, parseChangelogAtom, parseChangelogJson, selectChangelogEntries, type ChangelogDocument, type ChangelogJsonEntry, type FetchChangelogOptions, } from "./changelog.js";
8
+ export { DEFAULT_DOCS_LIMIT, DOCS_HUB_URL, DOCS_JSON_URL, MAX_DOCS_LIMIT, formatDocsHuman, searchDocs, type DocsCatalog, type DocsSearchDocument, type SearchDocsOptions, } from "./docs.js";
8
9
  export { assertFetchableUploadUrl, fetchUploadSource, filenameFromUploadUrl, resolveUploadFilename, FETCH_UPLOAD_SOURCE_DEFAULT_MAX_BYTES, FETCH_UPLOAD_SOURCE_MAX_REDIRECTS, FETCH_UPLOAD_SOURCE_TIMEOUT_MS, } from "./fetch-upload-source.js";
9
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";
10
11
  export { buildCliProvenance } from "./provenance.js";
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, reso
5
5
  export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, } from "./config.js";
6
6
  export { UploadsError } from "./errors.js";
7
7
  export { CHANGELOG_JSON_URL, CHANGELOG_PAGE_URL, CHANGELOG_XML_URL, DEFAULT_CHANGELOG_LIMIT, MAX_CHANGELOG_LIMIT, fetchChangelog, formatChangelogHuman, parseChangelogAtom, parseChangelogJson, selectChangelogEntries, } from "./changelog.js";
8
+ export { DEFAULT_DOCS_LIMIT, DOCS_HUB_URL, DOCS_JSON_URL, MAX_DOCS_LIMIT, formatDocsHuman, searchDocs, } from "./docs.js";
8
9
  export { assertFetchableUploadUrl, fetchUploadSource, filenameFromUploadUrl, resolveUploadFilename, FETCH_UPLOAD_SOURCE_DEFAULT_MAX_BYTES, FETCH_UPLOAD_SOURCE_MAX_REDIRECTS, FETCH_UPLOAD_SOURCE_TIMEOUT_MS, } from "./fetch-upload-source.js";
9
10
  export { createUploadsClient, } from "./client.js";
10
11
  export { buildCliProvenance } from "./provenance.js";
@@ -0,0 +1,4 @@
1
+ import { type ToolArgs } from "./args.js";
2
+ export declare const SEARCH_DOCS_DESCRIPTION = "Search the public uploads.sh documentation. Use this to answer questions about uploads.sh product workflows, including: attaching screenshots and video to GitHub PRs and issues; staging files before a PR exists; galleries; the GitHub App; comment config (.uploads.yml); hosted MCP and agent setup; screenshot capture and annotate; plans and limits; bring-your-own bucket. Same as `uploads docs`. Returns titles, URLs, and snippets. Pass `page` to fetch the full markdown of one page (slug, path, or URL). Omit `query` to list the catalog.";
3
+ export declare const SEARCH_DOCS_INPUT_SCHEMA: Record<string, unknown>;
4
+ export declare function runSearchDocsTool(args: ToolArgs): Promise<import("../docs.js").DocsSearchDocument>;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared MCP `search_docs` surface for stdio and hosted servers.
3
+ */
4
+ import { DEFAULT_DOCS_LIMIT, MAX_DOCS_LIMIT, searchDocs } from "../docs.js";
5
+ import { optPosInt, optString, usage } from "./args.js";
6
+ export const SEARCH_DOCS_DESCRIPTION = "Search the public uploads.sh documentation. Use this to answer questions about uploads.sh product workflows, including: attaching screenshots and video to GitHub PRs and issues; staging files before a PR exists; galleries; the GitHub App; comment config (.uploads.yml); hosted MCP and agent setup; screenshot capture and annotate; plans and limits; bring-your-own bucket. Same as `uploads docs`. Returns titles, URLs, and snippets. Pass `page` to fetch the full markdown of one page (slug, path, or URL). Omit `query` to list the catalog.";
7
+ export const SEARCH_DOCS_INPUT_SCHEMA = {
8
+ type: "object",
9
+ properties: {
10
+ query: {
11
+ type: "string",
12
+ description: "Search query (e.g. 'stage before a PR'). A single slug like 'attach' fetches that page.",
13
+ },
14
+ page: {
15
+ type: "string",
16
+ description: "Fetch one page as markdown. Accepts a slug (`attach`), path (`/docs/agents`), or https://uploads.sh URL.",
17
+ },
18
+ limit: {
19
+ type: "number",
20
+ description: `How many search hits to return (default ${DEFAULT_DOCS_LIMIT}, max ${MAX_DOCS_LIMIT}). Ignored when fetching one page.`,
21
+ },
22
+ },
23
+ additionalProperties: false,
24
+ };
25
+ export async function runSearchDocsTool(args) {
26
+ const query = optString(args, "query");
27
+ const page = optString(args, "page");
28
+ if (query && page)
29
+ usage("pass query or page, not both");
30
+ const limit = optPosInt(args, "limit");
31
+ if (limit !== undefined && limit > MAX_DOCS_LIMIT) {
32
+ usage(`limit must be ${MAX_DOCS_LIMIT} or less`);
33
+ }
34
+ return searchDocs({ query, page, limit });
35
+ }
@@ -22,6 +22,7 @@ export declare const reconcileResultSchema: JsonSchema;
22
22
  export declare const purgeExpiredResultSchema: JsonSchema;
23
23
  export declare const whoamiResultSchema: JsonSchema;
24
24
  export declare const changelogResultSchema: JsonSchema;
25
+ export declare const searchDocsResultSchema: JsonSchema;
25
26
  export declare const promoteToolResultSchema: JsonSchema;
26
27
  export declare const galleryResultSchema: JsonSchema;
27
28
  export declare const galleryFindResultSchema: JsonSchema;
@@ -263,6 +263,20 @@ export const changelogResultSchema = objectSchema({
263
263
  feed: { type: "string" },
264
264
  entries: { type: "array", items: changelogEntrySchema },
265
265
  }, ["url", "entries"]);
266
+ const docsSearchHitSchema = objectSchema({
267
+ title: { type: "string" },
268
+ url: { type: "string" },
269
+ page: { type: "string" },
270
+ snippet: { type: "string" },
271
+ body: { type: "string" },
272
+ truncated: { type: "boolean" },
273
+ }, ["title", "url", "page", "snippet"]);
274
+ export const searchDocsResultSchema = objectSchema({
275
+ url: { type: "string" },
276
+ query: { type: "string" },
277
+ results: { type: "array", items: docsSearchHitSchema },
278
+ total: { type: "number" },
279
+ }, ["url", "results", "total"]);
266
280
  export const promoteToolResultSchema = objectSchema({
267
281
  // `promotion` is optional (issue #702): a `keys`-only call (no `branch`)
268
282
  // never runs the branch sweep, so there's nothing to report under it.
@@ -345,6 +359,7 @@ export const hostedOutputSchemas = {
345
359
  purge_expired: purgeExpiredResultSchema,
346
360
  whoami: whoamiResultSchema,
347
361
  changelog: changelogResultSchema,
362
+ search_docs: searchDocsResultSchema,
348
363
  };
349
364
  /** Shared-shape stdio tools. Hosted-only tools (`promote`, `repo_link_status`) omitted. */
350
365
  export const stdioOutputSchemas = {
@@ -379,6 +394,7 @@ export const stdioOutputSchemas = {
379
394
  purge_expired: purgeExpiredResultSchema,
380
395
  whoami: whoamiResultSchema,
381
396
  changelog: changelogResultSchema,
397
+ search_docs: searchDocsResultSchema,
382
398
  report: objectSchema({
383
399
  ok: { type: "boolean" },
384
400
  id: { type: "string" },
@@ -20,6 +20,7 @@
20
20
  import { McpServer, type Implementation, type jsonSchemaValidator } from "@modelcontextprotocol/server";
21
21
  export { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, METADATA_PATH_CUE, MCP_EXAMPLE_PNG_BASE64, metadataArgWithCanonical, metadataProp, stateProp, optBool, optPosInt, optString, optStringArray, optStringRecord, usage, type ToolArgs, } from "./args.js";
22
22
  export { ToolBatchError, batchFailureMessage } from "./batch-error.js";
23
+ export { SEARCH_DOCS_DESCRIPTION, SEARCH_DOCS_INPUT_SCHEMA, runSearchDocsTool, } from "./docs-tool.js";
23
24
  export { mapBounded } from "../async.js";
24
25
  export { McpServer, type jsonSchemaValidator };
25
26
  export { commentResultSchema, deleteResultSchema, findFilesResultSchema, galleryFindResultSchema, galleryResultSchema, hostedOutputSchemas, listResultSchema, metadataFacetsResultSchema, metadataResultSchema, promoteToolResultSchema, purgeExpiredResultSchema, putResultSchema, reconcileResultSchema, repoLinkStatusResultSchema, stdioOutputSchemas, usageResultSchema, withOutputSchemas, } from "./output-schemas.js";
@@ -23,6 +23,7 @@ import { errorCodeFromUnknown, recordEvent } from "../telemetry.js";
23
23
  import { ToolBatchError } from "./batch-error.js";
24
24
  export { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, METADATA_PATH_CUE, MCP_EXAMPLE_PNG_BASE64, metadataArgWithCanonical, metadataProp, stateProp, optBool, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
25
25
  export { ToolBatchError, batchFailureMessage } from "./batch-error.js";
26
+ export { SEARCH_DOCS_DESCRIPTION, SEARCH_DOCS_INPUT_SCHEMA, runSearchDocsTool, } from "./docs-tool.js";
26
27
  export { mapBounded } from "../async.js";
27
28
  export { McpServer };
28
29
  export { commentResultSchema, deleteResultSchema, findFilesResultSchema, galleryFindResultSchema, galleryResultSchema, hostedOutputSchemas, listResultSchema, metadataFacetsResultSchema, metadataResultSchema, promoteToolResultSchema, purgeExpiredResultSchema, putResultSchema, reconcileResultSchema, repoLinkStatusResultSchema, stdioOutputSchemas, usageResultSchema, withOutputSchemas, } from "./output-schemas.js";
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * MCP tool set mirroring the CLI commands (put, attach, list, delete,
3
- * usage, reconcile, purge_expired, comment, whoami, doctor, changelog). Config is
3
+ * usage, reconcile, purge_expired, comment, whoami, doctor, changelog,
4
+ * search_docs). Config is
4
5
  * resolved fresh per tool call so a
5
6
  * per-call `workspace` argument behaves like the CLI's --workspace flag, and
6
7
  * a missing token surfaces as a tool error rather than a startup failure.
package/dist/mcp/tools.js CHANGED
@@ -15,6 +15,7 @@ import { batchFailureMessage, mcpDestroyPublic, mcpNoAuth, mcpOAuthAny, mcpOAuth
15
15
  import { attachmentFromText, buildReportPayload, parseReportType, REPORT_TYPES, submitReport, validateReportMessage, } from "../report.js";
16
16
  import { resolveApiUrl } from "../config.js";
17
17
  import { DEFAULT_CHANGELOG_LIMIT, MAX_CHANGELOG_LIMIT, fetchChangelog } from "../changelog.js";
18
+ import { SEARCH_DOCS_DESCRIPTION, SEARCH_DOCS_INPUT_SCHEMA, runSearchDocsTool, } from "./docs-tool.js";
18
19
  function mcpOptimizeOptions(args, defaults) {
19
20
  const quality = optPosInt(args, "optimizeQuality");
20
21
  if (quality !== undefined && quality > 100)
@@ -1553,6 +1554,19 @@ export function createUploadsMcpTools(opts) {
1553
1554
  return { ...target, ...result };
1554
1555
  },
1555
1556
  },
1557
+ {
1558
+ name: "search_docs",
1559
+ title: "Search uploads.sh docs",
1560
+ annotations: {
1561
+ readOnlyHint: true,
1562
+ destructiveHint: false,
1563
+ openWorldHint: true,
1564
+ },
1565
+ securitySchemes: mcpNoAuth,
1566
+ description: SEARCH_DOCS_DESCRIPTION,
1567
+ inputSchema: SEARCH_DOCS_INPUT_SCHEMA,
1568
+ handler: runSearchDocsTool,
1569
+ },
1556
1570
  {
1557
1571
  name: "changelog",
1558
1572
  title: "Product changelog",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
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",