@buildinternet/uploads 0.27.0 → 0.29.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
@@ -175,7 +175,7 @@ Config layers (first match wins): CLI flags → env vars → `--env-file` → `~
175
175
 
176
176
  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`.
177
177
 
178
- 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.
178
+ For HTTP clients there's also a hosted variant at `https://agents.uploads.sh/mcp` — the workspace is inferred from the bearer token, so only the URL and token are needed (`https://agents.uploads.sh/<workspace>/mcp` and the `mcp.uploads.sh` hostname also work). Tools: file operations (including `get_metadata` / `set_metadata` / `find_files`) plus `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`; all use the same bearer-token workspace scopes and gallery URLs come from the API — see `apps/mcp` in the repo. The hosted `put` also accepts a `metadata` param. `uploads install` registers the skills + hosted MCP + Grok/Cursor hooks (short progress; `--verbose` for underlying output). Claude and Codex use their plugins for the same pre-PR screenshot reminder (`uploads hook pre-pr-screenshot`). Its `put` takes no content type: the stored type is sniffed server-side from the bytes and checked against the workspace allowlist, and writes are rate limited per workspace.
179
179
 
180
180
  ## Programmatic use
181
181
 
package/dist/cli-brand.js CHANGED
@@ -156,7 +156,10 @@ function boxLines(lines, options = {}) {
156
156
  ].join("\n");
157
157
  }
158
158
  export function formatUpdateBanner(options) {
159
- return boxLines([`Update available ${options.current} → ${options.latest}`, `npm i -g @buildinternet/uploads`], { color: options.color, tone: BRAND.accent });
159
+ return boxLines([`Update available ${options.current} → ${options.latest}`, `uploads update`], {
160
+ color: options.color,
161
+ tone: BRAND.accent,
162
+ });
160
163
  }
