@buildinternet/uploads 0.28.0 → 0.30.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 +1 -1
- package/dist/cli-catalog.js +13 -2
- package/dist/cli-help.js +29 -11
- package/dist/cli.js +5 -0
- package/dist/client.d.ts +24 -1
- package/dist/commands/hook.d.ts +29 -0
- package/dist/commands/hook.js +204 -0
- package/dist/commands/install.d.ts +2 -0
- package/dist/commands/install.js +49 -7
- package/dist/commands.d.ts +37 -21
- package/dist/commands.js +35 -15
- package/dist/hooks-install.d.ts +22 -0
- package/dist/hooks-install.js +112 -0
- package/dist/telemetry.js +1 -0
- package/package.json +1 -1
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-catalog.js
CHANGED
|
@@ -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
|
|
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: "
|
|
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
|
-
/**
|
|
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 =
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
152
|
-
${style.body("
|
|
153
|
-
${style.body("`uploads mcp` for local stdio
|
|
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;
|
package/dist/client.d.ts
CHANGED
|
@@ -82,6 +82,24 @@ export interface PutResult {
|
|
|
82
82
|
* non-`gh/` key, existing object, no `replace`) instead of overwriting.
|
|
83
83
|
*/
|
|
84
84
|
wouldRefuse?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* The object's R2 provenance bag (`client`, `source-name`,
|
|
87
|
+
* `content-sha256`) — what the upload was made *by*, not the tags it was
|
|
88
|
+
* tagged *with*. Absent on a dry run, and on API deployments older than the
|
|
89
|
+
* split that gave this bag its own name.
|
|
90
|
+
*/
|
|
91
|
+
provenance?: Record<string, string>;
|
|
92
|
+
/**
|
|
93
|
+
* The queryable metadata (D1) this put stored, including server-derived
|
|
94
|
+
* pairs the client never sent (`gh.uploader`). Absent when the put carried
|
|
95
|
+
* no metadata — that case leaves any existing tags untouched, so the server
|
|
96
|
+
* reports nothing rather than implying an empty set. Means the same thing
|
|
97
|
+
* on `getMetadata`, `patchMetadata`, and `list({ metadata: true })`.
|
|
98
|
+
*
|
|
99
|
+
* An API older than the split returns the provenance bag here instead, so a
|
|
100
|
+
* client that must work against both reads `path`-style tags defensively —
|
|
101
|
+
* see `pathMetaHintFor` in commands.ts, which prefers what it sent.
|
|
102
|
+
*/
|
|
85
103
|
metadata?: Record<string, string>;
|
|
86
104
|
}
|
|
87
105
|
export interface ListItem {
|
|
@@ -106,7 +124,12 @@ export interface HeadResult {
|
|
|
106
124
|
size: number;
|
|
107
125
|
contentType: string;
|
|
108
126
|
uploaded?: string;
|
|
109
|
-
|
|
127
|
+
/**
|
|
128
|
+
* The object's R2 provenance bag — see `PutResult.provenance`. A plain head
|
|
129
|
+
* returns no queryable metadata at all: that tier lives in a separate store
|
|
130
|
+
* and takes a separate read, so call `getMetadata(key)` for it.
|
|
131
|
+
*/
|
|
132
|
+
provenance?: Record<string, string>;
|
|
110
133
|
}
|
|
111
134
|
export interface DeleteResult {
|
|
112
135
|
key: string;
|
|
@@ -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
|
+
}
|
package/dist/commands/install.js
CHANGED
|
@@ -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
|
|
11
|
-
|
|
12
|
-
|
|
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);
|
package/dist/commands.d.ts
CHANGED
|
@@ -123,6 +123,13 @@ export interface UploadPreparedImageResult {
|
|
|
123
123
|
result: PutResult;
|
|
124
124
|
prepared: PreparedUpload;
|
|
125
125
|
markdown: string;
|
|
126
|
+
/**
|
|
127
|
+
* The queryable metadata this upload actually sent — `opts.metadata` after
|
|
128
|
+
* any derived image facts were merged in. Callers that need to reason about
|
|
129
|
+
* what was stored (see `pathMetaHintFor`) must read this, not
|
|
130
|
+
* `result.metadata`, which is the API's R2 provenance echo.
|
|
131
|
+
*/
|
|
132
|
+
sentMetadata?: Record<string, string>;
|
|
126
133
|
}
|
|
127
134
|
/**
|
|
128
135
|
* Shared bytes-oriented upload tail: frame + optimize the bytes, resolve the
|
|
@@ -182,15 +189,22 @@ export declare function syncAttachmentsComment(client: UploadsClient, target: Gh
|
|
|
182
189
|
* (same tier as `state=`), and unlike `uploads screenshot` (which derives it
|
|
183
190
|
* from the captured URL), a plain `attach`/`put --pr`/`put --issue` of an
|
|
184
191
|
* already-existing image has nothing to derive it from, so it's easy to
|
|
185
|
-
* forget. Fires once per batch (not per file)
|
|
186
|
-
* server actually stored (`PutResult.metadata`), not what was requested, so
|
|
187
|
-
* a merge/validation drop still surfaces the gap. Non-image uploads (zips,
|
|
192
|
+
* forget. Fires once per batch (not per file). Non-image uploads (zips,
|
|
188
193
|
* PDFs, etc.) are exempt — "findable by page" doesn't apply to them.
|
|
194
|
+
*
|
|
195
|
+
* Checks the *resolved* metadata each upload actually sent (`--meta` pairs +
|
|
196
|
+
* sidecar manifest + derived image facts, index-aligned with `uploads`) —
|
|
197
|
+
* NOT `PutResult.metadata`. That field is the API's echo of the object's R2
|
|
198
|
+
* provenance bag (`client`, `source-name`, `content-sha256`, `uploaded-at`),
|
|
199
|
+
* never the queryable D1 tags, so it can't answer this question: reading it
|
|
200
|
+
* made the tip fire on every image, including ones uploaded with an explicit
|
|
201
|
+
* `--meta path=` (PR #509).
|
|
189
202
|
*/
|
|
190
|
-
export declare function pathMetaHintFor(uploads: {
|
|
203
|
+
export declare function pathMetaHintFor(uploads: readonly {
|
|
191
204
|
contentType: string;
|
|
192
|
-
|
|
193
|
-
|
|
205
|
+
}[],
|
|
206
|
+
/** Index-aligned with `uploads` — see `uploadPuts`/`uploadAttachments`. */
|
|
207
|
+
sentMetadata: readonly (Record<string, string> | undefined)[]): string | undefined;
|
|
194
208
|
export type AttachUploadItem = PutResult & {
|
|
195
209
|
file: string;
|
|
196
210
|
markdown: string;
|
|
@@ -211,6 +225,20 @@ export type AttachFailure = {
|
|
|
211
225
|
status?: number;
|
|
212
226
|
};
|
|
213
227
|
};
|
|
228
|
+
/** Shared shape of every prepare + put batch (`uploadPuts`/`uploadAttachments`). */
|
|
229
|
+
export interface UploadBatchResult<T> {
|
|
230
|
+
uploads: T[];
|
|
231
|
+
failures: AttachFailure[];
|
|
232
|
+
/** The original cause of the first failure — for rethrowing single-file CLI paths. */
|
|
233
|
+
firstError?: unknown;
|
|
234
|
+
/**
|
|
235
|
+
* Index-aligned with `uploads`: the queryable metadata each upload actually
|
|
236
|
+
* sent (flags + sidecar + derived image facts). Kept beside the items rather
|
|
237
|
+
* than on them so it stays out of the `--format json` upload objects, which
|
|
238
|
+
* spread the item wholesale. See `pathMetaHintFor`.
|
|
239
|
+
*/
|
|
240
|
+
sentMetadata: (Record<string, string> | undefined)[];
|
|
241
|
+
}
|
|
214
242
|
/**
|
|
215
243
|
* Prepare + put each path as a PR/issue attachment with bounded concurrency.
|
|
216
244
|
* Per-file errors collect in `failures` (does not throw). `firstError` is the
|
|
@@ -233,11 +261,7 @@ export declare function uploadAttachments(opts: {
|
|
|
233
261
|
/** Provenance `client` field (default uploads-cli). */
|
|
234
262
|
provenanceClient?: string;
|
|
235
263
|
concurrency?: number;
|
|
236
|
-
}): Promise<
|
|
237
|
-
uploads: AttachUploadItem[];
|
|
238
|
-
failures: AttachFailure[];
|
|
239
|
-
firstError?: unknown;
|
|
240
|
-
}>;
|
|
264
|
+
}): Promise<UploadBatchResult<AttachUploadItem>>;
|
|
241
265
|
/** A branch to stage attachments against pre-PR (`uploads attach --branch`). */
|
|
242
266
|
export interface BranchTarget {
|
|
243
267
|
repo: string;
|
|
@@ -266,11 +290,7 @@ export declare function uploadBranchAttachments(opts: {
|
|
|
266
290
|
deriveImageFacts?: boolean;
|
|
267
291
|
provenanceClient?: string;
|
|
268
292
|
concurrency?: number;
|
|
269
|
-
}): Promise<
|
|
270
|
-
uploads: AttachUploadItem[];
|
|
271
|
-
failures: AttachFailure[];
|
|
272
|
-
firstError?: unknown;
|
|
273
|
-
}>;
|
|
293
|
+
}): Promise<UploadBatchResult<AttachUploadItem>>;
|
|
274
294
|
export type PutUploadItem = PutResult & {
|
|
275
295
|
file: string;
|
|
276
296
|
markdown: string;
|
|
@@ -317,11 +337,7 @@ export declare function uploadPuts(opts: {
|
|
|
317
337
|
alt?: string;
|
|
318
338
|
width?: number;
|
|
319
339
|
concurrency?: number;
|
|
320
|
-
}): Promise<
|
|
321
|
-
uploads: PutUploadItem[];
|
|
322
|
-
failures: AttachFailure[];
|
|
323
|
-
firstError?: unknown;
|
|
324
|
-
}>;
|
|
340
|
+
}): Promise<UploadBatchResult<PutUploadItem>>;
|
|
325
341
|
export declare function runAttach(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
|
|
326
342
|
/**
|
|
327
343
|
* One source of truth for the "staged, but not going to auto-attach" advisory
|
package/dist/commands.js
CHANGED
|
@@ -410,7 +410,7 @@ export async function uploadPreparedImage(client, bytes, sourceName, opts) {
|
|
|
410
410
|
alt: opts.alt(prepared),
|
|
411
411
|
width: opts.width,
|
|
412
412
|
});
|
|
413
|
-
return { result, prepared, markdown };
|
|
413
|
+
return { result, prepared, markdown, sentMetadata: metadata };
|
|
414
414
|
}
|
|
415
415
|
export function frameOptionsFromFlags(flags) {
|
|
416
416
|
const raw = flagString(flags, "--frame");
|
|
@@ -647,13 +647,21 @@ Examples:
|
|
|
647
647
|
* (same tier as `state=`), and unlike `uploads screenshot` (which derives it
|
|
648
648
|
* from the captured URL), a plain `attach`/`put --pr`/`put --issue` of an
|
|
649
649
|
* already-existing image has nothing to derive it from, so it's easy to
|
|
650
|
-
* forget. Fires once per batch (not per file)
|
|
651
|
-
* server actually stored (`PutResult.metadata`), not what was requested, so
|
|
652
|
-
* a merge/validation drop still surfaces the gap. Non-image uploads (zips,
|
|
650
|
+
* forget. Fires once per batch (not per file). Non-image uploads (zips,
|
|
653
651
|
* PDFs, etc.) are exempt — "findable by page" doesn't apply to them.
|
|
652
|
+
*
|
|
653
|
+
* Checks the *resolved* metadata each upload actually sent (`--meta` pairs +
|
|
654
|
+
* sidecar manifest + derived image facts, index-aligned with `uploads`) —
|
|
655
|
+
* NOT `PutResult.metadata`. That field is the API's echo of the object's R2
|
|
656
|
+
* provenance bag (`client`, `source-name`, `content-sha256`, `uploaded-at`),
|
|
657
|
+
* never the queryable D1 tags, so it can't answer this question: reading it
|
|
658
|
+
* made the tip fire on every image, including ones uploaded with an explicit
|
|
659
|
+
* `--meta path=` (PR #509).
|
|
654
660
|
*/
|
|
655
|
-
export function pathMetaHintFor(uploads
|
|
656
|
-
|
|
661
|
+
export function pathMetaHintFor(uploads,
|
|
662
|
+
/** Index-aligned with `uploads` — see `uploadPuts`/`uploadAttachments`. */
|
|
663
|
+
sentMetadata) {
|
|
664
|
+
const missingPath = uploads.some((u, i) => u.contentType.startsWith("image/") && !sentMetadata[i]?.path);
|
|
657
665
|
return missingPath ? "tip: add --meta path=/route so this shot is findable by page" : undefined;
|
|
658
666
|
}
|
|
659
667
|
/**
|
|
@@ -697,6 +705,7 @@ async function uploadAttachmentBatch(opts) {
|
|
|
697
705
|
});
|
|
698
706
|
return {
|
|
699
707
|
ok: true,
|
|
708
|
+
sentMetadata: metadata,
|
|
700
709
|
upload: {
|
|
701
710
|
...result,
|
|
702
711
|
file,
|
|
@@ -719,17 +728,21 @@ async function uploadAttachmentBatch(opts) {
|
|
|
719
728
|
}
|
|
720
729
|
});
|
|
721
730
|
const uploads = [];
|
|
731
|
+
const sentMetadata = [];
|
|
722
732
|
const failures = [];
|
|
723
733
|
let firstError;
|
|
724
734
|
for (const slot of slots) {
|
|
725
|
-
|
|
735
|
+
// Pushed together so the two arrays stay index-aligned across failures.
|
|
736
|
+
if (slot.ok) {
|
|
726
737
|
uploads.push(slot.upload);
|
|
738
|
+
sentMetadata.push(slot.sentMetadata);
|
|
739
|
+
}
|
|
727
740
|
else {
|
|
728
741
|
firstError ??= slot.err;
|
|
729
742
|
failures.push({ file: slot.file, error: errorDetail(slot.err) });
|
|
730
743
|
}
|
|
731
744
|
}
|
|
732
|
-
return { uploads, failures, firstError };
|
|
745
|
+
return { uploads, failures, firstError, sentMetadata };
|
|
733
746
|
}
|
|
734
747
|
/**
|
|
735
748
|
* Prepare + put each path as a PR/issue attachment with bounded concurrency.
|
|
@@ -786,7 +799,7 @@ export async function uploadPuts(opts) {
|
|
|
786
799
|
// Sidecar manifest from a prior `screenshot --out` of this exact file
|
|
787
800
|
// (issue #469 lever 2) — see mergeSidecarMeta. Not applicable to stdin.
|
|
788
801
|
const metadata = file !== "-" ? mergeSidecarMeta(file, bytes, opts.metadata) : opts.metadata;
|
|
789
|
-
const { result, prepared, markdown } = await uploadPreparedImage(opts.client, bytes, sourceName, {
|
|
802
|
+
const { result, prepared, markdown, sentMetadata } = await uploadPreparedImage(opts.client, bytes, sourceName, {
|
|
790
803
|
frame: opts.frame,
|
|
791
804
|
optimize: opts.optimize,
|
|
792
805
|
ghTarget: opts.ghTarget,
|
|
@@ -807,6 +820,7 @@ export async function uploadPuts(opts) {
|
|
|
807
820
|
});
|
|
808
821
|
return {
|
|
809
822
|
ok: true,
|
|
823
|
+
sentMetadata,
|
|
810
824
|
upload: {
|
|
811
825
|
...result,
|
|
812
826
|
file,
|
|
@@ -827,17 +841,21 @@ export async function uploadPuts(opts) {
|
|
|
827
841
|
}
|
|
828
842
|
});
|
|
829
843
|
const uploads = [];
|
|
844
|
+
const sentMetadata = [];
|
|
830
845
|
const failures = [];
|
|
831
846
|
let firstError;
|
|
832
847
|
for (const slot of slots) {
|
|
833
|
-
|
|
848
|
+
// Pushed together so the two arrays stay index-aligned across failures.
|
|
849
|
+
if (slot.ok) {
|
|
834
850
|
uploads.push(slot.upload);
|
|
851
|
+
sentMetadata.push(slot.sentMetadata);
|
|
852
|
+
}
|
|
835
853
|
else {
|
|
836
854
|
firstError ??= slot.err;
|
|
837
855
|
failures.push({ file: slot.file, error: errorDetail(slot.err) });
|
|
838
856
|
}
|
|
839
857
|
}
|
|
840
|
-
return { uploads, failures, firstError };
|
|
858
|
+
return { uploads, failures, firstError, sentMetadata };
|
|
841
859
|
}
|
|
842
860
|
/**
|
|
843
861
|
* Best-effort call to `POST /v1/:workspace/github/promote` (server contract,
|
|
@@ -931,7 +949,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
931
949
|
const n = parsed.positionals.length;
|
|
932
950
|
process.stderr.write(`>> uploading ${n} file${n === 1 ? "" : "s"}\n`);
|
|
933
951
|
}
|
|
934
|
-
const { uploads, failures, firstError } = await uploadAttachments({
|
|
952
|
+
const { uploads, failures, firstError, sentMetadata } = await uploadAttachments({
|
|
935
953
|
client: ctx.client,
|
|
936
954
|
target,
|
|
937
955
|
files: parsed.positionals,
|
|
@@ -979,7 +997,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
979
997
|
}
|
|
980
998
|
}
|
|
981
999
|
// Lever 3 (issue #469): tip when an image lands here with no `path` meta.
|
|
982
|
-
const pathHint = uploads.length > 0 && !ctx.quiet ? pathMetaHintFor(uploads) : undefined;
|
|
1000
|
+
const pathHint = uploads.length > 0 && !ctx.quiet ? pathMetaHintFor(uploads, sentMetadata) : undefined;
|
|
983
1001
|
if (ctx.json) {
|
|
984
1002
|
await writeJson({
|
|
985
1003
|
target,
|
|
@@ -1672,7 +1690,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1672
1690
|
if (attachedRef)
|
|
1673
1691
|
process.stderr.write(`>> attached to ${attachedRef}\n`);
|
|
1674
1692
|
}
|
|
1675
|
-
const { uploads, failures, firstError } = await uploadPuts({
|
|
1693
|
+
const { uploads, failures, firstError, sentMetadata } = await uploadPuts({
|
|
1676
1694
|
client: ctx.client,
|
|
1677
1695
|
files,
|
|
1678
1696
|
nameOverride: nameFlag,
|
|
@@ -1707,7 +1725,9 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1707
1725
|
// `path` meta. Only relevant on the ghTarget path — the bare-put paths
|
|
1708
1726
|
// above (staging/auto/dated) aren't attached to a PR/issue yet, so there's
|
|
1709
1727
|
// nothing to look up from a page later.
|
|
1710
|
-
const pathHint = ghTarget && uploads.length > 0 && !ctx.quiet
|
|
1728
|
+
const pathHint = ghTarget && uploads.length > 0 && !ctx.quiet
|
|
1729
|
+
? pathMetaHintFor(uploads, sentMetadata)
|
|
1730
|
+
: undefined;
|
|
1711
1731
|
// One JSON `hint` slot, shared with the #393 nudge (mutually exclusive with
|
|
1712
1732
|
// it — nudge is undefined whenever staging took over). When staging fires,
|
|
1713
1733
|
// prefer the more actionable binding warning over the generic staging note
|
|
@@ -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