@buildinternet/uploads 0.30.0 → 0.31.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.
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Annotation spec: types, validation, and selector resolution.
3
+ *
4
+ * NOT exported outside this module directly — only `index.ts` re-exports
5
+ * these. Keep the public surface narrow so the renderer stays swappable
6
+ * (see the module header in `index.ts`).
7
+ */
8
+ /** Thrown by `validateSpec` and `resolveSelectors`; carries every collected error. */
9
+ export class AnnotateSpecError extends Error {
10
+ errors;
11
+ constructor(errors) {
12
+ super(errors
13
+ .map((e) => (e.index === null ? e.message : `annotations[${e.index}]: ${e.message}`))
14
+ .join("; "));
15
+ this.name = "AnnotateSpecError";
16
+ this.errors = errors;
17
+ }
18
+ }
19
+ const GEOMETRIC_TYPES = new Set(["box", "arrow", "label", "redact"]);
20
+ /**
21
+ * Default placement offsets used when a spec omits an explicit position —
22
+ * kept together so "where things land by default" has one home. Units are
23
+ * image pixels; `labelAt` is additionally multiplied by the render scale.
24
+ */
25
+ export const DEFAULT_PLACEMENT = {
26
+ /** Selector-only arrow: tail offset from the target center (resolveSelectors). */
27
+ arrowFrom: [120, -120],
28
+ /** Label with a target but no `at`: bubble offset above-right (renderLabel). */
29
+ labelAt: [30, -90],
30
+ };
31
+ /**
32
+ * Returns a rejection reason if a raw `svg` fragment is unsafe, else null.
33
+ * Shared by `validateSpec` and the renderer (defense in depth — the renderer
34
+ * re-checks in case a caller bypasses validation): `<script` is an obvious
35
+ * injection, and librsvg resolves href/xlink:href and CSS url() references,
36
+ * which has been an arbitrary-file-read vector (e.g. CVE-2023-38633).
37
+ */
38
+ export function unsafeSvgFragmentReason(fragment) {
39
+ if (/<script/i.test(fragment))
40
+ return "svg fragment must not contain <script";
41
+ if (/\bhref\s*=|\burl\s*\(/i.test(fragment)) {
42
+ return "svg fragment must not reference external resources (href= or url())";
43
+ }
44
+ return null;
45
+ }
46
+ function isFiniteNumber(v) {
47
+ return typeof v === "number" && Number.isFinite(v);
48
+ }
49
+ function isPoint(v) {
50
+ return Array.isArray(v) && v.length === 2 && isFiniteNumber(v[0]) && isFiniteNumber(v[1]);
51
+ }
52
+ function hasOwn(obj, key) {
53
+ return Object.prototype.hasOwnProperty.call(obj, key);
54
+ }
55
+ /** Validates one raw annotation, pushing any `SpecError`s onto `errors`. */
56
+ function validateAnnotation(raw, index, errors) {
57
+ const fail = (message) => errors.push({ index, message });
58
+ if (typeof raw !== "object" || raw === null) {
59
+ fail("must be an object");
60
+ return;
61
+ }
62
+ const a = raw;
63
+ const type = a.type;
64
+ if (typeof type !== "string") {
65
+ fail("missing or non-string type");
66
+ return;
67
+ }
68
+ const hasSelector = typeof a.selector === "string" && a.selector.length > 0;
69
+ const hasPixelGeometry = (() => {
70
+ switch (type) {
71
+ case "box":
72
+ case "redact":
73
+ return hasOwn(a, "x") || hasOwn(a, "y") || hasOwn(a, "w") || hasOwn(a, "h");
74
+ // `from` is deliberately allowed alongside a selector: the selector
75
+ // resolves the arrow's head (`to`), and an explicit `from` overrides
76
+ // the default tail placement (see resolveSelectors).
77
+ case "arrow":
78
+ return hasOwn(a, "to");
79
+ case "label":
80
+ return hasOwn(a, "target");
81
+ default:
82
+ return false;
83
+ }
84
+ })();
85
+ if (GEOMETRIC_TYPES.has(type) && hasSelector && hasPixelGeometry) {
86
+ fail("ambiguous: both pixel geometry and a selector are present, pick one");
87
+ return;
88
+ }
89
+ const requireBoxFields = () => {
90
+ if (hasSelector)
91
+ return;
92
+ if (!isFiniteNumber(a.x) ||
93
+ !isFiniteNumber(a.y) ||
94
+ !isFiniteNumber(a.w) ||
95
+ !isFiniteNumber(a.h)) {
96
+ fail(`${type} requires finite x, y, w, h (or a selector)`);
97
+ }
98
+ };
99
+ switch (type) {
100
+ case "box": {
101
+ requireBoxFields();
102
+ break;
103
+ }
104
+ case "redact": {
105
+ requireBoxFields();
106
+ if (hasOwn(a, "style") && a.style !== "blur" && a.style !== "solid") {
107
+ fail('redact style must be "blur" or "solid"');
108
+ }
109
+ break;
110
+ }
111
+ case "arrow": {
112
+ if (hasSelector)
113
+ break;
114
+ if (!isPoint(a.from) || !isPoint(a.to)) {
115
+ fail("arrow requires from/to points (or a selector)");
116
+ }
117
+ break;
118
+ }
119
+ case "label": {
120
+ if (typeof a.text !== "string" || a.text.length === 0) {
121
+ fail("label requires non-empty text");
122
+ }
123
+ if (hasSelector)
124
+ break;
125
+ if (hasOwn(a, "target") && !isPoint(a.target)) {
126
+ fail("label target must be a point");
127
+ }
128
+ if (hasOwn(a, "at") && !isPoint(a.at)) {
129
+ fail("label at must be a point");
130
+ }
131
+ break;
132
+ }
133
+ case "draw": {
134
+ if (!Array.isArray(a.points) || a.points.length < 2 || !a.points.every(isPoint)) {
135
+ fail("draw requires at least 2 points");
136
+ }
137
+ break;
138
+ }
139
+ case "svg": {
140
+ if (typeof a.fragment !== "string" || a.fragment.length === 0) {
141
+ fail("svg requires a non-empty fragment");
142
+ }
143
+ else {
144
+ const unsafe = unsafeSvgFragmentReason(a.fragment);
145
+ if (unsafe)
146
+ fail(unsafe);
147
+ }
148
+ break;
149
+ }
150
+ default:
151
+ fail(`unknown annotation type "${String(type)}"`);
152
+ }
153
+ }
154
+ /** Throws AnnotateSpecError (carries errors: SpecError[]) on invalid input. */
155
+ export function validateSpec(json) {
156
+ const errors = [];
157
+ if (typeof json !== "object" || json === null) {
158
+ throw new AnnotateSpecError([{ index: null, message: "spec must be an object" }]);
159
+ }
160
+ const raw = json;
161
+ if (raw.version !== 1) {
162
+ errors.push({ index: null, message: `version must be 1, got ${JSON.stringify(raw.version)}` });
163
+ }
164
+ if (!Array.isArray(raw.annotations) || raw.annotations.length === 0) {
165
+ errors.push({ index: null, message: "annotations must be a non-empty array" });
166
+ }
167
+ if (errors.length > 0)
168
+ throw new AnnotateSpecError(errors);
169
+ const annotations = raw.annotations;
170
+ annotations.forEach((a, i) => validateAnnotation(a, i, errors));
171
+ if (errors.length > 0)
172
+ throw new AnnotateSpecError(errors);
173
+ return { version: 1, annotations: annotations };
174
+ }
175
+ /** True if any annotation still carries an unresolved selector. */
176
+ export function hasSelectors(spec) {
177
+ return spec.annotations.some((a) => "selector" in a && typeof a.selector === "string");
178
+ }
179
+ /** All distinct selectors in the spec, in order. */
180
+ export function specSelectors(spec) {
181
+ const seen = [];
182
+ for (const a of spec.annotations) {
183
+ const sel = "selector" in a ? a.selector : undefined;
184
+ if (typeof sel === "string" && !seen.includes(sel))
185
+ seen.push(sel);
186
+ }
187
+ return seen;
188
+ }
189
+ function center(box) {
190
+ return [box.x + box.w / 2, box.y + box.h / 2];
191
+ }
192
+ /**
193
+ * Replace selector targeting with pixel geometry using measured boxes keyed
194
+ * by selector. Throws AnnotateSpecError naming any selector missing from
195
+ * boxes.
196
+ */
197
+ export function resolveSelectors(spec, boxes) {
198
+ const errors = [];
199
+ const resolved = spec.annotations.map((a, index) => {
200
+ const sel = "selector" in a ? a.selector : undefined;
201
+ if (typeof sel !== "string")
202
+ return a;
203
+ const box = boxes[sel];
204
+ if (!box) {
205
+ errors.push({ index, message: `selector "${sel}" was not found among the measured boxes` });
206
+ return a;
207
+ }
208
+ switch (a.type) {
209
+ case "box":
210
+ case "redact": {
211
+ const { selector: _selector, ...rest } = a;
212
+ return { ...rest, x: box.x, y: box.y, w: box.w, h: box.h };
213
+ }
214
+ case "arrow": {
215
+ const { selector: _selector, ...rest } = a;
216
+ const to = center(box);
217
+ // A selector-only arrow points at the element from its upper right;
218
+ // the renderer clamps if that lands outside the image.
219
+ const from = rest.from ??
220
+ [
221
+ to[0] + DEFAULT_PLACEMENT.arrowFrom[0],
222
+ to[1] + DEFAULT_PLACEMENT.arrowFrom[1],
223
+ ];
224
+ return { ...rest, from, to };
225
+ }
226
+ case "label": {
227
+ const { selector: _selector, ...rest } = a;
228
+ return { ...rest, target: center(box) };
229
+ }
230
+ default:
231
+ return a;
232
+ }
233
+ });
234
+ if (errors.length > 0)
235
+ throw new AnnotateSpecError(errors);
236
+ return { version: 1, annotations: resolved };
237
+ }
@@ -0,0 +1,15 @@
1
+ export interface TextMetrics {
2
+ /** SVG path `d` data, already positioned at the requested x/y baseline. */
3
+ d: string;
4
+ /** Total advance width in pixels at the requested font size. */
5
+ width: number;
6
+ /** Ascent + descent in pixels at the requested font size (line height). */
7
+ height: number;
8
+ }
9
+ /** Renders `text` to an SVG path at the given baseline origin and font size (px). */
10
+ export declare function textToPath(text: string, x: number, y: number, fontSize: number): TextMetrics;
11
+ /** Measures `text` at the given font size without generating path data. */
12
+ export declare function measureText(text: string, fontSize: number): {
13
+ width: number;
14
+ height: number;
15
+ };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Text-as-paths via opentype.js + the bundled Excalifont.
3
+ *
4
+ * Not exported outside this module — only `index.ts` re-exports the public
5
+ * surface (see the module header there). Rendering label text as SVG paths
6
+ * (instead of an SVG `<text>` element) sidesteps librsvg's fontconfig-based
7
+ * font resolution entirely, so the renderer produces identical output no
8
+ * matter which fonts happen to be installed on the host.
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+ import opentype from "opentype.js";
13
+ const FONT_URL = new URL("../../assets/Excalifont-Regular.ttf", import.meta.url);
14
+ let cachedFont;
15
+ function loadFont() {
16
+ if (!cachedFont) {
17
+ const buf = readFileSync(fileURLToPath(FONT_URL));
18
+ const arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
19
+ cachedFont = opentype.parse(arrayBuffer);
20
+ }
21
+ return cachedFont;
22
+ }
23
+ /** Renders `text` to an SVG path at the given baseline origin and font size (px). */
24
+ export function textToPath(text, x, y, fontSize) {
25
+ const font = loadFont();
26
+ const path = font.getPath(text, x, y, fontSize);
27
+ const width = font.getAdvanceWidth(text, fontSize);
28
+ const scale = fontSize / font.unitsPerEm;
29
+ const height = (font.ascender - font.descender) * scale;
30
+ return { d: path.toPathData(2), width, height };
31
+ }
32
+ /** Measures `text` at the given font size without generating path data. */
33
+ export function measureText(text, fontSize) {
34
+ const font = loadFont();
35
+ const width = font.getAdvanceWidth(text, fontSize);
36
+ const scale = fontSize / font.unitsPerEm;
37
+ const height = (font.ascender - font.descender) * scale;
38
+ return { width, height };
39
+ }
@@ -45,6 +45,21 @@ export declare function parseCommandArgs(args: string[]): CommandFlags;
45
45
  * `--repo a --repo b` → `"b"`). Genuinely repeatable flags should use