161
164
  export function formatAuthBanner(options = {}) {
162
165
  return boxLines(["Sign in via browser", "uploads login"], {
@@ -180,14 +180,30 @@ export const ROOT_COMMANDS = [
180
180
  { name: "setup", summary: "Inspect/configure advanced CLI settings" },
181
181
  {
182
182
  name: "install",
183
- summary: "Install the agent skills + register the remote MCP server",
183
+ summary: "Install agent skills, remote MCP, and harness hooks",
184
184
  essential: true,
185
185
  subcommands: [
186
186
  { name: "skill", summary: "Install the agent skills only" },
187
187
  { name: "mcp", summary: "Register the remote MCP server only" },
188
- { name: "all", summary: "Install skills and MCP (default)" },
188
+ { name: "hooks", summary: "Install PR screenshot hooks for Grok/Cursor" },
189
+ { name: "all", summary: "Install skills, MCP, and hooks (default)" },
189
190
  ],
190
191
  },
192
+ {
193
+ name: "hook",
194
+ summary: "Agent harness hook handlers (stdin → advisory JSON)",
195
+ subcommands: [
196
+ {
197
+ name: "pre-pr-screenshot",
198
+ summary: "Remind to stage screenshots before gh pr create",
199
+ },
200
+ ],
201
+ },
202
+ {
203
+ name: "update",
204
+ summary: "Update the CLI, then refresh the agent skills + MCP registration",
205
+ essential: true,
206
+ },
191
207
  {
192
208
  name: "login",
193
209
  summary: "Sign in via browser (or an enrollment code) and save credentials",
package/dist/cli-help.js CHANGED
@@ -6,24 +6,44 @@ const CMD_WIDTH = 22;
6
6
  function toRow(c) {
7
7
  return [c.usage ?? c.name, c.summary];
8
8
  }
9
- /** Preferred order for the short essentials help (subset of ROOT_COMMANDS). */
9
+ /**
10
+ * Display order for the short essentials help. Membership is *not* declared
11
+ * here — it comes from `essential: true` in cli-catalog.ts, the one source of
12
+ * truth (issue #491: this array used to decide membership too, and had already
13
+ * drifted, silently keeping `screenshot` out of short help). This list only
14
+ * says what order the essential commands appear in; the check below fails the
15
+ * build-time module load if the two ever disagree in either direction.
16
+ */
10
17
  const ESSENTIAL_ORDER = [
11
18
  "put",
12
19
  "attach",
20
+ "screenshot",
13
21
  "login",
14
22
  "whoami",
15
23
  "list",
16
24
  "delete",
17
25
  "doctor",
18
26
  "install",
27
+ "update",
19
28
  ];
20
29
  /** Day-to-day commands shown on bare `uploads` / `uploads help`. */
21
- const ESSENTIALS = ESSENTIAL_ORDER.map((name) => {
22
- const cmd = ROOT_COMMANDS.find((c) => c.name === name);
23
- if (!cmd)
24
- throw new Error(`cli-catalog missing essential command: ${name}`);
25
- return toRow(cmd);
26
- });
30
+ const ESSENTIALS = (() => {
31
+ const essential = ROOT_COMMANDS.filter((c) => c.essential);
32
+ const rank = new Map(ESSENTIAL_ORDER.map((name, i) => [name, i]));
33
+ const unordered = essential.filter((c) => !rank.has(c.name));
34
+ if (unordered.length > 0) {
35
+ throw new Error(`cli-help ESSENTIAL_ORDER missing essential command(s): ${unordered.map((c) => c.name).join(", ")}`);
36
+ }
37
+ const names = new Set(essential.map((c) => c.name));
38
+ const stale = ESSENTIAL_ORDER.filter((name) => !names.has(name));
39
+ if (stale.length > 0) {
40
+ throw new Error(`cli-help ESSENTIAL_ORDER names non-essential command(s): ${stale.join(", ")}`);
41
+ }
42
+ return essential
43
+ .slice()
44
+ .sort((a, b) => rank.get(a.name) - rank.get(b.name))
45
+ .map(toRow);
46
+ })();
27
47
  /** Full catalog (same surface as before, still discoverable via help --all). */
28
48
  const ALL_COMMANDS = ROOT_COMMANDS.map(toRow);
29
49
  function rows(style, items) {
@@ -147,10 +167,9 @@ ${section(style, "Examples:")}
147
167
  ${style.command("uploads logout")}
148
168
  ${style.command("uploads --version")}
149
169
 
150
- ${section(style, "Agent/MCP:")} ${style.body("`uploads install` sets up the agent skills and the hosted MCP server")}
151
- ${style.body("(https://agents.uploads.sh/mcp, workspace inferred from the token). Run")}
152
- ${style.body("`uploads mcp` for local stdio, or use createUploadsWorkerFileTools()")}
153
- ${style.body("from @buildinternet/uploads/agent on the Worker.")}
170
+ ${section(style, "Agent/MCP:")} ${style.body("`uploads install` sets up skills, hosted MCP, and hooks for")}
171
+ ${style.body("Grok/Cursor. Claude and Codex use their plugins for the same PR-screenshot")}
172
+ ${style.body("hook. Run `uploads mcp` for local stdio.")}
154
173
 
155
174
  ${style.muted("Tip: uploads help essentials only")}
156
175
  ${style.muted(" uploads help --all this full listing")}
package/dist/cli.js CHANGED
@@ -12,6 +12,8 @@ import { runInvite } from "./commands/invite.js";
12
12
  import { runAdmin } from "./commands/admin-enrollment.js";
13
13
  import { runMcp } from "./commands/mcp.js";
14
14
  import { runInstall } from "./commands/install.js";
15
+ import { runHook } from "./commands/hook.js";
16
+ import { runUpdate } from "./commands/update.js";
15
17
  import { runCompletion } from "./commands/completion.js";
16
18
  import { runLogout, runWhoami } from "./commands/session.js";
17
19
  import { runTelemetry } from "./commands/telemetry.js";
@@ -268,6 +270,13 @@ export async function runCli(argv) {
268
270
  case "install":
269
271
  code = await runInstall(cmdArgs, { globals: parsed.globals, json }, showHelp);
270
272
  break;
273
+ case "hook":
274
+ // Fail-open harness hooks — no token required, no telemetry noise.
275
+ code = await runHook(cmdArgs, showHelp);
276
+ break;
277
+ case "update":
278
+ code = await runUpdate(cmdArgs, { globals: parsed.globals }, showHelp);
279
+ break;
271
280
  case "completion":
272
281
  case "completions":
273
282
  code = await runCompletion(cmdArgs, showHelp);
package/dist/client.d.ts CHANGED
@@ -519,10 +519,18 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
519
519
  id: string;
520
520
  }>;
521
521
  findGalleriesByReference(opts: FindGalleriesByReferenceOptions): Promise<GalleryListResult>;
522
+ /**
523
+ * Upsert the managed attachments comment. `resync: true` marks an
524
+ * explicit "make the comment state correct" call (`uploads comment`), so
525
+ * the server hunts for the marker — and collapses any duplicate — instead
526
+ * of patching its cached comment id (issue #480). Older servers ignore
527
+ * the field.
528
+ */
522
529
  upsertGithubComment(opts: {
523
530
  repo: string;
524
531
  num: number;
525
532
  kind: "pull" | "issues";
533
+ resync?: boolean;
526
534
  }): Promise<GithubCommentResult>;
527
535
  /**
528
536
  * Promote a workspace's branch-staged attachments into a PR's stable
package/dist/client.js CHANGED
@@ -479,6 +479,13 @@ export function createUploadsClient(config) {
479
479
  params.set("cursor", opts.cursor);
480
480
  return request("GET", galleriesBase(config) + "/by-reference?" + params);
481
481
  },
482
+ /**
483
+ * Upsert the managed attachments comment. `resync: true` marks an
484
+ * explicit "make the comment state correct" call (`uploads comment`), so
485
+ * the server hunts for the marker — and collapses any duplicate — instead
486
+ * of patching its cached comment id (issue #480). Older servers ignore
487
+ * the field.
488
+ */
482
489
  async upsertGithubComment(opts) {
483
490
  return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/comment`, {
484
491
  body: new TextEncoder().encode(JSON.stringify(opts)),
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `uploads hook pre-pr-screenshot` — agent PreToolUse / beforeShellExecution
3
+ * handler. When the shell command is `gh pr create`, the branch touches UI
4
+ * files, and nothing is staged on uploads.sh, emit a non-blocking advisory.
5
+ *
6
+ * Always fail-open. Disable with UPLOADS_HOOK_DISABLE=1.
7
+ */
8
+ export type HookDeps = {
9
+ stdin: string;
10
+ testFiles?: string;
11
+ cwd?: string;
12
+ countStaged?: (branch: string) => Promise<number | null>;
13
+ isFork?: () => boolean | null;
14
+ git?: {
15
+ isRepo: () => boolean;
16
+ branch: () => string | null;
17
+ changedFiles: () => string[];
18
+ };
19
+ };
20
+ /** Claude/Codex: tool_input.command · Grok: toolInput.command · Cursor: command */
21
+ export declare function shellCommandFromHookInput(raw: unknown): string;
22
+ export declare function isCursorHookInput(raw: unknown): boolean;
23
+ export declare function looksLikeGhPrCreate(command: string): boolean;
24
+ export declare function isVisualPath(filePath: string): boolean;
25
+ export declare function anyVisual(files: string[]): boolean;
26
+ export declare function formatAdvisory(message: string, cursor: boolean): string;
27
+ /** Returns advisory JSON, or null when silent. Never throws for product paths. */
28
+ export declare function runPrePrScreenshot(deps: HookDeps): Promise<string | null>;
29
+ export declare function runHook(args: string[], help?: boolean): Promise<number>;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * `uploads hook pre-pr-screenshot` — agent PreToolUse / beforeShellExecution
3
+ * handler. When the shell command is `gh pr create`, the branch touches UI
4
+ * files, and nothing is staged on uploads.sh, emit a non-blocking advisory.
5
+ *
6
+ * Always fail-open. Disable with UPLOADS_HOOK_DISABLE=1.
7
+ */
8
+ import { execFileSync } from "node:child_process";
9
+ import { createUploadsClient } from "../client.js";
10
+ import { resolveConfig } from "../config.js";
11
+ import { writeCommandHelp } from "../cli-style.js";
12
+ const HOOK_CMD = "pre-pr-screenshot";
13
+ const VISUAL_EXT = /\.(astro|tsx|jsx|vue|svelte|html|css|scss|less)$/i;
14
+ const EMAIL_PATH = /(?:^|\/)email\//i;
15
+ const FIND_TIMEOUT_MS = 5_000;
16
+ const HOOK_HELP = `uploads hook <name> — agent harness hook handlers (stdin JSON → stdout JSON)
17
+
18
+ Usage:
19
+ uploads hook pre-pr-screenshot
20
+
21
+ Invoked by Claude Code / Codex / Grok / Cursor hooks. Never blocks.
22
+
23
+ pre-pr-screenshot
24
+ If the shell command is \`gh pr create\`, the branch touches UI files, and
25
+ no screenshots are staged for the branch, emit an advisory to stage with
26
+ \`uploads attach … --branch\`.
27
+
28
+ Disable with UPLOADS_HOOK_DISABLE=1.
29
+ `;
30
+ /** Claude/Codex: tool_input.command · Grok: toolInput.command · Cursor: command */
31
+ export function shellCommandFromHookInput(raw) {
32
+ if (!raw || typeof raw !== "object")
33
+ return "";
34
+ const o = raw;
35
+ const toolInput = (o.tool_input ?? o.toolInput);
36
+ if (toolInput && typeof toolInput.command === "string")
37
+ return toolInput.command;
38
+ if (typeof o.command === "string")
39
+ return o.command;
40
+ return "";
41
+ }
42
+ export function isCursorHookInput(raw) {
43
+ if (!raw || typeof raw !== "object")
44
+ return false;
45
+ const o = raw;
46
+ return "conversation_id" in o || "workspace_roots" in o || "cursor_version" in o;
47
+ }
48
+ export function looksLikeGhPrCreate(command) {
49
+ return command.includes("gh pr create");
50
+ }
51
+ export function isVisualPath(filePath) {
52
+ return VISUAL_EXT.test(filePath) || EMAIL_PATH.test(filePath);
53
+ }
54
+ export function anyVisual(files) {
55
+ return files.some(isVisualPath);
56
+ }
57
+ export function formatAdvisory(message, cursor) {
58
+ if (cursor) {
59
+ return JSON.stringify({ additional_context: message, agentMessage: message });
60
+ }
61
+ return JSON.stringify({
62
+ hookSpecificOutput: {
63
+ hookEventName: "PreToolUse",
64
+ additionalContext: message,
65
+ },
66
+ systemMessage: message,
67
+ });
68
+ }
69
+ function runGit(args, cwd, timeoutMs = 5_000) {
70
+ try {
71
+ return execFileSync("git", args, {
72
+ cwd,
73
+ encoding: "utf8",
74
+ timeout: timeoutMs,
75
+ stdio: ["ignore", "pipe", "ignore"],
76
+ }).trim();
77
+ }
78
+ catch {
79
+ return "";
80
+ }
81
+ }
82
+ function defaultGit(cwd) {
83
+ return {
84
+ isRepo: () => runGit(["rev-parse", "--is-inside-work-tree"], cwd) === "true",
85
+ branch: () => {
86
+ const b = runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
87
+ return !b || b === "HEAD" ? null : b;
88
+ },
89
+ changedFiles: () => {
90
+ const defaultBranch = runGit(["remote", "show", "origin"], cwd, 8_000)
91
+ .split("\n")
92
+ .map((l) => l.match(/HEAD branch:\s*(.+)/)?.[1]?.trim())
93
+ .find(Boolean) || "main";
94
+ const mergeBase = runGit(["merge-base", `origin/${defaultBranch}`, "HEAD"], cwd) ||
95
+ runGit(["merge-base", defaultBranch, "HEAD"], cwd);
96
+ const diff = mergeBase
97
+ ? runGit(["diff", "--name-only", mergeBase, "HEAD"], cwd)
98
+ : runGit(["diff", "--name-only", "HEAD"], cwd);
99
+ return diff ? diff.split("\n").filter(Boolean) : [];
100
+ },
101
+ };
102
+ }
103
+ async function defaultCountStaged(branch) {
104
+ try {
105
+ const config = resolveConfig({ requireToken: false });
106
+ if (!config.token)
107
+ return null;
108
+ const client = createUploadsClient(config);
109
+ const result = await Promise.race([
110
+ client.findFiles({ "gh.branch": branch.toLowerCase() }, { limit: 1 }),
111
+ new Promise((_, reject) => {
112
+ setTimeout(() => reject(new Error("find timeout")), FIND_TIMEOUT_MS);
113
+ }),
114
+ ]);
115
+ return Array.isArray(result.items) ? result.items.length : 0;
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ function defaultIsFork(cwd) {
122
+ try {
123
+ const out = execFileSync("gh", ["repo", "view", "--json", "isFork", "-q", ".isFork"], {
124
+ cwd,
125
+ encoding: "utf8",
126
+ timeout: 3_000,
127
+ stdio: ["ignore", "pipe", "ignore"],
128
+ }).trim();
129
+ if (out === "true")
130
+ return true;
131
+ if (out === "false")
132
+ return false;
133
+ return null;
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ /** Returns advisory JSON, or null when silent. Never throws for product paths. */
140
+ export async function runPrePrScreenshot(deps) {
141
+ if (process.env.UPLOADS_HOOK_DISABLE === "1")
142
+ return null;
143
+ let raw;
144
+ try {
145
+ raw = deps.stdin.trim() ? JSON.parse(deps.stdin) : null;
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ const command = shellCommandFromHookInput(raw);
151
+ if (!command || !looksLikeGhPrCreate(command))
152
+ return null;
153
+ const cwd = deps.cwd ?? process.cwd();
154
+ const git = deps.git ?? defaultGit(cwd);
155
+ if (!git.isRepo())
156
+ return null;
157
+ const branch = git.branch();
158
+ if (!branch)
159
+ return null;
160
+ const testFiles = deps.testFiles ?? process.env.UPLOADS_HOOK_TEST_FILES;
161
+ const changed = testFiles ? testFiles.split("\n").filter(Boolean) : git.changedFiles();
162
+ if (!anyVisual(changed))
163
+ return null;
164
+ const staged = await (deps.countStaged ?? defaultCountStaged)(branch);
165
+ // null = error/unconfigured → fail open; >0 = already staged
166
+ if (staged === null || staged > 0)
167
+ return null;
168
+ const fork = (deps.isFork ?? (() => defaultIsFork(cwd)))();
169
+ const forkNote = fork === true
170
+ ? " Note: this looks like a fork branch, so staged screenshots won't auto-promote into the PR comment yet (see issue #317) — attach them manually if you use uploads."
171
+ : "";
172
+ const message = `This PR touches UI files (astro/tsx/jsx/vue/svelte/html/css/scss/less or an /email/ path) but no screenshots are staged for branch '${branch}' on uploads.sh. ` +
173
+ `Consider running \`uploads attach <shot.png> --branch --state after\` (and a --state before if useful) before or after opening the PR — the managed attachments comment assembles from staged files automatically.${forkNote}`;
174
+ return formatAdvisory(message, isCursorHookInput(raw));
175
+ }
176
+ async function readStdin() {
177
+ if (process.stdin.isTTY)
178
+ return "";
179
+ const chunks = [];
180
+ for await (const chunk of process.stdin) {
181
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
182
+ }
183
+ return Buffer.concat(chunks).toString("utf8");
184
+ }
185
+ export async function runHook(args, help = false) {
186
+ const name = args[0];
187
+ if (help || !name || name === "--help" || name === "-h") {
188
+ writeCommandHelp(HOOK_HELP);
189
+ return 0;
190
+ }
191
+ if (name !== HOOK_CMD) {
192
+ process.stderr.write(`unknown hook: ${name} (expected ${HOOK_CMD})\n`);
193
+ return 2;
194
+ }
195
+ try {
196
+ const out = await runPrePrScreenshot({ stdin: await readStdin() });
197
+ if (out)
198
+ process.stdout.write(out);
199
+ }
200
+ catch {
201
+ // fail-open
202
+ }
203
+ return 0;
204
+ }
@@ -1,8 +1,18 @@
1
1
  import { type GlobalFlags } from "../cli-args.js";
2
2
  import { type CommandRunner } from "../github-gh.js";
3
3
  export declare const DEFAULT_MCP_URL = "https://agents.uploads.sh/mcp";
4
+ export interface StepResult {
5
+ command: string[];
6
+ ok: boolean;
7
+ skipped?: "dry-run" | "sign-in";
8
+ error?: string;
9
+ output?: string;
10
+ }
11
+ export declare function runStep(run: CommandRunner, command: string[]): StepResult;
4
12
  export declare function runInstall(args: string[], opts: {
5
13
  globals: GlobalFlags;
6
14
  json?: boolean;
7
15
  runner?: CommandRunner;
16
+ /** Override home for hook installs (tests). */
17
+ home?: string;
8
18
  }, help?: boolean): Promise<number>;
@@ -2,28 +2,35 @@ import { flagBool, flagString, parseCommandArgs, UsageError, } from "../cli-args
2
2
  import { resolveConfig } from "../config.js";
3
3
  import { execRunner } from "../github-gh.js";
4
4
  import { writeCommandHelp } from "../cli-style.js";
5
+ import { HOOK_COMMAND, installHookManifests } from "../hooks-install.js";
5
6
  export const DEFAULT_MCP_URL = "https://agents.uploads.sh/mcp";
6
7
  const SKILL_SOURCE = "buildinternet/uploads";
7
8
  const SKILL_NAMES = ["uploads-cli", "github-screenshots"];
8
- const INSTALL_HELP = `uploads install — set up agent integrations (skills + remote MCP)
9
+ const INSTALL_HELP = `uploads install — set up agent integrations (skills + remote MCP + hooks)
9
10
 
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.
11
+ Installs the github-screenshots and uploads-cli agent skills, registers the
12
+ hosted MCP server with Claude Code, and installs the PR screenshot reminder
13
+ hook for Grok / Cursor when those tools are present. The remote MCP endpoint
14
+ infers your workspace from the bearer token, so only the token is needed.
15
+
16
+ Claude Code and Codex ship the same reminder via their plugins (same command:
17
+ \`${HOOK_COMMAND}\`) — install those plugins instead of relying on this step.
13
18
 
14
19
  Usage:
15
- uploads install [skill|mcp|all] (default: all)
20
+ uploads install [skill|mcp|hooks|all] (default: all)
16
21
 
17
22
  What it does:
18
23
  skill Agent skills (via npx skills) — github-screenshots: visuals into
19
24
  PRs/issues; uploads-cli: full CLI reference
20
25
  mcp Hosted MCP server in Claude Code — put, list, attach, galleries
26
+ hooks PR screenshot reminder for Grok / Cursor (user-global manifests)
21
27
 
22
28
  What runs under the hood:
23
29
  skill npx -y skills add ${SKILL_SOURCE} --skill <name> -g -y -a '*'
24
30
  (once per skill: ${SKILL_NAMES.join(", ")})
25
31
  mcp claude mcp add --transport http uploads ${DEFAULT_MCP_URL} \\
26
32
  --header "Authorization: Bearer <token>"
33
+ hooks write/merge ~/.grok/hooks/… and ~/.cursor/hooks.json when present
27
34
 
28
35
  Options:
29
36
  --url <endpoint> Remote MCP endpoint (default: ${DEFAULT_MCP_URL})
@@ -35,6 +42,7 @@ Examples:
35
42
  uploads install
36
43
  uploads install skill
37
44
  uploads install mcp
45
+ uploads install hooks
38
46
  uploads install --dry-run
39
47
  `;
40
48
  /** Mask Bearer credentials and the configured token in any printed text. */
@@ -46,7 +54,7 @@ function redactor(token) {
46
54
  return out;
47
55
  };
48
56
  }
49
- function runStep(run, command) {
57
+ export function runStep(run, command) {
50
58
  try {
51
59
  const output = run(command[0], command.slice(1)).trim();
52
60
  return { command, ok: true, output: output || undefined };
@@ -123,6 +131,18 @@ function printSuccessFooter(steps, signedIn) {
123
131
  process.stdout.write("\nNot signed in yet? Run `uploads login` once so put/attach/MCP can authenticate.\n");
124
132
  }
125
133
  }
134
+ function printHookResults(writes) {
135
+ if (writes.length === 0) {
136
+ process.stdout.write("hooks: nothing to do (no ~/.grok or ~/.cursor; Claude/Codex use their plugins)\n");
137
+ return;
138
+ }
139
+ for (const w of writes) {
140
+ if (w.error)
141
+ process.stderr.write(`hooks:${w.path}: skipped — ${w.error}\n`);
142
+ else
143
+ process.stdout.write(`hooks:${w.path}: ${w.action}\n`);
144
+ }
145
+ }
126
146
  export async function runInstall(args, opts, help = false) {
127
147
  const parsed = parseCommandArgs(args);
128
148
  if (help || parsed.help) {
@@ -130,8 +150,8 @@ export async function runInstall(args, opts, help = false) {
130
150
  return 0;
131
151
  }
132
152
  const target = parsed.positionals[0] ?? "all";
133
- if (!["skill", "mcp", "all"].includes(target)) {
134
- throw new UsageError(`unknown install target: ${target} (expected skill, mcp, or all)`);
153
+ if (!["skill", "mcp", "hooks", "all"].includes(target)) {
154
+ throw new UsageError(`unknown install target: ${target} (expected skill, mcp, hooks, or all)`);
135
155
  }
136
156
  const url = flagString(parsed.flags, "--url") ?? DEFAULT_MCP_URL;
137
157
  const name = flagString(parsed.flags, "--name") ?? "uploads";
@@ -143,6 +163,7 @@ export async function runInstall(args, opts, help = false) {
143
163
  const signedIn = Boolean(token);
144
164
  const redact = redactor(token);
145
165
  const results = {};
166
+ let hookWrites = [];
146
167
  if (target === "skill" || target === "all") {
147
168
  if (human)
148
169
  process.stdout.write("Installing skills…\n");
@@ -169,6 +190,22 @@ export async function runInstall(args, opts, help = false) {
169
190
  results.mcp = dryRun ? { command, ok: true, skipped: "dry-run" } : runStep(run, command);
170
191
  }
171
192
  }
193
+ if (target === "hooks" || target === "all") {
194
+ if (human)
195
+ process.stdout.write("Installing agent hooks…\n");
196
+ hookWrites = installHookManifests({ home: opts.home, dryRun });
197
+ // Surface as a synthetic step so --json / failure accounting stay simple.
198
+ const hookErrors = hookWrites.filter((w) => w.error);
199
+ results.hooks = {
200
+ command: [HOOK_COMMAND],
201
+ ok: hookErrors.length === 0,
202
+ skipped: dryRun ? "dry-run" : undefined,
203
+ output: hookWrites
204
+ .map((w) => `${w.path}: ${w.action}${w.error ? ` (${w.error})` : ""}`)
205
+ .join("\n"),
206
+ error: hookErrors.length > 0 ? hookErrors.map((w) => w.error).join("; ") : undefined,
207
+ };
208
+ }
172
209
  const failed = Object.values(results).some((r) => !r.ok);
173
210
  if (opts.json) {
174
211
  const steps = Object.fromEntries(Object.entries(results).map(([key, r]) => [
@@ -185,6 +222,11 @@ export async function runInstall(args, opts, help = false) {
185
222
  return failed ? 1 : 0;
186
223
  }
187
224
  printHumanSteps(results, redact, verbose);
225
+ // Path-level detail for hooks (printHumanSteps only shows the synthetic step).
226
+ if ((target === "hooks" || target === "all") &&
227
+ (dryRun || (human && (verbose || hookWrites.some((w) => w.action !== "skipped"))))) {
228
+ printHookResults(hookWrites);
229
+ }
188
230
  const skillResults = Object.entries(results)
189
231
  .filter(([step]) => step.startsWith("skill:"))
190
232
  .map(([, r]) => r);
@@ -0,0 +1,14 @@
1
+ import { type GlobalFlags } from "../cli-args.js";
2
+ import { type CommandRunner } from "../github-gh.js";
3
+ import { type InstallSource } from "../install-source.js";
4
+ import { type UpdateStatus } from "../update-check.js";
5
+ export interface RunUpdateOptions {
6
+ globals: GlobalFlags;
7
+ /** Injected in tests. */
8
+ runner?: CommandRunner;
9
+ /** Injected in tests; defaults to detection from this module's path. */
10
+ source?: InstallSource;
11
+ /** Injected in tests; defaults to a cache-bypassing registry check. */
12
+ check?: () => Promise<UpdateStatus>;
13
+ }
14
+ export declare function runUpdate(args: string[], opts: RunUpdateOptions, help?: boolean): Promise<number>;
@@ -0,0 +1,125 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { flagBool, parseCommandArgs, UsageError } from "../cli-args.js";
4
+ import { execRunner } from "../github-gh.js";
5
+ import { writeCommandHelp } from "../cli-style.js";
6
+ import { detectInstallSource } from "../install-source.js";
7
+ import { checkForUpdate } from "../update-check.js";
8
+ import { runInstall, runStep } from "./install.js";
9
+ const UPDATE_HELP = `uploads update — update the CLI and refresh agent integrations
10
+
11
+ Upgrades the globally installed npm package, then re-runs \`uploads install\` so
12
+ the agent skills and the MCP registration match the new version. Skills and the
13
+ MCP registration drift on their own, so this refreshes them even when the CLI is
14
+ already current.
15
+
16
+ Usage:
17
+ uploads update [options]
18
+
19
+ Options:
20
+ --dry-run Print the plan without running anything
21
+ --skip-install Upgrade the npm package only; leave skills and MCP alone
22
+ --verbose Show the output of the underlying commands
23
+
24
+ Examples:
25
+ uploads update
26
+ uploads update --dry-run
27
+ uploads update --skip-install
28
+ `;
29
+ /** Why an upgrade was skipped, phrased for the user. */
30
+ const SKIP_REASON = {
31
+ workspace: "this is a workspace checkout, not a global install",
32
+ npx: "this ran from an npx cache, which is discarded after the run",
33
+ unknown: "this is a local project dependency, not a global install",
34
+ };
35
+ function thisModulePath() {
36
+ const path = fileURLToPath(import.meta.url);
37
+ try {
38
+ return realpathSync(path);
39
+ }
40
+ catch {
41
+ return path;
42
+ }
43
+ }
44
+ function isPermissionError(message) {
45
+ return /EACCES|EPERM|permission denied/i.test(message);
46
+ }
47
+ export async function runUpdate(args, opts, help = false) {
48
+ const parsed = parseCommandArgs(args);
49
+ if (help || parsed.help) {
50
+ writeCommandHelp(UPDATE_HELP);
51
+ return 0;
52
+ }
53
+ if (parsed.positionals.length > 0) {
54
+ throw new UsageError(`update takes no arguments (got ${parsed.positionals[0]})`);
55
+ }
56
+ const dryRun = flagBool(parsed.flags, "--dry-run");
57
+ const verbose = flagBool(parsed.flags, "--verbose");
58
+ const skipInstall = flagBool(parsed.flags, "--skip-install");
59
+ const run = opts.runner ?? execRunner;
60
+ const source = opts.source ?? detectInstallSource(thisModulePath());
61
+ // ttlMs 0 bypasses the once-a-day cache: `update` must not trust yesterday's read.
62
+ const status = await (opts.check ?? (() => checkForUpdate({ ttlMs: 0 })))();
63
+ const willUpgrade = status.updateAvailable && source.kind === "global";
64
+ // Resolved through PATH: in the normal single-install case this is the
65
+ // just-upgraded binary, but with multiple `uploads` binaries on PATH it
66
+ // could resolve to a different, stale one. Accepted risk, not a guarantee.
67
+ const refreshCommand = ["uploads", "install"];
68
+ // --- plan ---
69
+ if (willUpgrade) {
70
+ process.stdout.write(`CLI ${status.current} → ${status.latest}\n`);
71
+ }
72
+ else if (status.updateAvailable) {
73
+ process.stdout.write(`CLI ${status.current} is behind ${status.latest}, but the upgrade is skipped — ` +
74
+ `${SKIP_REASON[source.kind] ?? "the install source is not a global install"}.\n` +
75
+ `Upgrade by hand with: ${source.upgradeCommand.join(" ")}\n`);
76
+ }
77
+ else {
78
+ process.stdout.write(`CLI already at ${status.current}\n`);
79
+ }
80
+ if (dryRun) {
81
+ if (willUpgrade) {
82
+ process.stdout.write(`upgrade: would run — ${source.upgradeCommand.join(" ")}\n`);
83
+ }
84
+ if (!skipInstall) {
85
+ process.stdout.write(`refresh: would run — ${refreshCommand.join(" ")}\n`);
86
+ }
87
+ return 0;
88
+ }
89
+ // --- upgrade ---
90
+ if (willUpgrade) {
91
+ process.stdout.write("Upgrading the CLI…\n");
92
+ const result = runStep(run, source.upgradeCommand);
93
+ if (!result.ok) {
94
+ const message = result.error ?? "";
95
+ process.stderr.write(`upgrade: failed — ${message}\n`);
96
+ if (isPermissionError(message)) {
97
+ process.stderr.write("The global install directory is not writable by your user. Fix the ownership " +
98
+ `of your npm prefix so global installs work without sudo, then re-run \`uploads update\`.\n`);
99
+ }
100
+ process.stderr.write(`Run it by hand: ${source.upgradeCommand.join(" ")}\n`);
101
+ return 1;
102
+ }
103
+ process.stdout.write("upgrade: ok\n");
104
+ if (verbose && result.output)
105
+ process.stdout.write(` ${result.output}\n`);
106
+ }
107
+ if (skipInstall)
108
+ return 0;
109
+ // --- refresh ---
110
+ // After an upgrade the in-process code is the OLD version, so spawn the newly
111
+ // installed binary. Its skill list is the one that should be installed.
112
+ if (willUpgrade) {
113
+ process.stdout.write("Refreshing skills and MCP…\n");
114
+ const result = runStep(run, refreshCommand);
115
+ if (!result.ok) {
116
+ process.stderr.write(`refresh: failed — ${result.error ?? ""}\n`);
117
+ process.stderr.write("Run it by hand: uploads install\n");
118
+ return 1;
119
+ }
120
+ process.stdout.write(result.output ? `${result.output}\n` : "refresh: ok\n");
121
+ return 0;
122
+ }
123
+ // Nothing changed, so the in-process install code is already current.
124
+ return runInstall(verbose ? ["--verbose"] : [], { globals: opts.globals, runner: run });
125
+ }
@@ -166,7 +166,16 @@ export declare function commentViaSuffix(via: AttachmentsCommentResult["via"]):
166
166
  */
167
167
  export declare class GithubCommentAuthorizationError extends Error {
168
168
  }
169
- export declare function syncAttachmentsComment(client: UploadsClient, target: GhTarget, run: CommandRunner, workspace?: string): Promise<AttachmentsCommentResult>;
169
+ /**
170
+ * `opts.resync` marks an explicit `uploads comment` invocation rather than a
171
+ * background sync (attach, screenshot, put --comment). It costs the server one
172
+ * extra comment listing and in exchange collapses any duplicate managed
173
+ * comment (issue #480) — worth it on the rare, explicitly-asked-for resync,
174
+ * not on every attach.
175
+ */
176
+ export declare function syncAttachmentsComment(client: UploadsClient, target: GhTarget, run: CommandRunner, workspace?: string, opts?: {
177
+ resync?: boolean;
178
+ }): Promise<AttachmentsCommentResult>;
170
179
  /**
171
180
  * Lever 3 (issue #469): a nudge for when an image lands on a PR/issue with
172
181
  * no `path` metadata — `path` is one of the highest-value queryable tags
package/dist/commands.js CHANGED
@@ -450,13 +450,21 @@ export function commentViaSuffix(via) {
450
450
  */
451
451
  export class GithubCommentAuthorizationError extends Error {
452
452
  }
453
- export async function syncAttachmentsComment(client, target, run, workspace) {
453
+ /**
454
+ * `opts.resync` marks an explicit `uploads comment` invocation rather than a
455
+ * background sync (attach, screenshot, put --comment). It costs the server one
456
+ * extra comment listing and in exchange collapses any duplicate managed
457
+ * comment (issue #480) — worth it on the rare, explicitly-asked-for resync,
458
+ * not on every attach.
459
+ */
460
+ export async function syncAttachmentsComment(client, target, run, workspace, opts = {}) {
454
461
  let bot;
455
462
  try {
456
463
  bot = await client.upsertGithubComment({
457
464
  repo: target.repo,
458
465
  num: target.num,
459
466
  kind: target.kind,
467
+ ...(opts.resync ? { resync: true } : {}),
460
468
  });
461
469
  }
462
470
  catch {
@@ -2258,6 +2266,7 @@ async function resyncCommentAfterMetaSet(ctx, key, touchedKeys) {
2258
2266
  repo: target.repo,
2259
2267
  num: target.num,
2260
2268
  kind: target.kind,
2269
+ resync: true,
2261
2270
  });
2262
2271
  if (bot.posted) {
2263
2272
  if (!ctx.quiet && !ctx.json) {
@@ -2336,7 +2345,9 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
2336
2345
  const target = ghTargetFromFlags(parsed.flags, run);
2337
2346
  if (!target)
2338
2347
  throw new UsageError("comment requires --pr or --issue");
2339
- const result = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
2348
+ const result = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace, {
2349
+ resync: true,
2350
+ });
2340
2351
  if (ctx.json) {
2341
2352
  await writeJson({ ...target, ...result });
2342
2353
  }
@@ -55,7 +55,8 @@ export declare function resolveGhTitle(target: GhTarget, run?: CommandRunner): s
55
55
  export declare function ghMetadataFromTargetWithTitle(target: GhTarget, run?: CommandRunner): Record<string, string>;
56
56
  /**
57
57
  * Create the managed attachments comment, or edit it in place if it already
58
- * exists. Never touches any other comment. Body is passed via stdin
58
+ * exists. Never touches any other comment except best-effort deletes of
59
+ * duplicate marker comments (see below). Body is passed via stdin
59
60
  * (`-F body=@-`) so it is never shell-interpolated.
60
61
  *
61
62
  * `marker` identifies which comment to hunt for (see `findManagedComment`);
@@ -63,6 +64,27 @@ export declare function ghMetadataFromTargetWithTitle(target: GhTarget, run?: Co
63
64
  * (built via `attachmentsCommentBody(items, galleries, marker)`), so patching
64
65
  * an adopted legacy comment migrates it to the namespaced marker in place.
65
66
  * Defaults to the shared legacy marker for backward compatibility.
67
+ *
68
+ * Self-healing dedupe (issue #486, mirroring the bot path's #470/#484 fix):
69
+ * a create race (two concurrent `uploads attach` runs, neither finding an
70
+ * existing comment) can leave more than one marker comment on the thread.
71
+ * This path has no id cache, so unlike the bot path a duplicate here never
72
+ * heals on its own — every sync just patches the oldest and leaves the rest
73
+ * stale. After patching (or creating), any extra exact-`marker` hits are
74
+ * deleted best-effort via `gh api -X DELETE`; a failed delete is swallowed
75
+ * and never fails the caller's command, and the next sync retries anyway.
76
+ *
77
+ * On why this duplicates the bot path rather than deferring to it: the gh
78
+ * fallback is a supported path, not a stopgap, so it is held at behavioral
79
+ * parity deliberately. This file already reimplements the hunt, the legacy
80
+ * adoption and the create-vs-patch gate against a different transport (the
81
+ * `gh` subprocess, not the App's token), and #486 existed precisely because
82
+ * the two drifted. Treat any behavior change to `upsertBotComment`
83
+ * (apps/api/src/github-comment.ts) as owing a matching change here. Note
84
+ * this is the one place the CLI deletes a GitHub resource under the
85
+ * invoking human's own credentials — bounded to comments carrying this
86
+ * workspace's exact namespaced marker, whose content is always
87
+ * regenerable.
66
88
  */
67
89
  export declare function upsertAttachmentsComment(target: GhTarget, body: string, run?: CommandRunner, marker?: string, opts?: {
68
90
  createIfMissing?: boolean;
package/dist/github-gh.js CHANGED
@@ -187,7 +187,9 @@ export function ghMetadataFromTargetWithTitle(target, run = execRunner) {
187
187
  /**
188
188
  * PR comments live on the issues endpoint, so one path covers PRs and issues.
189
189
  * `--paginate` follows Link headers and merges every page into one array, so the
190
- * marker comment is found even on threads past 100 comments.
190
+ * marker comment is found even on threads past 100 comments. GitHub returns
191
+ * comments oldest-first, so `hits[0]` (after merging paginated pages, which
192
+ * preserve that order) is the oldest exact-`marker` hit.
191
193
  *
192
194
  * Hunts for `marker` (the namespaced, per-workspace marker) first; when none
193
195
  * is found, falls back to a comment carrying the shared legacy
@@ -195,6 +197,12 @@ export function ghMetadataFromTargetWithTitle(target, run = execRunner) {
195
197
  * migrated in place. When `marker` IS the legacy marker (no workspace to
196
198
  * namespace with) this collapses to a single hunt, unchanged from pre-4b
197
199
  * behavior.
200
+ *
201
+ * Collects EVERY comment carrying `marker` (a create race can leave more
202
+ * than one — issue #486, mirroring the bot path's #470 fix): the oldest is
203
+ * `comment`, the rest come back as `extras` for the caller to delete. Only
204
+ * exact-`marker` hits are ever extras — a legacy (unnamespaced) comment may
205
+ * belong to a different workspace, so it is adopted at most, never deleted.
198
206
  */
199
207
  function findManagedComment(target, run, marker) {
200
208
  const raw = run("gh", [
@@ -203,16 +211,25 @@ function findManagedComment(target, run, marker) {
203
211
  "--paginate",
204
212
  ]);
205
213
  const comments = JSON.parse(raw);
206
- const namespacedHit = comments.find((c) => typeof c.body === "string" && c.body.includes(marker));
207
- if (namespacedHit)
208
- return namespacedHit;
214
+ const hits = comments.filter((c) => typeof c.body === "string" && c.body.includes(marker));
215
+ if (hits.length > 0) {
216
+ // In legacy mode (no workspace to namespace with) our "exact" marker IS
217
+ // the shared one, so a second hit is not our own duplicate — it may be
218
+ // another workspace's comment. Adopt the oldest and never delete: the
219
+ // adopt-only contract is about the marker being ambiguous, which is just
220
+ // as true when it is the marker we are hunting on.
221
+ const extras = marker === ATTACHMENTS_MARKER ? undefined : hits.slice(1);
222
+ return { comment: hits[0], extras };
223
+ }
209
224
  if (marker === ATTACHMENTS_MARKER)
210
- return undefined;
211
- return comments.find((c) => typeof c.body === "string" && c.body.includes(ATTACHMENTS_MARKER));
225
+ return {};
226
+ const legacyHit = comments.find((c) => typeof c.body === "string" && c.body.includes(ATTACHMENTS_MARKER));
227
+ return { comment: legacyHit };
212
228
  }
213
229
  /**
214
230
  * Create the managed attachments comment, or edit it in place if it already
215
- * exists. Never touches any other comment. Body is passed via stdin
231
+ * exists. Never touches any other comment except best-effort deletes of
232
+ * duplicate marker comments (see below). Body is passed via stdin
216
233
  * (`-F body=@-`) so it is never shell-interpolated.
217
234
  *
218
235
  * `marker` identifies which comment to hunt for (see `findManagedComment`);
@@ -220,10 +237,41 @@ function findManagedComment(target, run, marker) {
220
237
  * (built via `attachmentsCommentBody(items, galleries, marker)`), so patching
221
238
  * an adopted legacy comment migrates it to the namespaced marker in place.
222
239
  * Defaults to the shared legacy marker for backward compatibility.
240
+ *
241
+ * Self-healing dedupe (issue #486, mirroring the bot path's #470/#484 fix):
242
+ * a create race (two concurrent `uploads attach` runs, neither finding an
243
+ * existing comment) can leave more than one marker comment on the thread.
244
+ * This path has no id cache, so unlike the bot path a duplicate here never
245
+ * heals on its own — every sync just patches the oldest and leaves the rest
246
+ * stale. After patching (or creating), any extra exact-`marker` hits are
247
+ * deleted best-effort via `gh api -X DELETE`; a failed delete is swallowed
248
+ * and never fails the caller's command, and the next sync retries anyway.
249
+ *
250
+ * On why this duplicates the bot path rather than deferring to it: the gh
251
+ * fallback is a supported path, not a stopgap, so it is held at behavioral
252
+ * parity deliberately. This file already reimplements the hunt, the legacy
253
+ * adoption and the create-vs-patch gate against a different transport (the
254
+ * `gh` subprocess, not the App's token), and #486 existed precisely because
255
+ * the two drifted. Treat any behavior change to `upsertBotComment`
256
+ * (apps/api/src/github-comment.ts) as owing a matching change here. Note
257
+ * this is the one place the CLI deletes a GitHub resource under the
258
+ * invoking human's own credentials — bounded to comments carrying this
259
+ * workspace's exact namespaced marker, whose content is always
260
+ * regenerable.
223
261
  */
224
262
  export function upsertAttachmentsComment(target, body, run = execRunner, marker = ATTACHMENTS_MARKER, opts = {}) {
225
263
  const createIfMissing = opts.createIfMissing ?? true;
226
- const existing = findManagedComment(target, run, marker);
264
+ const { comment: existing, extras } = findManagedComment(target, run, marker);
265
+ const deleteExtras = () => {
266
+ for (const extra of extras ?? []) {
267
+ try {
268
+ run("gh", ["api", `repos/${target.repo}/issues/comments/${extra.id}`, "-X", "DELETE"]);
269
+ }
270
+ catch {
271
+ // Best effort only — a failed delete must never fail the caller's command.
272
+ }
273
+ }
274
+ };
227
275
  if (existing) {
228
276
  run("gh", [
229
277
  "api",
@@ -233,12 +281,15 @@ export function upsertAttachmentsComment(target, body, run = execRunner, marker
233
281
  "-F",
234
282
  "body=@-",
235
283
  ], body);
284
+ deleteExtras();
236
285
  return { action: "updated" };
237
286
  }
238
287
  // Patch-only (createIfMissing false, i.e. an empty body) with no existing
239
288
  // comment: nothing to do — never create one just to say it's empty.
240
289
  if (!createIfMissing)
241
290
  return { action: "skipped" };
291
+ // No existing marker hit means `extras` is necessarily empty here (see
292
+ // `findManagedComment`) — nothing to delete after a create.
242
293
  run("gh", ["api", `repos/${target.repo}/issues/${target.num}/comments`, "-F", "body=@-"], body);
243
294
  return { action: "created" };
244
295
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Install thin user-global hook manifests for Grok and Cursor.
3
+ *
4
+ * Claude and Codex ship the same PreToolUse hook via their plugins
5
+ * (`hooks/hooks.json` → `uploads hook pre-pr-screenshot`). Do not also write
6
+ * ~/.codex/hooks.json here, or the reminder would fire twice.
7
+ *
8
+ * Idempotent: skip when our command string is already present.
9
+ */
10
+ export declare const HOOK_COMMAND = "uploads hook pre-pr-screenshot";
11
+ export type HookWriteResult = {
12
+ path: string;
13
+ action: "wrote" | "merged" | "skipped" | "would-write" | "would-merge";
14
+ error?: string;
15
+ };
16
+ export type InstallHooksOptions = {
17
+ home?: string;
18
+ dryRun?: boolean;
19
+ targets?: Array<"grok" | "cursor">;
20
+ };
21
+ /** User-global manifests for Grok + Cursor only. */
22
+ export declare function installHookManifests(opts?: InstallHooksOptions): HookWriteResult[];
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Install thin user-global hook manifests for Grok and Cursor.
3
+ *
4
+ * Claude and Codex ship the same PreToolUse hook via their plugins
5
+ * (`hooks/hooks.json` → `uploads hook pre-pr-screenshot`). Do not also write
6
+ * ~/.codex/hooks.json here, or the reminder would fire twice.
7
+ *
8
+ * Idempotent: skip when our command string is already present.
9
+ */
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ export const HOOK_COMMAND = "uploads hook pre-pr-screenshot";
14
+ const TIMEOUT_SEC = 15;
15
+ const GROK_PAYLOAD = {
16
+ description: "Advisory reminder to stage screenshots on uploads.sh before opening a PR that touches UI files.",
17
+ hooks: {
18
+ PreToolUse: [
19
+ {
20
+ matcher: "Bash",
21
+ hooks: [
22
+ {
23
+ type: "command",
24
+ command: HOOK_COMMAND,
25
+ timeout: TIMEOUT_SEC,
26
+ statusMessage: "Checking staged screenshots",
27
+ },
28
+ ],
29
+ },
30
+ ],
31
+ },
32
+ };
33
+ function containsCommand(filePath) {
34
+ try {
35
+ return fs.readFileSync(filePath, "utf8").includes(HOOK_COMMAND);
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ function writeJson(filePath, value) {
42
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
43
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
44
+ }
45
+ function commit(filePath, next, dryRun, existed) {
46
+ if (containsCommand(filePath))
47
+ return { path: filePath, action: "skipped" };
48
+ if (dryRun) {
49
+ return { path: filePath, action: existed ? "would-merge" : "would-write" };
50
+ }
51
+ writeJson(filePath, next);
52
+ return { path: filePath, action: existed ? "merged" : "wrote" };
53
+ }
54
+ function writeGrok(home, dryRun) {
55
+ const filePath = path.join(home, ".grok", "hooks", "uploads-pre-pr-screenshot.json");
56
+ return commit(filePath, GROK_PAYLOAD, dryRun, fs.existsSync(filePath));
57
+ }
58
+ function mergeCursor(home, dryRun) {
59
+ const filePath = path.join(home, ".cursor", "hooks.json");
60
+ const existed = fs.existsSync(filePath);
61
+ let existing = { version: 1, hooks: {} };
62
+ if (existed) {
63
+ try {
64
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
65
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
66
+ return { path: filePath, action: "skipped", error: "hooks.json is not an object" };
67
+ }
68
+ existing = raw;
69
+ }
70
+ catch (err) {
71
+ return {
72
+ path: filePath,
73
+ action: "skipped",
74
+ error: `could not parse existing hooks.json: ${err instanceof Error ? err.message : String(err)}`,
75
+ };
76
+ }
77
+ }
78
+ const hooks = existing.hooks && typeof existing.hooks === "object"
79
+ ? { ...existing.hooks }
80
+ : {};
81
+ const list = Array.isArray(hooks.beforeShellExecution) ? [...hooks.beforeShellExecution] : [];
82
+ list.push({ command: HOOK_COMMAND, timeout: TIMEOUT_SEC });
83
+ hooks.beforeShellExecution = list;
84
+ existing.hooks = hooks;
85
+ if (existing.version === undefined)
86
+ existing.version = 1;
87
+ return commit(filePath, existing, dryRun, existed);
88
+ }
89
+ /** User-global manifests for Grok + Cursor only. */
90
+ export function installHookManifests(opts = {}) {
91
+ const home = opts.home ?? os.homedir();
92
+ const dryRun = Boolean(opts.dryRun);
93
+ const targets = opts.targets ??
94
+ [
95
+ fs.existsSync(path.join(home, ".grok")) && "grok",
96
+ fs.existsSync(path.join(home, ".cursor")) && "cursor",
97
+ ].filter(Boolean);
98
+ const results = [];
99
+ for (const t of targets) {
100
+ try {
101
+ results.push(t === "grok" ? writeGrok(home, dryRun) : mergeCursor(home, dryRun));
102
+ }
103
+ catch (err) {
104
+ results.push({
105
+ path: t,
106
+ action: "skipped",
107
+ error: err instanceof Error ? err.message : String(err),
108
+ });
109
+ }
110
+ }
111
+ return results;
112
+ }
@@ -0,0 +1,14 @@
1
+ export type InstallKind = "global" | "workspace" | "npx" | "unknown";
2
+ export type PackageManager = "npm" | "pnpm" | "bun";
3
+ export interface InstallSource {
4
+ kind: InstallKind;
5
+ /** Falls back to npm for every non-global kind. */
6
+ manager: PackageManager;
7
+ /** Upgrades the global install. Only meaningful when kind is "global". */
8
+ upgradeCommand: string[];
9
+ }
10
+ /**
11
+ * @param modulePath Absolute path of a file inside the installed package,
12
+ * normally `realpathSync(fileURLToPath(import.meta.url))`.
13
+ */
14
+ export declare function detectInstallSource(modulePath: string): InstallSource;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Classify where the running CLI was installed from.
3
+ *
4
+ * `uploads update` upgrades the global npm package. That is only safe when the
5
+ * CLI actually came from a global install — upgrading a workspace checkout
6
+ * would overwrite a developer's build with the published version.
7
+ *
8
+ * Pure and path-only: no filesystem or process access, so it is fully testable.
9
+ */
10
+ import { PACKAGE_NAME } from "./update-check.js";
11
+ const UPGRADE_COMMANDS = {
12
+ npm: ["npm", "install", "-g", `${PACKAGE_NAME}@latest`],
13
+ pnpm: ["pnpm", "add", "-g", `${PACKAGE_NAME}@latest`],
14
+ bun: ["bun", "add", "-g", `${PACKAGE_NAME}@latest`],
15
+ };
16
+ function classify(path) {
17
+ // npx is checked first: a cache entry can also contain a global-looking marker.
18
+ if (path.includes("/_npx/"))
19
+ return { kind: "npx", manager: "npm" };
20
+ if (path.includes("/.bun/install/global/"))
21
+ return { kind: "global", manager: "bun" };
22
+ if (path.includes("/pnpm/global/"))
23
+ return { kind: "global", manager: "pnpm" };
24
+ if (path.includes("/lib/node_modules/"))
25
+ return { kind: "global", manager: "npm" };
26
+ // Windows npm globals have no `lib` segment: `<prefix>\npm\node_modules\<pkg>`.
27
+ if (path.includes("/npm/node_modules/"))
28
+ return { kind: "global", manager: "npm" };
29
+ // No node_modules segment at all means we are running out of a source checkout.
30
+ if (!path.includes("/node_modules/"))
31
+ return { kind: "workspace", manager: "npm" };
32
+ return { kind: "unknown", manager: "npm" };
33
+ }
34
+ /**
35
+ * @param modulePath Absolute path of a file inside the installed package,
36
+ * normally `realpathSync(fileURLToPath(import.meta.url))`.
37
+ */
38
+ export function detectInstallSource(modulePath) {
39
+ const normalized = modulePath.split("\\").join("/");
40
+ const { kind, manager } = classify(normalized);
41
+ return { kind, manager, upgradeCommand: UPGRADE_COMMANDS[manager] };
42
+ }
package/dist/mcp/tools.js CHANGED
@@ -1158,7 +1158,10 @@ export function createUploadsMcpTools(opts) {
1158
1158
  if (!target)
1159
1159
  usage("comment requires pr or issue");
1160
1160
  const { config, client } = clientFor(args);
1161
- const result = await syncAttachmentsComment(client, target, run, config.workspace);
1161
+ // Explicit resync, same as `uploads comment` (issue #480).
1162
+ const result = await syncAttachmentsComment(client, target, run, config.workspace, {
1163
+ resync: true,
1164
+ });
1162
1165
  return { ...target, ...result };
1163
1166
  },
1164
1167
  },
package/dist/telemetry.js CHANGED
@@ -28,6 +28,7 @@ const NESTED_COMMANDS = new Set([
28
28
  "completions",
29
29
  "config",
30
30
  "gallery",
31
+ "hook",
31
32
  "install",
32
33
  "meta",
33
34
  "telemetry",
@@ -116,7 +116,7 @@ export async function maybeHintUpdate(opts = {}) {
116
116
  if (!status.updateAvailable || !status.latest)
117
117
  return;
118
118
  const write = opts.write ?? ((text) => process.stderr.write(text));
119
- write(`hint: ${PACKAGE_NAME}@${status.latest} is available (you have ${status.current}). Update: npm i -g ${PACKAGE_NAME}\n`);
119
+ write(`hint: ${PACKAGE_NAME}@${status.latest} is available (you have ${status.current}). Update: uploads update\n`);
120
120
  }
121
121
  catch {
122
122
  // Never surface update-check failures.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,