@pi-archimedes/image-paste 2.6.3 → 2.7.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.
package/README.md CHANGED
@@ -42,6 +42,8 @@ New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0.
42
42
  > ```json
43
43
  > { "app.clipboard.pasteImage": [] }
44
44
  > ```
45
+ >
46
+ > **First-run offer (once ever, all platforms)** — when installed via the suite, on the first TUI session with image-paste enabled, if `~/.pi/agent/keybindings.json` doesn't exist yet, the suite offers to create it with exactly the snippet above: accepting creates the file and the TUI reloads automatically (the cleared binding applies immediately; if the reload fails, run `/reload`), declining or cancelling (Esc) never asks again — re-open by deleting `archimedes.imagePaste.keybindingsPromptDone` from `~/.pi/agent/settings.json`.
45
47
 
46
48
  ## Per-platform requirements
47
49
 
@@ -49,6 +51,17 @@ New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0.
49
51
  - **macOS** — the only image reader on macOS is the `@mariozechner/clipboard` native module (no other CLI fallback); it ships inside the `pi-coding-agent` installation but must be importable from the extension's location, so if your Pi install's layout puts it out of resolution reach, a read reports the reader as unavailable — make the module resolvable beside the extension and `/reload`.
50
52
  - **Windows** — the `@mariozechner/clipboard` native module first, with a PowerShell fallback.
51
53
 
54
+ ## Settings
55
+
56
+ `~/.pi/agent/settings.json`, under `archimedes.imagePaste` (strict JSON):
57
+
58
+ | Setting | Type | Default | Description |
59
+ |---------|------|---------|-------------|
60
+ | `enabled` | bool | `true` | On/off for the extension, via `/plugins` (suite-managed) |
61
+ | `keybindingsPromptDone` | bool | `false` | Set true once the first-run keybindings.json offer has been answered (accept or decline); delete to re-open the offer |
62
+
63
+ In the suite on/off is managed by the suite: toggle via `/plugins` (`archimedes.imagePaste.enabled`, default on).
64
+
52
65
  ## Part of the suite
53
66
 
54
67
  In [pi-archimedes](https://github.com/danielcherubini/pi-archimedes), image-paste works alongside the framed editor, the status bar, and the todo board; on/off is managed by the suite (`/plugins`, `archimedes.imagePaste.enabled`, default on).
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "@pi-archimedes/image-paste",
3
- "version": "2.6.3",
3
+ "version": "2.7.1",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/danielcherubini/pi-archimedes.git"
7
+ },
4
8
  "type": "module",
5
9
  "keywords": [
6
10
  "pi-package"
@@ -10,6 +14,13 @@
10
14
  "src"
11
15
  ],
12
16
  "main": "./src/index.ts",
