@pi-archimedes/image-paste 2.7.0 → 2.7.2

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 attempts an automatic reload where the runtime supports it (on current Pi 0.85.1 you're notified to run `/reload`, and the cleared binding applies once it runs), 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,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/image-paste",
3
- "version": "2.7.0",
3
+ "version": "2.7.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -14,6 +14,13 @@
14
14
  "src"
15
15
  ],
16
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.2"
23
+ },
17
24
  "peerDependencies": {
18
25
  "@earendil-works/pi-coding-agent": ">=0.1.0",
19
26
  "@earendil-works/pi-tui": ">=0.1.0"
@@ -0,0 +1,523 @@
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, CREATED_RELOAD_HINT } = 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, opts?: { withReload?: boolean }) {
90
+ const confirm = vi.fn();
91
+ const notify = vi.fn();
92
+ // Intentionally mirrors the base ExtensionContext the real session_start
93
+ // handler receives: no command-only methods like `reload` (Pi 0.85.1
94
+ // attaches reload to the command context only), so tests stay honest about
95
+ // what the real ctx provides. The auto-reload branch is covered separately
96
+ // by passing { withReload: true }, which simulates a future Pi version
97
+ // that puts reload() on the event ctx.
98
+ const reload = opts?.withReload ? vi.fn(async () => {}) : undefined;
99
+ const ctx = {
100
+ mode,
101
+ hasUI: true,
102
+ ui: { confirm, notify },
103
+ ...(reload ? { reload } : {}),
104
+ } as unknown as ExtensionContext;
105
+ return { ctx, confirm, notify, reload };
106
+ }
107
+
108
+ function callArgs(m: ReturnType<typeof vi.fn>): unknown[][] {
109
+ return m.mock.calls as unknown[][];
110
+ }
111
+
112
+ beforeEach(() => {
113
+ cleanupArtifacts();
114
+ existsSyncSpy.mockReset();
115
+ existsSyncSpy.mockImplementation((p: unknown) => fs.existsSync(String(p)));
116
+ });
117
+
118
+ afterAll(() => {
119
+ try {
120
+ fs.rmSync(tempDir, { recursive: true, force: true });
121
+ } catch {
122
+ // ignore
123
+ }
124
+ });
125
+
126
+ // ── gate matrix ──────────────────────────────────────────────────────────────
127
+ // isConfigEnabled × {t,f} × ctx.mode ∈ {tui, rpc, json, print} × file {absent,
128
+ // present} × flag {unset, set} × confirm outcome {yes, no, cancel-resolves-false}
129
+ // (Esc/timeout: `confirm` resolves `false` either way → counts as decline).
130
+
131
+ interface Case {
132
+ enabled: boolean;
133
+ mode: Mode;
134
+ file: "absent" | "present";
135
+ flag: "unset" | "set";
136
+ outcome: "yes" | "no" | "cancel-resolves-false";
137
+ }
138
+
139
+ const CASES: Case[] = (() => {
140
+ const cases: Case[] = [];
141
+ for (const enabled of [false, true]) {
142
+ for (const mode of ["tui", "rpc", "json", "print"] as Mode[]) {
143
+ for (const file of ["absent", "present"] as const) {
144
+ for (const flag of ["unset", "set"] as const) {
145
+ for (const outcome of ["yes", "no", "cancel-resolves-false"] as const) {
146
+ cases.push({ enabled, mode, file, flag, outcome });
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+ return cases;
153
+ })();
154
+
155
+ describe("offerKeybindingFix — gate matrix", () => {
156
+ it.each(CASES)(
157
+ "$enabled/$mode/$file/$flag → $outcome",
158
+ async ({ enabled, mode, file, flag, outcome }) => {
159
+ // ── setup ──
160
+ const ns: Record<string, unknown> = {};
161
+ if (enabled === false) ns.enabled = false;
162
+ if (flag === "set") ns.keybindingsPromptDone = true;
163
+ const settingsBefore = { ...ns };
164
+ if (Object.keys(settingsBefore).length > 0) {
165
+ writeSettings({ [NAMESPACE]: settingsBefore });
166
+ }
167
+ const settingsBeforeJson = readSettings();
168
+
169
+ if (file === "present") {
170
+ fs.writeFileSync(keybindingsPath(), CONCURRENT_CONTENT, "utf-8");
171
+ }
172
+
173
+ const { ctx, confirm, notify } = makeCtx(mode);
174
+ if (outcome === "yes") confirm.mockResolvedValue(true);
175
+ else confirm.mockResolvedValue(false); // no + cancel: confirm resolves false
176
+
177
+ await offerKeybindingFix(ctx);
178
+
179
+ const shouldAsk =
180
+ enabled === true &&
181
+ mode === "tui" &&
182
+ flag === "unset" &&
183
+ file === "absent";
184
+
185
+ // ── gate behavior ──
186
+ expect(confirm).toHaveBeenCalledTimes(shouldAsk ? 1 : 0);
187
+ // never fires when the plugin is disabled
188
+ if (enabled === false) expect(confirm).not.toHaveBeenCalled();
189
+ // never fires when the file already exists
190
+ if (file === "present") expect(confirm).not.toHaveBeenCalled();
191
+ // never fires when the flag is already consumed
192
+ if (flag === "set") expect(confirm).not.toHaveBeenCalled();
193
+ // never fires in non-TUI modes
194
+ if (mode !== "tui") expect(confirm).not.toHaveBeenCalled();
195
+
196
+ if (shouldAsk) {
197
+ // confirm is called with the TITLE FIRST (docs/extensions.md:165)
198
+ const [title, message] = callArgs(confirm)[0] as [string, string];
199
+ expect(title).toBe("First run");
200
+ expect(typeof message).toBe("string");
201
+ expect(message).toContain("keybindings.json");
202
+ }
203
+
204
+ // ── file effects ──
205
+ const kbExists = fs.existsSync(keybindingsPath());
206
+ if (shouldAsk && outcome === "yes") {
207
+ // yes → file written with the exact snippet content
208
+ expect(kbExists).toBe(true);
209
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
210
+ } else {
211
+ // never written when the offer never fires; the pre-existing file is never
212
+ // clobbered
213
+ expect(kbExists).toBe(file === "present");
214
+ }
215
+
216
+ // ── flag effects ──
217
+ if (shouldAsk) {
218
+ // offer fires once and sets the flag on every outcome (yes, no, cancel → decline)
219
+ expect(readFlag()).toBe(true);
220
+ } else {
221
+ // non-TUI / non-offer rows never consume the flag — settings byte-identical
222
+ expect(readSettings()).toEqual(settingsBeforeJson);
223
+ expect(readFlag()).toBe(flag === "set");
224
+ }
225
+
226
+ // ── notifications ──
227
+ if (shouldAsk && outcome === "yes") {
228
+ // Base-shape ctx has no reload, so the offer emits the /reload hint
229
+ expect(
230
+ callArgs(notify).some(
231
+ (c) => c[0] === CREATED_RELOAD_HINT && c[1] === "info",
232
+ ),
233
+ ).toBe(true);
234
+ } else {
235
+ expect(notify).not.toHaveBeenCalled();
236
+ }
237
+ },
238
+ );
239
+ });
240
+
241
+ // ── flag persistence regression: load-modify-save preserves other keys ──────
242
+ // saveConfig REPLACES the namespace object (settings-io.ts:29-33), so a bare
243
+ // saveConfig(ns, { keybindingsPromptDone: true }) would erase other keys under
244
+ // archimedes.imagePaste.
245
+
246
+ describe("flag write preserves existing settings", () => {
247
+ async function run(outcome: "yes" | "no") {
248
+ writeSettings({
249
+ [NAMESPACE]: { enabled: true, someOtherKey: 42, nested: { a: 1 } },
250
+ "other.ns": { z: 1 },
251
+ });
252
+ const { ctx, confirm } = makeCtx("tui");
253
+ confirm.mockResolvedValue(outcome === "yes");
254
+ await offerKeybindingFix(ctx);
255
+ }
256
+
257
+ it("yes: snippet file written; other archimedes.imagePaste keys survive the flag set", async () => {
258
+ await run("yes");
259
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
260
+ const data = readSettings();
261
+ const ns = data[NAMESPACE] as Record<string, unknown>;
262
+ expect(ns).toEqual({
263
+ enabled: true,
264
+ someOtherKey: 42,
265
+ nested: { a: 1 },
266
+ keybindingsPromptDone: true,
267
+ });
268
+ expect(data["other.ns"]).toEqual({ z: 1 });
269
+ });
270
+
271
+ it("no: decline sets the flag via load-modify-save; keys survive, file untouched", async () => {
272
+ await run("no");
273
+ expect(fs.existsSync(keybindingsPath())).toBe(false);
274
+ const ns = readSettings()[NAMESPACE] as Record<string, unknown>;
275
+ expect(ns).toEqual({
276
+ enabled: true,
277
+ someOtherKey: 42,
278
+ nested: { a: 1 },
279
+ keybindingsPromptDone: true,
280
+ });
281
+ });
282
+ });
283
+
284
+ // ── error paths ──────────────────────────────────────────────────────────────
285
+
286
+ describe("file write throws → flag stays false (self-heals next session)", () => {
287
+ it("yes with an unwritable tmp target: no file, flag unset, error notified, no throw", async () => {
288
+ // Make the (pid-predictable) tmp path unwritable: a directory.
289
+ fs.mkdirSync(tmpPath(), { recursive: true });
290
+
291
+ const { ctx, confirm, notify } = makeCtx("tui");
292
+ confirm.mockResolvedValue(true);
293
+
294
+ await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
295
+
296
+ expect(confirm).toHaveBeenCalledTimes(1);
297
+ expect(fs.existsSync(keybindingsPath())).toBe(false);
298
+ expect(readFlag()).toBe(false);
299
+ expect(
300
+ readSettings()[NAMESPACE] ?? {},
301
+ ).toEqual({}); // nothing persisted to settings
302
+
303
+ expect(notify).toHaveBeenCalledTimes(1);
304
+ const [msg, type] = notify.mock.calls[0] as [string, string];
305
+ expect(type).toBe("warning");
306
+ expect(msg.length).toBeGreaterThan(0);
307
+ });
308
+ });
309
+
310
+ describe("flag write throws → notify, no crash (file write already succeeded)", () => {
311
+ it("yes with an unwritable settings target: file kept, flag unset, both notifies, no throw", async () => {
312
+ // Make settings.json an unwritable target: a directory (rename + direct
313
+ // write onto it both fail with EISDIR).
314
+ fs.mkdirSync(settingsPath(), { recursive: true });
315
+
316
+ const { ctx, confirm, notify } = makeCtx("tui");
317
+ confirm.mockResolvedValue(true);
318
+
319
+ await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
320
+
321
+ // The file write strictly precedes the flag set and survived:
322
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
323
+ // Flag persistence failed: settings.json is still the directory, no flag
324
+ expect(fs.statSync(settingsPath()).isDirectory()).toBe(true);
325
+ expect(readFlag()).toBe(false);
326
+ // Gate 4 blocks the re-offer anyway (file now exists) — by design
327
+
328
+ const args = callArgs(notify);
329
+ expect(
330
+ args.some((c) => c[0] === CREATED_RELOAD_HINT && c[1] === "info"),
331
+ ).toBe(true); // the file WAS created (base-shape ctx → /reload hint)
332
+ expect(args.some((c) => c[1] === "warning" && String(c[0]).length > 0)).toBe(true); // flag save error: non-empty warning message
333
+ });
334
+ });
335
+
336
+ // ── accept with base-shape ctx (no reload — Pi 0.85.1 event ctx) ──────────
337
+ // The real session_start ctx has no reload property (Pi 0.85.1 attaches it
338
+ // to the command context only), so the offer must fall back to a /reload
339
+ // hint instead of claiming it reloaded.
340
+
341
+ describe("accept with base-shape ctx (no reload)", () => {
342
+ it("yes: fallback /reload hint notified, file written, flag persisted, no throw", async () => {
343
+ const { ctx, confirm, notify } = makeCtx("tui");
344
+ confirm.mockResolvedValue(true);
345
+
346
+ await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
347
+
348
+ // File written with the exact snippet
349
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
350
+ // Flag persisted
351
+ expect(readFlag()).toBe(true);
352
+ // The truthful fallback message — no "reloading now" claim
353
+ expect(
354
+ callArgs(notify).some(
355
+ (c) => c[0] === CREATED_RELOAD_HINT && c[1] === "info",
356
+ ),
357
+ ).toBe(true);
358
+ expect(callArgs(notify).some((c) => c[0] === CREATED_NOTIFY)).toBe(false);
359
+ });
360
+
361
+ it("no path: no created notification at all", async () => {
362
+ const { ctx, confirm, notify } = makeCtx("tui");
363
+ confirm.mockResolvedValue(false);
364
+ await offerKeybindingFix(ctx);
365
+ expect(
366
+ callArgs(notify).some(
367
+ (c) => c[0] === CREATED_NOTIFY || c[0] === CREATED_RELOAD_HINT,
368
+ ),
369
+ ).toBe(false);
370
+ });
371
+ });
372
+
373
+ // ── auto-reload branch: simulates a future Pi version that puts ────────────
374
+ // reload() on the event ctx. A reload-carrying ctx (makeCtx with
375
+ // { withReload: true }) covers that branch; the base-shape tests above
376
+ // cover current Pi.
377
+
378
+ describe("auto-reload branch (future Pi with reload on the event ctx)", () => {
379
+ it("yes path: reload called exactly once after flag is persisted and CREATED_NOTIFY emitted", async () => {
380
+ // Track invocation order so we can assert flag is persisted before reload.
381
+ const order: string[] = [];
382
+ const { ctx, confirm, notify, reload } = makeCtx("tui", { withReload: true });
383
+ confirm.mockResolvedValue(true);
384
+ // Wrap the reload spy to record when it's called relative to flag write.
385
+ reload!.mockImplementation(async () => {
386
+ // At this point the flag must already be persisted to disk.
387
+ order.push(readFlag() ? "flag-then-reload" : "reload-before-flag");
388
+ });
389
+ notify.mockImplementation((...args: unknown[]) => {
390
+ if ((args[0] as string) === CREATED_NOTIFY) order.push("notify");
391
+ });
392
+
393
+ await offerKeybindingFix(ctx);
394
+
395
+ // reload called exactly once
396
+ expect(reload).toHaveBeenCalledTimes(1);
397
+ // flag was already set when reload ran
398
+ expect(order).toContain("flag-then-reload");
399
+ expect(order).not.toContain("reload-before-flag");
400
+ // CREATED_NOTIFY emitted before reload
401
+ const notifyIdx = order.indexOf("notify");
402
+ const reloadIdx = order.indexOf("flag-then-reload");
403
+ expect(notifyIdx).toBeGreaterThanOrEqual(0);
404
+ expect(reloadIdx).toBeGreaterThan(notifyIdx);
405
+ // File written
406
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
407
+ // CREATED_NOTIFY contains "reloading now"
408
+ expect(CREATED_NOTIFY).toContain("reloading now");
409
+ });
410
+
411
+ it("no path: reload NOT called", async () => {
412
+ const { ctx, confirm, reload } = makeCtx("tui", { withReload: true });
413
+ confirm.mockResolvedValue(false);
414
+ await offerKeybindingFix(ctx);
415
+ expect(reload).not.toHaveBeenCalled();
416
+ });
417
+
418
+ it("cancel (resolves false) path: reload NOT called", async () => {
419
+ const { ctx, confirm, reload } = makeCtx("tui", { withReload: true });
420
+ confirm.mockResolvedValue(false); // Esc/timeout: same as no
421
+ await offerKeybindingFix(ctx);
422
+ expect(reload).not.toHaveBeenCalled();
423
+ });
424
+
425
+ it("file-write-fails path: reload NOT called", async () => {
426
+ // Block the file write by making the tmp path a directory
427
+ fs.mkdirSync(tmpPath(), { recursive: true });
428
+ const { ctx, confirm, reload } = makeCtx("tui", { withReload: true });
429
+ confirm.mockResolvedValue(true);
430
+ await offerKeybindingFix(ctx);
431
+ expect(reload).not.toHaveBeenCalled();
432
+ });
433
+
434
+ it("TOCTOU path: reload NOT called (file appeared before the rename)", async () => {
435
+ // Same spy materialization as the concurrent-creation test: the file
436
+ // appears between gate 4 and the pre-rename re-check, so the offer ends
437
+ // before the reload step is ever reached.
438
+ let kbHits = 0;
439
+ existsSyncSpy.mockImplementation((p: unknown) => {
440
+ const s = String(p);
441
+ if (s === keybindingsPath()) {
442
+ kbHits += 1;
443
+ if (kbHits >= 2) {
444
+ if (!fs.existsSync(s)) fs.writeFileSync(s, CONCURRENT_CONTENT, "utf-8");
445
+ return true;
446
+ }
447
+ }
448
+ return fs.existsSync(s);
449
+ });
450
+
451
+ const { ctx, confirm, reload } = makeCtx("tui", { withReload: true });
452
+ confirm.mockResolvedValue(true);
453
+ await offerKeybindingFix(ctx);
454
+ expect(reload).not.toHaveBeenCalled();
455
+ });
456
+
457
+ it("reload rejects: offer still resolves, flag persisted, file present, CREATED_NOTIFY emitted", async () => {
458
+ const { ctx, confirm, notify, reload } = makeCtx("tui", { withReload: true });
459
+ confirm.mockResolvedValue(true);
460
+ reload!.mockRejectedValue(new Error("Reload failed"));
461
+
462
+ await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
463
+
464
+ // File must have been written
465
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
466
+ // Flag must have been set (reload runs after flag)
467
+ expect(readFlag()).toBe(true);
468
+ // CREATED_NOTIFY must have been emitted (fires before reload)
469
+ expect(
470
+ (notify.mock.calls as unknown[][]).some(
471
+ (c) => c[0] === CREATED_NOTIFY && c[1] === "info",
472
+ ),
473
+ ).toBe(true);
474
+ // reload was still attempted once
475
+ expect(reload).toHaveBeenCalledTimes(1);
476
+ });
477
+ });
478
+
479
+ describe("concurrent creation is never clobbered", () => {
480
+ it("file created between gate 4 and the pre-rename re-check: left intact, flag set", async () => {
481
+ // Simulate: the first keybindings.json check (gate 4) sees nothing; the
482
+ // second (pre-rename re-check) sees a file that a concurrent process just
483
+ // created — the spy materializes it on that hit.
484
+ let kbHits = 0;
485
+ existsSyncSpy.mockImplementation((p: unknown) => {
486
+ const s = String(p);
487
+ if (s === keybindingsPath()) {
488
+ kbHits += 1;
489
+ // NOTE: coupled to the module's two existsSync calls — gate-4 check,
490
+ // then the pre-rename re-check (keybinding-offer.ts). If the module
491
+ // de-duplicates or adds an existence check, update kbHits accordingly.
492
+ if (kbHits >= 2) {
493
+ if (!fs.existsSync(s)) {
494
+ fs.writeFileSync(s, CONCURRENT_CONTENT, "utf-8");
495
+ }
496
+ return true;
497
+ }
498
+ }
499
+ return fs.existsSync(s);
500
+ });
501
+
502
+ const { ctx, confirm, notify } = makeCtx("tui");
503
+ confirm.mockResolvedValue(true);
504
+
505
+ await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
506
+
507
+ // The concurrently created file is never clobbered by the snippet
508
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(CONCURRENT_CONTENT);
509
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).not.toBe(SNIPPET);
510
+ // The tmp file is cleaned up
511
+ expect(fs.existsSync(tmpPath())).toBe(false);
512
+ // The offer ends: the offer was made and the fix is in force (file exists)
513
+ expect(readFlag()).toBe(true);
514
+ // We did not create the file, so we do not claim to — and the offer
515
+ // ends before any reload step (no "reloading now" / /reload-hint
516
+ // notification either)
517
+ expect(
518
+ callArgs(notify).some(
519
+ (c) => c[0] === CREATED_NOTIFY || c[0] === CREATED_RELOAD_HINT,
520
+ ),
521
+ ).toBe(false);
522
+ });
523
+ });
@@ -0,0 +1,180 @@
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
+ /** Fallback wording for runtimes whose event ctx has no reload (Pi 0.85.1). */
23
+ export const CREATED_RELOAD_HINT =
24
+ "Created ~/.pi/agent/keybindings.json — run /reload to apply";
25
+
26
+ interface PromptConfig {
27
+ keybindingsPromptDone: boolean;
28
+ }
29
+
30
+ const PROMPT_DEFAULTS: PromptConfig = { keybindingsPromptDone: false };
31
+
32
+ function messageOf(error: unknown): string {
33
+ return error instanceof Error ? error.message : String(error);
34
+ }
35
+
36
+ /**
37
+ * Persist `keybindingsPromptDone: true` via updateConfig: optimistic re-read
38
+ * check + bounded retry, so a concurrent write to settings.json (e.g. another
39
+ * session's /plugins toggle) is detected and re-read — the flag save cannot
40
+ * clobber a newer `enabled` value. Never throws: a failed save notifies and
41
+ * leaves the flag unset (gate 4 blocks the re-offer once the file exists anyway).
42
+ */
43
+ function markPromptDone(ctx: ExtensionContext): void {
44
+ try {
45
+ updateConfig<PromptConfig>(
46
+ NAMESPACE,
47
+ PROMPT_DEFAULTS,
48
+ (cfg) => ({ ...cfg, keybindingsPromptDone: true }),
49
+ );
50
+ } catch (error) {
51
+ ctx.ui.notify(
52
+ `Could not persist keybinding prompt flag: ${messageOf(error)}`,
53
+ "warning",
54
+ );
55
+ }
56
+ }
57
+
58
+ /**
59
+ * First-run keybinding offer (once ever, all platforms).
60
+ *
61
+ * On the first TUI session, if `~/.pi/agent/keybindings.json` does not exist,
62
+ * offer to create it with the docs snippet so Pi's built-in clipboard paste
63
+ * doesn't double-fire with image-paste's Ctrl+V handler. Accepting writes the
64
+ * file (atomic tmp+rename, pre-rename existence re-check) and then sets the
65
+ * one-shot flag and, when the runtime exposes `reload()` on the session ctx,
66
+ * reloads the TUI (the exact /reload flow: re-reads keybindings.json and
67
+ * re-binds shortcuts) so the cleared built-in binding applies immediately. On
68
+ * Pi 0.85.1 the event ctx has no `reload`, so the user is notified to run
69
+ * `/reload` instead. Declining or cancelling (Esc/timeout — `confirm`
70
+ * resolves `false` either way) sets the flag without touching the file. A
71
+ * failed file write leaves the flag unset, so the offer self-heals next session.
72
+ *
73
+ * **Concurrent-session edge:** if the user runs `/new` or `/reload` while the
74
+ * confirm dialog is still open, `ctx.ui.confirm` resolves `false` (the TUI
75
+ * tears down). This counts as a decline — the flag is set and the offer will
76
+ * not appear again. To reset it, delete `archimedes.imagePaste.keybindingsPromptDone`
77
+ * from `~/.pi/agent/settings.json`.
78
+ */
79
+ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<void> {
80
+ // Gate 1: extension on.
81
+ // NOTE: reading archimedes.imagePaste.enabled in-package is a sanctioned
82
+ // exception to the AGENTS.md "Plugin on/off" rule and ADR 0012 — this
83
+ // function runs from the meta session_start handler before plugin
84
+ // registration, so there is no registration gate to catch it here.
85
+ // See docs/decisions/0012-plugin-gate-in-package-namespace.md § Exception.
86
+ if (!isConfigEnabled(NAMESPACE)) {
87
+ return;
88
+ }
89
+
90
+ // Gate 2: interactive TUI only. Deliberate: the flag is NOT consumed in
91
+ // non-TUI modes, so a later TUI session still gets the offer.
92
+ if (ctx.mode !== "tui") {
93
+ return;
94
+ }
95
+
96
+ // Gate 3: not yet consumed
97
+ if (loadConfig<PromptConfig>(NAMESPACE, { ...PROMPT_DEFAULTS }).keybindingsPromptDone === true) {
98
+ return;
99
+ }
100
+
101
+ // Gate 4: file absent — never merge into or rewrite an existing user file
102
+ const keybindingsPath = join(getAgentDir(), "keybindings.json");
103
+ if (existsSync(keybindingsPath)) {
104
+ return;
105
+ }
106
+
107
+ // Gate 5: ask (confirm signature is TITLE first — docs/extensions.md:165)
108
+ const confirmed = await ctx.ui.confirm(CONFIRM_TITLE, CONFIRM_MESSAGE);
109
+
110
+ // No, or cancel (Esc/timeout — `confirm` resolves `false` either way counts
111
+ // as decline): set the flag and do nothing else.
112
+ if (!confirmed) {
113
+ markPromptDone(ctx);
114
+ return;
115
+ }
116
+
117
+ // Yes: write the file atomically (tmp + rename) — strictly BEFORE the flag
118
+ // is set, so a failed write leaves both gates open and the offer self-heals.
119
+ const tmpPath = `${keybindingsPath}.${process.pid}.tmp`;
120
+ try {
121
+ writeFileSync(tmpPath, SNIPPET_JSON, "utf-8");
122
+ // Re-check immediately before the rename so a concurrently created file is
123
+ // never clobbered (TOCTOU).
124
+ if (existsSync(keybindingsPath)) {
125
+ // Someone created the file in the meantime: leave it intact, clean up
126
+ // our tmp, and end the offer (gate 4 blocks re-offer anyway).
127
+ try {
128
+ unlinkSync(tmpPath);
129
+ } catch {
130
+ // ignore
131
+ }
132
+ markPromptDone(ctx);
133
+ return;
134
+ }
135
+ renameSync(tmpPath, keybindingsPath);
136
+ } catch (error) {
137
+ // File write (or rename) failed: do NOT set the flag — the offer self-
138
+ // heals next session. Best-effort tmp cleanup first.
139
+ try {
140
+ unlinkSync(tmpPath);
141
+ } catch {
142
+ // ignore
143
+ }
144
+ ctx.ui.notify(
145
+ `Could not create keybindings.json: ${messageOf(error)}`,
146
+ "warning",
147
+ );
148
+ return;
149
+ }
150
+
151
+ // File written successfully → THEN set the flag (deliberately after the
152
+ // write, per the design).
153
+ markPromptDone(ctx);
154
+
155
+ // Auto-apply: when the runtime exposes `reload()` on this ctx, run the
156
+ // exact /reload TUI flow (re-reads keybindings.json + re-binds extension
157
+ // shortcuts) so the cleared built-in binding applies immediately. Pi
158
+ // 0.85.1 attaches `reload()` ONLY to the command context — the base
159
+ // context that event handlers (like this session_start handler) receive
160
+ // has no `reload` property, so auto-reload activates only on Pi versions
161
+ // that add it to the event ctx; otherwise the user is told to run
162
+ // /reload. reload() must be the LAST use of ctx — the reload invalidates
163
+ // this extension instance. If the reload itself fails, the TUI shows its
164
+ // own "Reload failed" status; the file + flag are already persisted, so a
165
+ // manual /reload heals it and the offer is not repeated (flag gate).
166
+ // Cast: reload() is typed on ExtensionCommandContext; the session_start
167
+ // handler receives the base ExtensionContext, hence the presence guard.
168
+ const reload = (ctx as ExtensionCommandContext).reload;
169
+ if (typeof reload === "function") {
170
+ ctx.ui.notify(CREATED_NOTIFY, "info");
171
+ try {
172
+ await reload();
173
+ } catch {
174
+ // Swallowed on purpose: the TUI shows its own "Reload failed" status;
175
+ // the offer never throws.
176
+ }
177
+ } else {
178
+ ctx.ui.notify(CREATED_RELOAD_HINT, "info");
179
+ }
180
+ }