46
46
  * `flagValues` instead.
47
47
  */
48
+ /**
49
+ * Rewrites short aliases (e.g. `-o` -> `--out`) before `parseCommandArgs`,
50
+ * which only understands `--long` flags — a bare short flag would otherwise
51
+ * land in positionals.
52
+ */
53
+ export declare function expandFlagAliases(args: string[], aliases: Record<string, string>): string[];
54
+ /**
55
+ * Pulls a `--flag -` pair (the stdin convention) out of `args` before the
56
+ * generic parse: `parseCommandArgs` treats a bare `-` as a flag boundary, so
57
+ * it can never be consumed as a flag's value.
58
+ */
59
+ export declare function extractDashValue(args: string[], flag: string): {
60
+ args: string[];
61
+ dash: boolean;
62
+ };
48
63
  export declare function flagString(flags: CommandFlags["flags"], name: string): string | undefined;
49
64
  export declare function flagBool(flags: CommandFlags["flags"], name: string): boolean;
50
65
  /**
package/dist/cli-args.js CHANGED
@@ -139,6 +139,27 @@ export function parseCommandArgs(args) {
139
139
  * `--repo a --repo b` → `"b"`). Genuinely repeatable flags should use
140
140
  * `flagValues` instead.
141
141
  */
142
+ /**
143
+ * Rewrites short aliases (e.g. `-o` -> `--out`) before `parseCommandArgs`,
144
+ * which only understands `--long` flags — a bare short flag would otherwise
145
+ * land in positionals.
146
+ */
147
+ export function expandFlagAliases(args, aliases) {
148
+ return args.map((a) => aliases[a] ?? a);
149
+ }
150
+ /**
151
+ * Pulls a `--flag -` pair (the stdin convention) out of `args` before the
152
+ * generic parse: `parseCommandArgs` treats a bare `-` as a flag boundary, so
153
+ * it can never be consumed as a flag's value.
154
+ */
155
+ export function extractDashValue(args, flag) {
156
+ for (let i = 0; i < args.length; i++) {
157
+ if (args[i] === flag && args[i + 1] === "-") {
158
+ return { args: [...args.slice(0, i), ...args.slice(i + 2)], dash: true };
159
+ }
160
+ }
161
+ return { args, dash: false };
162
+ }
142
163
  export function flagString(flags, name) {
143
164
  const value = flags.get(name);
144
165
  if (typeof value === "string")
@@ -29,6 +29,8 @@ export declare const PUT_LIKE_FLAGS: readonly string[];
29
29
  * screenshot never parses.
30
30
  */
31
31
  export declare const SCREENSHOT_FLAGS: readonly string[];
32
+ /** All flags `uploads annotate` actually reads (verified against commands/annotate.ts). */
33
+ export declare const ANNOTATE_FLAGS: readonly string[];
32
34
  export declare const LIST_LIKE_FLAGS: readonly string[];
33
35
  export declare const ROOT_COMMANDS: readonly CatalogCommand[];
34
36
  export declare const COMPLETION_SHELLS: readonly ["bash", "zsh", "fish"];
@@ -59,6 +59,7 @@ export const SCREENSHOT_FLAGS = [
59
59
  "--dark",
60
60
  "--light",
61
61
  "--wait",
62
+ "--annotate",
62
63
  "--out",
63
64
  "--no-sidecar",
64
65
  "--no-upload",
@@ -92,6 +93,16 @@ export const SCREENSHOT_FLAGS = [
92
93
  "--help",
93
94
  "-h",
94
95
  ];
96
+ /** All flags `uploads annotate` actually reads (verified against commands/annotate.ts). */
97
+ export const ANNOTATE_FLAGS = [
98
+ "--spec",
99
+ "-o",
100
+ "--out",
101
+ "--seed",
102
+ "--format",
103
+ "--help",
104
+ "-h",
105
+ ];
95
106
  export const LIST_LIKE_FLAGS = [
96
107
  "--prefix",
97
108
  "--limit",
@@ -125,6 +136,11 @@ export const ROOT_COMMANDS = [
125
136
  summary: "Capture a URL or .html file and host it (local browser or remote render)",
126
137
  essential: true,
127
138
  },
139
+ {
140
+ name: "annotate",
141
+ usage: "annotate <image>",
142
+ summary: "Bake hand-drawn boxes, arrows, labels, and redactions onto an image",
143
+ },
128
144
  {
129
145
  name: "gallery",
130
146
  summary: "Create and organize public media galleries",
package/dist/cli.js CHANGED
@@ -19,6 +19,7 @@ import { runLogout, runWhoami } from "./commands/session.js";
19
19
  import { runTelemetry } from "./commands/telemetry.js";
20
20
  import { runReport } from "./commands/report.js";
21
21
  import { runScreenshot } from "./commands/screenshot.js";
22
+ import { runAnnotate } from "./commands/annotate.js";
22
23
  import { packageVersion } from "./package-version.js";
23
24
  import { checkForUpdate, maybeHintUpdate } from "./update-check.js";
24
25
  import { maybeSyncSessionCliVersion } from "./session-cli-version.js";
@@ -281,6 +282,11 @@ export async function runCli(argv) {
281
282
  case "completions":
282
283
  code = await runCompletion(cmdArgs, showHelp);
283
284
  break;
285
+ case "annotate":
286
+ // No CliContext needed — annotate is a pure local pixel transform,
287
+ // never touches auth/client/upload.
288
+ code = await runAnnotate(cmdArgs, showHelp);
289
+ break;
284
290
  case "attach":
285
291
  case "put":
286
292
  case "staged":
@@ -0,0 +1,3 @@
1
+ export declare function runAnnotate(args: string[], help?: boolean,
2
+ /** Injectable for tests — avoids depending on a real stdin stream. */
3
+ readStdinImpl?: () => Promise<string>): Promise<number>;
@@ -0,0 +1,124 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, extname, join, dirname } from "node:path";
3
+ import sharp from "sharp";
4
+ import { expandFlagAliases, extractDashValue, flagInt, flagString, parseCommandArgs, UsageError, } from "../cli-args.js";
5
+ import { writeCommandHelp } from "../cli-style.js";
6
+ import { readStdin, writeJson, writeStdout } from "../io.js";
7
+ const ANNOTATE_HELP = `uploads annotate <image> --spec <file|-> [options]
8
+
9
+ Bake hand-drawn boxes, arrows, labels, freeform strokes, and redactions onto
10
+ an existing image. image is a path to a local PNG/JPEG file. The spec is a
11
+ JSON document (see the annotate-screenshots skill for the full format);
12
+ pass a file path or "-" to read it from stdin.
13
+
14
+ Selector-bearing specs (annotations that point at a CSS selector instead of
15
+ pixel coordinates) are rejected here — selectors can only be resolved
16
+ against a live page, via "uploads screenshot --annotate".
17
+
18
+ Options:
19
+ --spec <file|-> JSON annotation spec (required)
20
+ -o, --out <file> Output PNG path (default: <stem>.annotated.png next to the image)
21
+ --seed <n> Fix the rough.js seed for deterministic sketchy rendering (default: 7)
22
+ --format human|json Output format (default: human)
23
+
24
+ Exit codes: 0 ok · 1 invalid spec · 2 usage.
25
+
26
+ Examples:
27
+ uploads annotate ./shot.png --spec ./callouts.json
28
+ cat ./callouts.json | uploads annotate ./shot.png --spec -
29
+ uploads annotate ./shot.png --spec ./callouts.json --out ./shot.marked.png
30
+ `;
31
+ function defaultOutPath(imagePath) {
32
+ const ext = extname(imagePath);
33
+ const stem = ext ? basename(imagePath, ext) : basename(imagePath);
34
+ return join(dirname(imagePath), `${stem}.annotated.png`);
35
+ }
36
+ export async function runAnnotate(args, help = false,
37
+ /** Injectable for tests — avoids depending on a real stdin stream. */
38
+ readStdinImpl = readStdin) {
39
+ if (help) {
40
+ writeCommandHelp(ANNOTATE_HELP);
41
+ return 0;
42
+ }
43
+ const { args: preArgs, dash: specFromDash } = extractDashValue(expandFlagAliases(args, { "-o": "--out" }), "--spec");
44
+ const parsed = parseCommandArgs(preArgs);
45
+ if (parsed.help) {
46
+ writeCommandHelp(ANNOTATE_HELP);
47
+ return 0;
48
+ }
49
+ const imagePath = parsed.positionals[0];
50
+ if (!imagePath) {
51
+ writeCommandHelp(ANNOTATE_HELP);
52
+ return 2;
53
+ }
54
+ if (parsed.positionals.length > 1) {
55
+ throw new UsageError("annotate takes exactly one image argument");
56
+ }
57
+ const specArg = specFromDash ? "-" : flagString(parsed.flags, "--spec");
58
+ if (!specArg) {
59
+ throw new UsageError("--spec is required (a file path or - for stdin)");
60
+ }
61
+ const outFlag = flagString(parsed.flags, "--out");
62
+ const seed = flagInt(parsed.flags, "--seed", "--seed");
63
+ const format = flagString(parsed.flags, "--format");
64
+ if (format && format !== "human" && format !== "json") {
65
+ throw new UsageError(`invalid --format: ${format} (use human or json)`);
66
+ }
67
+ const wantJson = format === "json";
68
+ let specText;
69
+ if (specArg === "-") {
70
+ specText = await readStdinImpl();
71
+ }
72
+ else {
73
+ try {
74
+ specText = readFileSync(specArg, "utf8");
75
+ }
76
+ catch (err) {
77
+ throw new UsageError(`could not read --spec ${specArg}: ${err instanceof Error ? err.message : String(err)}`);
78
+ }
79
+ }
80
+ const { validateSpec, hasSelectors, renderAnnotations, clampReport, AnnotateSpecError } = await import("../annotate/index.js");
81
+ let specJson;
82
+ try {
83
+ specJson = JSON.parse(specText);
84
+ }
85
+ catch (err) {
86
+ await writeStdout("");
87
+ process.stderr.write(`spec is not valid JSON: ${err instanceof Error ? err.message : String(err)}\n`);
88
+ return 1;
89
+ }
90
+ let spec;
91
+ try {
92
+ spec = validateSpec(specJson);
93
+ }
94
+ catch (err) {
95
+ if (err instanceof AnnotateSpecError) {
96
+ for (const e of err.errors) {
97
+ const prefix = e.index === null ? "spec" : `annotations[${e.index}]`;
98
+ process.stderr.write(`${prefix}: ${e.message}\n`);
99
+ }
100
+ return 1;
101
+ }
102
+ throw err;
103
+ }
104
+ if (hasSelectors(spec)) {
105
+ throw new UsageError('annotate works on pixels; selectors need "uploads screenshot --annotate" (live page required)');
106
+ }
107
+ const image = readFileSync(imagePath);
108
+ const meta = await sharp(image).metadata();
109
+ const width = meta.width ?? 0;
110
+ const height = meta.height ?? 0;
111
+ const warnings = clampReport(spec, width, height);
112
+ for (const w of warnings)
113
+ process.stderr.write(`${w}\n`);
114
+ const rendered = await renderAnnotations(image, spec, seed !== undefined ? { seed } : undefined);
115
+ const outPath = outFlag ?? defaultOutPath(imagePath);
116
+ writeFileSync(outPath, rendered);
117
+ if (wantJson) {
118
+ await writeJson({ out: outPath, width, height, warnings });
119
+ }
120
+ else {
121
+ await writeStdout(`${outPath}\n`);
122
+ }
123
+ return 0;
124
+ }
@@ -1,5 +1,5 @@
1
1
  import { parseCommandArgs, UsageError } from "../cli-args.js";
2
- import { COMPLETION_SHELLS, GLOBAL_FLAGS, LIST_LIKE_FLAGS, PUT_LIKE_FLAGS, SCREENSHOT_FLAGS, ROOT_COMMANDS, isCompletionShell, } from "../cli-catalog.js";
2
+ import { ANNOTATE_FLAGS, COMPLETION_SHELLS, GLOBAL_FLAGS, LIST_LIKE_FLAGS, PUT_LIKE_FLAGS, SCREENSHOT_FLAGS, ROOT_COMMANDS, isCompletionShell, } from "../cli-catalog.js";
3
3
  import { writeCommandHelp } from "../cli-style.js";
4
4
  const HELP = `uploads completion <shell>
5
5
 
@@ -29,6 +29,7 @@ function bashScript() {
29
29
  const putFlags = PUT_LIKE_FLAGS.join(" ");
30
30
  const listFlags = LIST_LIKE_FLAGS.join(" ");
31
31
  const screenshotFlags = SCREENSHOT_FLAGS.join(" ");
32
+ const annotateFlags = ANNOTATE_FLAGS.join(" ");
32
33
  const subMaps = ROOT_COMMANDS.filter((c) => c.subcommands?.length).map((c) => {
33
34
  const names = c.subcommands.map((s) => s.name).join(" ");
34
35
  return ` ${c.name}) subs="${names}" ;;`;
@@ -54,6 +55,7 @@ _uploads() {
54
55
  local -a put_flags=(${putFlags})
55
56
  local -a list_flags=(${listFlags})
56
57
  local -a screenshot_flags=(${screenshotFlags})
58
+ local -a annotate_flags=(${annotateFlags})
57
59
 
58
60
  # Find the first non-global positional (the subcommand).
59
61
  local cmd="" i=1
@@ -107,6 +109,9 @@ ${subMaps.join("\n")}
107
109
  screenshot)
108
110
  COMPREPLY=( $(compgen -W "\${screenshot_flags[*]}" -- "$cur") )
109
111
  ;;
112
+ annotate)
113
+ COMPREPLY=( $(compgen -W "\${annotate_flags[*]}" -- "$cur") )
114
+ ;;
110
115
  list|find)
111
116
  COMPREPLY=( $(compgen -W "\${list_flags[*]}" -- "$cur") )
112
117
  ;;
@@ -119,7 +124,7 @@ ${subMaps.join("\n")}
119
124
 
120
125
  # File paths for upload-style commands.
121
126
  case "$cmd" in
122
- put|attach|screenshot)
127
+ put|attach|screenshot|annotate)
123
128
  COMPREPLY=( $(compgen -f -- "$cur") )
124
129
  ;;
125
130
  esac
@@ -199,7 +204,7 @@ ${globalArgs} \\
199
204
  args)
200
205
  case $line[1] in
201
206
  ${subCases}
202
- put|attach|screenshot)
207
+ put|attach|screenshot|annotate)
203
208
  _files
204
209
  ;;
205
210
  esac
@@ -265,8 +270,19 @@ function fishScript() {
265
270
  continue;
266
271
  lines.push(`complete -c uploads -n '__fish_seen_subcommand_from list find' -l ${flag.slice(2)}`);
267
272
  }
268
- // File completion for put/attach/screenshot
269
- lines.push(`complete -c uploads -n '__fish_seen_subcommand_from put attach screenshot' -F`);
273
+ for (const flag of ANNOTATE_FLAGS) {
274
+ if (flag === "-o")
275
+ continue; // folded into --out's paired completion below
276
+ if (flag === "--out") {
277
+ lines.push(`complete -c uploads -n '__fish_seen_subcommand_from annotate' -s o -l out`);
278
+ continue;
279
+ }
280
+ if (!flag.startsWith("--"))
281
+ continue;
282
+ lines.push(`complete -c uploads -n '__fish_seen_subcommand_from annotate' -l ${flag.slice(2)}`);
283
+ }
284
+ // File completion for put/attach/screenshot/annotate
285
+ lines.push(`complete -c uploads -n '__fish_seen_subcommand_from put attach screenshot annotate' -F`);
270
286
  return lines.join("\n") + "\n";
271
287
  }
272
288
  export function generateCompletionScript(shell) {
@@ -5,10 +5,11 @@ import { writeCommandHelp } from "../cli-style.js";
5
5
  import { HOOK_COMMAND, installHookManifests } from "../hooks-install.js";
6
6
  export const DEFAULT_MCP_URL = "https://agents.uploads.sh/mcp";
7
7
  const SKILL_SOURCE = "buildinternet/uploads";
8
- const SKILL_NAMES = ["uploads-cli", "github-screenshots"];
8
+ const SKILL_NAMES = ["uploads-cli", "github-screenshots", "annotate-screenshots"];
9
9
  const INSTALL_HELP = `uploads install — set up agent integrations (skills + remote MCP + hooks)
10
10
 
11
- Installs the github-screenshots and uploads-cli agent skills, registers the
11
+ Installs the github-screenshots, uploads-cli, and annotate-screenshots agent
12
+ skills, registers the
12
13
  hosted MCP server with Claude Code, and installs the PR screenshot reminder
13
14
  hook for Grok / Cursor when those tools are present. The remote MCP endpoint
14
15
  infers your workspace from the bearer token, so only the token is needed.
@@ -21,7 +22,8 @@ Usage:
21
22
 
22
23
  What it does:
23
24
  skill Agent skills (via npx skills) — github-screenshots: visuals into
24
- PRs/issues; uploads-cli: full CLI reference
25
+ PRs/issues; uploads-cli: full CLI reference; annotate-screenshots:
26
+ hand-drawn callouts and redaction on screenshots
25
27
  mcp Hosted MCP server in Claude Code — put, list, attach, galleries
26
28
  hooks PR screenshot reminder for Grok / Cursor (user-global manifests)
27
29
 
@@ -1,6 +1,19 @@
1
1
  import { type CliContext } from "../commands.js";
2
2
  import { type CommandRunner } from "../github-gh.js";
3
3
  import { captureScreenshot } from "../screenshot.js";
4
+ /**
5
+ * The slice of `../annotate/index.js` this command needs. Typed against the
6
+ * real module (so signatures stay honest) but loaded only via dynamic
7
+ * `import()` — never statically — to keep sharp/roughjs out of any bundle
8
+ * that pulls in commands/screenshot.ts. Injectable for tests as
9
+ * `loadAnnotateModule`, mirroring the `captureLocalImpl`-style seams
10
+ * elsewhere in this file's tests.
11
+ */
12
+ export type AnnotateModule = Pick<typeof import("../annotate/index.js"), "validateSpec" | "hasSelectors" | "specSelectors" | "resolveSelectors" | "renderAnnotations" | "clampReport" | "AnnotateSpecError">;
4
13
  export declare function runScreenshot(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner,
5
14
  /** Injectable for tests — avoids launching a real browser or hitting the network. */
6
- captureImpl?: typeof captureScreenshot): Promise<number>;
15
+ captureImpl?: typeof captureScreenshot,
16
+ /** Injectable for tests — avoids depending on a real stdin stream. */
17
+ readStdinImpl?: () => Promise<string>,
18
+ /** Injectable for tests — avoids depending on sharp/roughjs. */
19
+ loadAnnotateModule?: () => Promise<AnnotateModule>): Promise<number>;