17
+ "exports": {
18
+ ".": "./src/index.ts",
19
+ "./keybinding-offer": "./src/keybinding-offer.ts"
20
+ },
21
+ "dependencies": {
22
+ "@pi-archimedes/core": "2.7.1"
23
+ },
13
24
  "peerDependencies": {
14
25
  "@earendil-works/pi-coding-agent": ">=0.1.0",
15
26
  "@earendil-works/pi-tui": ">=0.1.0"
@@ -0,0 +1,472 @@
1
+ import {
2
+ afterAll,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ it,
7
+ vi,
8
+ } from "vitest";
9
+
10
+ // ── hoisted: must use require() since vi.hoisted runs before imports ────────
11
+
12
+ const { tempDir, fs, join, existsSyncSpy } = vi.hoisted(() => {
13
+ const fs = require("node:fs");
14
+ const { join } = require("node:path");
15
+ const { tmpdir } = require("node:os");
16
+ const { randomUUID } = require("node:crypto");
17
+ const dir = join(tmpdir(), `keybinding-offer-test-${randomUUID()}`);
18
+ fs.mkdirSync(dir, { recursive: true });
19
+ // Pass-through by default; specific tests re-target it (the concurrent-file
20
+ // case needs keybindings.json to appear between gate 4 and the pre-rename
21
+ // re-check).
22
+ const existsSyncSpy = vi.fn((p: unknown) => fs.existsSync(String(p)));
23
+ return { tempDir: dir, fs, join, existsSyncSpy };
24
+ });
25
+
26
+ // ── mocks: registered before the module under test is first imported ─────────
27
+ // The mock MUST be registered before keybinding-offer.js (and the settings-io
28
+ // it pulls in) is loaded: module-scope path constants capture getAgentDir() at
29
+ // load time (settings-io.ts:5).
30
+
31
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
32
+ getAgentDir: () => tempDir,
33
+ }));
34
+
35
+ vi.mock("node:fs", async (importOriginal) => {
36
+ const actual = await importOriginal<typeof import("node:fs")>();
37
+ return { ...actual, existsSync: existsSyncSpy };
38
+ });
39
+
40
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
41
+
42
+ // Identical to the package's ExtensionMode (dist types:208-209), which is not
43
+ // re-exported from the package root.
44
+ type Mode = "tui" | "rpc" | "json" | "print";
45
+
46
+ const { offerKeybindingFix, CREATED_NOTIFY } = await import("./keybinding-offer.js");
47
+
48
+ // ── fixtures ─────────────────────────────────────────────────────────────────
49
+
50
+ const NAMESPACE = "archimedes.imagePaste";
51
+ /** The exact snippet from packages/image-paste/README.md → "Paste shortcuts". */
52
+ const SNIPPET = '{ "app.clipboard.pasteImage": [] }';
53
+ /** What a concurrent process writes into keybindings.json at rename time. */
54
+ const CONCURRENT_CONTENT = '{ "app.clipboard.pasteImage": ["ctrl+v"] }';
55
+
56
+
57
+ const settingsPath = (): string => join(tempDir, "settings.json");
58
+ const keybindingsPath = (): string => join(tempDir, "keybindings.json");
59
+ const tmpPath = (): string => `${keybindingsPath()}.${process.pid}.tmp`;
60
+
61
+ function writeSettings(obj: object): void {
62
+ fs.writeFileSync(settingsPath(), JSON.stringify(obj), "utf-8");
63
+ }
64
+
65
+ function readSettings(): Record<string, unknown> {
66
+ if (!fs.existsSync(settingsPath())) return {};
67
+ try {
68
+ return JSON.parse(fs.readFileSync(settingsPath(), "utf-8")) as Record<string, unknown>;
69
+ } catch {
70
+ return {};
71
+ }
72
+ }
73
+
74
+ function readFlag(): boolean {
75
+ const ns = readSettings()[NAMESPACE] as Record<string, unknown> | undefined;
76
+ return ns?.keybindingsPromptDone === true;
77
+ }
78
+
79
+ function cleanupArtifacts(): void {
80
+ for (const p of [settingsPath(), settingsPath() + ".tmp", keybindingsPath(), tmpPath()]) {
81
+ try {
82
+ fs.rmSync(p, { recursive: true, force: true });
83
+ } catch {
84
+ // ignore
85
+ }
86
+ }
87
+ }
88
+
89
+ function makeCtx(mode: Mode) {
90
+ const confirm = vi.fn();
91
+ const notify = vi.fn();
92
+ const reload = vi.fn(async () => {});
93
+ const ctx = {
94
+ mode,
95
+ hasUI: true,
96
+ ui: { confirm, notify },
97
+ reload,
98
+ } as unknown as ExtensionContext;
99
+ return { ctx, confirm, notify, reload };
100
+ }
101
+
102
+ function callArgs(m: ReturnType<typeof vi.fn>): unknown[][] {
103
+ return m.mock.calls as unknown[][];
104
+ }
105
+
106
+ beforeEach(() => {
107
+ cleanupArtifacts();
108
+ existsSyncSpy.mockReset();
109
+ existsSyncSpy.mockImplementation((p: unknown) => fs.existsSync(String(p)));
110
+ });
111
+
112
+ afterAll(() => {
113
+ try {
114
+ fs.rmSync(tempDir, { recursive: true, force: true });
115
+ } catch {
116
+ // ignore
117
+ }
118
+ });
119
+
120
+ // ── gate matrix ──────────────────────────────────────────────────────────────
121
+ // isConfigEnabled × {t,f} × ctx.mode ∈ {tui, rpc, json, print} × file {absent,
122
+ // present} × flag {unset, set} × confirm outcome {yes, no, cancel-resolves-false}
123
+ // (Esc/timeout: `confirm` resolves `false` either way → counts as decline).
124
+
125
+ interface Case {
126
+ enabled: boolean;
127
+ mode: Mode;
128
+ file: "absent" | "present";
129
+ flag: "unset" | "set";
130
+ outcome: "yes" | "no" | "cancel-resolves-false";
131
+ }
132
+
133
+ const CASES: Case[] = (() => {
134
+ const cases: Case[] = [];
135
+ for (const enabled of [false, true]) {
136
+ for (const mode of ["tui", "rpc", "json", "print"] as Mode[]) {
137
+ for (const file of ["absent", "present"] as const) {
138
+ for (const flag of ["unset", "set"] as const) {
139
+ for (const outcome of ["yes", "no", "cancel-resolves-false"] as const) {
140
+ cases.push({ enabled, mode, file, flag, outcome });
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+ return cases;
147
+ })();
148
+
149
+ describe("offerKeybindingFix — gate matrix", () => {
150
+ it.each(CASES)(
151
+ "$enabled/$mode/$file/$flag → $outcome",
152
+ async ({ enabled, mode, file, flag, outcome }) => {
153
+ // ── setup ──
154
+ const ns: Record<string, unknown> = {};
155
+ if (enabled === false) ns.enabled = false;
156
+ if (flag === "set") ns.keybindingsPromptDone = true;
157
+ const settingsBefore = { ...ns };
158
+ if (Object.keys(settingsBefore).length > 0) {
159
+ writeSettings({ [NAMESPACE]: settingsBefore });
160
+ }
161
+ const settingsBeforeJson = readSettings();
162
+
163
+ if (file === "present") {
164
+ fs.writeFileSync(keybindingsPath(), CONCURRENT_CONTENT, "utf-8");
165
+ }
166
+
167
+ const { ctx, confirm, notify } = makeCtx(mode);
168
+ if (outcome === "yes") confirm.mockResolvedValue(true);
169
+ else confirm.mockResolvedValue(false); // no + cancel: confirm resolves false
170
+
171
+ await offerKeybindingFix(ctx);
172
+
173
+ const shouldAsk =
174
+ enabled === true &&
175
+ mode === "tui" &&
176
+ flag === "unset" &&
177
+ file === "absent";
178
+
179
+ // ── gate behavior ──
180
+ expect(confirm).toHaveBeenCalledTimes(shouldAsk ? 1 : 0);
181
+ // never fires when the plugin is disabled
182
+ if (enabled === false) expect(confirm).not.toHaveBeenCalled();
183
+ // never fires when the file already exists
184
+ if (file === "present") expect(confirm).not.toHaveBeenCalled();
185
+ // never fires when the flag is already consumed
186
+ if (flag === "set") expect(confirm).not.toHaveBeenCalled();
187
+ // never fires in non-TUI modes
188
+ if (mode !== "tui") expect(confirm).not.toHaveBeenCalled();
189
+
190
+ if (shouldAsk) {
191
+ // confirm is called with the TITLE FIRST (docs/extensions.md:165)
192
+ const [title, message] = callArgs(confirm)[0] as [string, string];
193
+ expect(title).toBe("First run");
194
+ expect(typeof message).toBe("string");
195
+ expect(message).toContain("keybindings.json");
196
+ }
197
+
198
+ // ── file effects ──
199
+ const kbExists = fs.existsSync(keybindingsPath());
200
+ if (shouldAsk && outcome === "yes") {
201
+ // yes → file written with the exact snippet content
202
+ expect(kbExists).toBe(true);
203
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
204
+ } else {
205
+ // never written when the offer never fires; the pre-existing file is never
206
+ // clobbered
207
+ expect(kbExists).toBe(file === "present");
208
+ }
209
+
210
+ // ── flag effects ──
211
+ if (shouldAsk) {
212
+ // offer fires once and sets the flag on every outcome (yes, no, cancel → decline)
213
+ expect(readFlag()).toBe(true);
214
+ } else {
215
+ // non-TUI / non-offer rows never consume the flag — settings byte-identical
216
+ expect(readSettings()).toEqual(settingsBeforeJson);
217
+ expect(readFlag()).toBe(flag === "set");
218
+ }
219
+
220
+ // ── notifications ──
221
+ if (shouldAsk && outcome === "yes") {
222
+ expect(
223
+ callArgs(notify).some(
224
+ (c) => c[0] === CREATED_NOTIFY && c[1] === "info",
225
+ ),
226
+ ).toBe(true);
227
+ } else {
228
+ expect(notify).not.toHaveBeenCalled();
229
+ }
230
+ },
231
+ );
232
+ });
233
+
234
+ // ── flag persistence regression: load-modify-save preserves other keys ──────
235
+ // saveConfig REPLACES the namespace object (settings-io.ts:29-33), so a bare
236
+ // saveConfig(ns, { keybindingsPromptDone: true }) would erase other keys under
237
+ // archimedes.imagePaste.
238
+
239
+ describe("flag write preserves existing settings", () => {
240
+ async function run(outcome: "yes" | "no") {
241
+ writeSettings({
242
+ [NAMESPACE]: { enabled: true, someOtherKey: 42, nested: { a: 1 } },
243
+ "other.ns": { z: 1 },
244
+ });
245
+ const { ctx, confirm } = makeCtx("tui");
246
+ confirm.mockResolvedValue(outcome === "yes");
247
+ await offerKeybindingFix(ctx);
248
+ }
249
+
250
+ it("yes: snippet file written; other archimedes.imagePaste keys survive the flag set", async () => {
251
+ await run("yes");
252
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
253
+ const data = readSettings();
254
+ const ns = data[NAMESPACE] as Record<string, unknown>;
255
+ expect(ns).toEqual({
256
+ enabled: true,
257
+ someOtherKey: 42,
258
+ nested: { a: 1 },
259
+ keybindingsPromptDone: true,
260
+ });
261
+ expect(data["other.ns"]).toEqual({ z: 1 });
262
+ });
263
+
264
+ it("no: decline sets the flag via load-modify-save; keys survive, file untouched", async () => {
265
+ await run("no");
266
+ expect(fs.existsSync(keybindingsPath())).toBe(false);
267
+ const ns = readSettings()[NAMESPACE] as Record<string, unknown>;
268
+ expect(ns).toEqual({
269
+ enabled: true,
270
+ someOtherKey: 42,
271
+ nested: { a: 1 },
272
+ keybindingsPromptDone: true,
273
+ });
274
+ });
275
+ });
276
+
277
+ // ── error paths ──────────────────────────────────────────────────────────────
278
+
279
+ describe("file write throws → flag stays false (self-heals next session)", () => {
280
+ it("yes with an unwritable tmp target: no file, flag unset, error notified, no throw", async () => {
281
+ // Make the (pid-predictable) tmp path unwritable: a directory.
282
+ fs.mkdirSync(tmpPath(), { recursive: true });
283
+
284
+ const { ctx, confirm, notify } = makeCtx("tui");
285
+ confirm.mockResolvedValue(true);
286
+
287
+ await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
288
+
289
+ expect(confirm).toHaveBeenCalledTimes(1);
290
+ expect(fs.existsSync(keybindingsPath())).toBe(false);
291
+ expect(readFlag()).toBe(false);
292
+ expect(
293
+ readSettings()[NAMESPACE] ?? {},
294
+ ).toEqual({}); // nothing persisted to settings
295
+
296
+ expect(notify).toHaveBeenCalledTimes(1);
297
+ const [msg, type] = notify.mock.calls[0] as [string, string];
298
+ expect(type).toBe("warning");
299
+ expect(msg.length).toBeGreaterThan(0);
300
+ });
301
+ });
302
+
303
+ describe("flag write throws → notify, no crash (file write already succeeded)", () => {
304
+ it("yes with an unwritable settings target: file kept, flag unset, both notifies, no throw", async () => {
305
+ // Make settings.json an unwritable target: a directory (rename + direct
306
+ // write onto it both fail with EISDIR).
307
+ fs.mkdirSync(settingsPath(), { recursive: true });
308
+
309
+ const { ctx, confirm, notify } = makeCtx("tui");
310
+ confirm.mockResolvedValue(true);
311
+
312
+ await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
313
+
314
+ // The file write strictly precedes the flag set and survived:
315
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
316
+ // Flag persistence failed: settings.json is still the directory, no flag
317
+ expect(fs.statSync(settingsPath()).isDirectory()).toBe(true);
318
+ expect(readFlag()).toBe(false);
319
+ // Gate 4 blocks the re-offer anyway (file now exists) — by design
320
+
321
+ const args = callArgs(notify);
322
+ expect(
323
+ args.some((c) => c[0] === CREATED_NOTIFY && c[1] === "info"),
324
+ ).toBe(true); // the file WAS created
325
+ expect(args.some((c) => c[1] === "warning" && String(c[0]).length > 0)).toBe(true); // flag save error: non-empty warning message
326
+ });
327
+ });
328
+
329
+ // ── auto-reload on accept ────────────────────────────────────────────────────
330
+
331
+ describe("auto-reload on accept", () => {
332
+ it("yes path: ctx.reload() called exactly once after flag is persisted and CREATED_NOTIFY emitted", async () => {
333
+ // Track invocation order so we can assert flag is persisted before reload.
334
+ const order: string[] = [];
335
+ const { ctx, confirm, notify, reload } = makeCtx("tui");
336
+ confirm.mockResolvedValue(true);
337
+ // Wrap the reload spy to record when it's called relative to flag write.
338
+ reload.mockImplementation(async () => {
339
+ // At this point the flag must already be persisted to disk.
340
+ order.push(readFlag() ? "flag-then-reload" : "reload-before-flag");
341
+ });
342
+ notify.mockImplementation((...args: unknown[]) => {
343
+ if ((args[0] as string) === CREATED_NOTIFY) order.push("notify");
344
+ });
345
+
346
+ await offerKeybindingFix(ctx);
347
+
348
+ // reload called exactly once
349
+ expect(reload).toHaveBeenCalledTimes(1);
350
+ // flag was already set when reload ran
351
+ expect(order).toContain("flag-then-reload");
352
+ expect(order).not.toContain("reload-before-flag");
353
+ // CREATED_NOTIFY emitted before reload
354
+ const notifyIdx = order.indexOf("notify");
355
+ const reloadIdx = order.indexOf("flag-then-reload");
356
+ expect(notifyIdx).toBeGreaterThanOrEqual(0);
357
+ expect(reloadIdx).toBeGreaterThan(notifyIdx);
358
+ // File written
359
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
360
+ // CREATED_NOTIFY contains "reloading now"
361
+ expect(CREATED_NOTIFY).toContain("reloading now");
362
+ });
363
+
364
+ it("no path: ctx.reload() NOT called", async () => {
365
+ const { ctx, confirm, reload } = makeCtx("tui");
366
+ confirm.mockResolvedValue(false);
367
+ await offerKeybindingFix(ctx);
368
+ expect(reload).not.toHaveBeenCalled();
369
+ });
370
+
371
+ it("cancel (resolves false) path: ctx.reload() NOT called", async () => {
372
+ const { ctx, confirm, reload } = makeCtx("tui");
373
+ confirm.mockResolvedValue(false); // Esc/timeout: same as no
374
+ await offerKeybindingFix(ctx);
375
+ expect(reload).not.toHaveBeenCalled();
376
+ });
377
+
378
+ it("file-write-fails path: ctx.reload() NOT called", async () => {
379
+ // Block the file write by making the tmp path a directory
380
+ fs.mkdirSync(tmpPath(), { recursive: true });
381
+ const { ctx, confirm, reload } = makeCtx("tui");
382
+ confirm.mockResolvedValue(true);
383
+ await offerKeybindingFix(ctx);
384
+ expect(reload).not.toHaveBeenCalled();
385
+ });
386
+
387
+ it("reload rejects: offer still resolves, flag persisted, file present, CREATED_NOTIFY emitted", async () => {
388
+ const { ctx, confirm, notify, reload } = makeCtx("tui");
389
+ confirm.mockResolvedValue(true);
390
+ reload.mockRejectedValue(new Error("Reload failed"));
391
+
392
+ await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
393
+
394
+ // File must have been written
395
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
396
+ // Flag must have been set (reload runs after flag)
397
+ expect(readFlag()).toBe(true);
398
+ // CREATED_NOTIFY must have been emitted (fires before reload)
399
+ expect(
400
+ (notify.mock.calls as unknown[][]).some(
401
+ (c) => c[0] === CREATED_NOTIFY && c[1] === "info",
402
+ ),
403
+ ).toBe(true);
404
+ // reload was still attempted once
405
+ expect(reload).toHaveBeenCalledTimes(1);
406
+ });
407
+ });
408
+
409
+ describe("TOCTOU path: ctx.reload() NOT called", () => {
410
+ it("file created between gate 4 and pre-rename re-check: reload NOT called", async () => {
411
+ let kbHits = 0;
412
+ existsSyncSpy.mockImplementation((p: unknown) => {
413
+ const s = String(p);
414
+ if (s === keybindingsPath()) {
415
+ kbHits += 1;
416
+ if (kbHits >= 2) {
417
+ if (!fs.existsSync(s)) fs.writeFileSync(s, CONCURRENT_CONTENT, "utf-8");
418
+ return true;
419
+ }
420
+ }
421
+ return fs.existsSync(s);
422
+ });
423
+
424
+ const { ctx, confirm, reload } = makeCtx("tui");
425
+ confirm.mockResolvedValue(true);
426
+ await offerKeybindingFix(ctx);
427
+
428
+ expect(reload).not.toHaveBeenCalled();
429
+ });
430
+ });
431
+
432
+ describe("concurrent creation is never clobbered", () => {
433
+ it("file created between gate 4 and the pre-rename re-check: left intact, flag set", async () => {
434
+ // Simulate: the first keybindings.json check (gate 4) sees nothing; the
435
+ // second (pre-rename re-check) sees a file that a concurrent process just
436
+ // created — the spy materializes it on that hit.
437
+ let kbHits = 0;
438
+ existsSyncSpy.mockImplementation((p: unknown) => {
439
+ const s = String(p);
440
+ if (s === keybindingsPath()) {
441
+ kbHits += 1;
442
+ // NOTE: coupled to the module's two existsSync calls — gate-4 check,
443
+ // then the pre-rename re-check (keybinding-offer.ts). If the module
444
+ // de-duplicates or adds an existence check, update kbHits accordingly.
445
+ if (kbHits >= 2) {
446
+ if (!fs.existsSync(s)) {
447
+ fs.writeFileSync(s, CONCURRENT_CONTENT, "utf-8");
448
+ }
449
+ return true;
450
+ }
451
+ }
452
+ return fs.existsSync(s);
453
+ });
454
+
455
+ const { ctx, confirm, notify } = makeCtx("tui");
456
+ confirm.mockResolvedValue(true);
457
+
458
+ await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
459
+
460
+ // The concurrently created file is never clobbered by the snippet
461
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(CONCURRENT_CONTENT);
462
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).not.toBe(SNIPPET);
463
+ // The tmp file is cleaned up
464
+ expect(fs.existsSync(tmpPath())).toBe(false);
465
+ // The offer ends: the offer was made and the fix is in force (file exists)
466
+ expect(readFlag()).toBe(true);
467
+ // We did not create the file, so we do not claim to
468
+ expect(
469
+ callArgs(notify).some((c) => c[0] === CREATED_NOTIFY),
470
+ ).toBe(false);
471
+ });
472
+ });
@@ -0,0 +1,165 @@
1
+ import { existsSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+
7
+ import { isConfigEnabled, loadConfig, updateConfig } from "@pi-archimedes/core/settings-io";
8
+
9
+ const NAMESPACE = "archimedes.imagePaste";
10
+
11
+ const CONFIRM_TITLE = "First run";
12
+ const CONFIRM_MESSAGE =
13
+ "~/.pi/agent/keybindings.json is missing. Without it, Pi's built-in " +
14
+ "app.clipboard.pasteImage binding (Ctrl+V on Linux/macOS, Alt+V on Windows) " +
15
+ "double-fires with image-paste's Ctrl+V handler. Create the file now with the " +
16
+ "docs snippet? It clears the built-in binding (/reload applies it).";
17
+
18
+ /** Exactly the snippet from packages/image-paste/README.md → "Paste shortcuts". */
19
+ const SNIPPET_JSON = '{ "app.clipboard.pasteImage": [] }';
20
+
21
+ export const CREATED_NOTIFY = "Created ~/.pi/agent/keybindings.json — reloading now";
22
+
23
+ interface PromptConfig {
24
+ keybindingsPromptDone: boolean;
25
+ }
26
+
27
+ const PROMPT_DEFAULTS: PromptConfig = { keybindingsPromptDone: false };
28
+
29
+ function messageOf(error: unknown): string {
30
+ return error instanceof Error ? error.message : String(error);
31
+ }
32
+
33
+ /**
34
+ * Persist `keybindingsPromptDone: true` via updateConfig: optimistic re-read
35
+ * check + bounded retry, so a concurrent write to settings.json (e.g. another
36
+ * session's /plugins toggle) is detected and re-read — the flag save cannot
37
+ * clobber a newer `enabled` value. Never throws: a failed save notifies and
38
+ * leaves the flag unset (gate 4 blocks the re-offer once the file exists anyway).
39
+ */
40
+ function markPromptDone(ctx: ExtensionContext): void {
41
+ try {
42
+ updateConfig<PromptConfig>(
43
+ NAMESPACE,
44
+ PROMPT_DEFAULTS,
45
+ (cfg) => ({ ...cfg, keybindingsPromptDone: true }),
46
+ );
47
+ } catch (error) {
48
+ ctx.ui.notify(
49
+ `Could not persist keybinding prompt flag: ${messageOf(error)}`,
50
+ "warning",
51
+ );
52
+ }
53
+ }
54
+
55
+ /**
56
+ * First-run keybinding offer (once ever, all platforms).
57
+ *
58
+ * On the first TUI session, if `~/.pi/agent/keybindings.json` does not exist,
59
+ * offer to create it with the docs snippet so Pi's built-in clipboard paste
60
+ * doesn't double-fire with image-paste's Ctrl+V handler. Accepting writes the
61
+ * file (atomic tmp+rename, pre-rename existence re-check) and then sets the
62
+ * one-shot flag and reloads the TUI (`ctx.reload()` — the exact /reload flow:
63
+ * re-reads keybindings.json and re-binds shortcuts) so the cleared built-in
64
+ * binding applies immediately. Declining or cancelling (Esc/timeout — `confirm`
65
+ * resolves `false` either way) sets the flag without touching the file. A
66
+ * failed file write leaves the flag unset, so the offer self-heals next session.
67
+ *
68
+ * **Concurrent-session edge:** if the user runs `/new` or `/reload` while the
69
+ * confirm dialog is still open, `ctx.ui.confirm` resolves `false` (the TUI
70
+ * tears down). This counts as a decline — the flag is set and the offer will
71
+ * not appear again. To reset it, delete `archimedes.imagePaste.keybindingsPromptDone`
72
+ * from `~/.pi/agent/settings.json`.
73
+ */
74
+ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<void> {
75
+ // Gate 1: extension on.
76
+ // NOTE: reading archimedes.imagePaste.enabled in-package is a sanctioned
77
+ // exception to the AGENTS.md "Plugin on/off" rule and ADR 0012 — this
78
+ // function runs from the meta session_start handler before plugin
79
+ // registration, so there is no registration gate to catch it here.
80
+ // See docs/decisions/0012-plugin-gate-in-package-namespace.md § Exception.
81
+ if (!isConfigEnabled(NAMESPACE)) {
82
+ return;
83
+ }
84
+
85
+ // Gate 2: interactive TUI only. Deliberate: the flag is NOT consumed in
86
+ // non-TUI modes, so a later TUI session still gets the offer.
87
+ if (ctx.mode !== "tui") {
88
+ return;
89
+ }
90
+
91
+ // Gate 3: not yet consumed
92
+ if (loadConfig<PromptConfig>(NAMESPACE, { ...PROMPT_DEFAULTS }).keybindingsPromptDone === true) {
93
+ return;
94
+ }
95
+
96
+ // Gate 4: file absent — never merge into or rewrite an existing user file
97
+ const keybindingsPath = join(getAgentDir(), "keybindings.json");
98
+ if (existsSync(keybindingsPath)) {
99
+ return;
100
+ }
101
+
102
+ // Gate 5: ask (confirm signature is TITLE first — docs/extensions.md:165)
103
+ const confirmed = await ctx.ui.confirm(CONFIRM_TITLE, CONFIRM_MESSAGE);
104
+
105
+ // No, or cancel (Esc/timeout — `confirm` resolves `false` either way counts
106
+ // as decline): set the flag and do nothing else.
107
+ if (!confirmed) {
108
+ markPromptDone(ctx);
109
+ return;
110
+ }
111
+
112
+ // Yes: write the file atomically (tmp + rename) — strictly BEFORE the flag
113
+ // is set, so a failed write leaves both gates open and the offer self-heals.
114
+ const tmpPath = `${keybindingsPath}.${process.pid}.tmp`;
115
+ try {
116
+ writeFileSync(tmpPath, SNIPPET_JSON, "utf-8");
117
+ // Re-check immediately before the rename so a concurrently created file is
118
+ // never clobbered (TOCTOU).
119
+ if (existsSync(keybindingsPath)) {
120
+ // Someone created the file in the meantime: leave it intact, clean up
121
+ // our tmp, and end the offer (gate 4 blocks re-offer anyway).
122
+ try {
123
+ unlinkSync(tmpPath);
124
+ } catch {
125
+ // ignore
126
+ }
127
+ markPromptDone(ctx);
128
+ return;
129
+ }
130
+ renameSync(tmpPath, keybindingsPath);
131
+ } catch (error) {
132
+ // File write (or rename) failed: do NOT set the flag — the offer self-
133
+ // heals next session. Best-effort tmp cleanup first.
134
+ try {
135
+ unlinkSync(tmpPath);
136
+ } catch {
137
+ // ignore
138
+ }
139
+ ctx.ui.notify(
140
+ `Could not create keybindings.json: ${messageOf(error)}`,
141
+ "warning",
142
+ );
143
+ return;
144
+ }
145
+
146
+ // File written successfully → THEN set the flag (deliberately after the
147
+ // write, per the design).
148
+ markPromptDone(ctx);
149
+ ctx.ui.notify(CREATED_NOTIFY, "info");
150
+
151
+ // Auto-apply: ctx.reload() runs the exact /reload TUI flow (re-reads
152
+ // keybindings.json + re-binds extension shortcuts). Must be the LAST
153
+ // use of ctx — the reload invalidates this extension instance. If the
154
+ // reload itself fails, the TUI shows its own "Reload failed" status;
155
+ // the file + flag are already persisted, so a manual /reload heals
156
+ // it and the offer is not repeated (flag gate).
157
+ // Cast: reload() is typed on ExtensionCommandContext; the session_start
158
+ // handler receives the base ExtensionContext, but at TUI runtime the
159
+ // reload action is always present (verified: runner.js:611).
160
+ try {
161
+ await (ctx as ExtensionCommandContext).reload();
162
+ } catch {
163
+ // Swallowed on purpose: see comment above. The offer never throws.
164
+ }
165
+ }