@buildinternet/uploads 0.28.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
 
@@ -180,12 +180,23 @@ 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)" },
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
+ },
189
200
  ],
190
201
  },
191
202
  {
package/dist/cli-help.js CHANGED
@@ -6,10 +6,18 @@ 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",
@@ -19,12 +27,23 @@ const ESSENTIAL_ORDER = [
19
27
  "update",
20
28
  ];
21
29
  /** Day-to-day commands shown on bare `uploads` / `uploads help`. */
22
- const ESSENTIALS = ESSENTIAL_ORDER.map((name) => {
23
- const cmd = ROOT_COMMANDS.find((c) => c.name === name);
24
- if (!cmd)
25
- throw new Error(`cli-catalog missing essential command: ${name}`);
26
- return toRow(cmd);
27
- });
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
+ })();
28
47
  /** Full catalog (same surface as before, still discoverable via help --all). */
29
48
  const ALL_COMMANDS = ROOT_COMMANDS.map(toRow);
30
49
  function rows(style, items) {
@@ -148,10 +167,9 @@ ${section(style, "Examples:")}
148
167
  ${style.command("uploads logout")}
149
168
  ${style.command("uploads --version")}
150
169
 
151
- ${section(style, "Agent/MCP:")} ${style.body("`uploads install` sets up the agent skills and the hosted MCP server")}
152
- ${style.body("(https://agents.uploads.sh/mcp, workspace inferred from the token). Run")}
153
- ${style.body("`uploads mcp` for local stdio, or use createUploadsWorkerFileTools()")}
154
- ${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.")}
155
173
 
156
174
  ${style.muted("Tip: uploads help essentials only")}
157
175
  ${style.muted(" uploads help --all this full listing")}
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ 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";
15
16
  import { runUpdate } from "./commands/update.js";
16
17
  import { runCompletion } from "./commands/completion.js";
17
18
  import { runLogout, runWhoami } from "./commands/session.js";
@@ -269,6 +270,10 @@ export async function runCli(argv) {
269
270
  case "install":
270
271
  code = await runInstall(cmdArgs, { globals: parsed.globals, json }, showHelp);
271
272
  break;
273
+ case "hook":
274
+ // Fail-open harness hooks — no token required, no telemetry noise.
275
+ code = await runHook(cmdArgs, showHelp);
276
+ break;
272
277
  case "update":
273
278
  code = await runUpdate(cmdArgs, { globals: parsed.globals }, showHelp);
274
279
  break;
@@ -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
+ }
@@ -13,4 +13,6 @@ export declare function runInstall(args: string[], opts: {
13
13
  globals: GlobalFlags;
14
14
  json?: boolean;
15
15
  runner?: CommandRunner;
16
+ /** Override home for hook installs (tests). */
17
+ home?: string;
16
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. */
@@ -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,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
+ }
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.28.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,