@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 CHANGED
@@ -13,6 +13,13 @@
13
13
 
14
14
  1Password integration for the Pi coding agent — with a focus on **secure, transparent credential injection** so bare `gh`, `aws`, `heroku`, and other 1P-protected CLIs "just work" inside Pi without the LLM ever seeing tokens.
15
15
 
16
+ ## Breaking changes in v2.0.0
17
+
18
+ - **`AuthStorage` is gone.** Pi 0.80.8 removed the `AuthStorage` API this extension was built on. Credentials now resolve through a stateless **credential API** that this package exports for other extensions to consume; onboarding writes `!op read '…'` entries to `~/.pi/agent/auth.json` that resolve fresh on every use.
19
+ - **Availability-branched onboarding.** When the `op` CLI is installed and an account is configured, setup opens the 1Password **vault → item → field picker**; when `op` is unavailable it falls back to **masked manual entry**.
20
+ - **The `1p_run` tool has been retired.** Transparent credential injection into bare CLIs (`gh`, `aws`, …) is unchanged — it runs through the bash spawn-hook / `user_bash` env injection, so a dedicated run tool is no longer needed.
21
+ - **Existing entries keep working.** Any `!op read` entry already in `~/.pi/agent/auth.json` resolves unchanged. No migration action is required.
22
+
16
23
  ## Quick Start
17
24
 
18
25
  Get transparent credential injection in under a minute:
@@ -22,12 +29,12 @@ Get transparent credential injection in under a minute:
22
29
  2. Install the extension:
23
30
 
24
31
  ```bash
25
- pi install @jmcombs/pi-1password
32
+ pi install npm:@jmcombs/pi-1password
26
33
  ```
27
34
 
28
35
  3. In any Pi chat, type:
29
36
  ```
30
- /1password_onboard
37
+ /1password_setup
31
38
  ```
32
39
 
33
40
  The command opens a beautiful guided interface (with live filtering and consistent styling) that lets you:
@@ -44,7 +51,7 @@ Run `/1password_diagnose` anytime to see which variables are currently active.
44
51
 
45
52
  This extension works by storing **references** (never the raw secrets) in `~/.pi/agent/auth.json` using the `!op read` syntax.
46
53
 
47
- The easiest way to create these entries is with the guided `/1password_onboard` command shown above.
54
+ The easiest way to create these entries is with the guided `/1password_setup` command shown above.
48
55
 
49
56
  You can also manage entries manually if you prefer:
50
57
 
@@ -65,12 +72,12 @@ Result: the agent can run `gh auth status`, `gh repo view ...`, `aws sts get-cal
65
72
 
66
73
  `/1password_diagnose` will show exactly which vars are active (names only).
67
74
 
68
- ## /1password_onboard
75
+ ## /1password_setup
69
76
 
70
77
  Run this command for a polished, guided setup experience:
71
78
 
72
79
  ```
