@yagni-app/code-staging 0.3.0-staging.1064.1 → 0.3.0-staging.1067.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -48,21 +48,33 @@ export interface ImageAttachment {
48
48
  */
49
49
  export declare function unwrapBracketedPaste(data: string): string | null;
50
50
  /**
51
- * Recognize a pasted temp-image path written by a Ghostty-based terminal
52
- * (cmux, Ghostty). Those terminals name the file `clipboard-<ts>-<id>.<ext>`
53
- * in the system temp dir. We match strictly clipboard- prefix, image
54
- * extension, real file, and genuine image magic bytes — so a path the user
55
- * typed by hand (e.g. /Users/me/photo.png) is never silently converted.
56
- * Returns the decoded image, or null when the pasted text is not one such path.
51
+ * Recognize a pasted payload that is entirely image-file paths and decode
52
+ * them. This covers Finder/desktop drag-drop and Ghostty/cmux's Cmd+V
53
+ * (which writes the clipboard image to a `clipboard-*.png` temp file and
54
+ * pastes its path) Claude Code parity: dragging a screenshot in becomes
55
+ * `[Image #N]`, not a literal path.
56
+ *
57
+ * Guardrails against converting something the user meant as text: the WHOLE
58
+ * paste must be path tokens, every token must be an absolute path to a real
59
+ * file with an image extension AND genuine image magic bytes, and the
60
+ * conversion is visible — each image becomes a chip in the prompt, so nothing
61
+ * is ever attached silently. Returns null when the paste is not such a list
62
+ * (caller passes it through as ordinary text).
57
63
  */
58
- export declare function readPastedImagePath(pastedText: string): ClipboardImage | null;
64
+ export declare function readPastedImagePaths(pastedText: string): ClipboardImage[] | null;
59
65
  /**
60
66
  * Read an image from the system clipboard without forking pi. Best-effort and
61
- * cross-platform: native module if present, else osascript/pngpaste (macOS),
67
+ * cross-platform using ONLY tools the OS ships with: osascript (macOS),
62
68
  * wl-paste (Wayland), xclip (X11), PowerShell (Windows/WSL). Returns null when
63
69
  * the clipboard holds no image (caller then pastes text instead).
64
70
  */
65
71
  export declare const defaultClipboardImageReader: ClipboardImageReader;
72
+ /**
73
+ * Parse osascript's clipboard dump — `«data PNGf89504E47…»` — into raw bytes.
74
+ * The 4-char tag after `«data ` is the pasteboard flavor; the hex that follows
75
+ * is the image. Returns null when the output holds no such dump.
76
+ */
77
+ export declare function parseOsascriptImageHex(out: string): Uint8Array | null;
66
78
  /**
67
79
  * A CustomEditor that turns clipboard image paste into numbered `[Image #N]`
68
80
  * chips. The image bytes live here, keyed by chip number; the editor text holds
@@ -64,58 +64,98 @@ const IMAGE_MAGIC = [
64
64
  [[0x47, 0x49, 0x46, 0x38], "image/gif"], // GIF8
65
65
  [[0x52, 0x49, 0x46, 0x46], "image/webp"], // RIFF (webp container)
66
66
  ];
67
+ /** A text paste this long is prose, not a dragged file list — bail early. */
68
+ const MAX_PASTED_PATHS = 20;
67
69
  /**
68
- * Recognize a pasted temp-image path written by a Ghostty-based terminal
69
- * (cmux, Ghostty). Those terminals name the file `clipboard-<ts>-<id>.<ext>`
70
- * in the system temp dir. We match strictly — clipboard- prefix, image
71
- * extension, real file, and genuine image magic bytes so a path the user
72
- * typed by hand (e.g. /Users/me/photo.png) is never silently converted.
73
- * Returns the decoded image, or null when the pasted text is not one such path.
70
+ * Split a pasted payload into path tokens the way terminals produce them:
71
+ * Finder/desktop drag-drop inserts absolute paths with backslash-escaped
72
+ * spaces (POSIX) or quote-wrapped paths (Windows Terminal), multiple files
73
+ * separated by whitespace. Returns [] when the payload can't be a path list
74
+ * (e.g. an unbalanced quote from ordinary prose like "don't").
74
75
  */
75
- export function readPastedImagePath(pastedText) {
76
- const trimmed = pastedText.trim();
77
- if (trimmed.includes("\n") || trimmed.includes(" "))
78
- return null; // one token only
79
- // Validate the RAW input first: an attacker could smuggle the `clipboard-`
80
- // prefix through backslash escapes (e.g. `\c\l\i\p\b\o\a\r\d-`), so we
81
- // must confirm the un-escaped form is a well-formed tmp image path before any
82
- // unescaping or path resolution. cmux tmp paths contain no escapable chars
83
- // (no spaces/metacharacters), so a legitimate paste has NO backslashes at all.
84
- // On Windows the backslash IS the path separator (and cmd/PowerShell do no
85
- // backslash-escaping), so the escape-smuggling rejection applies only where
86
- // a backslash could be an escape: POSIX shells.
76
+ function splitPathTokens(text) {
87
77
  const isWindows = process.platform === "win32";
88
- if (!isWindows && trimmed.includes("\\"))
89
- return null; // escapes => not a plain cmux tmp path
90
- const path = trimmed;
91
- if (isWindows ? !isAbsolute(path) : !path.startsWith("/"))
92
- return null;
93
- const name = basename(path);
94
- if (!name.startsWith("clipboard-"))
95
- return null;
96
- const ext = name.split(".").pop()?.toLowerCase() ?? "";
97
- if (!(ext in IMAGE_EXT_MIME))
98
- return null;
99
- if (!existsSync(path))
100
- return null;
101
- let bytes;
102
- try {
103
- bytes = readFileSync(path);
104
- }
105
- catch {
106
- return null;
78
+ const tokens = [];
79
+ let cur = "";
80
+ let quote = null;
81
+ for (let i = 0; i < text.length; i++) {
82
+ const ch = text[i];
83
+ if (quote) {
84
+ if (ch === quote)
85
+ quote = null;
86
+ else
87
+ cur += ch;
88
+ continue;
89
+ }
90
+ if (ch === '"' || ch === "'") {
91
+ quote = ch;
92
+ continue;
93
+ }
94
+ // POSIX shells escape spaces/metacharacters with a backslash; on Windows
95
+ // the backslash IS the path separator, so no unescaping there.
96
+ if (!isWindows && ch === "\\" && i + 1 < text.length) {
97
+ cur += text[++i];
98
+ continue;
99
+ }
100
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
101
+ if (cur)
102
+ tokens.push(cur);
103
+ cur = "";
104
+ continue;
105
+ }
106
+ cur += ch;
107
107
  }
108
- if (bytes.length === 0)
108
+ if (quote !== null)
109
+ return [];
110
+ if (cur)
111
+ tokens.push(cur);
112
+ return tokens;
113
+ }
114
+ /**
115
+ * Recognize a pasted payload that is entirely image-file paths and decode
116
+ * them. This covers Finder/desktop drag-drop and Ghostty/cmux's Cmd+V
117
+ * (which writes the clipboard image to a `clipboard-*.png` temp file and
118
+ * pastes its path) — Claude Code parity: dragging a screenshot in becomes
119
+ * `[Image #N]`, not a literal path.
120
+ *
121
+ * Guardrails against converting something the user meant as text: the WHOLE
122
+ * paste must be path tokens, every token must be an absolute path to a real
123
+ * file with an image extension AND genuine image magic bytes, and the
124
+ * conversion is visible — each image becomes a chip in the prompt, so nothing
125
+ * is ever attached silently. Returns null when the paste is not such a list
126
+ * (caller passes it through as ordinary text).
127
+ */
128
+ export function readPastedImagePaths(pastedText) {
129
+ const tokens = splitPathTokens(pastedText.trim());
130
+ if (tokens.length === 0 || tokens.length > MAX_PASTED_PATHS)
109
131
  return null;
110
- for (const [magic, mime] of IMAGE_MAGIC) {
111
- if (magic.every((b, i) => bytes[i] === b))
112
- return { bytes, mimeType: mime };
132
+ const isWindows = process.platform === "win32";
133
+ const images = [];
134
+ for (const path of tokens) {
135
+ if (isWindows ? !isAbsolute(path) : !path.startsWith("/"))
136
+ return null;
137
+ const ext = basename(path).split(".").pop()?.toLowerCase() ?? "";
138
+ if (!(ext in IMAGE_EXT_MIME))
139
+ return null;
140
+ if (!existsSync(path))
141
+ return null;
142
+ let bytes;
143
+ try {
144
+ bytes = readFileSync(path);
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ const magic = IMAGE_MAGIC.find(([m]) => m.every((b, i) => bytes[i] === b));
150
+ if (!magic)
151
+ return null;
152
+ images.push({ bytes, mimeType: magic[1] });
113
153
  }
114
- return null;
154
+ return images;
115
155
  }
116
156
  /**
117
157
  * Read an image from the system clipboard without forking pi. Best-effort and
118
- * cross-platform: native module if present, else osascript/pngpaste (macOS),
158
+ * cross-platform using ONLY tools the OS ships with: osascript (macOS),
119
159
  * wl-paste (Wayland), xclip (X11), PowerShell (Windows/WSL). Returns null when
120
160
  * the clipboard holds no image (caller then pastes text instead).
121
161
  */
@@ -145,7 +185,45 @@ function runBase64(command, args, timeoutMs = 3000) {
145
185
  return out.length > 0 ? out : null;
146
186
  }
147
187
  function readMacClipboardImage() {
148
- // pngpaste writes the clipboard image to a PNG file (cleanest on macOS).
188
+ return readMacClipboardViaOsascript() ?? readMacClipboardViaPngpaste();
189
+ }
190
+ /**
191
+ * Parse osascript's clipboard dump — `«data PNGf89504E47…»` — into raw bytes.
192
+ * The 4-char tag after `«data ` is the pasteboard flavor; the hex that follows
193
+ * is the image. Returns null when the output holds no such dump.
194
+ */
195
+ export function parseOsascriptImageHex(out) {
196
+ const m = /«data \w{4}((?:[0-9A-Fa-f]{2})+)»/.exec(out);
197
+ if (!m)
198
+ return null;
199
+ const bytes = Buffer.from(m[1], "hex");
200
+ return bytes.length > 0 ? bytes : null;
201
+ }
202
+ function readMacClipboardViaOsascript() {
203
+ // AppleScript ships with macOS, so this route needs no install (it is how
204
+ // Claude Code reads clipboard images too). Screenshots and browser
205
+ // "Copy image" put a PNG flavor on the pasteboard; try JPEG second.
206
+ for (const [cls, mime] of [
207
+ ["PNGf", "image/png"],
208
+ ["JPEG", "image/jpeg"],
209
+ ]) {
210
+ const res = spawnSync("osascript", ["-e", `the clipboard as «class ${cls}»`], {
211
+ timeout: 5000,
212
+ maxBuffer: 256 * 1024 * 1024, // the hex dump doubles the image size
213
+ });
214
+ if (res.error || res.status !== 0)
215
+ continue;
216
+ const out = Buffer.isBuffer(res.stdout) ? res.stdout.toString("utf8") : String(res.stdout ?? "");
217
+ const bytes = parseOsascriptImageHex(out);
218
+ if (bytes)
219
+ return { bytes, mimeType: mime };
220
+ }
221
+ return null;
222
+ }
223
+ function readMacClipboardViaPngpaste() {
224
+ // Optional fallback for pasteboard flavors osascript can't coerce to
225
+ // PNG/JPEG (e.g. TIFF-only sources) — pngpaste converts anything to PNG.
226
+ // Nice when installed, never required.
149
227
  const dest = join(tmpdir(), `yagni-clip-${randomUUID()}.png`);
150
228
  try {
151
229
  const res = spawnSync("pngpaste", [dest], { timeout: 3000 });
@@ -222,27 +300,31 @@ export class ChipEditor extends CustomEditor {
222
300
  * Kitty-protocol terminals that genuinely deliver the Cmd modifier.
223
301
  */
224
302
  handleInput(data) {
225
- // Cmd+V on Ghostty-based terminals (cmux, Ghostty): the terminal writes the
226
- // clipboard image to a temp file and pastes its PATH as bracketed text, so
227
- // the payload arrives here as "\x1b[200~<path>\x1b[201~" (pi's Terminal
228
- // re-wraps it). handlePaste is private and consumed inside super.handleInput,
229
- // so we strip the markers here and convert a bare image path to a chip.
303
+ // Pasted image PATHS arrive as bracketed text ("\x1b[200~<paths>\x1b[201~",
304
+ // pi's Terminal re-wraps every paste): Finder/desktop drag-drop inserts the
305
+ // file's path, and Ghostty-based terminals (cmux, Ghostty) write a Cmd+V'd
306
+ // clipboard image to a temp file and paste ITS path. handlePaste is private
307
+ // and consumed inside super.handleInput, so we strip the markers here and
308
+ // convert an all-image-paths payload into chips.
230
309
  const unwrapped = unwrapBracketedPaste(data);
231
310
  if (unwrapped !== null) {
232
- const image = readPastedImagePath(unwrapped);
311
+ const images = readPastedImagePaths(unwrapped);
233
312
  logImagePaste({
234
313
  event: "paste_path",
235
- outcome: image ? "ok" : "passthrough",
236
- mimeType: image?.mimeType,
237
- bytes: image?.bytes.length,
238
- file: image ? unwrapped : undefined,
239
- detail: image ? undefined : `not a lone image path: ${unwrapped.slice(0, 60)}`,
314
+ outcome: images ? "ok" : "passthrough",
315
+ imageCount: images?.length,
316
+ bytes: images?.reduce((sum, i) => sum + i.bytes.length, 0),
317
+ detail: images ? undefined : `not an image path list: ${unwrapped.slice(0, 60)}`,
240
318
  });
241
- if (image) {
242
- this.dropChip(image.bytes, image.mimeType);
319
+ if (images) {
320
+ images.forEach((image, i) => {
321
+ if (i > 0)
322
+ this.insertTextAtCursor(" ");
323
+ this.dropChip(image.bytes, image.mimeType);
324
+ });
243
325
  return;
244
326
  }
245
- // Not a lone image path — re-wrap and let pi handle the paste normally.
327
+ // Not a list of image paths — re-wrap and let pi handle the paste normally.
246
328
  super.handleInput(data);
247
329
  return;
248
330
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "0.3.0-staging.1064.1",
3
+ "version": "0.3.0-staging.1067.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -38,5 +38,5 @@
38
38
  "@earendil-works/pi-tui": "0.84.1",
39
39
  "typebox": "^1.3.11"
40
40
  },
41
- "yagniSourceSha": "e38a99de02a31e45c61d006943113caf8817e0bd"
41
+ "yagniSourceSha": "30603566bf16560437d0729158a147e177426f01"
42
42
  }