pi-image-tools 1.0.2 → 1.0.4

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/src/clipboard.ts CHANGED
@@ -1,117 +1,385 @@
1
- import { spawnSync } from "node:child_process";
2
- import { createRequire } from "node:module";
3
-
4
- import type { ClipboardImage, ClipboardModule } from "./types.js";
5
-
6
- const require = createRequire(import.meta.url);
7
-
8
- let cachedClipboardModule: ClipboardModule | null | undefined;
9
-
10
- function loadClipboardModule(): ClipboardModule | null {
11
- if (cachedClipboardModule !== undefined) {
12
- return cachedClipboardModule;
13
- }
14
-
15
- try {
16
- cachedClipboardModule = require("@mariozechner/clipboard") as ClipboardModule;
17
- } catch {
18
- cachedClipboardModule = null;
19
- }
20
-
21
- return cachedClipboardModule;
22
- }
23
-
24
- async function readClipboardImageViaNativeModule(): Promise<ClipboardImage | null> {
25
- const clipboard = loadClipboardModule();
26
- if (!clipboard || !clipboard.hasImage()) {
27
- return null;
28
- }
29
-
30
- const imageData = await clipboard.getImageBinary();
31
- if (!imageData || imageData.length === 0) {
32
- return null;
33
- }
34
-
35
- const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData);
36
- return { bytes, mimeType: "image/png" };
37
- }
38
-
39
- function encodePowerShell(script: string): string {
40
- return Buffer.from(script, "utf16le").toString("base64");
41
- }
42
-
43
- function readClipboardImageViaPowerShell(): ClipboardImage | null {
44
- const script = `
45
- $ErrorActionPreference = 'Stop'
46
- Add-Type -AssemblyName System.Windows.Forms
47
- Add-Type -AssemblyName System.Drawing
48
-
49
- if (-not [System.Windows.Forms.Clipboard]::ContainsImage()) {
50
- return
51
- }
52
-
53
- $image = [System.Windows.Forms.Clipboard]::GetImage()
54
- if ($null -eq $image) {
55
- return
56
- }
57
-
58
- $stream = New-Object System.IO.MemoryStream
59
- try {
60
- $image.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
61
- [System.Convert]::ToBase64String($stream.ToArray())
62
- } finally {
63
- $stream.Dispose()
64
- $image.Dispose()
65
- }
66
- `;
67
-
68
- const result = spawnSync(
69
- "powershell.exe",
70
- [
71
- "-NoProfile",
72
- "-NonInteractive",
73
- "-ExecutionPolicy",
74
- "Bypass",
75
- "-STA",
76
- "-EncodedCommand",
77
- encodePowerShell(script),
78
- ],
79
- {
80
- encoding: "utf8",
81
- timeout: 6000,
82
- maxBuffer: 50 * 1024 * 1024,
83
- windowsHide: true,
84
- },
85
- );
86
-
87
- if (result.error || result.status !== 0) {
88
- return null;
89
- }
90
-
91
- const base64 = result.stdout.trim();
92
- if (!base64) {
93
- return null;
94
- }
95
-
96
- try {
97
- const bytes = Buffer.from(base64, "base64");
98
- if (bytes.length === 0) {
99
- return null;
100
- }
101
- return { bytes: new Uint8Array(bytes), mimeType: "image/png" };
102
- } catch {
103
- return null;
104
- }
105
- }
106
-
107
- export async function readClipboardImage(platform: NodeJS.Platform = process.platform): Promise<ClipboardImage | null> {
108
- if (platform !== "win32") {
109
- return null;
110
- }
111
-
112
- try {
113
- return (await readClipboardImageViaNativeModule()) ?? readClipboardImageViaPowerShell();
114
- } catch {
115
- return readClipboardImageViaPowerShell();
116
- }
117
- }
1
+ import { spawnSync } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+
4
+ import type { ClipboardImage, ClipboardModule } from "./types.js";
5
+
6
+ const require = createRequire(import.meta.url);
7
+
8
+ const LIST_TYPES_TIMEOUT_MS = 1000;
9
+ const READ_TIMEOUT_MS = 5000;
10
+ const MAX_BUFFER_BYTES = 50 * 1024 * 1024;
11
+ const SUPPORTED_IMAGE_MIME_TYPES = [
12
+ "image/png",
13
+ "image/jpeg",
14
+ "image/webp",
15
+ "image/gif",
16
+ "image/bmp",
17
+ ] as const;
18
+
19
+ let cachedClipboardModule: ClipboardModule | null | undefined;
20
+
21
+ interface CommandResult {
22
+ ok: boolean;
23
+ stdout: Buffer;
24
+ missingCommand: boolean;
25
+ }
26
+
27
+ interface ClipboardReadResult {
28
+ available: boolean;
29
+ image: ClipboardImage | null;
30
+ }
31
+
32
+ function isErrnoException(error: Error): error is NodeJS.ErrnoException {
33
+ return "code" in error;
34
+ }
35
+
36
+ function hasGraphicalSession(platform: NodeJS.Platform, environment: NodeJS.ProcessEnv): boolean {
37
+ return platform !== "linux" || Boolean(environment.DISPLAY || environment.WAYLAND_DISPLAY);
38
+ }
39
+
40
+ function isWaylandSession(environment: NodeJS.ProcessEnv): boolean {
41
+ return Boolean(environment.WAYLAND_DISPLAY) || environment.XDG_SESSION_TYPE === "wayland";
42
+ }
43
+
44
+ function normalizeMimeType(mimeType: string): string {
45
+ return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
46
+ }
47
+
48
+ function selectPreferredImageMimeType(mimeTypes: readonly string[]): string | null {
49
+ const normalized = mimeTypes
50
+ .map((mimeType) => mimeType.trim())
51
+ .filter((mimeType) => mimeType.length > 0)
52
+ .map((mimeType) => ({ raw: mimeType, normalized: normalizeMimeType(mimeType) }));
53
+
54
+ for (const preferredMimeType of SUPPORTED_IMAGE_MIME_TYPES) {
55
+ const match = normalized.find((mimeType) => mimeType.normalized === preferredMimeType);
56
+ if (match) {
57
+ return match.raw;
58
+ }
59
+ }
60
+
61
+ const firstImage = normalized.find((mimeType) => mimeType.normalized.startsWith("image/"));
62
+ return firstImage?.raw ?? null;
63
+ }
64
+
65
+ function loadClipboardModule(
66
+ platform: NodeJS.Platform = process.platform,
67
+ environment: NodeJS.ProcessEnv = process.env,
68
+ ): ClipboardModule | null {
69
+ if (cachedClipboardModule !== undefined) {
70
+ return cachedClipboardModule;
71
+ }
72
+
73
+ if (environment.TERMUX_VERSION || !hasGraphicalSession(platform, environment)) {
74
+ cachedClipboardModule = null;
75
+ return cachedClipboardModule;
76
+ }
77
+
78
+ try {
79
+ cachedClipboardModule = require("@mariozechner/clipboard") as ClipboardModule;
80
+ } catch {
81
+ cachedClipboardModule = null;
82
+ }
83
+
84
+ return cachedClipboardModule;
85
+ }
86
+
87
+ async function readClipboardImageViaNativeModule(
88
+ platform: NodeJS.Platform,
89
+ environment: NodeJS.ProcessEnv,
90
+ ): Promise<ClipboardReadResult> {
91
+ const clipboard = loadClipboardModule(platform, environment);
92
+ if (!clipboard) {
93
+ return { available: false, image: null };
94
+ }
95
+
96
+ if (!clipboard.hasImage()) {
97
+ return { available: true, image: null };
98
+ }
99
+
100
+ const imageData = await clipboard.getImageBinary();
101
+ if (!imageData || imageData.length === 0) {
102
+ return { available: true, image: null };
103
+ }
104
+
105
+ const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData);
106
+ return {
107
+ available: true,
108
+ image: {
109
+ bytes,
110
+ mimeType: "image/png",
111
+ },
112
+ };
113
+ }
114
+
115
+ function runCommand(
116
+ command: string,
117
+ args: string[],
118
+ timeout: number,
119
+ ): CommandResult {
120
+ const result = spawnSync(command, args, {
121
+ timeout,
122
+ maxBuffer: MAX_BUFFER_BYTES,
123
+ });
124
+
125
+ if (result.error) {
126
+ return {
127
+ ok: false,
128
+ stdout: Buffer.alloc(0),
129
+ missingCommand: isErrnoException(result.error) && result.error.code === "ENOENT",
130
+ };
131
+ }
132
+
133
+ const stdout = Buffer.isBuffer(result.stdout)
134
+ ? result.stdout
135
+ : Buffer.from(result.stdout ?? "", typeof result.stdout === "string" ? "utf8" : undefined);
136
+
137
+ return {
138
+ ok: result.status === 0,
139
+ stdout,
140
+ missingCommand: false,
141
+ };
142
+ }
143
+
144
+ function encodePowerShell(script: string): string {
145
+ return Buffer.from(script, "utf16le").toString("base64");
146
+ }
147
+
148
+ function readClipboardImageViaPowerShell(): ClipboardReadResult {
149
+ const script = `
150
+ $ErrorActionPreference = 'Stop'
151
+ Add-Type -AssemblyName System.Windows.Forms
152
+ Add-Type -AssemblyName System.Drawing
153
+
154
+ if (-not [System.Windows.Forms.Clipboard]::ContainsImage()) {
155
+ return
156
+ }
157
+
158
+ $image = [System.Windows.Forms.Clipboard]::GetImage()
159
+ if ($null -eq $image) {
160
+ return
161
+ }
162
+
163
+ $stream = New-Object System.IO.MemoryStream
164
+ try {
165
+ $image.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
166
+ [System.Convert]::ToBase64String($stream.ToArray())
167
+ } finally {
168
+ $stream.Dispose()
169
+ $image.Dispose()
170
+ }
171
+ `;
172
+
173
+ const result = spawnSync(
174
+ "powershell.exe",
175
+ [
176
+ "-NoProfile",
177
+ "-NonInteractive",
178
+ "-ExecutionPolicy",
179
+ "Bypass",
180
+ "-STA",
181
+ "-EncodedCommand",
182
+ encodePowerShell(script),
183
+ ],
184
+ {
185
+ encoding: "utf8",
186
+ timeout: READ_TIMEOUT_MS,
187
+ maxBuffer: MAX_BUFFER_BYTES,
188
+ windowsHide: true,
189
+ },
190
+ );
191
+
192
+ if (result.error) {
193
+ return {
194
+ available: !isErrnoException(result.error) || result.error.code !== "ENOENT",
195
+ image: null,
196
+ };
197
+ }
198
+
199
+ if (result.status !== 0) {
200
+ return { available: true, image: null };
201
+ }
202
+
203
+ const base64 = result.stdout.trim();
204
+ if (!base64) {
205
+ return { available: true, image: null };
206
+ }
207
+
208
+ try {
209
+ const bytes = Buffer.from(base64, "base64");
210
+ if (bytes.length === 0) {
211
+ return { available: true, image: null };
212
+ }
213
+
214
+ return {
215
+ available: true,
216
+ image: {
217
+ bytes: new Uint8Array(bytes),
218
+ mimeType: "image/png",
219
+ },
220
+ };
221
+ } catch {
222
+ return { available: true, image: null };
223
+ }
224
+ }
225
+
226
+ function readClipboardImageViaWlPaste(): ClipboardReadResult {
227
+ const listTypes = runCommand("wl-paste", ["--list-types"], LIST_TYPES_TIMEOUT_MS);
228
+ if (listTypes.missingCommand) {
229
+ return { available: false, image: null };
230
+ }
231
+
232
+ if (!listTypes.ok) {
233
+ return { available: true, image: null };
234
+ }
235
+
236
+ const mimeTypes = listTypes.stdout
237
+ .toString("utf8")
238
+ .split(/\r?\n/)
239
+ .map((mimeType) => mimeType.trim())
240
+ .filter((mimeType) => mimeType.length > 0);
241
+
242
+ const selectedMimeType = selectPreferredImageMimeType(mimeTypes);
243
+ if (!selectedMimeType) {
244
+ return { available: true, image: null };
245
+ }
246
+
247
+ const imageData = runCommand(
248
+ "wl-paste",
249
+ ["--type", selectedMimeType, "--no-newline"],
250
+ READ_TIMEOUT_MS,
251
+ );
252
+
253
+ if (!imageData.ok || imageData.stdout.length === 0) {
254
+ return { available: true, image: null };
255
+ }
256
+
257
+ return {
258
+ available: true,
259
+ image: {
260
+ bytes: new Uint8Array(imageData.stdout),
261
+ mimeType: normalizeMimeType(selectedMimeType),
262
+ },
263
+ };
264
+ }
265
+
266
+ function readClipboardImageViaXclip(): ClipboardReadResult {
267
+ const targets = runCommand(
268
+ "xclip",
269
+ ["-selection", "clipboard", "-t", "TARGETS", "-o"],
270
+ LIST_TYPES_TIMEOUT_MS,
271
+ );
272
+
273
+ if (targets.missingCommand) {
274
+ return { available: false, image: null };
275
+ }
276
+
277
+ const advertisedMimeTypes = targets.ok
278
+ ? targets.stdout
279
+ .toString("utf8")
280
+ .split(/\r?\n/)
281
+ .map((mimeType) => mimeType.trim())
282
+ .filter((mimeType) => mimeType.length > 0)
283
+ : [];
284
+
285
+ const preferredMimeType =
286
+ advertisedMimeTypes.length > 0 ? selectPreferredImageMimeType(advertisedMimeTypes) : null;
287
+ const mimeTypesToTry = preferredMimeType
288
+ ? [preferredMimeType, ...SUPPORTED_IMAGE_MIME_TYPES]
289
+ : [...SUPPORTED_IMAGE_MIME_TYPES];
290
+
291
+ for (const mimeType of mimeTypesToTry) {
292
+ const imageData = runCommand(
293
+ "xclip",
294
+ ["-selection", "clipboard", "-t", mimeType, "-o"],
295
+ READ_TIMEOUT_MS,
296
+ );
297
+
298
+ if (imageData.ok && imageData.stdout.length > 0) {
299
+ return {
300
+ available: true,
301
+ image: {
302
+ bytes: new Uint8Array(imageData.stdout),
303
+ mimeType: normalizeMimeType(mimeType),
304
+ },
305
+ };
306
+ }
307
+ }
308
+
309
+ return { available: true, image: null };
310
+ }
311
+
312
+ function getUnavailableReaderMessage(platform: NodeJS.Platform): string {
313
+ switch (platform) {
314
+ case "linux":
315
+ return "No Linux clipboard image reader is available. Install wl-clipboard or xclip, or ensure @mariozechner/clipboard is installed.";
316
+ case "darwin":
317
+ return "No macOS clipboard image reader is available. Ensure @mariozechner/clipboard is installed.";
318
+ case "win32":
319
+ return "No Windows clipboard image reader is available. Ensure PowerShell is available or @mariozechner/clipboard is installed.";
320
+ default:
321
+ return `Clipboard image paste is not supported on platform: ${platform}`;
322
+ }
323
+ }
324
+
325
+ export async function readClipboardImage(options?: {
326
+ environment?: NodeJS.ProcessEnv;
327
+ platform?: NodeJS.Platform;
328
+ }): Promise<ClipboardImage | null> {
329
+ const environment = options?.environment ?? process.env;
330
+ const platform = options?.platform ?? process.platform;
331
+
332
+ if (environment.TERMUX_VERSION) {
333
+ return null;
334
+ }
335
+
336
+ if (!hasGraphicalSession(platform, environment)) {
337
+ throw new Error("Clipboard image paste requires a graphical desktop session with DISPLAY or WAYLAND_DISPLAY.");
338
+ }
339
+
340
+ const readerResults: ClipboardReadResult[] = [];
341
+
342
+ const recordResult = (result: ClipboardReadResult): ClipboardImage | null => {
343
+ readerResults.push(result);
344
+ return result.image;
345
+ };
346
+
347
+ if (platform === "win32") {
348
+ const nativeImage = recordResult(await readClipboardImageViaNativeModule(platform, environment));
349
+ if (nativeImage) {
350
+ return nativeImage;
351
+ }
352
+
353
+ const powerShellImage = recordResult(readClipboardImageViaPowerShell());
354
+ if (powerShellImage) {
355
+ return powerShellImage;
356
+ }
357
+ } else if (platform === "linux") {
358
+ const sessionReaders = isWaylandSession(environment)
359
+ ? [readClipboardImageViaWlPaste, readClipboardImageViaXclip]
360
+ : [readClipboardImageViaXclip, readClipboardImageViaWlPaste];
361
+
362
+ for (const reader of sessionReaders) {
363
+ const image = recordResult(reader());
364
+ if (image) {
365
+ return image;
366
+ }
367
+ }
368
+
369
+ const nativeImage = recordResult(await readClipboardImageViaNativeModule(platform, environment));
370
+ if (nativeImage) {
371
+ return nativeImage;
372
+ }
373
+ } else {
374
+ const nativeImage = recordResult(await readClipboardImageViaNativeModule(platform, environment));
375
+ if (nativeImage) {
376
+ return nativeImage;
377
+ }
378
+ }
379
+
380
+ if (readerResults.some((result) => result.available)) {
381
+ return null;
382
+ }
383
+
384
+ throw new Error(getUnavailableReaderMessage(platform));
385
+ }
package/src/commands.ts CHANGED
@@ -1,79 +1,79 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
-
3
- import type { PasteImageCommandHandlers } from "./types.js";
4
-
5
- const SUBCOMMAND_CLIPBOARD = "clipboard";
6
- const SUBCOMMAND_RECENT = "recent";
7
-
8
- const ARGUMENT_COMPLETIONS = [
9
- {
10
- value: SUBCOMMAND_CLIPBOARD,
11
- label: SUBCOMMAND_CLIPBOARD,
12
- description: "Attach image from clipboard",
13
- },
14
- {
15
- value: SUBCOMMAND_RECENT,
16
- label: SUBCOMMAND_RECENT,
17
- description: "Open recent images picker and attach selected image",
18
- },
19
- {
20
- value: "help",
21
- label: "help",
22
- description: "Show usage",
23
- },
24
- ] as const;
25
-
26
- function parseArgs(args: string): string[] {
27
- return args
28
- .trim()
29
- .split(/\s+/)
30
- .map((token) => token.trim().toLowerCase())
31
- .filter((token) => token.length > 0);
32
- }
33
-
34
- function usageMessage(): string {
35
- return "Usage: /paste-image [clipboard|recent]";
36
- }
37
-
38
- export function registerPasteImageCommand(
39
- pi: ExtensionAPI,
40
- handlers: PasteImageCommandHandlers,
41
- ): void {
42
- pi.registerCommand("paste-image", {
43
- description: "Attach an image from clipboard or use a recent-image picker",
44
- getArgumentCompletions: (argumentPrefix) => {
45
- const normalized = argumentPrefix.trim().toLowerCase();
46
- if (!normalized) {
47
- return [...ARGUMENT_COMPLETIONS];
48
- }
49
-
50
- const matches = ARGUMENT_COMPLETIONS.filter((item) => item.value.startsWith(normalized));
51
- return matches.length > 0 ? matches.map((item) => ({ ...item })) : null;
52
- },
53
- handler: async (args, ctx) => {
54
- const tokens = parseArgs(args);
55
-
56
- if (tokens.length === 0 || tokens[0] === SUBCOMMAND_CLIPBOARD) {
57
- await handlers.fromClipboard(ctx);
58
- return;
59
- }
60
-
61
- if (tokens[0] === SUBCOMMAND_RECENT) {
62
- if (tokens.length > 1) {
63
- ctx.ui.notify(usageMessage(), "warning");
64
- return;
65
- }
66
-
67
- await handlers.fromRecent(ctx);
68
- return;
69
- }
70
-
71
- if (tokens[0] === "help") {
72
- ctx.ui.notify(usageMessage(), "info");
73
- return;
74
- }
75
-
76
- ctx.ui.notify(usageMessage(), "warning");
77
- },
78
- });
79
- }
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+
3
+ import type { PasteImageCommandHandlers } from "./types.js";
4
+
5
+ const SUBCOMMAND_CLIPBOARD = "clipboard";
6
+ const SUBCOMMAND_RECENT = "recent";
7
+
8
+ const ARGUMENT_COMPLETIONS = [
9
+ {
10
+ value: SUBCOMMAND_CLIPBOARD,
11
+ label: SUBCOMMAND_CLIPBOARD,
12
+ description: "Attach image from clipboard",
13
+ },
14
+ {
15
+ value: SUBCOMMAND_RECENT,
16
+ label: SUBCOMMAND_RECENT,
17
+ description: "Open recent images picker and attach selected image",
18
+ },
19
+ {
20
+ value: "help",
21
+ label: "help",
22
+ description: "Show usage",
23
+ },
24
+ ] as const;
25
+
26
+ function parseArgs(args: string): string[] {
27
+ return args
28
+ .trim()
29
+ .split(/\s+/)
30
+ .map((token) => token.trim().toLowerCase())
31
+ .filter((token) => token.length > 0);
32
+ }
33
+
34
+ function usageMessage(): string {
35
+ return "Usage: /paste-image [clipboard|recent]";
36
+ }
37
+
38
+ export function registerPasteImageCommand(
39
+ pi: ExtensionAPI,
40
+ handlers: PasteImageCommandHandlers,
41
+ ): void {
42
+ pi.registerCommand("paste-image", {
43
+ description: "Attach an image from clipboard or use a recent-image picker",
44
+ getArgumentCompletions: (argumentPrefix) => {
45
+ const normalized = argumentPrefix.trim().toLowerCase();
46
+ if (!normalized) {
47
+ return [...ARGUMENT_COMPLETIONS];
48
+ }
49
+
50
+ const matches = ARGUMENT_COMPLETIONS.filter((item) => item.value.startsWith(normalized));
51
+ return matches.length > 0 ? matches.map((item) => ({ ...item })) : null;
52
+ },
53
+ handler: async (args, ctx) => {
54
+ const tokens = parseArgs(args);
55
+
56
+ if (tokens.length === 0 || tokens[0] === SUBCOMMAND_CLIPBOARD) {
57
+ await handlers.fromClipboard(ctx);
58
+ return;
59
+ }
60
+
61
+ if (tokens[0] === SUBCOMMAND_RECENT) {
62
+ if (tokens.length > 1) {
63
+ ctx.ui.notify(usageMessage(), "warning");
64
+ return;
65
+ }
66
+
67
+ await handlers.fromRecent(ctx);
68
+ return;
69
+ }
70
+
71
+ if (tokens[0] === "help") {
72
+ ctx.ui.notify(usageMessage(), "info");
73
+ return;
74
+ }
75
+
76
+ ctx.ui.notify(usageMessage(), "warning");
77
+ },
78
+ });
79
+ }