73
- /1password_onboard
80
+ /1password_setup
74
81
  ```
75
82
 
76
83
  It provides a filterable, bordered interface that walks you through:
@@ -124,7 +131,7 @@ You need a working 1Password CLI that Pi can talk to:
124
131
 
125
132
  ### Weekly Maintenance of Supported Tools
126
133
 
127
- This extension maintains a curated list of 60+ 1Password shell plugins. A weekly GitHub Actions workflow fetches the latest data from 1password.dev, updates `data/shell-plugins.json`, and opens a PR for review. This keeps `/1password_onboard` current without manual maintenance.
134
+ This extension maintains a curated list of 60+ 1Password shell plugins. A weekly GitHub Actions workflow fetches the latest data from 1password.dev, updates `data/shell-plugins.json`, and opens a PR for review. This keeps `/1password_setup` current without manual maintenance.
128
135
 
129
136
  ## Development / Local Testing
130
137
 
@@ -0,0 +1,328 @@
1
+ /**
2
+ * @jmcombs/pi-1password — stateless, importable credential API.
3
+ *
4
+ * This module is the public credential surface of the 1Password extension. Other
5
+ * pi extensions declare `@jmcombs/pi-1password` as a hard dependency (D2) and
6
+ * import these functions instead of touching pi internals (`AuthStorage`,
7
+ * `ModelRuntime`, `readStoredCredential` — all removed in pi 0.80.8).
8
+ *
9
+ * Every function is **stateless** (D3): it reads `~/.pi/agent/auth.json` and/or
10
+ * runs the `op` CLI fresh on each call and relies on no module-level session
11
+ * state, so a consumer that imports a fresh module instance behaves identically
12
+ * to the host extension.
13
+ *
14
+ * Storage shape (D4): entries are provider-shaped and keyed by logical name —
15
+ * `{"context7": {"type":"api_key","key":"!op read 'op://Vault/Item/field'"}}` for
16
+ * a 1Password reference, or `{"context7": {"type":"api_key","key":"<literal>"}}`
17
+ * for a manually entered key. Legacy bare-string entries
18
+ * (`{"context7": "!op read '…'"}`) still resolve on read.
19
+ *
20
+ * See docs/1p-credential-api/API.md for the full reference.
21
+ */
22
+
23
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
24
+ import {
25
+ deleteAuthEntry,
26
+ getOpStatus,
27
+ pickOpReferenceSimple,
28
+ readAuthJson,
29
+ resolveShellValue,
30
+ writeProviderAuthEntry,
31
+ } from "./index.js";
32
+ import { inputInBorderedPopup, selectInBorderedPopup } from "./ui/bordered-popups.js";
33
+
34
+ /**
35
+ * The minimal context capability the onboarding surface needs: just `ui`.
36
+ *
37
+ * Typing to `Pick<ExtensionContext, "ui">` (rather than the whole
38
+ * `ExtensionContext` or the narrower command-only context) keeps these
39
+ * functions callable from **every** pi entry point that exposes `ctx.ui` —
40
+ * command handlers, tool `execute()`, and event/shortcut handlers — and from a
41
+ * bare `{ ui }` test double, without demanding the ~15 unrelated context members
42
+ * they never touch. This is the least-coupled signature and it makes the ctx
43
+ * path unit-testable.
44
+ */
45
+ export type UiContext = Pick<ExtensionContext, "ui">;
46
+
47
+ /** Options accepted by {@link onboardSecret} / {@link changeSecret}. */
48
+ export interface OnboardOptions {
49
+ /** Logical name the secret is stored and resolved under (e.g. `"context7"`). */
50
+ readonly name: string;
51
+ /** Human-readable label shown in onboarding prompts (e.g. `"Context7"`). */
52
+ readonly label: string;
53
+ /** When true, overwrite an existing entry instead of failing. */
54
+ readonly overwrite?: boolean;
55
+ }
56
+
57
+ /** Result of an onboarding / change operation. Never contains the secret value. */
58
+ export interface OnboardResult {
59
+ readonly ok: boolean;
60
+ readonly message: string;
61
+ }
62
+
63
+ /** Result of {@link verifySecret}. Never contains the secret value. */
64
+ export interface VerifyResult {
65
+ readonly ok: boolean;
66
+ readonly resolved: boolean;
67
+ readonly error?: string;
68
+ }
69
+
70
+ /** Result of {@link deleteSecret}. */
71
+ export interface DeleteResult {
72
+ readonly ok: boolean;
73
+ }
74
+
75
+ /**
76
+ * Whether 1Password vault integration is usable: the `op` CLI is installed
77
+ * **and** an auth path is **configured** — a service-account
78
+ * token, 1Password Connect env, or a desktop/CLI account. It does **not** gate on
79
+ * `op whoami`/`signedIn`, which reports a false "not signed in" for cold CLI
80
+ * invocations under the desktop-app biometric integration even when `op read`
81
+ * works. The check is passive: no unlock and no Touch ID prompt at check time;
82
+ * the account session unlocks lazily on the first `op read`. Used to branch
83
+ * onboarding between the vault picker and manual key entry.
84
+ *
85
+ * @returns `true` when `op` is available and configured, otherwise `false`.
86
+ */
87
+ export async function is1PasswordAvailable(): Promise<boolean> {
88
+ const status = await getOpStatus();
89
+ return status.available && status.configured;
90
+ }
91
+
92
+ /**
93
+ * Resolve a stored secret to its concrete value (D5). Reads `auth.json` fresh,
94
+ * takes `parsed[name]`, and resolves it via the shared `!op read` / shell / literal
95
+ * resolver — handling **both** a provider-shaped object (`.key`) and a bare literal
96
+ * string. Fails closed: returns `undefined` when the entry is missing or `op read`
97
+ * fails, never the unresolved raw value.
98
+ *
99
+ * @param name Logical name to resolve (e.g. `"context7"`).
100
+ * @returns The resolved secret, or `undefined` if it cannot be resolved.
101
+ */
102
+ export async function resolveSecret(name: string): Promise<string | undefined> {
103
+ const parsed = await readAuthJson();
104
+ const entry = parsed[name];
105
+ const resolved = await resolveShellValue(
106
+ typeof entry === "string" ? entry : (entry as { key?: unknown } | undefined)?.key,
107
+ );
108
+ return resolved ?? undefined;
109
+ }
110
+
111
+ /** Verbatim cancel outcome used everywhere the user backs out. */
112
+ const CANCELLED: OnboardResult = { ok: false, message: "Onboarding cancelled." };
113
+
114
+ /**
115
+ * Prompt for a literal API key on a **masked** screen — one bullet per typed
116
+ * character, the value never drawn on screen. Shared by the
117
+ * "Type or paste the key" source and the op-unavailable branch.
118
+ */
119
+ async function promptMaskedKey(ctx: UiContext, label: string): Promise<string | undefined> {
120
+ return inputInBorderedPopup(ctx, {
121
+ title: `Enter your ${label} API key`,
122
+ helpText: "Enter to save • Esc = cancel",
123
+ mask: true,
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Write the entry, then verify it resolves (post-save verify) and return
129
+ * a friendly, human-only outcome message. Never surfaces `changeSecret`,
130
+ * `auth.json`, or the raw `name` (except inside a `/{name}_onboard` command token).
131
+ */
132
+ async function saveAndReport(
133
+ opts: OnboardOptions,
134
+ storedValue: string,
135
+ overwrite: boolean,
136
+ kind: "ref" | "literal",
137
+ opAvailable: boolean,
138
+ ): Promise<OnboardResult> {
139
+ const res = await writeProviderAuthEntry(opts.name, storedValue, { overwrite });
140
+ if (!res.success) {
141
+ return { ok: false, message: `Couldn't save your ${opts.label} key. Please try again.` };
142
+ }
143
+
144
+ const verified = await verifySecret(opts.name);
145
+ if (kind === "ref") {
146
+ if (verified.ok) {
147
+ return {
148
+ ok: true,
149
+ message: `${opts.label} is set up. Your key stays in 1Password and unlocks on demand.`,
150
+ };
151
+ }
152
+ return {
153
+ ok: true,
154
+ message: `Saved. 1Password couldn't read it yet — unlock it, or check the reference.`,
155
+ };
156
+ }
157
+
158
+ if (verified.ok) {
159
+ return {
160
+ ok: true,
161
+ message: opAvailable
162
+ ? `${opts.label} is set up. Your key is stored locally on this machine.`
163
+ : `${opts.label} is set up, stored locally. Install 1Password CLI to use your vault.`,
164
+ };
165
+ }
166
+ return {
167
+ ok: true,
168
+ message: `Saved, but the key looks empty — re-run onboarding to fix it.`,
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Interactively onboard a secret.
174
+ *
175
+ * Order of operations:
176
+ * 1. **Existing-key gate first** — if `name` already has a value and `overwrite`
177
+ * isn't set, offer *Replace it* / *Keep the current key*; keeping (or Esc)
178
+ * returns without touching anything.
179
+ * 2. **Branch on {@link is1PasswordAvailable}:**
180
+ * - **available →** a source menu: *Locate in 1Password* (live vault → item →
181
+ * field browse, storing `!op read '<ref>'`), *Type or paste the key* (masked
182
+ * literal entry), or *Enter a 1Password reference* (validated `op://` path).
183
+ * - **not available →** straight to masked literal entry.
184
+ * 3. **Write, then verify** the entry resolves and report a friendly outcome.
185
+ *
186
+ * No user-entered value or resolved secret is ever returned or logged; messages
187
+ * never leak `auth.json`, `changeSecret`, or the raw `name` (outside a
188
+ * `/{name}_onboard` token). Refuses to overwrite unless `opts.overwrite` (or
189
+ * {@link changeSecret}) is used.
190
+ *
191
+ * @param ctx The extension context (drives the onboarding UI; works from command
192
+ * handlers, tool `execute()`, and a `{ ui }` double alike).
193
+ * @param opts `{ name, label, overwrite? }`.
194
+ * @returns `{ ok, message }` describing the outcome (never the secret).
195
+ */
196
+ export async function onboardSecret(ctx: UiContext, opts: OnboardOptions): Promise<OnboardResult> {
197
+ let overwrite = opts.overwrite ?? false;
198
+
199
+ // 1. Existing-key gate FIRST — before any source/browse work.
200
+ if (!overwrite) {
201
+ const parsed = await readAuthJson();
202
+ if (parsed[opts.name] !== undefined) {
203
+ const choice = await selectInBorderedPopup(ctx, {
204
+ title: `${opts.label} is already set up`,
205
+ items: [
206
+ { value: "replace", label: "Replace it" },
207
+ { value: "keep", label: "Keep the current key" },
208
+ ],
209
+ helpText: "↑↓ • Enter • Esc = keep current",
210
+ maxVisible: 5,
211
+ });
212
+ if (choice !== "replace") {
213
+ return { ok: false, message: `Kept your existing ${opts.label} key. Nothing changed.` };
214
+ }
215
+ overwrite = true;
216
+ }
217
+ }
218
+
219
+ const opAvailable = await is1PasswordAvailable();
220
+
221
+ // 2. Branch on 1Password availability.
222
+ if (opAvailable) {
223
+ const source = await selectInBorderedPopup(ctx, {
224
+ title: `Set up your ${opts.label} key`,
225
+ items: [
226
+ {
227
+ value: "browse",
228
+ label: "Locate in 1Password",
229
+ description: "Browse your vaults and select item",
230
+ },
231
+ {
232
+ value: "paste",
233
+ label: "Type or paste the key",
234
+ description: "Manually insert your key",
235
+ },
236
+ {
237
+ value: "ref",
238
+ label: "Enter a 1Password reference",
239
+ description: "Advanced: an op://vault/item/field path",
240
+ },
241
+ { value: "cancel", label: "Cancel" },
242
+ ],
243
+ helpText: "↑↓ • Enter • Esc = cancel",
244
+ maxVisible: 6,
245
+ });
246
+ if (!source || source === "cancel") return CANCELLED;
247
+
248
+ if (source === "browse") {
249
+ // pickOpReferenceSimple owns its own one-voice error/empty notifications.
250
+ const opRef = await pickOpReferenceSimple(ctx);
251
+ if (!opRef) return CANCELLED;
252
+ return saveAndReport(opts, `!op read '${opRef}'`, overwrite, "ref", true);
253
+ }
254
+
255
+ if (source === "paste") {
256
+ const key = await promptMaskedKey(ctx, opts.label);
257
+ if (!key) return CANCELLED;
258
+ return saveAndReport(opts, key, overwrite, "literal", true);
259
+ }
260
+
261
+ // source === "ref" — an op:// path is a pointer, not a secret → plaintext input.
262
+ const ref = await inputInBorderedPopup(ctx, {
263
+ title: `1Password reference for ${opts.label}`,
264
+ prompt: "Enter the op:// reference to your key's field.",
265
+ defaultValue: "op://Vault/Item/field",
266
+ helpText: "Format op://vault/item/field • Enter to confirm • Esc = cancel",
267
+ });
268
+ if (!ref) return CANCELLED;
269
+ if (!/^op:\/\/[^/]+\/[^/]+\/[^/]+$/.test(ref)) {
270
+ ctx.ui.notify("That doesn't look complete — use op://vault/item/field.", "warning");
271
+ return CANCELLED;
272
+ }
273
+ return saveAndReport(opts, `!op read '${ref}'`, overwrite, "ref", true);
274
+ }
275
+
276
+ // Branch B — 1Password not available → straight to masked literal entry.
277
+ const key = await promptMaskedKey(ctx, opts.label);
278
+ if (!key) return CANCELLED;
279
+ return saveAndReport(opts, key, overwrite, "literal", false);
280
+ }
281
+
282
+ /**
283
+ * Change an existing secret: {@link onboardSecret} with `overwrite: true`. Runs the
284
+ * same availability-branched flow and replaces any current entry for `opts.name`.
285
+ *
286
+ * @param ctx The extension context.
287
+ * @param opts `{ name, label }` (overwrite is forced on).
288
+ * @returns `{ ok, message }`.
289
+ */
290
+ export async function changeSecret(ctx: UiContext, opts: OnboardOptions): Promise<OnboardResult> {
291
+ return onboardSecret(ctx, { ...opts, overwrite: true });
292
+ }
293
+
294
+ /**
295
+ * Verify that a stored secret resolves to a non-empty value **without returning the
296
+ * value**. Useful for onboarding confirmation and diagnostics.
297
+ *
298
+ * @param name Logical name to verify.
299
+ * @returns `{ ok, resolved, error? }` — `ok`/`resolved` are `true` only when
300
+ * `resolveSecret` yields a non-empty value; `error` explains a failure.
301
+ */
302
+ export async function verifySecret(name: string): Promise<VerifyResult> {
303
+ try {
304
+ const resolved = await resolveSecret(name);
305
+ if (typeof resolved === "string" && resolved.length > 0) {
306
+ return { ok: true, resolved: true };
307
+ }
308
+ return {
309
+ ok: false,
310
+ resolved: false,
311
+ error: `No value resolved for "${name}" (missing entry, or \`op read\` failed / returned empty).`,
312
+ };
313
+ } catch (e) {
314
+ return { ok: false, resolved: false, error: e instanceof Error ? e.message : String(e) };
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Delete a stored secret, removing `parsed[name]` from `auth.json` under the file
320
+ * lock (D4 concurrency).
321
+ *
322
+ * @param name Logical name to remove.
323
+ * @returns `{ ok }` — `true` when an entry was present and removed, `false` when
324
+ * there was nothing to remove.
325
+ */
326
+ export async function deleteSecret(name: string): Promise<DeleteResult> {
327
+ return deleteAuthEntry(name);
328
+ }