@jmcombs/pi-1password 1.0.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -6
- package/credential-api.ts +328 -0
- package/index.ts +501 -258
- package/package.json +4 -3
- package/ui/bordered-popups.ts +43 -13
package/index.ts
CHANGED
|
@@ -13,23 +13,43 @@
|
|
|
13
13
|
* - https://pi.dev/docs/extensions
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import { createBashTool, createLocalBashOperations } from "@earendil-works/pi-coding-agent";
|
|
18
|
-
import { Type, type Static } from "typebox";
|
|
19
|
-
import { promisify } from "node:util";
|
|
20
16
|
import { exec } from "node:child_process";
|
|
21
|
-
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
17
|
+
import { chmod, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
22
18
|
import { homedir } from "node:os";
|
|
23
19
|
import { dirname, join } from "node:path";
|
|
24
|
-
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
25
20
|
import { fileURLToPath } from "node:url";
|
|
26
|
-
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
// `createLocalBashOperations` is NOT on oh-my-pi's shim. Accessing it through a
|
|
24
|
+
// namespace import makes a missing member `undefined` at runtime rather than a
|
|
25
|
+
// hard ESM link error that would take this whole module (and every consumer that
|
|
26
|
+
// imports it, e.g. via `resolveSecret`/`onboardSecret`) down on that runtime.
|
|
27
|
+
import * as piRuntime from "@earendil-works/pi-coding-agent";
|
|
28
|
+
// `createBashTool` + `getAgentDir` are always present on the pi runtime (and on
|
|
29
|
+
// oh-my-pi's legacy-pi compat shim), so they stay as static named imports.
|
|
30
|
+
import { createBashTool, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { type Static, Type } from "typebox";
|
|
32
|
+
|
|
33
|
+
import type { UiContext } from "./credential-api.js";
|
|
27
34
|
import {
|
|
28
35
|
confirmInBorderedPopup,
|
|
29
36
|
inputInBorderedPopup,
|
|
30
37
|
selectInBorderedPopup,
|
|
31
38
|
} from "./ui/bordered-popups.js";
|
|
32
39
|
|
|
40
|
+
// ── Public stateless credential API (D1/D3) ────────────────────────────
|
|
41
|
+
// The 1Password extension is the credential authority: consumer extensions
|
|
42
|
+
// import these from "@jmcombs/pi-1password" and never touch pi internals.
|
|
43
|
+
// See docs/1p-credential-api/API.md.
|
|
44
|
+
export {
|
|
45
|
+
changeSecret,
|
|
46
|
+
deleteSecret,
|
|
47
|
+
is1PasswordAvailable,
|
|
48
|
+
onboardSecret,
|
|
49
|
+
resolveSecret,
|
|
50
|
+
verifySecret,
|
|
51
|
+
} from "./credential-api.js";
|
|
52
|
+
|
|
33
53
|
const execAsync = promisify(exec);
|
|
34
54
|
|
|
35
55
|
// ── Internal helpers for diagnostics (properly typed) ──────────────────
|
|
@@ -37,7 +57,19 @@ const execAsync = promisify(exec);
|
|
|
37
57
|
interface OpStatus {
|
|
38
58
|
available: boolean;
|
|
39
59
|
version: string | null;
|
|
60
|
+
/**
|
|
61
|
+
* DIAGNOSTIC ONLY. Whether `op whoami` reports a live CLI session. Under the
|
|
62
|
+
* 1Password desktop-app biometric integration this is `false` for a cold
|
|
63
|
+
* invocation even when `op read` works — so it MUST NOT gate availability.
|
|
64
|
+
* Surfaced in diagnostics; never used for access decisions.
|
|
65
|
+
*/
|
|
40
66
|
signedIn: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Whether an `op` auth path is CONFIGURED (service-account token, Connect env,
|
|
69
|
+
* or a desktop/CLI account). This — not `signedIn` — is what gates availability.
|
|
70
|
+
* Determined passively: no unlock, no Touch ID prompt.
|
|
71
|
+
*/
|
|
72
|
+
configured: boolean;
|
|
41
73
|
account: Record<string, unknown> | null;
|
|
42
74
|
}
|
|
43
75
|
|
|
@@ -69,38 +101,73 @@ interface OpField {
|
|
|
69
101
|
type?: string;
|
|
70
102
|
}
|
|
71
103
|
|
|
72
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Whether an `op` auth path is CONFIGURED, checked passively (no unlock, no Touch
|
|
106
|
+
* ID). True when any of: `OP_SERVICE_ACCOUNT_TOKEN` is set; both `OP_CONNECT_HOST`
|
|
107
|
+
* and `OP_CONNECT_TOKEN` are set; or `op account list --format=json` exits 0 and
|
|
108
|
+
* parses to a non-empty array. Any failure/timeout/unparsable output ⇒ `false`;
|
|
109
|
+
* never throws. Never logs secret values.
|
|
110
|
+
*/
|
|
111
|
+
async function isOpConfigured(): Promise<boolean> {
|
|
112
|
+
if ((process.env.OP_SERVICE_ACCOUNT_TOKEN ?? "").length > 0) return true;
|
|
113
|
+
if (
|
|
114
|
+
(process.env.OP_CONNECT_HOST ?? "").length > 0 &&
|
|
115
|
+
(process.env.OP_CONNECT_TOKEN ?? "").length > 0
|
|
116
|
+
) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
73
119
|
try {
|
|
74
|
-
const { stdout } = await execAsync("op --
|
|
75
|
-
|
|
120
|
+
const { stdout } = await execAsync("op account list --format=json", {
|
|
121
|
+
encoding: "utf8",
|
|
122
|
+
timeout: 5000,
|
|
123
|
+
});
|
|
124
|
+
const parsed: unknown = JSON.parse(stdout);
|
|
125
|
+
return Array.isArray(parsed) && parsed.length > 0;
|
|
126
|
+
} catch {
|
|
127
|
+
// Non-zero exit, timeout, or unparsable output → treat as not configured.
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
76
131
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
account: null,
|
|
94
|
-
};
|
|
95
|
-
}
|
|
132
|
+
/**
|
|
133
|
+
* Probe the `op` CLI. Runs `op --version` (→ `available`), `op whoami` (→
|
|
134
|
+
* `signedIn`, DIAGNOSTIC ONLY — see below), and {@link isOpConfigured} (→
|
|
135
|
+
* `configured`) fresh on every call. Reused by {@link is1PasswordAvailable}.
|
|
136
|
+
*
|
|
137
|
+
* `signedIn` from `op whoami` is unreliable for gating: under the 1Password
|
|
138
|
+
* desktop-app biometric integration it returns non-zero for a cold CLI invocation
|
|
139
|
+
* even when `op read` works. Availability therefore gates on `configured`, not
|
|
140
|
+
* `signedIn`; the account session is unlocked lazily on the first
|
|
141
|
+
* `op read`. All `op` probes use a 5s timeout and never throw.
|
|
142
|
+
*/
|
|
143
|
+
export async function getOpStatus(): Promise<OpStatus> {
|
|
144
|
+
let version: string | null = null;
|
|
145
|
+
try {
|
|
146
|
+
const { stdout } = await execAsync("op --version", { encoding: "utf8", timeout: 5000 });
|
|
147
|
+
version = stdout.trim();
|
|
96
148
|
} catch {
|
|
97
|
-
return {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
149
|
+
return { available: false, version: null, signedIn: false, configured: false, account: null };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// signedIn — DIAGNOSTIC ONLY (see the OpStatus.signedIn doc). A
|
|
153
|
+
// non-zero `op whoami` is expected under app-integration and is NOT a gate.
|
|
154
|
+
let signedIn = false;
|
|
155
|
+
let account: Record<string, unknown> | null = null;
|
|
156
|
+
try {
|
|
157
|
+
const { stdout: whoamiOut } = await execAsync("op whoami --format json", {
|
|
158
|
+
encoding: "utf8",
|
|
159
|
+
timeout: 5000,
|
|
160
|
+
});
|
|
161
|
+
account = JSON.parse(whoamiOut) as Record<string, unknown>;
|
|
162
|
+
signedIn = true;
|
|
163
|
+
} catch {
|
|
164
|
+
signedIn = false;
|
|
165
|
+
account = null;
|
|
103
166
|
}
|
|
167
|
+
|
|
168
|
+
const configured = await isOpConfigured();
|
|
169
|
+
|
|
170
|
+
return { available: true, version, signedIn, configured, account };
|
|
104
171
|
}
|
|
105
172
|
|
|
106
173
|
async function inspectPluginIfRelevant(command: string): Promise<PluginInspection | null> {
|
|
@@ -133,7 +200,6 @@ async function inspectPluginIfRelevant(command: string): Promise<PluginInspectio
|
|
|
133
200
|
return { plugin: firstWord, output: (stdout || "").trim() };
|
|
134
201
|
} catch (e: unknown) {
|
|
135
202
|
const error = e as { stderr?: string; message?: string };
|
|
136
|
-
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
137
203
|
const msg = (error.stderr?.trim() ?? error.message) || "Unknown error";
|
|
138
204
|
return { plugin: firstWord, error: msg };
|
|
139
205
|
}
|
|
@@ -143,17 +209,22 @@ function formatOpStatus(status: OpStatus): string {
|
|
|
143
209
|
if (!status.available) {
|
|
144
210
|
return "1Password CLI (`op`) is not available in PATH.";
|
|
145
211
|
}
|
|
146
|
-
if (!status.
|
|
147
|
-
return `op ${status.version ?? "unknown"} is installed but
|
|
212
|
+
if (!status.configured) {
|
|
213
|
+
return `op ${status.version ?? "unknown"} is installed but no 1Password account is configured. Run 'op signin', or set OP_SERVICE_ACCOUNT_TOKEN (or OP_CONNECT_HOST + OP_CONNECT_TOKEN).`;
|
|
148
214
|
}
|
|
149
215
|
const acct = status.account ?? {};
|
|
150
216
|
const name =
|
|
151
217
|
(acct.name as string | undefined) ??
|
|
152
218
|
(acct.email as string | undefined) ??
|
|
153
219
|
(acct.account_uuid as string | undefined) ??
|
|
154
|
-
|
|
155
|
-
const url = (acct.url as string | undefined) ??
|
|
156
|
-
|
|
220
|
+
null;
|
|
221
|
+
const url = (acct.url as string | undefined) ?? null;
|
|
222
|
+
if (status.signedIn && name) {
|
|
223
|
+
return `op ${status.version ?? "unknown"} — signed in as ${name}${url ? ` (${url})` : ""}`;
|
|
224
|
+
}
|
|
225
|
+
// Configured but no live CLI session (typical under the desktop-app biometric
|
|
226
|
+
// integration) — the account session unlocks on the first `op read`.
|
|
227
|
+
return `op ${status.version ?? "unknown"} — 1Password account configured (session unlocks on first use).`;
|
|
157
228
|
}
|
|
158
229
|
|
|
159
230
|
// ── Shell env loading from ~/.pi/agent/auth.json (top-level keys per user choice A) ──
|
|
@@ -189,7 +260,13 @@ const KNOWN_PROVIDER_KEYS = new Set([
|
|
|
189
260
|
"xiaomi-token-plan-sgp",
|
|
190
261
|
]);
|
|
191
262
|
|
|
192
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Resolve a single stored auth value to its concrete secret. A `!op read '<ref>'`
|
|
265
|
+
* value runs the 1Password CLI; any other `!<cmd>` runs in a minimal shell; a bare
|
|
266
|
+
* literal is returned as-is. Fails closed (returns `null`) on any error. Reused by
|
|
267
|
+
* {@link resolveSecret} and {@link warmOpSessionIfNeeded}.
|
|
268
|
+
*/
|
|
269
|
+
export async function resolveShellValue(raw: unknown): Promise<string | null> {
|
|
193
270
|
if (typeof raw !== "string") return null;
|
|
194
271
|
const trimmed = raw.trim();
|
|
195
272
|
|
|
@@ -238,7 +315,6 @@ async function loadShellEnvMap(): Promise<Record<string, string>> {
|
|
|
238
315
|
const map = new Map<string, string>();
|
|
239
316
|
|
|
240
317
|
try {
|
|
241
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
242
318
|
const raw = await readFile(authPath, "utf8");
|
|
243
319
|
const parsed = JSON.parse(raw) as AuthJson;
|
|
244
320
|
|
|
@@ -261,7 +337,7 @@ async function loadShellEnvMap(): Promise<Record<string, string>> {
|
|
|
261
337
|
// In-memory map for the current session (populated on session_start)
|
|
262
338
|
let currentShellEnv: Record<string, string> = {};
|
|
263
339
|
|
|
264
|
-
// Curated shell plugin list (loaded once at startup for /
|
|
340
|
+
// Curated shell plugin list (loaded once at startup for /1password_setup suggestions)
|
|
265
341
|
let curatedPlugins: CuratedPlugin[] = [];
|
|
266
342
|
|
|
267
343
|
/** Returns the *names* of currently injected shell env vars (never the values). Safe for diagnostics / LLM. */
|
|
@@ -269,7 +345,7 @@ export function getShellEnvNames(): string[] {
|
|
|
269
345
|
return Object.keys(currentShellEnv);
|
|
270
346
|
}
|
|
271
347
|
|
|
272
|
-
// ── Curated list + auth.json writer (for /
|
|
348
|
+
// ── Curated list + auth.json writer (for /1password_setup) ────────────
|
|
273
349
|
|
|
274
350
|
/** Load the maintained list of 1P shell plugins (generated by scripts/update-1p-shell-plugins.ts). */
|
|
275
351
|
async function loadCuratedPlugins(): Promise<CuratedPlugin[]> {
|
|
@@ -298,12 +374,10 @@ async function addAuthEntry(
|
|
|
298
374
|
const authDir = getAgentDir();
|
|
299
375
|
const authPath = join(authDir, "auth.json");
|
|
300
376
|
|
|
301
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
302
377
|
await mkdir(authDir, { recursive: true });
|
|
303
378
|
|
|
304
379
|
const existing = new Map<string, unknown>();
|
|
305
380
|
try {
|
|
306
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
307
381
|
const raw = await readFile(authPath, "utf8");
|
|
308
382
|
const parsed: unknown = JSON.parse(raw);
|
|
309
383
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
@@ -328,24 +402,369 @@ async function addAuthEntry(
|
|
|
328
402
|
// Convention: single quotes around the op:// ref
|
|
329
403
|
existing.set(envVar, `!op read '${opRef}'`);
|
|
330
404
|
|
|
331
|
-
const content = JSON.stringify(Object.fromEntries(existing), null, 2)
|
|
332
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
405
|
+
const content = `${JSON.stringify(Object.fromEntries(existing), null, 2)}\n`;
|
|
333
406
|
await writeFile(authPath, content, "utf8");
|
|
334
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
335
407
|
await chmod(authPath, 0o600);
|
|
336
408
|
|
|
337
409
|
return { success: true, message: "Entry added.", path: authPath };
|
|
338
410
|
}
|
|
339
411
|
|
|
340
|
-
// ──
|
|
412
|
+
// ── Stateless auth.json access + locked provider-shaped writer (D3/D4) ──
|
|
341
413
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
414
|
+
/** Absolute path to the agent's auth.json (honors PI_CODING_AGENT_DIR). */
|
|
415
|
+
function getAuthFilePath(): string {
|
|
416
|
+
return join(getAgentDir(), "auth.json");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Read and parse auth.json fresh, returning the top-level object (or `{}` if the
|
|
421
|
+
* file is missing / unreadable / not a JSON object). No caching — every call hits
|
|
422
|
+
* disk, per the stateless contract (D3).
|
|
423
|
+
*/
|
|
424
|
+
export async function readAuthJson(): Promise<Record<string, unknown>> {
|
|
425
|
+
try {
|
|
426
|
+
const raw = await readFile(getAuthFilePath(), "utf8");
|
|
427
|
+
const parsed: unknown = JSON.parse(raw);
|
|
428
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
429
|
+
return parsed as Record<string, unknown>;
|
|
430
|
+
}
|
|
431
|
+
} catch {
|
|
432
|
+
// Missing or invalid → behave as an empty store.
|
|
433
|
+
}
|
|
434
|
+
return {};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Result of a locked provider-shaped write. */
|
|
438
|
+
export interface ProviderWriteResult {
|
|
439
|
+
readonly success: boolean;
|
|
440
|
+
readonly message: string;
|
|
441
|
+
readonly alreadyExists?: boolean;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Acquire an exclusive advisory lock via an `O_EXCL` lockfile next to auth.json.
|
|
446
|
+
* Retries with jittered backoff; force-clears a lock older than the timeout
|
|
447
|
+
* (treated as stale). Returns a release function that removes the lockfile.
|
|
448
|
+
*/
|
|
449
|
+
async function acquireAuthLock(lockPath: string): Promise<() => Promise<void>> {
|
|
450
|
+
const timeoutMs = 5000;
|
|
451
|
+
const start = Date.now();
|
|
452
|
+
for (;;) {
|
|
453
|
+
try {
|
|
454
|
+
// "wx" = O_CREAT | O_EXCL | O_WRONLY — fails if the lockfile already exists.
|
|
455
|
+
const handle = await open(lockPath, "wx");
|
|
456
|
+
await handle.close();
|
|
457
|
+
return async (): Promise<void> => {
|
|
458
|
+
try {
|
|
459
|
+
await unlink(lockPath);
|
|
460
|
+
} catch {
|
|
461
|
+
// Already gone — nothing to release.
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
} catch (e) {
|
|
465
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
466
|
+
if (code !== "EEXIST") throw e;
|
|
467
|
+
if (Date.now() - start > timeoutMs) {
|
|
468
|
+
// Presumed stale (holder crashed); clear it and retry.
|
|
469
|
+
try {
|
|
470
|
+
await unlink(lockPath);
|
|
471
|
+
} catch {
|
|
472
|
+
// Someone else cleared it first.
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
await new Promise((resolve) => setTimeout(resolve, 25 + Math.floor(Math.random() * 25)));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Read-modify-write auth.json under an exclusive lock, then commit atomically via
|
|
482
|
+
* temp-write + rename. `mutator` returns the next object to persist, or `null` to
|
|
483
|
+
* abort with no write. Every intermediate file is chmod 0600.
|
|
484
|
+
*/
|
|
485
|
+
async function mutateAuthFileLocked(
|
|
486
|
+
mutator: (current: Record<string, unknown>) => Record<string, unknown> | null,
|
|
487
|
+
): Promise<{ changed: boolean }> {
|
|
488
|
+
const authDir = getAgentDir();
|
|
489
|
+
const authPath = join(authDir, "auth.json");
|
|
490
|
+
const lockPath = `${authPath}.lock`;
|
|
491
|
+
|
|
492
|
+
await mkdir(authDir, { recursive: true });
|
|
493
|
+
const release = await acquireAuthLock(lockPath);
|
|
494
|
+
try {
|
|
495
|
+
let current: Record<string, unknown> = {};
|
|
496
|
+
try {
|
|
497
|
+
const raw = await readFile(authPath, "utf8");
|
|
498
|
+
const parsed: unknown = JSON.parse(raw);
|
|
499
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
500
|
+
current = parsed as Record<string, unknown>;
|
|
501
|
+
}
|
|
502
|
+
} catch {
|
|
503
|
+
// Missing or invalid JSON → start fresh.
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const next = mutator(current);
|
|
507
|
+
if (next === null) return { changed: false };
|
|
508
|
+
|
|
509
|
+
const content = `${JSON.stringify(next, null, 2)}\n`;
|
|
510
|
+
const tmpPath = `${authPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
511
|
+
try {
|
|
512
|
+
await writeFile(tmpPath, content, { encoding: "utf8", mode: 0o600 });
|
|
513
|
+
await chmod(tmpPath, 0o600);
|
|
514
|
+
await rename(tmpPath, authPath);
|
|
515
|
+
await chmod(authPath, 0o600);
|
|
516
|
+
} catch (e) {
|
|
517
|
+
try {
|
|
518
|
+
await unlink(tmpPath);
|
|
519
|
+
} catch {
|
|
520
|
+
// Temp file may not exist — ignore.
|
|
521
|
+
}
|
|
522
|
+
throw e;
|
|
523
|
+
}
|
|
524
|
+
return { changed: true };
|
|
525
|
+
} finally {
|
|
526
|
+
await release();
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Write a provider-shaped entry `{[name]:{type:"api_key",key}}` to auth.json under
|
|
532
|
+
* the lock (D4). `key` is either a `!op read '<ref>'` command (vault) or a literal
|
|
533
|
+
* secret (manual). Refuses to clobber an existing key unless `overwrite` is set.
|
|
534
|
+
*/
|
|
535
|
+
export async function writeProviderAuthEntry(
|
|
536
|
+
name: string,
|
|
537
|
+
key: string,
|
|
538
|
+
options: { overwrite?: boolean } = {},
|
|
539
|
+
): Promise<ProviderWriteResult> {
|
|
540
|
+
let alreadyExists = false;
|
|
541
|
+
const { changed } = await mutateAuthFileLocked((current) => {
|
|
542
|
+
alreadyExists = Object.hasOwn(current, name);
|
|
543
|
+
if (alreadyExists && !options.overwrite) return null;
|
|
544
|
+
return { ...current, [name]: { type: "api_key", key } };
|
|
545
|
+
});
|
|
546
|
+
if (!changed) {
|
|
547
|
+
return {
|
|
548
|
+
success: false,
|
|
549
|
+
alreadyExists: true,
|
|
550
|
+
message: `Key "${name}" already exists in auth.json. Use changeSecret (overwrite) or remove it first.`,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
return { success: true, message: "Entry saved." };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Remove `parsed[name]` from auth.json under the lock. `ok` is `true` when an entry
|
|
558
|
+
* was present and removed, `false` when there was nothing to remove.
|
|
559
|
+
*/
|
|
560
|
+
export async function deleteAuthEntry(name: string): Promise<{ ok: boolean }> {
|
|
561
|
+
let existed = false;
|
|
562
|
+
await mutateAuthFileLocked((current) => {
|
|
563
|
+
if (!Object.hasOwn(current, name)) {
|
|
564
|
+
existed = false;
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
existed = true;
|
|
568
|
+
const next = { ...current };
|
|
569
|
+
delete next[name];
|
|
570
|
+
return next;
|
|
571
|
+
});
|
|
572
|
+
return { ok: existed };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Scan all auth.json values — top-level strings AND nested provider-shaped `.key`
|
|
577
|
+
* values (D7; `loadShellEnvMap` inspects neither nested keys nor provider ids) —
|
|
578
|
+
* for the first `!op read ` reference. Returns the trimmed value or `null`.
|
|
579
|
+
*/
|
|
580
|
+
export function findFirstOpRef(parsed: Record<string, unknown>): string | null {
|
|
581
|
+
for (const value of Object.values(parsed)) {
|
|
582
|
+
const candidate =
|
|
583
|
+
typeof value === "string"
|
|
584
|
+
? value
|
|
585
|
+
: value && typeof value === "object" && !Array.isArray(value)
|
|
586
|
+
? (value as { key?: unknown }).key
|
|
587
|
+
: undefined;
|
|
588
|
+
if (typeof candidate === "string" && /^!op read /.test(candidate.trim())) {
|
|
589
|
+
return candidate.trim();
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Warm the 1Password account session on load (D7/D8). Scans auth.json for any
|
|
597
|
+
* `!op read ` reference and, if one exists, runs a single best-effort `op read`
|
|
598
|
+
* (value discarded) so the OS-level biometric prompt lands at startup. Silent and
|
|
599
|
+
* fail-closed: never throws, does nothing when there are no vault references.
|
|
600
|
+
*/
|
|
601
|
+
export async function warmOpSessionIfNeeded(): Promise<void> {
|
|
602
|
+
try {
|
|
603
|
+
const parsed = await readAuthJson();
|
|
604
|
+
const firstRef = findFirstOpRef(parsed);
|
|
605
|
+
if (!firstRef) return;
|
|
606
|
+
const ref = firstRef.replace(/^!op read\s+/, "").replace(/^['"]|['"]$/g, "");
|
|
607
|
+
try {
|
|
608
|
+
await execAsync(`op read "${ref}"`, {
|
|
609
|
+
encoding: "utf8",
|
|
610
|
+
maxBuffer: 1024 * 1024,
|
|
611
|
+
timeout: 15000,
|
|
612
|
+
});
|
|
613
|
+
} catch {
|
|
614
|
+
// Best effort — a failed warm-up must not disrupt startup.
|
|
615
|
+
}
|
|
616
|
+
} catch {
|
|
617
|
+
// Never let warm-on-load throw.
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Interactive vault → item → field picker (custom bordered TUI) that returns a
|
|
623
|
+
* fully-qualified `op://Vault/Item/field` reference or `null` on cancel. Used by
|
|
624
|
+
* both the `/1password_setup` command and the public {@link onboardSecret} flow.
|
|
625
|
+
*/
|
|
626
|
+
export async function pickOpReferenceSimple(ctx: UiContext): Promise<string | null> {
|
|
627
|
+
const browseHelp = "↑↓ • Enter • Esc = cancel • Type to filter";
|
|
628
|
+
|
|
629
|
+
ctx.ui.setStatus("1p-onboard", "Loading vaults...");
|
|
630
|
+
let vaultNames: string[] = [];
|
|
631
|
+
try {
|
|
632
|
+
const { stdout } = await execAsync(`op vault list --format json`, {
|
|
633
|
+
encoding: "utf8",
|
|
634
|
+
timeout: 20000,
|
|
635
|
+
});
|
|
636
|
+
const parsed = JSON.parse(stdout || "[]") as OpVault[];
|
|
637
|
+
vaultNames = parsed.map((v) => v.name).sort((a, b) => a.localeCompare(b));
|
|
638
|
+
} catch {
|
|
639
|
+
ctx.ui.notify("Couldn't reach 1Password — make sure it's unlocked.", "warning");
|
|
640
|
+
ctx.ui.setStatus("1p-onboard", undefined);
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
ctx.ui.setStatus("1p-onboard", undefined);
|
|
644
|
+
|
|
645
|
+
if (vaultNames.length === 0) {
|
|
646
|
+
ctx.ui.notify("No vaults found in your 1Password account.", "warning");
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const vaultItems = [
|
|
651
|
+
...vaultNames.map((name) => ({ value: name, label: name })),
|
|
652
|
+
{ value: "__cancel", label: "Cancel" },
|
|
653
|
+
];
|
|
654
|
+
const chosenVault = await selectInBorderedPopup(ctx, {
|
|
655
|
+
title: "Choose a vault",
|
|
656
|
+
items: vaultItems,
|
|
657
|
+
helpText: browseHelp,
|
|
658
|
+
maxVisible: 14,
|
|
659
|
+
});
|
|
660
|
+
if (!chosenVault || chosenVault === "__cancel") return null;
|
|
661
|
+
|
|
662
|
+
ctx.ui.setStatus("1p-onboard", `Loading items from ${chosenVault}...`);
|
|
663
|
+
let items: OpItem[] = [];
|
|
664
|
+
try {
|
|
665
|
+
const cmd = `op item list --vault ${JSON.stringify(chosenVault)} --categories "API Credential,Login,Secure Note,Password" --format json`;
|
|
666
|
+
const { stdout } = await execAsync(cmd, {
|
|
667
|
+
encoding: "utf8",
|
|
668
|
+
timeout: 25000,
|
|
669
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
670
|
+
});
|
|
671
|
+
const parsed = JSON.parse(stdout || "[]") as OpItem[];
|
|
672
|
+
items = parsed.sort((a, b) => (a.title || "").localeCompare(b.title || ""));
|
|
673
|
+
} catch {
|
|
674
|
+
ctx.ui.notify(`Couldn't load items from "${chosenVault}". Try again.`, "warning");
|
|
675
|
+
ctx.ui.setStatus("1p-onboard", undefined);
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
ctx.ui.setStatus("1p-onboard", undefined);
|
|
679
|
+
|
|
680
|
+
if (items.length === 0) {
|
|
681
|
+
ctx.ui.notify(`No API keys or logins found in "${chosenVault}".`, "warning");
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const itemItems = [
|
|
686
|
+
...items.map((it) => ({
|
|
687
|
+
value: it.id,
|
|
688
|
+
label: `${it.title}${it.category ? ` — ${it.category}` : ""}`,
|
|
689
|
+
})),
|
|
690
|
+
{ value: "__cancel", label: "Cancel" },
|
|
691
|
+
];
|
|
692
|
+
const chosenItemId = await selectInBorderedPopup(ctx, {
|
|
693
|
+
title: `Choose an item in ${chosenVault}`,
|
|
694
|
+
items: itemItems,
|
|
695
|
+
helpText: browseHelp,
|
|
696
|
+
maxVisible: 16,
|
|
697
|
+
});
|
|
698
|
+
if (!chosenItemId || chosenItemId === "__cancel") return null;
|
|
699
|
+
|
|
700
|
+
const chosenItem = items.find((it) => it.id === chosenItemId);
|
|
701
|
+
if (!chosenItem) return null;
|
|
702
|
+
|
|
703
|
+
ctx.ui.setStatus("1p-onboard", "Loading fields...");
|
|
704
|
+
let fields: { label: string; type?: string }[] = [];
|
|
705
|
+
try {
|
|
706
|
+
const { stdout } = await execAsync(
|
|
707
|
+
`op item get ${JSON.stringify(chosenItem.id)} --format json`,
|
|
708
|
+
{
|
|
709
|
+
encoding: "utf8",
|
|
710
|
+
timeout: 15000,
|
|
711
|
+
},
|
|
712
|
+
);
|
|
713
|
+
const full = JSON.parse(stdout || "null") as { fields?: OpField[] } | null;
|
|
714
|
+
fields = full?.fields?.filter(Boolean) ?? [];
|
|
715
|
+
} catch {
|
|
716
|
+
ctx.ui.notify("Couldn't load that item's fields. Try again.", "warning");
|
|
717
|
+
ctx.ui.setStatus("1p-onboard", undefined);
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
ctx.ui.setStatus("1p-onboard", undefined);
|
|
721
|
+
|
|
722
|
+
if (fields.length === 0) {
|
|
723
|
+
ctx.ui.notify("That item has no fields to use.", "warning");
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Auto-skip the field step when the item has exactly one credential-type field
|
|
728
|
+
// (a concealed value, or a label like password / credential / api key / secret /
|
|
729
|
+
// token) — the overwhelmingly common case for an API-key item.
|
|
730
|
+
const credentialFields = fields.filter(isCredentialField);
|
|
731
|
+
let chosenFieldLabel: string | null;
|
|
732
|
+
if (credentialFields.length === 1) {
|
|
733
|
+
chosenFieldLabel = credentialFields[0]?.label ?? null;
|
|
734
|
+
} else {
|
|
735
|
+
const fieldItems = [
|
|
736
|
+
...fields.map((f) => ({
|
|
737
|
+
value: f.label,
|
|
738
|
+
label: `${f.label} (${f.type ?? "text"})`,
|
|
739
|
+
})),
|
|
740
|
+
{ value: "__cancel", label: "Cancel" },
|
|
741
|
+
];
|
|
742
|
+
chosenFieldLabel = await selectInBorderedPopup(ctx, {
|
|
743
|
+
title: "Which field holds the key?",
|
|
744
|
+
items: fieldItems,
|
|
745
|
+
helpText: browseHelp,
|
|
746
|
+
maxVisible: 12,
|
|
747
|
+
});
|
|
748
|
+
if (!chosenFieldLabel || chosenFieldLabel === "__cancel") return null;
|
|
749
|
+
}
|
|
750
|
+
if (!chosenFieldLabel) return null;
|
|
751
|
+
|
|
752
|
+
return `op://${chosenVault}/${chosenItem.title}/${chosenFieldLabel}`;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Heuristic: does this 1Password field hold a credential value? True for a
|
|
757
|
+
* concealed field, or a label naming a password / credential / api key / secret /
|
|
758
|
+
* token. Uses only the field's label and type — never its value — so nothing
|
|
759
|
+
* secret is read here.
|
|
760
|
+
*/
|
|
761
|
+
function isCredentialField(field: { label: string; type?: string }): boolean {
|
|
762
|
+
const type = (field.type ?? "").toUpperCase();
|
|
763
|
+
const label = field.label.toLowerCase();
|
|
764
|
+
return type === "CONCEALED" || /password|credential|api[\s_-]?key|secret|token/.test(label);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// ── Tool schemas ───────────────────────────────────────────────────────
|
|
349
768
|
|
|
350
769
|
const diagnoseSchema = Type.Object({});
|
|
351
770
|
export type DiagnoseInput = Static<typeof diagnoseSchema>;
|
|
@@ -356,9 +775,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
356
775
|
// Load initial shell env (top-level keys from auth.json)
|
|
357
776
|
currentShellEnv = await loadShellEnvMap();
|
|
358
777
|
|
|
359
|
-
// Load curated list for /
|
|
778
|
+
// Load curated list for /1password_setup
|
|
360
779
|
curatedPlugins = await loadCuratedPlugins();
|
|
361
780
|
|
|
781
|
+
// Warm the 1Password account session if any auth.json value uses `!op read`
|
|
782
|
+
// (D7/D8) so the OS-level biometric prompt lands once, at startup. No-op and
|
|
783
|
+
// silent when there are no vault references.
|
|
784
|
+
await warmOpSessionIfNeeded();
|
|
785
|
+
|
|
362
786
|
// ── Bash tool wrapper with transparent 1P env injection ───────────────
|
|
363
787
|
const cwd = process.cwd();
|
|
364
788
|
const injectedBash = createBashTool(cwd, {
|
|
@@ -370,89 +794,21 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
370
794
|
});
|
|
371
795
|
pi.registerTool(injectedBash);
|
|
372
796
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
797
|
+
// Transparent 1P injection for user `!bash` commands — only when the runtime
|
|
798
|
+
// exposes `createLocalBashOperations` (real pi does; oh-my-pi's compat shim does
|
|
799
|
+
// not). Absent it, we skip the hook rather than crash the module load.
|
|
800
|
+
if (typeof piRuntime.createLocalBashOperations === "function") {
|
|
801
|
+
pi.on("user_bash", () => ({ operations: piRuntime.createLocalBashOperations() }));
|
|
802
|
+
} else if (process.env.HEADROOM_DEBUG) {
|
|
803
|
+
console.error(
|
|
804
|
+
"[1password] createLocalBashOperations unavailable on this runtime; user `!` 1P injection disabled (transparent agent-bash injection still active).",
|
|
805
|
+
);
|
|
806
|
+
}
|
|
376
807
|
|
|
377
808
|
pi.on("session_start", async () => {
|
|
378
809
|
currentShellEnv = await loadShellEnvMap();
|
|
379
810
|
curatedPlugins = await loadCuratedPlugins();
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
// ── 1p_run ───────────────────────────────────────────────────────────
|
|
383
|
-
pi.registerTool({
|
|
384
|
-
name: "1p_run",
|
|
385
|
-
label: "1Password Run Command",
|
|
386
|
-
description:
|
|
387
|
-
"Run a shell command with 1Password credential injection via `op run -- <command>`. Includes automatic diagnostics on failure.",
|
|
388
|
-
parameters: runSchema,
|
|
389
|
-
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
390
|
-
const status = await getOpStatus();
|
|
391
|
-
const pluginInfo = await inspectPluginIfRelevant(params.command);
|
|
392
|
-
|
|
393
|
-
if (!status.available) {
|
|
394
|
-
return {
|
|
395
|
-
content: [{ type: "text", text: `1p_run failed: ${formatOpStatus(status)}` }],
|
|
396
|
-
details: { error: "op not found", opStatus: status },
|
|
397
|
-
};
|
|
398
|
-
}
|
|
399
|
-
if (!status.signedIn) {
|
|
400
|
-
return {
|
|
401
|
-
content: [
|
|
402
|
-
{
|
|
403
|
-
type: "text",
|
|
404
|
-
text: `1p_run failed: ${formatOpStatus(status)}\n\nPlease run 'op signin' in your terminal.`,
|
|
405
|
-
},
|
|
406
|
-
],
|
|
407
|
-
details: { error: "not signed in", opStatus: status },
|
|
408
|
-
};
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
try {
|
|
412
|
-
const { stdout, stderr } = await execAsync(`op run -- ${params.command}`, {
|
|
413
|
-
encoding: "utf8",
|
|
414
|
-
maxBuffer: 5 * 1024 * 1024,
|
|
415
|
-
timeout: 120000,
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
const output = (stdout || "").trim();
|
|
419
|
-
const err = (stderr || "").trim();
|
|
420
|
-
|
|
421
|
-
let text = output || "(no stdout)";
|
|
422
|
-
if (err) text += `\n\n[stderr]\n${err}`;
|
|
423
|
-
|
|
424
|
-
return {
|
|
425
|
-
content: [{ type: "text", text }],
|
|
426
|
-
details: {
|
|
427
|
-
command: params.command,
|
|
428
|
-
opStatus: status,
|
|
429
|
-
pluginInspection: pluginInfo,
|
|
430
|
-
},
|
|
431
|
-
};
|
|
432
|
-
} catch (error: unknown) {
|
|
433
|
-
const err = error as { stderr?: string; message?: string };
|
|
434
|
-
const rawError = (err.stderr?.trim() ?? err.message ?? "") || String(error);
|
|
435
|
-
const diagnostic = formatOpStatus(status);
|
|
436
|
-
|
|
437
|
-
let helpful = `Command failed under 1Password injection.\n\n${diagnostic}`;
|
|
438
|
-
|
|
439
|
-
if (pluginInfo) {
|
|
440
|
-
helpful += `\n\nPlugin status for "${pluginInfo.plugin}":\n${pluginInfo.output ?? pluginInfo.error ?? ""}`;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
helpful += `\n\nError from command:\n${rawError}`;
|
|
444
|
-
|
|
445
|
-
return {
|
|
446
|
-
content: [{ type: "text", text: helpful }],
|
|
447
|
-
details: {
|
|
448
|
-
error: rawError,
|
|
449
|
-
command: params.command,
|
|
450
|
-
opStatus: status,
|
|
451
|
-
pluginInspection: pluginInfo,
|
|
452
|
-
},
|
|
453
|
-
};
|
|
454
|
-
}
|
|
455
|
-
},
|
|
811
|
+
await warmOpSessionIfNeeded();
|
|
456
812
|
});
|
|
457
813
|
|
|
458
814
|
// ── Shared diagnostic logic (used by both 1p_diagnose tool and /1password_diagnose command) ──
|
|
@@ -467,7 +823,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
467
823
|
if (info) inspections.push(info);
|
|
468
824
|
}
|
|
469
825
|
|
|
470
|
-
let report = formatOpStatus(status)
|
|
826
|
+
let report = `${formatOpStatus(status)}\n\n`;
|
|
471
827
|
|
|
472
828
|
if (inspections.length > 0) {
|
|
473
829
|
report += "Plugin configuration:\n";
|
|
@@ -505,7 +861,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
505
861
|
name: "1p_diagnose",
|
|
506
862
|
label: "1Password Diagnostics",
|
|
507
863
|
description:
|
|
508
|
-
"Check the current status of the 1Password CLI (`op`), sign-in state, plugin configuration, and active shell env injection (from ~/.pi/agent/auth.json). Use this when
|
|
864
|
+
"Check the current status of the 1Password CLI (`op`), sign-in state, plugin configuration, and active shell env injection (from ~/.pi/agent/auth.json). Use this when 1password_setup or 1password_diagnose are not working as expected, or to verify transparent token injection for bare `gh` / `aws` etc.",
|
|
509
865
|
parameters: diagnoseSchema,
|
|
510
866
|
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
511
867
|
const { report, details } = await get1PasswordDiagnosticReport();
|
|
@@ -533,130 +889,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
533
889
|
|
|
534
890
|
// TODO (known limitation): /1password_diagnose currently gathers data directly
|
|
535
891
|
// for reliability and presents it. Injecting a prompt via sendUserMessage does not
|
|
536
|
-
// reliably cause the LLM to start a new turn and use 1p_diagnose
|
|
892
|
+
// reliably cause the LLM to start a new turn and use 1p_diagnose for
|
|
537
893
|
// nicer formatting, because regular command handlers have limited access to
|
|
538
894
|
// the "deliverAs: nextTurn" / sendUserMessage APIs that force LLM reasoning.
|
|
539
895
|
// This should be revisited when better support exists or a different pattern
|
|
540
896
|
// is found. Tracked as a follow-up issue.
|
|
541
897
|
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
async function pickOpReferenceSimple(ctx: ExtensionCommandContext): Promise<string | null> {
|
|
545
|
-
ctx.ui.setStatus("1p-onboard", "Loading vaults...");
|
|
546
|
-
let vaultNames: string[] = [];
|
|
547
|
-
try {
|
|
548
|
-
const { stdout } = await execAsync(`op vault list --format json`, {
|
|
549
|
-
encoding: "utf8",
|
|
550
|
-
timeout: 20000,
|
|
551
|
-
});
|
|
552
|
-
const parsed = JSON.parse(stdout || "[]") as OpVault[];
|
|
553
|
-
vaultNames = parsed.map((v) => v.name).sort((a, b) => a.localeCompare(b));
|
|
554
|
-
} catch {
|
|
555
|
-
ctx.ui.notify("Failed to load vaults from 1Password.", "error");
|
|
556
|
-
ctx.ui.setStatus("1p-onboard", undefined);
|
|
557
|
-
return null;
|
|
558
|
-
}
|
|
559
|
-
ctx.ui.setStatus("1p-onboard", undefined);
|
|
560
|
-
|
|
561
|
-
if (vaultNames.length === 0) {
|
|
562
|
-
ctx.ui.notify("No vaults found in 1Password.", "warning");
|
|
563
|
-
return null;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
const vaultItems = [
|
|
567
|
-
...vaultNames.map((name) => ({ value: name, label: name })),
|
|
568
|
-
{ value: "__cancel", label: "Cancel" },
|
|
569
|
-
];
|
|
570
|
-
const chosenVault = await selectInBorderedPopup(ctx, {
|
|
571
|
-
title: "Select 1Password vault",
|
|
572
|
-
items: vaultItems,
|
|
573
|
-
helpText: "↑↓ • Enter • Esc = cancel • Type to filter",
|
|
574
|
-
maxVisible: 14,
|
|
575
|
-
});
|
|
576
|
-
if (!chosenVault || chosenVault === "__cancel") return null;
|
|
577
|
-
|
|
578
|
-
ctx.ui.setStatus("1p-onboard", `Loading items from ${chosenVault}...`);
|
|
579
|
-
let items: OpItem[] = [];
|
|
580
|
-
try {
|
|
581
|
-
const cmd = `op item list --vault ${JSON.stringify(chosenVault)} --categories "API Credential,Login,Secure Note,Password" --format json`;
|
|
582
|
-
const { stdout } = await execAsync(cmd, {
|
|
583
|
-
encoding: "utf8",
|
|
584
|
-
timeout: 25000,
|
|
585
|
-
maxBuffer: 8 * 1024 * 1024,
|
|
586
|
-
});
|
|
587
|
-
const parsed = JSON.parse(stdout || "[]") as OpItem[];
|
|
588
|
-
items = parsed.sort((a, b) => (a.title || "").localeCompare(b.title || ""));
|
|
589
|
-
} catch {
|
|
590
|
-
ctx.ui.notify("Failed to load items.", "error");
|
|
591
|
-
ctx.ui.setStatus("1p-onboard", undefined);
|
|
592
|
-
return null;
|
|
593
|
-
}
|
|
594
|
-
ctx.ui.setStatus("1p-onboard", undefined);
|
|
595
|
-
|
|
596
|
-
if (items.length === 0) {
|
|
597
|
-
ctx.ui.notify("No matching items in that vault.", "warning");
|
|
598
|
-
return null;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
const itemItems = [
|
|
602
|
-
...items.map((it) => ({
|
|
603
|
-
value: it.id,
|
|
604
|
-
label: `${it.title}${it.category ? " — " + it.category : ""}`,
|
|
605
|
-
})),
|
|
606
|
-
{ value: "__cancel", label: "Cancel" },
|
|
607
|
-
];
|
|
608
|
-
const chosenItemId = await selectInBorderedPopup(ctx, {
|
|
609
|
-
title: `Select item in ${chosenVault}`,
|
|
610
|
-
items: itemItems,
|
|
611
|
-
helpText: "↑↓ • Enter • Esc = cancel • Type to filter (can be long)",
|
|
612
|
-
maxVisible: 16,
|
|
613
|
-
});
|
|
614
|
-
if (!chosenItemId || chosenItemId === "__cancel") return null;
|
|
615
|
-
|
|
616
|
-
const chosenItem = items.find((it) => it.id === chosenItemId);
|
|
617
|
-
if (!chosenItem) return null;
|
|
618
|
-
|
|
619
|
-
ctx.ui.setStatus("1p-onboard", "Loading fields...");
|
|
620
|
-
let fields: { label: string; type?: string }[] = [];
|
|
621
|
-
try {
|
|
622
|
-
const { stdout } = await execAsync(
|
|
623
|
-
`op item get ${JSON.stringify(chosenItem.id)} --format json`,
|
|
624
|
-
{ encoding: "utf8", timeout: 15000 },
|
|
625
|
-
);
|
|
626
|
-
const full = JSON.parse(stdout || "null") as { fields?: OpField[] } | null;
|
|
627
|
-
fields = full?.fields?.filter(Boolean) ?? [];
|
|
628
|
-
} catch {
|
|
629
|
-
ctx.ui.notify("Failed to load fields.", "error");
|
|
630
|
-
ctx.ui.setStatus("1p-onboard", undefined);
|
|
631
|
-
return null;
|
|
632
|
-
}
|
|
633
|
-
ctx.ui.setStatus("1p-onboard", undefined);
|
|
634
|
-
|
|
635
|
-
if (fields.length === 0) {
|
|
636
|
-
ctx.ui.notify("Selected item has no fields.", "warning");
|
|
637
|
-
return null;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
const fieldItems = [
|
|
641
|
-
...fields.map((f) => ({
|
|
642
|
-
value: f.label,
|
|
643
|
-
label: `${f.label} (${f.type ?? "text"})`,
|
|
644
|
-
})),
|
|
645
|
-
{ value: "__cancel", label: "Cancel" },
|
|
646
|
-
];
|
|
647
|
-
const chosenFieldLabel = await selectInBorderedPopup(ctx, {
|
|
648
|
-
title: `Select field for "${chosenItem.title}"`,
|
|
649
|
-
items: fieldItems,
|
|
650
|
-
helpText: "↑↓ • Enter • Esc = cancel",
|
|
651
|
-
maxVisible: 12,
|
|
652
|
-
});
|
|
653
|
-
if (!chosenFieldLabel || chosenFieldLabel === "__cancel") return null;
|
|
654
|
-
|
|
655
|
-
return `op://${chosenVault}/${chosenItem.title}/${chosenFieldLabel}`;
|
|
656
|
-
}
|
|
898
|
+
// Vault → item → field picker now lives at module scope as the exported
|
|
899
|
+
// pickOpReferenceSimple (reused by the public onboardSecret API).
|
|
657
900
|
|
|
658
|
-
// ── /
|
|
659
|
-
pi.registerCommand("
|
|
901
|
+
// ── /1password_setup — fully custom bordered TUI onboarding (curated + manual + vault/item/field)
|
|
902
|
+
pi.registerCommand("1password_setup", {
|
|
660
903
|
description:
|
|
661
904
|
"Guided setup: pick from supported tools or enter a custom op:// reference, write '!op read ...' entry to ~/.pi/agent/auth.json for transparent injection.",
|
|
662
905
|
handler: async (_args, ctx) => {
|