@jmcombs/pi-1password 1.0.2 → 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 +496 -250
- package/package.json +4 -3
- package/ui/bordered-popups.ts +41 -11
package/index.ts
CHANGED
|
@@ -14,25 +14,42 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { exec } from "node:child_process";
|
|
17
|
-
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
17
|
+
import { chmod, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
18
18
|
import { homedir } from "node:os";
|
|
19
19
|
import { dirname, join } from "node:path";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { promisify } from "node:util";
|
|
22
|
-
import type { ExtensionAPI
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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";
|
|
28
31
|
import { type Static, Type } from "typebox";
|
|
29
32
|
|
|
33
|
+
import type { UiContext } from "./credential-api.js";
|
|
30
34
|
import {
|
|
31
35
|
confirmInBorderedPopup,
|
|
32
36
|
inputInBorderedPopup,
|
|
33
37
|
selectInBorderedPopup,
|
|
34
38
|
} from "./ui/bordered-popups.js";
|
|
35
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
|
+
|
|
36
53
|
const execAsync = promisify(exec);
|
|
37
54
|
|
|
38
55
|
// ── Internal helpers for diagnostics (properly typed) ──────────────────
|
|
@@ -40,7 +57,19 @@ const execAsync = promisify(exec);
|
|
|
40
57
|
interface OpStatus {
|
|
41
58
|
available: boolean;
|
|
42
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
|
+
*/
|
|
43
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;
|
|
44
73
|
account: Record<string, unknown> | null;
|
|
45
74
|
}
|
|
46
75
|
|
|
@@ -72,38 +101,73 @@ interface OpField {
|
|
|
72
101
|
type?: string;
|
|
73
102
|
}
|
|
74
103
|
|
|
75
|
-
|
|
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
|
+
}
|
|
76
119
|
try {
|
|
77
|
-
const { stdout } = await execAsync("op --
|
|
78
|
-
|
|
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
|
+
}
|
|
79
131
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
account: null,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
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();
|
|
99
148
|
} catch {
|
|
100
|
-
return {
|
|
101
|
-
available: false,
|
|
102
|
-
version: null,
|
|
103
|
-
signedIn: false,
|
|
104
|
-
account: null,
|
|
105
|
-
};
|
|
149
|
+
return { available: false, version: null, signedIn: false, configured: false, account: null };
|
|
106
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;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const configured = await isOpConfigured();
|
|
169
|
+
|
|
170
|
+
return { available: true, version, signedIn, configured, account };
|
|
107
171
|
}
|
|
108
172
|
|
|
109
173
|
async function inspectPluginIfRelevant(command: string): Promise<PluginInspection | null> {
|
|
@@ -145,17 +209,22 @@ function formatOpStatus(status: OpStatus): string {
|
|
|
145
209
|
if (!status.available) {
|
|
146
210
|
return "1Password CLI (`op`) is not available in PATH.";
|
|
147
211
|
}
|
|
148
|
-
if (!status.
|
|
149
|
-
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).`;
|
|
150
214
|
}
|
|
151
215
|
const acct = status.account ?? {};
|
|
152
216
|
const name =
|
|
153
217
|
(acct.name as string | undefined) ??
|
|
154
218
|
(acct.email as string | undefined) ??
|
|
155
219
|
(acct.account_uuid as string | undefined) ??
|
|
156
|
-
|
|
157
|
-
const url = (acct.url as string | undefined) ??
|
|
158
|
-
|
|
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).`;
|
|
159
228
|
}
|
|
160
229
|
|
|
161
230
|
// ── Shell env loading from ~/.pi/agent/auth.json (top-level keys per user choice A) ──
|
|
@@ -191,7 +260,13 @@ const KNOWN_PROVIDER_KEYS = new Set([
|
|
|
191
260
|
"xiaomi-token-plan-sgp",
|
|
192
261
|
]);
|
|
193
262
|
|
|
194
|
-
|
|
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> {
|
|
195
270
|
if (typeof raw !== "string") return null;
|
|
196
271
|
const trimmed = raw.trim();
|
|
197
272
|
|
|
@@ -262,7 +337,7 @@ async function loadShellEnvMap(): Promise<Record<string, string>> {
|
|
|
262
337
|
// In-memory map for the current session (populated on session_start)
|
|
263
338
|
let currentShellEnv: Record<string, string> = {};
|
|
264
339
|
|
|
265
|
-
// Curated shell plugin list (loaded once at startup for /
|
|
340
|
+
// Curated shell plugin list (loaded once at startup for /1password_setup suggestions)
|
|
266
341
|
let curatedPlugins: CuratedPlugin[] = [];
|
|
267
342
|
|
|
268
343
|
/** Returns the *names* of currently injected shell env vars (never the values). Safe for diagnostics / LLM. */
|
|
@@ -270,7 +345,7 @@ export function getShellEnvNames(): string[] {
|
|
|
270
345
|
return Object.keys(currentShellEnv);
|
|
271
346
|
}
|
|
272
347
|
|
|
273
|
-
// ── Curated list + auth.json writer (for /
|
|
348
|
+
// ── Curated list + auth.json writer (for /1password_setup) ────────────
|
|
274
349
|
|
|
275
350
|
/** Load the maintained list of 1P shell plugins (generated by scripts/update-1p-shell-plugins.ts). */
|
|
276
351
|
async function loadCuratedPlugins(): Promise<CuratedPlugin[]> {
|
|
@@ -334,15 +409,362 @@ async function addAuthEntry(
|
|
|
334
409
|
return { success: true, message: "Entry added.", path: authPath };
|
|
335
410
|
}
|
|
336
411
|
|
|
337
|
-
// ──
|
|
412
|
+
// ── Stateless auth.json access + locked provider-shaped writer (D3/D4) ──
|
|
338
413
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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 ───────────────────────────────────────────────────────
|
|
346
768
|
|
|
347
769
|
const diagnoseSchema = Type.Object({});
|
|
348
770
|
export type DiagnoseInput = Static<typeof diagnoseSchema>;
|
|
@@ -353,9 +775,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
353
775
|
// Load initial shell env (top-level keys from auth.json)
|
|
354
776
|
currentShellEnv = await loadShellEnvMap();
|
|
355
777
|
|
|
356
|
-
// Load curated list for /
|
|
778
|
+
// Load curated list for /1password_setup
|
|
357
779
|
curatedPlugins = await loadCuratedPlugins();
|
|
358
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
|
+
|
|
359
786
|
// ── Bash tool wrapper with transparent 1P env injection ───────────────
|
|
360
787
|
const cwd = process.cwd();
|
|
361
788
|
const injectedBash = createBashTool(cwd, {
|
|
@@ -367,89 +794,21 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
367
794
|
});
|
|
368
795
|
pi.registerTool(injectedBash);
|
|
369
796
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
+
}
|
|
373
807
|
|
|
374
808
|
pi.on("session_start", async () => {
|
|
375
809
|
currentShellEnv = await loadShellEnvMap();
|
|
376
810
|
curatedPlugins = await loadCuratedPlugins();
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
// ── 1p_run ───────────────────────────────────────────────────────────
|
|
380
|
-
pi.registerTool({
|
|
381
|
-
name: "1p_run",
|
|
382
|
-
label: "1Password Run Command",
|
|
383
|
-
description:
|
|
384
|
-
"Run a shell command with 1Password credential injection via `op run -- <command>`. Includes automatic diagnostics on failure.",
|
|
385
|
-
parameters: runSchema,
|
|
386
|
-
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
387
|
-
const status = await getOpStatus();
|
|
388
|
-
const pluginInfo = await inspectPluginIfRelevant(params.command);
|
|
389
|
-
|
|
390
|
-
if (!status.available) {
|
|
391
|
-
return {
|
|
392
|
-
content: [{ type: "text", text: `1p_run failed: ${formatOpStatus(status)}` }],
|
|
393
|
-
details: { error: "op not found", opStatus: status },
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
if (!status.signedIn) {
|
|
397
|
-
return {
|
|
398
|
-
content: [
|
|
399
|
-
{
|
|
400
|
-
type: "text",
|
|
401
|
-
text: `1p_run failed: ${formatOpStatus(status)}\n\nPlease run 'op signin' in your terminal.`,
|
|
402
|
-
},
|
|
403
|
-
],
|
|
404
|
-
details: { error: "not signed in", opStatus: status },
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
try {
|
|
409
|
-
const { stdout, stderr } = await execAsync(`op run -- ${params.command}`, {
|
|
410
|
-
encoding: "utf8",
|
|
411
|
-
maxBuffer: 5 * 1024 * 1024,
|
|
412
|
-
timeout: 120000,
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
const output = (stdout || "").trim();
|
|
416
|
-
const err = (stderr || "").trim();
|
|
417
|
-
|
|
418
|
-
let text = output || "(no stdout)";
|
|
419
|
-
if (err) text += `\n\n[stderr]\n${err}`;
|
|
420
|
-
|
|
421
|
-
return {
|
|
422
|
-
content: [{ type: "text", text }],
|
|
423
|
-
details: {
|
|
424
|
-
command: params.command,
|
|
425
|
-
opStatus: status,
|
|
426
|
-
pluginInspection: pluginInfo,
|
|
427
|
-
},
|
|
428
|
-
};
|
|
429
|
-
} catch (error: unknown) {
|
|
430
|
-
const err = error as { stderr?: string; message?: string };
|
|
431
|
-
const rawError = (err.stderr?.trim() ?? err.message ?? "") || String(error);
|
|
432
|
-
const diagnostic = formatOpStatus(status);
|
|
433
|
-
|
|
434
|
-
let helpful = `Command failed under 1Password injection.\n\n${diagnostic}`;
|
|
435
|
-
|
|
436
|
-
if (pluginInfo) {
|
|
437
|
-
helpful += `\n\nPlugin status for "${pluginInfo.plugin}":\n${pluginInfo.output ?? pluginInfo.error ?? ""}`;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
helpful += `\n\nError from command:\n${rawError}`;
|
|
441
|
-
|
|
442
|
-
return {
|
|
443
|
-
content: [{ type: "text", text: helpful }],
|
|
444
|
-
details: {
|
|
445
|
-
error: rawError,
|
|
446
|
-
command: params.command,
|
|
447
|
-
opStatus: status,
|
|
448
|
-
pluginInspection: pluginInfo,
|
|
449
|
-
},
|
|
450
|
-
};
|
|
451
|
-
}
|
|
452
|
-
},
|
|
811
|
+
await warmOpSessionIfNeeded();
|
|
453
812
|
});
|
|
454
813
|
|
|
455
814
|
// ── Shared diagnostic logic (used by both 1p_diagnose tool and /1password_diagnose command) ──
|
|
@@ -502,7 +861,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
502
861
|
name: "1p_diagnose",
|
|
503
862
|
label: "1Password Diagnostics",
|
|
504
863
|
description:
|
|
505
|
-
"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.",
|
|
506
865
|
parameters: diagnoseSchema,
|
|
507
866
|
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
508
867
|
const { report, details } = await get1PasswordDiagnosticReport();
|
|
@@ -530,130 +889,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
530
889
|
|
|
531
890
|
// TODO (known limitation): /1password_diagnose currently gathers data directly
|
|
532
891
|
// for reliability and presents it. Injecting a prompt via sendUserMessage does not
|
|
533
|
-
// 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
|
|
534
893
|
// nicer formatting, because regular command handlers have limited access to
|
|
535
894
|
// the "deliverAs: nextTurn" / sendUserMessage APIs that force LLM reasoning.
|
|
536
895
|
// This should be revisited when better support exists or a different pattern
|
|
537
896
|
// is found. Tracked as a follow-up issue.
|
|
538
897
|
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
async function pickOpReferenceSimple(ctx: ExtensionCommandContext): Promise<string | null> {
|
|
542
|
-
ctx.ui.setStatus("1p-onboard", "Loading vaults...");
|
|
543
|
-
let vaultNames: string[] = [];
|
|
544
|
-
try {
|
|
545
|
-
const { stdout } = await execAsync(`op vault list --format json`, {
|
|
546
|
-
encoding: "utf8",
|
|
547
|
-
timeout: 20000,
|
|
548
|
-
});
|
|
549
|
-
const parsed = JSON.parse(stdout || "[]") as OpVault[];
|
|
550
|
-
vaultNames = parsed.map((v) => v.name).sort((a, b) => a.localeCompare(b));
|
|
551
|
-
} catch {
|
|
552
|
-
ctx.ui.notify("Failed to load vaults from 1Password.", "error");
|
|
553
|
-
ctx.ui.setStatus("1p-onboard", undefined);
|
|
554
|
-
return null;
|
|
555
|
-
}
|
|
556
|
-
ctx.ui.setStatus("1p-onboard", undefined);
|
|
557
|
-
|
|
558
|
-
if (vaultNames.length === 0) {
|
|
559
|
-
ctx.ui.notify("No vaults found in 1Password.", "warning");
|
|
560
|
-
return null;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
const vaultItems = [
|
|
564
|
-
...vaultNames.map((name) => ({ value: name, label: name })),
|
|
565
|
-
{ value: "__cancel", label: "Cancel" },
|
|
566
|
-
];
|
|
567
|
-
const chosenVault = await selectInBorderedPopup(ctx, {
|
|
568
|
-
title: "Select 1Password vault",
|
|
569
|
-
items: vaultItems,
|
|
570
|
-
helpText: "↑↓ • Enter • Esc = cancel • Type to filter",
|
|
571
|
-
maxVisible: 14,
|
|
572
|
-
});
|
|
573
|
-
if (!chosenVault || chosenVault === "__cancel") return null;
|
|
574
|
-
|
|
575
|
-
ctx.ui.setStatus("1p-onboard", `Loading items from ${chosenVault}...`);
|
|
576
|
-
let items: OpItem[] = [];
|
|
577
|
-
try {
|
|
578
|
-
const cmd = `op item list --vault ${JSON.stringify(chosenVault)} --categories "API Credential,Login,Secure Note,Password" --format json`;
|
|
579
|
-
const { stdout } = await execAsync(cmd, {
|
|
580
|
-
encoding: "utf8",
|
|
581
|
-
timeout: 25000,
|
|
582
|
-
maxBuffer: 8 * 1024 * 1024,
|
|
583
|
-
});
|
|
584
|
-
const parsed = JSON.parse(stdout || "[]") as OpItem[];
|
|
585
|
-
items = parsed.sort((a, b) => (a.title || "").localeCompare(b.title || ""));
|
|
586
|
-
} catch {
|
|
587
|
-
ctx.ui.notify("Failed to load items.", "error");
|
|
588
|
-
ctx.ui.setStatus("1p-onboard", undefined);
|
|
589
|
-
return null;
|
|
590
|
-
}
|
|
591
|
-
ctx.ui.setStatus("1p-onboard", undefined);
|
|
592
|
-
|
|
593
|
-
if (items.length === 0) {
|
|
594
|
-
ctx.ui.notify("No matching items in that vault.", "warning");
|
|
595
|
-
return null;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
const itemItems = [
|
|
599
|
-
...items.map((it) => ({
|
|
600
|
-
value: it.id,
|
|
601
|
-
label: `${it.title}${it.category ? ` — ${it.category}` : ""}`,
|
|
602
|
-
})),
|
|
603
|
-
{ value: "__cancel", label: "Cancel" },
|
|
604
|
-
];
|
|
605
|
-
const chosenItemId = await selectInBorderedPopup(ctx, {
|
|
606
|
-
title: `Select item in ${chosenVault}`,
|
|
607
|
-
items: itemItems,
|
|
608
|
-
helpText: "↑↓ • Enter • Esc = cancel • Type to filter (can be long)",
|
|
609
|
-
maxVisible: 16,
|
|
610
|
-
});
|
|
611
|
-
if (!chosenItemId || chosenItemId === "__cancel") return null;
|
|
612
|
-
|
|
613
|
-
const chosenItem = items.find((it) => it.id === chosenItemId);
|
|
614
|
-
if (!chosenItem) return null;
|
|
615
|
-
|
|
616
|
-
ctx.ui.setStatus("1p-onboard", "Loading fields...");
|
|
617
|
-
let fields: { label: string; type?: string }[] = [];
|
|
618
|
-
try {
|
|
619
|
-
const { stdout } = await execAsync(
|
|
620
|
-
`op item get ${JSON.stringify(chosenItem.id)} --format json`,
|
|
621
|
-
{ encoding: "utf8", timeout: 15000 },
|
|
622
|
-
);
|
|
623
|
-
const full = JSON.parse(stdout || "null") as { fields?: OpField[] } | null;
|
|
624
|
-
fields = full?.fields?.filter(Boolean) ?? [];
|
|
625
|
-
} catch {
|
|
626
|
-
ctx.ui.notify("Failed to load fields.", "error");
|
|
627
|
-
ctx.ui.setStatus("1p-onboard", undefined);
|
|
628
|
-
return null;
|
|
629
|
-
}
|
|
630
|
-
ctx.ui.setStatus("1p-onboard", undefined);
|
|
631
|
-
|
|
632
|
-
if (fields.length === 0) {
|
|
633
|
-
ctx.ui.notify("Selected item has no fields.", "warning");
|
|
634
|
-
return null;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
const fieldItems = [
|
|
638
|
-
...fields.map((f) => ({
|
|
639
|
-
value: f.label,
|
|
640
|
-
label: `${f.label} (${f.type ?? "text"})`,
|
|
641
|
-
})),
|
|
642
|
-
{ value: "__cancel", label: "Cancel" },
|
|
643
|
-
];
|
|
644
|
-
const chosenFieldLabel = await selectInBorderedPopup(ctx, {
|
|
645
|
-
title: `Select field for "${chosenItem.title}"`,
|
|
646
|
-
items: fieldItems,
|
|
647
|
-
helpText: "↑↓ • Enter • Esc = cancel",
|
|
648
|
-
maxVisible: 12,
|
|
649
|
-
});
|
|
650
|
-
if (!chosenFieldLabel || chosenFieldLabel === "__cancel") return null;
|
|
651
|
-
|
|
652
|
-
return `op://${chosenVault}/${chosenItem.title}/${chosenFieldLabel}`;
|
|
653
|
-
}
|
|
898
|
+
// Vault → item → field picker now lives at module scope as the exported
|
|
899
|
+
// pickOpReferenceSimple (reused by the public onboardSecret API).
|
|
654
900
|
|
|
655
|
-
// ── /
|
|
656
|
-
pi.registerCommand("
|
|
901
|
+
// ── /1password_setup — fully custom bordered TUI onboarding (curated + manual + vault/item/field)
|
|
902
|
+
pi.registerCommand("1password_setup", {
|
|
657
903
|
description:
|
|
658
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.",
|
|
659
905
|
handler: async (_args, ctx) => {
|