@buildinternet/uploads 0.10.1 → 0.11.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
@@ -39,7 +39,8 @@ global `uploads` form above.
39
39
 
40
40
  Commands: `attach`, `put`, `gallery`, `comment`, `list`, `find`, `meta`, `delete`, `usage`,
41
41
  `reconcile`, `purge-expired`, `setup`, `install`, `login`, `whoami` (alias `status`),
42
- `logout`, `invite`, `admin`, `config`, `doctor`, `health`, `mcp`, `completion`.
42
+ `logout`, `invite`, `admin`, `config`, `telemetry`, `report`, `doctor`, `health`, `mcp`,
43
+ `completion`.
43
44
 
44
45
  **Help:** bare `uploads` / `uploads help` / `--help` shows essentials; use
45
46
  `uploads help --all` (or `--help --all`) for the full command list. Per-command:
@@ -57,6 +58,19 @@ newer npm release is available (at most once/day, `~/.cache/uploads/`). Silence
57
58
  with `--quiet`, `--json`, `UPLOADS_NO_UPDATE=1`, or `NO_UPDATE_NOTIFIER=1`. Not used
58
59
  for `uploads mcp`.
59
60
 
61
+ **Telemetry:** the CLI and MCP server send anonymous usage pings (command name,
62
+ version, OS/arch, exit code, duration, optional error code) to
63
+ `POST /v1/telemetry` on the configured API. No arguments, paths, tokens, workspace
64
+ names, or file content. Opt out with `UPLOADS_TELEMETRY_DISABLED=1`,
65
+ `DO_NOT_TRACK=1`, or `uploads telemetry disable`. First interactive run prints a
66
+ one-line notice.
67
+
68
+ **Diagnostic reports (opt-in):** `uploads report "what broke"` sends a short
69
+ message to the team. Attach a log with `--file ./trace.log`, or pipe:
70
+ `uploads doctor --json 2>&1 | uploads report "doctor failed"`. Never automatic —
71
+ also available as the MCP `report` tool when the user asks to submit feedback.
72
+ Attachments are text-only (max 256 KiB) and stored under an unguessable R2 key.
73
+
60
74
  **Exit codes:** `0` ok, `2` usage/token/file, `3` auth/policy, `4` network, `1` other.
61
75
  Failures go to stderr; under `--format json|url|markdown` they also go to stdout so
62
76
  piped runs stay self-diagnosing. Prefer JSON `code` over message text. `put --dry-run`
@@ -114,7 +128,7 @@ Config layers (first match wins): CLI flags → env vars → `--env-file` → `~
114
128
 
115
129
  ## MCP server
116
130
 
117
- `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`, `set_metadata`, `find_files`, `usage`, `reconcile`, `purge_expired`, `comment`, `health`, 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`); `set_metadata` and `find_files` mirror `uploads meta set` and `uploads 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 (`health` needs no auth).
131
+ `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`, `health`, 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 (`health` needs no auth).
118
132
 
119
133
  ```json
120
134
  { "command": "uploads", "args": ["--env-file", "/path/to/.env", "mcp"] }
@@ -122,7 +136,7 @@ Config layers (first match wins): CLI flags → env vars → `--env-file` → `~
122
136
 
123
137
  Or with `UPLOADS_TOKEN`/`UPLOADS_WORKSPACE` in the environment or user config. Claude Code: `claude mcp add uploads -- uploads --env-file /path/to/.env mcp`.
124
138
 
125
- 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 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 skill + hosted MCP (short progress; `--verbose` for underlying output). 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.
139
+ 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 (short progress; `--verbose` for underlying output). 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.
126
140
 
127
141
  ## Programmatic use
128
142
 
@@ -159,4 +173,4 @@ pnpm pack:check # verify the npm tarball contents
159
173
 
160
174
  Maintainer release instructions: [`docs/releasing.md`](../../docs/releasing.md).
161
175
 
162
- Agent-oriented usage: [`skills/uploads-cli/SKILL.md`](../../skills/uploads-cli/SKILL.md). REST details: [`docs/api.md`](../../docs/api.md).
176
+ Agent-oriented usage: [`skills/uploads-cli/SKILL.md`](../../skills/uploads-cli/SKILL.md) (full CLI reference) and [`skills/github-screenshots/SKILL.md`](../../skills/github-screenshots/SKILL.md) (visuals into PRs/issues). REST details: [`docs/api.md`](../../docs/api.md).
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Run `fn` over `values` with bounded concurrency, preserving input order
3
+ * in the returned array. Used by multi-file attach (and similar fan-outs).
4
+ */
5
+ export declare function mapBounded<T, R>(values: readonly T[], concurrency: number, fn: (value: T, index: number) => Promise<R>): Promise<R[]>;
package/dist/async.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Run `fn` over `values` with bounded concurrency, preserving input order
3
+ * in the returned array. Used by multi-file attach (and similar fan-outs).
4
+ */
5
+ export async function mapBounded(values, concurrency, fn) {
6
+ if (values.length === 0)
7
+ return [];
8
+ const limit = Math.max(1, Math.min(concurrency, values.length));
9
+ const result = Array.from({ length: values.length });
10
+ let next = 0;
11
+ async function worker() {
12
+ for (;;) {
13
+ const index = next++;
14
+ if (index >= values.length)
15
+ return;
16
+ result[index] = await fn(values[index], index);
17
+ }
18
+ }
19
+ await Promise.all(Array.from({ length: limit }, worker));
20
+ return result;
21
+ }
@@ -59,7 +59,7 @@ export const ROOT_COMMANDS = [
59
59
  },
60
60
  {
61
61
  name: "put",
62
- usage: "put <file>",
62
+ usage: "put <file...>",
63
63
  summary: "Upload (+ URL + markdown for GitHub)",
64
64
  essential: true,
65
65
  },
@@ -110,12 +110,12 @@ export const ROOT_COMMANDS = [
110
110
  { name: "setup", summary: "Inspect/configure advanced CLI settings" },
111
111
  {
112
112
  name: "install",
113
- summary: "Install the agent skill + register the remote MCP server",
113
+ summary: "Install the agent skills + register the remote MCP server",
114
114
  essential: true,
115
115
  subcommands: [
116
- { name: "skill", summary: "Install the agent skill only" },
116
+ { name: "skill", summary: "Install the agent skills only" },
117
117
  { name: "mcp", summary: "Register the remote MCP server only" },
118
- { name: "all", summary: "Install skill and MCP (default)" },
118
+ { name: "all", summary: "Install skills and MCP (default)" },
119
119
  ],
120
120
  },
121
121
  {
@@ -154,6 +154,20 @@ export const ROOT_COMMANDS = [
154
154
  { name: "set", summary: "Set one UPLOADS_* key" },
155
155
  ],
156
156
  },
157
+ {
158
+ name: "telemetry",
159
+ summary: "Manage anonymous usage telemetry (status / enable / disable)",
160
+ subcommands: [
161
+ { name: "status", summary: "Show whether telemetry is enabled" },
162
+ { name: "enable", summary: "Enable anonymous usage telemetry" },
163
+ { name: "disable", summary: "Disable anonymous usage telemetry" },
164
+ ],
165
+ },
166
+ {
167
+ name: "report",
168
+ usage: "report [message]",
169
+ summary: "Send a diagnostic report (optional log attachment; explicit opt-in)",
170
+ },
157
171
  {
158
172
  name: "doctor",
159
173
  summary: "Health + auth + workspace checks",
package/dist/cli-help.js CHANGED
@@ -147,7 +147,7 @@ ${section(style, "Examples:")}
147
147
  ${style.command("uploads logout")}
148
148
  ${style.command("uploads --version")}
149
149
 
150
- ${section(style, "Agent/MCP:")} ${style.body("`uploads install` sets up the agent skill and the hosted MCP server")}
150
+ ${section(style, "Agent/MCP:")} ${style.body("`uploads install` sets up the agent skills and the hosted MCP server")}
151
151
  ${style.body("(https://agents.uploads.sh/mcp, workspace inferred from the token). Run")}
152
152
  ${style.body("`uploads mcp` for local stdio, or use createUploadsWorkerFileTools()")}
153
153
  ${style.body("from @buildinternet/uploads/agent on the Worker.")}
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createUploadsClient } from "./client.js";
2
- import { resolveApiUrl, resolveConfig } from "./config.js";
2
+ import { DEFAULT_API_URL, resolveApiUrl, resolveConfig } from "./config.js";
3
3
  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";
@@ -14,8 +14,11 @@ import { runMcp } from "./commands/mcp.js";
14
14
  import { runInstall } from "./commands/install.js";
15
15
  import { runCompletion } from "./commands/completion.js";
16
16
  import { runLogout, runWhoami } from "./commands/session.js";
17
+ import { runTelemetry } from "./commands/telemetry.js";
18
+ import { runReport } from "./commands/report.js";
17
19
  import { packageVersion } from "./package-version.js";
18
20
  import { checkForUpdate, maybeHintUpdate } from "./update-check.js";
21
+ import { errorCodeFromUnknown, maybeShowFirstRunNotice, recordEvent, telemetryCommandName, } from "./telemetry.js";
19
22
  async function writeRootHelp(options = {}) {
20
23
  // Best-effort version check so the help header can show a banner when outdated.
21
24
  // Short timeout; cache still applies (once/day). Never blocks help on network.
@@ -144,10 +147,47 @@ function usageHint(argv) {
144
147
  }
145
148
  }
146
149
  export async function runCli(argv) {
150
+ const telemetryStart = Date.now();
151
+ const telemetryCmd = telemetryCommandName(argv);
152
+ // Prefer --api-url from argv early so every exit path can pass it to telemetry.
153
+ let apiUrl = DEFAULT_API_URL;
154
+ try {
155
+ apiUrl = resolveApiUrl(parseArgv(argv).globals);
156
+ }
157
+ catch {
158
+ // Usage errors while parsing globals fall through to the main try/catch.
159
+ }
160
+ const skipTelemetry = argv.includes("--version") ||
161
+ argv.includes("-V") ||
162
+ telemetryCmd === "telemetry" ||
163
+ telemetryCmd.startsWith("telemetry ") ||
164
+ // Report is itself a deliberate submit; skip the automatic usage ping.
165
+ telemetryCmd === "report";
166
+ // One-time notice for interactive humans (never MCP / json / quiet).
167
+ if (!skipTelemetry) {
168
+ const wantsQuiet = argv.includes("--json") || argv.includes("--quiet") || argv[2] === "mcp";
169
+ maybeShowFirstRunNotice({ interactive: wantsQuiet ? false : undefined });
170
+ }
171
+ const flushTelemetry = (code, err) => {
172
+ if (skipTelemetry)
173
+ return;
174
+ // Long-lived MCP process: per-tool events come from the MCP server; skip
175
+ // a process-level "mcp" ping so we don't double-count or hang exit.
176
+ if (telemetryCmd === "mcp")
177
+ return;
178
+ recordEvent({
179
+ surface: "cli",
180
+ command: telemetryCmd,
181
+ exitCode: code,
182
+ durationMs: Date.now() - telemetryStart,
183
+ errorCode: err !== undefined ? errorCodeFromUnknown(err) : undefined,
184
+ }, { apiUrl });
185
+ };
147
186
  try {
148
187
  const parsed = parseArgv(argv);
149
188
  const json = parsed.globals.json ?? false;
150
189
  const quiet = parsed.globals.quiet ?? false;
190
+ apiUrl = resolveApiUrl(parsed.globals);
151
191
  if (parsed.globals.version) {
152
192
  process.stdout.write(`${packageVersion()}\n`);
153
193
  return 0;
@@ -163,14 +203,16 @@ export async function runCli(argv) {
163
203
  envFile: parsed.globals.envFile,
164
204
  });
165
205
  // Explicit help exits 0; bare `uploads` is usage → 2.
166
- return parsed.help || isHelpCommand ? 0 : 2;
206
+ const code = parsed.help || isHelpCommand ? 0 : 2;
207
+ flushTelemetry(code);
208
+ return code;
167
209
  }
168
210
  const cmdArgs = parsed.rest.slice(1);
169
211
  const showHelp = parsed.help || cmdArgs.some(isHelpFlag);
170
212
  let code;
171
213
  switch (parsed.command) {
172
214
  case "health":
173
- code = await runHealth({ apiUrl: resolveApiUrl(parsed.globals), json }, cmdArgs, showHelp);
215
+ code = await runHealth({ apiUrl, json }, cmdArgs, showHelp);
174
216
  break;
175
217
  case "config":
176
218
  code = await runConfig(cmdArgs, { json, envFile: parsed.globals.envFile }, showHelp);
@@ -179,7 +221,7 @@ export async function runCli(argv) {
179
221
  code = await runSetup(cmdArgs, { json, envFile: parsed.globals.envFile }, showHelp);
180
222
  break;
181
223
  case "login":
182
- code = await runLogin(cmdArgs, { json, apiUrl: resolveApiUrl(parsed.globals) }, showHelp);
224
+ code = await runLogin(cmdArgs, { json, apiUrl }, showHelp);
183
225
  break;
184
226
  case "whoami":
185
227
  case "status":
@@ -195,10 +237,16 @@ export async function runCli(argv) {
195
237
  code = await runLogout(cmdArgs, { json, envFile: parsed.globals.envFile }, showHelp);
196
238
  break;
197
239
  case "invite":
198
- code = await runInvite(cmdArgs, { json, apiUrl: resolveApiUrl(parsed.globals) }, showHelp);
240
+ code = await runInvite(cmdArgs, { json, apiUrl }, showHelp);
199
241
  break;
200
242
  case "admin":
201
- code = await runAdmin(cmdArgs, { json, apiUrl: resolveApiUrl(parsed.globals) }, showHelp);
243
+ code = await runAdmin(cmdArgs, { json, apiUrl }, showHelp);
244
+ break;
245
+ case "telemetry":
246
+ code = await runTelemetry(cmdArgs, { json, apiUrl }, showHelp);
247
+ break;
248
+ case "report":
249
+ code = await runReport(cmdArgs, { json, apiUrl }, showHelp);
202
250
  break;
203
251
  case "mcp":
204
252
  code = await runMcp(cmdArgs, { globals: parsed.globals }, showHelp);
@@ -271,6 +319,7 @@ export async function runCli(argv) {
271
319
  token: parsed.globals.token,
272
320
  envFile: parsed.globals.envFile,
273
321
  });
322
+ flushTelemetry(2);
274
323
  return 2;
275
324
  }
276
325
  }
@@ -278,6 +327,7 @@ export async function runCli(argv) {
278
327
  if (code === 0 && !showHelp) {
279
328
  await maybeHintUpdate({ quiet: quiet || json, command: parsed.command });
280
329
  }
330
+ flushTelemetry(code);
281
331
  return code;
282
332
  }
283
333
  catch (err) {
@@ -285,6 +335,8 @@ export async function runCli(argv) {
285
335
  errorOut(err, format);
286
336
  if (err instanceof UsageError && format !== "json")
287
337
  usageHint(argv);
288
- return exitCode(err);
338
+ const code = exitCode(err);
339
+ flushTelemetry(code, err);
340
+ return code;
289
341
  }
290
342
  }
@@ -4,22 +4,24 @@ import { execRunner } from "../github-gh.js";
4
4
  import { writeCommandHelp } from "../cli-style.js";
5
5
  export const DEFAULT_MCP_URL = "https://agents.uploads.sh/mcp";
6
6
  const SKILL_SOURCE = "buildinternet/uploads";
7
- const SKILL_NAME = "uploads-cli";
8
- const INSTALL_HELP = `uploads install — set up agent integrations (skill + remote MCP)
7
+ const SKILL_NAMES = ["uploads-cli", "github-screenshots"];
8
+ const INSTALL_HELP = `uploads install — set up agent integrations (skills + remote MCP)
9
9
 
10
- Installs the uploads-cli agent skill and registers the hosted MCP server
11
- with Claude Code. The remote MCP endpoint infers your workspace from the
12
- bearer token, so only the token is needed.
10
+ Installs the github-screenshots and uploads-cli agent skills and registers
11
+ the hosted MCP server with Claude Code. The remote MCP endpoint infers your
12
+ workspace from the bearer token, so only the token is needed.
13
13
 
14
14
  Usage:
15
15
  uploads install [skill|mcp|all] (default: all)
16
16
 
17
17
  What it does:
18
- skill Agent skill (via npx skills) — when to host files / embed in PRs
18
+ skill Agent skills (via npx skills) — github-screenshots: visuals into
19
+ PRs/issues; uploads-cli: full CLI reference
19
20
  mcp Hosted MCP server in Claude Code — put, list, attach, galleries
20
21
 
21
22
  What runs under the hood:
22
- skill npx -y skills add ${SKILL_SOURCE} --skill ${SKILL_NAME} -g -y -a '*'
23
+ skill npx -y skills add ${SKILL_SOURCE} --skill <name> -g -y -a '*'
24
+ (once per skill: ${SKILL_NAMES.join(", ")})
23
25
  mcp claude mcp add --transport http uploads ${DEFAULT_MCP_URL} \\
24
26
  --header "Authorization: Bearer <token>"
25
27
 
@@ -57,9 +59,9 @@ function runStep(run, command) {
57
59
  return { command, ok: false, error: hint };
58
60
  }
59
61
  }
60
- function skillCommand() {
62
+ function skillCommand(skill) {
61
63
  // -g global, -y non-interactive, -a '*' every agent (skips the multi-select TUI)
62
- return ["npx", "-y", "skills", "add", SKILL_SOURCE, "--skill", SKILL_NAME, "-g", "-y", "-a", "*"];
64
+ return ["npx", "-y", "skills", "add", SKILL_SOURCE, "--skill", skill, "-g", "-y", "-a", "*"];
63
65
  }
64
66
  function mcpCommand(name, url, bearer) {
65
67
  return [
@@ -142,10 +144,14 @@ export async function runInstall(args, opts, help = false) {
142
144
  const redact = redactor(token);
143
145
  const results = {};
144
146
  if (target === "skill" || target === "all") {
145
- const command = skillCommand();
146
147
  if (human)
147
- process.stdout.write("Installing skill…\n");
148
- results.skill = dryRun ? { command, ok: true, skipped: "dry-run" } : runStep(run, command);
148
+ process.stdout.write("Installing skills…\n");
149
+ for (const skill of SKILL_NAMES) {
150
+ const command = skillCommand(skill);
151
+ results[`skill:${skill}`] = dryRun
152
+ ? { command, ok: true, skipped: "dry-run" }
153
+ : runStep(run, command);
154
+ }
149
155
  }
150
156
  if (target === "mcp" || target === "all") {
151
157
  if (!dryRun && !token) {
@@ -179,14 +185,26 @@ export async function runInstall(args, opts, help = false) {
179
185
  return failed ? 1 : 0;
180
186
  }
181
187
  printHumanSteps(results, redact, verbose);
188
+ const skillResults = Object.entries(results)
189
+ .filter(([step]) => step.startsWith("skill:"))
190
+ .map(([, r]) => r);
191
+ const skillsOk = skillResults.length > 0 && skillResults.every((r) => r.ok);
192
+ const skillsFailed = skillResults.some((r) => !r.ok);
182
193
  if (!failed && !dryRun) {
183
- printSuccessFooter(Object.keys(results), signedIn);
194
+ const stepLabels = [
195
+ ...new Set(Object.keys(results).map((k) => (k.startsWith("skill:") ? "skills" : k))),
196
+ ];
197
+ printSuccessFooter(stepLabels, signedIn);
184
198
  }
185
- else if (failed && !dryRun && results.skill?.ok && results.mcp && !results.mcp.ok) {
199
+ else if (failed && !dryRun && skillsOk && results.mcp && !results.mcp.ok) {
186
200
  const next = results.mcp.skipped === "sign-in"
187
201
  ? "Sign in with `uploads login`, then re-run `uploads install mcp`."
188
202
  : "Fix the MCP step above, then re-run `uploads install mcp`.";
189
- process.stdout.write(`\nSkill is installed. ${next}\n`);
203
+ process.stdout.write(`\nSkills are installed. ${next}\n`);
204
+ }
205
+ else if (failed && !dryRun && skillsFailed) {
206
+ // Mixed or total skill failure used to print only per-step lines (#191).
207
+ process.stdout.write("\nSkill install incomplete. Fix the errors above, then re-run `uploads install skill`.\n");
190
208
  }
191
209
  return failed ? 1 : 0;
192
210
  }
@@ -1,4 +1,5 @@
1
1
  import { parseCommandArgs } from "../cli-args.js";
2
+ import { resolveApiUrl } from "../config.js";
2
3
  import { createMcpServer } from "../mcp/server.js";
3
4
  import { serveStdio } from "../mcp/stdio.js";
4
5
  import { createUploadsMcpTools } from "../mcp/tools.js";
@@ -31,6 +32,7 @@ export async function runMcp(args, opts, help = false) {
31
32
  const server = createMcpServer({
32
33
  serverInfo: { name: "uploads", version: packageVersion() },
33
34
  tools: createUploadsMcpTools({ globals: opts.globals }),
35
+ apiUrl: resolveApiUrl(opts.globals),
34
36
  });
35
37
  await serveStdio(server);
36
38
  return 0;
@@ -0,0 +1,4 @@
1
+ export declare function runReport(args: string[], opts?: {
2
+ json?: boolean;
3
+ apiUrl?: string;
4
+ }, help?: boolean): Promise<number>;
@@ -0,0 +1,164 @@
1
+ /**
2
+ * `uploads report` — explicit opt-in diagnostic message (+ optional log).
3
+ */
4
+ import { createInterface } from "node:readline/promises";
5
+ import { flagBool, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
6
+ import { writeCommandHelp } from "../cli-style.js";
7
+ import { writeJson } from "../io.js";
8
+ import { attachmentFromText, buildReportPayload, loadReportAttachment, MAX_REPORT_ATTACHMENT_BYTES, MAX_REPORT_MESSAGE, parseReportType, reportFallbackHint, REPORT_TYPES, submitReport, validateReportMessage, } from "../report.js";
9
+ const REPORT_HELP = `uploads report [message] — send a diagnostic report (explicit opt-in)
10
+
11
+ Nothing is sent unless you run this (or the MCP report tool).
12
+
13
+ Options:
14
+ --file <path> Attach a text log/trace (max 256 KiB)
15
+ --type <t> bug | error | idea | other (default: other)
16
+ --contact <value> Optional email/handle for follow-up
17
+ --command <name> Command that failed (e.g. put) — no args/paths
18
+ --error-code <code> Optional UploadsError code (e.g. KEY_POLICY)
19
+ --dry-run Print the payload without sending
20
+ --json JSON on stdout
21
+
22
+ Examples:
23
+ uploads report "put fails with KEY_POLICY on custom prefixes"
24
+ uploads report --type bug --file ./trace.log "crash during optimize"
25
+ uploads doctor --json 2>&1 | uploads report "doctor failed"
26
+ `;
27
+ /**
28
+ * Bound stdin read. Stops once maxBytes is exceeded (returns what was read
29
+ * plus a flag so callers can reject oversized input).
30
+ */
31
+ async function readStdinBounded(maxBytes) {
32
+ const chunks = [];
33
+ let total = 0;
34
+ let exceeded = false;
35
+ for await (const chunk of process.stdin) {
36
+ const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
37
+ total += buf.byteLength;
38
+ if (total > maxBytes) {
39
+ exceeded = true;
40
+ const keep = maxBytes - (total - buf.byteLength);
41
+ if (keep > 0)
42
+ chunks.push(buf.subarray(0, keep));
43
+ break; // stop reading; peer close drains the rest
44
+ }
45
+ chunks.push(buf);
46
+ }
47
+ return { text: Buffer.concat(chunks).toString("utf8"), exceeded };
48
+ }
49
+ export async function runReport(args, opts = {}, help = false) {
50
+ const parsed = parseCommandArgs(args);
51
+ if (help || parsed.help) {
52
+ writeCommandHelp(REPORT_HELP);
53
+ return 0;
54
+ }
55
+ const typeFlag = flagString(parsed.flags, "--type");
56
+ if (typeFlag && !parseReportType(typeFlag)) {
57
+ throw new UsageError(`--type must be one of: ${REPORT_TYPES.join(", ")}`);
58
+ }
59
+ const type = parseReportType(typeFlag) ?? "other";
60
+ const dryRun = flagBool(parsed.flags, "--dry-run");
61
+ const json = opts.json || flagBool(parsed.flags, "--json");
62
+ const filePath = flagString(parsed.flags, "--file");
63
+ const positional = parsed.positionals.length > 0 ? parsed.positionals.join(" ") : undefined;
64
+ const hasPositional = Boolean(positional?.trim());
65
+ const stdinIsPipe = !process.stdin.isTTY;
66
+ // Only consume stdin when we still need a message or a piped attachment.
67
+ // Skip when --file already supplies the log and a positional message exists.
68
+ const needStdinAsMessage = stdinIsPipe && !hasPositional;
69
+ const needStdinAsAttachment = stdinIsPipe && hasPositional && !filePath;
70
+ let piped = "";
71
+ let pipedExceeded = false;
72
+ if (needStdinAsMessage || needStdinAsAttachment) {
73
+ const max = needStdinAsAttachment ? MAX_REPORT_ATTACHMENT_BYTES : MAX_REPORT_MESSAGE + 1; // +1 so we can detect "too long"
74
+ const result = await readStdinBounded(max);
75
+ piped = result.text;
76
+ pipedExceeded = result.exceeded;
77
+ }
78
+ const pipedTrimmed = piped.trim();
79
+ let raw = null;
80
+ if (hasPositional)
81
+ raw = positional;
82
+ else if (pipedTrimmed)
83
+ raw = pipedTrimmed;
84
+ else if (process.stdin.isTTY) {
85
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
86
+ try {
87
+ const answer = await rl.question("What's going wrong? (blank to cancel)\n> ");
88
+ raw = answer.trim() || null;
89
+ }
90
+ finally {
91
+ rl.close();
92
+ }
93
+ }
94
+ if (raw === null) {
95
+ if (json)
96
+ await writeJson({ ok: false, cancelled: true });
97
+ else
98
+ process.stderr.write("Cancelled — no report sent.\n");
99
+ return 0;
100
+ }
101
+ if (needStdinAsMessage && (pipedExceeded || raw.length > MAX_REPORT_MESSAGE)) {
102
+ throw new UsageError('piped input is too long for the message — use: `… | uploads report "summary"`');
103
+ }
104
+ const validated = validateReportMessage(raw);
105
+ if (!validated.ok)
106
+ throw new UsageError(validated.error);
107
+ let attachment;
108
+ try {
109
+ if (filePath)
110
+ attachment = loadReportAttachment(filePath);
111
+ else if (needStdinAsAttachment && pipedTrimmed) {
112
+ if (pipedExceeded) {
113
+ throw new Error(`attachment exceeds ${MAX_REPORT_ATTACHMENT_BYTES} bytes`);
114
+ }
115
+ attachment = attachmentFromText(piped, "stdin.log");
116
+ }
117
+ }
118
+ catch (err) {
119
+ throw new UsageError(err instanceof Error ? err.message : String(err));
120
+ }
121
+ const payload = buildReportPayload(validated.message, {
122
+ type,
123
+ contact: flagString(parsed.flags, "--contact")?.trim() || undefined,
124
+ surface: "cli",
125
+ command: flagString(parsed.flags, "--command"),
126
+ errorCode: flagString(parsed.flags, "--error-code"),
127
+ attachment,
128
+ });
129
+ if (dryRun) {
130
+ const preview = {
131
+ ...payload,
132
+ attachment: payload.attachment
133
+ ? {
134
+ filename: payload.attachment.filename,
135
+ contentType: payload.attachment.contentType,
136
+ bytes: new TextEncoder().encode(payload.attachment.body).byteLength,
137
+ body: "[omitted in dry-run]",
138
+ }
139
+ : undefined,
140
+ };
141
+ if (json)
142
+ await writeJson({ dryRun: true, payload: preview });
143
+ else
144
+ process.stdout.write(`[dry-run] would POST:\n${JSON.stringify(preview, null, 2)}\n`);
145
+ return 0;
146
+ }
147
+ const result = await submitReport(payload, { apiUrl: opts.apiUrl });
148
+ if (result.ok) {
149
+ if (json) {
150
+ await writeJson({ ok: true, id: result.id, hasAttachment: result.hasAttachment });
151
+ }
152
+ else {
153
+ process.stdout.write(`Thanks — report received (id: ${result.id}${result.hasAttachment ? ", with attachment" : ""})\n`);
154
+ }
155
+ return 0;
156
+ }
157
+ if (json)
158
+ await writeJson({ ok: false, error: result.error });
159
+ else {
160
+ process.stderr.write(`error: couldn't send report: ${result.error}\n`);
161
+ process.stderr.write(`hint: ${reportFallbackHint()}\n`);
162
+ }
163
+ return 1;
164
+ }
@@ -0,0 +1,4 @@
1
+ export declare function runTelemetry(args: string[], opts?: {
2
+ json?: boolean;
3
+ apiUrl?: string;
4
+ }, help?: boolean): Promise<number>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `uploads telemetry` — status / enable / disable for anonymous usage pings.
3
+ */
4
+ import { flagBool, parseCommandArgs } from "../cli-args.js";
5
+ import { writeCommandHelp } from "../cli-style.js";
6
+ import { writeJson } from "../io.js";
7
+ import { setTelemetryEnabled, telemetryStatus, defaultTelemetryDataDir } from "../telemetry.js";
8
+ const TELEMETRY_HELP = `uploads telemetry — manage anonymous usage telemetry
9
+
10
+ Subcommands:
11
+ status Show whether telemetry is enabled and where events go
12
+ enable Enable anonymous usage telemetry
13
+ disable Disable anonymous usage telemetry
14
+
15
+ What is collected (when enabled):
16
+ command name, CLI version, OS/arch, runtime, exit code, duration,
17
+ client kind/agent, anonymous id, optional allowlisted error code.
18
+ Never arguments, paths, tokens, workspace names, or file content.
19
+
20
+ Opt out without this command:
21
+ UPLOADS_TELEMETRY_DISABLED=1
22
+ DO_NOT_TRACK=1
23
+
24
+ Examples:
25
+ uploads telemetry status
26
+ uploads telemetry status --json
27
+ uploads telemetry disable
28
+ uploads telemetry enable
29
+ `;
30
+ export async function runTelemetry(args, opts = {}, help = false) {
31
+ const parsed = parseCommandArgs(args);
32
+ if (help || parsed.help || parsed.positionals.length === 0) {
33
+ writeCommandHelp(TELEMETRY_HELP);
34
+ return 0;
35
+ }
36
+ const sub = parsed.positionals[0];
37
+ const json = opts.json || flagBool(parsed.flags, "--json");
38
+ switch (sub) {
39
+ case "status": {
40
+ const s = telemetryStatus({ apiUrl: opts.apiUrl });
41
+ if (json) {
42
+ await writeJson({
43
+ enabled: s.enabled,
44
+ reason: s.reason ?? null,
45
+ anonId: s.anonId,
46
+ clientKind: s.clientKind,
47
+ agentName: s.agentName ?? null,
48
+ endpoint: s.endpoint,
49
+ dataDir: defaultTelemetryDataDir(),
50
+ });
51
+ return 0;
52
+ }
53
+ process.stdout.write(`Telemetry: ${s.enabled ? "enabled" : "disabled"}\n`);
54
+ if (!s.enabled && s.reason)
55
+ process.stdout.write(`Reason: ${s.reason}\n`);
56
+ process.stdout.write(`Anon ID: ${s.anonId}\n`);
57
+ process.stdout.write(`Kind: ${s.clientKind}${s.agentName ? ` (${s.agentName})` : ""}\n`);
58
+ process.stdout.write(`Endpoint: ${s.endpoint}\n`);
59
+ process.stdout.write("\nAnonymous non-PII usage only (no paths, tokens, or content).\n");
60
+ process.stdout.write("See `uploads telemetry --help` for what is sent and how to opt out.\n");
61
+ return 0;
62
+ }
63
+ case "enable": {
64
+ setTelemetryEnabled(true);
65
+ const s = telemetryStatus({ apiUrl: opts.apiUrl });
66
+ if (json) {
67
+ await writeJson({ enabled: s.enabled, reason: s.reason ?? null });
68
+ }
69
+ else if (s.enabled) {
70
+ process.stdout.write("Telemetry enabled.\n");
71
+ }
72
+ else {
73
+ process.stdout.write(`Telemetry still disabled (${s.reason ?? "opt-out active"}).\n`);
74
+ process.stdout.write("Cleared the local disable file; env opt-outs still apply.\n");
75
+ }
76
+ return 0;
77
+ }
78
+ case "disable": {
79
+ setTelemetryEnabled(false);
80
+ if (json)
81
+ await writeJson({ enabled: false });
82
+ else
83
+ process.stdout.write("Telemetry disabled.\n");
84
+ return 0;
85
+ }
86
+ default:
87
+ process.stderr.write(`error: unknown telemetry subcommand: ${sub}\n`);
88
+ process.stderr.write("hint: uploads telemetry --help\n");
89
+ return 2;
90
+ }
91
+ }