@jmcombs/pi-context7 1.0.0 → 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,33 @@
13
13
 
14
14
  Real-time documentation for the Pi coding agent via [Context7](https://context7.com). Gives the agent access to up-to-date, version-aware docs and code examples without polluting context with outdated information.
15
15
 
16
+ ## Breaking changes in v2.0.0
17
+
18
+ - **`AuthStorage` is gone.** Pi 0.80.8 removed the `AuthStorage` API this extension used to store and read its API key. Credentials now resolve through the [`@jmcombs/pi-1password`](https://www.npmjs.com/package/@jmcombs/pi-1password) **credential API**, which this package now depends on directly and **installs automatically** — no separate install step.
19
+ - **Availability-branched onboarding.** When the `op` CLI is installed and an account is configured, setup opens a 1Password **vault → item → field picker**; when `op` is unavailable it falls back to **masked manual key entry**.
20
+ - **Existing keys keep working.** Any Context7 key already in `~/.pi/agent/auth.json` — a literal value or an `!op read` reference — resolves unchanged. No migration action is required.
21
+
22
+ ## What's New — 1Password credential integration
23
+
24
+ Context7 now handles your API key through the [`@jmcombs/pi-1password`](https://www.npmjs.com/package/@jmcombs/pi-1password) credential API, which installs automatically as a dependency. What this means for you:
25
+
26
+ - **Onboarding branches on 1Password availability.** If the `op` CLI is installed and an account is configured, `/context7_setup` opens a live **vault → item → field picker** (or lets you type an `op://…` reference) and stores it as a `!op read '…'` entry that resolves fresh on every use. If `op` is not available, it falls back to **manual API-key entry** and nudges you to enable the 1Password extension for vault integration.
27
+ - **Existing keys keep working.** Any Context7 key already in `~/.pi/agent/auth.json` — a literal key or an `!op read` reference — continues to resolve unchanged. No migration action is required.
28
+ - **The key is never exposed to the model.** Entry happens entirely in the TUI, and only the resolved value is used to call the Context7 API.
29
+ - **Startup warm-up.** With the [`@jmcombs/pi-1password`](https://www.npmjs.com/package/@jmcombs/pi-1password) extension installed and enabled, a one-time `op read` runs at session startup, so the 1Password biometric unlock prompt lands once and later key resolves are silent.
30
+
31
+ ```mermaid
32
+ flowchart TD
33
+ A["/context7_setup or first tool use"] --> B{"is1PasswordAvailable()<br/>(op installed AND configured)"}
34
+ B -- "Yes" --> C["Live vault → item → field picker<br/>or manual op:// reference"]
35
+ C --> D["Store !op read 'op://…' entry"]
36
+ B -- "No" --> E["Manual API-key entry<br/>+ nudge to enable 1Password"]
37
+ E --> F["Store literal api_key entry"]
38
+ D --> G["resolveSecret('context7')<br/>resolves fresh on each tool call"]
39
+ F --> G
40
+ G --> H["Bearer token → Context7 API<br/>(never shown to the LLM)"]
41
+ ```
42
+
16
43
  ## Quick Start
17
44
 
18
45
  Get better library documentation in your agent in under a minute.
@@ -20,16 +47,16 @@ Get better library documentation in your agent in under a minute.
20
47
  1. Install the extension:
21
48
 
22
49
  ```bash
23
- pi install @jmcombs/pi-context7
50
+ pi install npm:@jmcombs/pi-context7
24
51
  ```
25
52
 
26
53
  2. Configure your Context7 API key:
27
54
 
28
55
  ```
29
- /context7_onboard
56
+ /context7_setup
30
57
  ```
31
58
 
32
- The command opens a clean, bordered prompt where you can securely enter your key. You can choose to save it permanently or use it only for the current session.
59
+ The command opens the onboarding flow above — a 1Password vault picker when `op` is available, or a secure manual-entry prompt otherwise. The value is never visible to the LLM.
33
60
 
34
61
  After setup, just ask the agent for documentation normally:
35
62
 
@@ -46,28 +73,23 @@ This extension registers two tools:
46
73
  - `context7_search` — Finds the correct Context7 library ID for a programming language, framework, or library.
47
74
  - `context7_get_docs` — Retrieves detailed, version-specific documentation and real code examples for that library.
48
75
 
49
- The tools support two authentication modes:
50
-
51
- - **Persisted keys** — Saved via `/context7_onboard` into `~/.pi/agent/auth.json` (supports plain keys and `!op read` references).
52
- - **Runtime-only keys** — Entered ad-hoc when a tool is called and kept only for the current session (also supports `!op read` references).
53
-
54
- This design lets you use Context7 without ever leaking keys into the LLM context.
76
+ Both tools resolve the key through `resolveSecret("context7")` from `@jmcombs/pi-1password`, reading `~/.pi/agent/auth.json` fresh on each call (a literal key or an `!op read` reference). If no key is stored, the tool automatically runs onboarding (the availability branch above), then re-resolves — preserving the "prompt on first use" experience. The key is never leaked into the LLM context.
55
77
 
56
- ## /context7_onboard
78
+ ## /context7_setup
57
79
 
58
- Run this command to securely configure your Context7 API key:
80
+ Run this command to configure (or update) your Context7 API key at any time:
59
81
 
60
82
  ```
61
- /context7_onboard
83
+ /context7_setup
62
84
  ```
63
85
 
64
- It supports:
86
+ It delegates to the `@jmcombs/pi-1password` onboarding flow, which:
65
87
 
66
- - Entering keys directly or via `!op read` references
67
- - Overwriting an existing key (with confirmation)
68
- - Choosing between permanent storage and runtime-only for the current session
88
+ - Branches on 1Password availability — vault picker (`op://` reference) when `op` is configured, secure manual entry otherwise.
89
+ - Stores the value in `~/.pi/agent/auth.json` (`0600`), never exposing it to the model.
90
+ - Reports the outcome as a status notification.
69
91
 
70
- The command never exposes the actual key to the model.
92
+ To rotate an existing key, install and enable the [`@jmcombs/pi-1password`](https://www.npmjs.com/package/@jmcombs/pi-1password) extension and re-run onboarding through it, or edit `auth.json` directly.
71
93
 
72
94
  ## After Setup
73
95
 
@@ -81,7 +103,7 @@ Examples of good prompts:
81
103
 
82
104
  ## Checking Status
83
105
 
84
- If you ever need to update or rotate your key, just run `/context7_onboard` again. It will detect the existing key and offer to overwrite it.
106
+ `/context7_setup` will not silently overwrite an existing key — if one is already stored it reports that and leaves it in place. To rotate a key, remove the existing `context7` entry from `~/.pi/agent/auth.json` (or update it through the `@jmcombs/pi-1password` extension), then run `/context7_setup` again.
85
107
 
86
108
  ## Development
87
109
 
package/index.ts CHANGED
@@ -3,18 +3,22 @@
3
3
  *
4
4
  * Registers `context7_search` and `context7_get_docs` tools that let the LLM
5
5
  * find and retrieve version-aware documentation and code snippets from the
6
- * Context7 API. If no Context7 API key is configured, the tool prompts the user
7
- * interactively via the TUI (never leaking the key into the agent's context).
8
- * The key can also be set manually by running `/context7_onboard`.
6
+ * Context7 API. Credentials are handled entirely through the imported
7
+ * `@jmcombs/pi-1password` credential API (`resolveSecret` / `onboardSecret`),
8
+ * so the key is never leaked into the agent's context.
9
9
  *
10
- * Supported configuration (if not using interactive prompt):
11
- * 1. `AuthStorage` under the "context7" key (`~/.pi/agent/auth.json`)
12
- * 2. Auto-prompt via the TUI if no key is found
10
+ * Credential handling:
11
+ * 1. `resolveSecret("context7")` reads `~/.pi/agent/auth.json` and resolves the
12
+ * stored entry (literal key or `!op read 'op://…'` reference) fresh on each use.
13
+ * 2. If nothing is stored, the tool auto-invokes `onboardSecret`, which branches
14
+ * on 1Password availability — the live vault picker when `op` is configured,
15
+ * manual API-key entry otherwise — then re-resolves.
16
+ * 3. `/context7_setup` runs the same onboarding flow on demand.
13
17
  */
14
18
 
15
- import { AuthStorage, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
- import { Type, type Static } from "typebox";
17
- import { confirmInBorderedPopup, inputInBorderedPopup } from "./ui/bordered-popups.js";
19
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ import { onboardSecret, resolveSecret } from "@jmcombs/pi-1password";
21
+ import { type Static, Type } from "typebox";
18
22
 
19
23
  const CONTEXT7_API_BASE = "https://context7.com/api/v2";
20
24
 
@@ -75,7 +79,7 @@ function formatDocs(data: Context7DocsResponse, query: string): string {
75
79
  const { codeSnippets = [], infoSnippets = [] } = data;
76
80
 
77
81
  if (codeSnippets.length === 0 && infoSnippets.length === 0) {
78
- return "No documentation snippets found for " + query + ".";
82
+ return `No documentation snippets found for ${query}.`;
79
83
  }
80
84
 
81
85
  const parts: string[] = [];
@@ -84,13 +88,13 @@ function formatDocs(data: Context7DocsResponse, query: string): string {
84
88
  parts.push("--- CODE SNIPPETS ---");
85
89
  for (const snippet of codeSnippets) {
86
90
  if (snippet.codeTitle) {
87
- parts.push("\n## " + snippet.codeTitle);
91
+ parts.push(`\n## ${snippet.codeTitle}`);
88
92
  }
89
93
  if (snippet.codeList && snippet.codeList.length > 0) {
90
94
  for (const item of snippet.codeList) {
91
95
  if (item.code) {
92
96
  const lang = item.language ?? "typescript";
93
- parts.push("```" + lang + "\n" + item.code + "\n```\n");
97
+ parts.push(`\`\`\`${lang}\n${item.code}\n\`\`\`\n`);
94
98
  }
95
99
  }
96
100
  }
@@ -101,57 +105,23 @@ function formatDocs(data: Context7DocsResponse, query: string): string {
101
105
  parts.push("\n--- INFO SNIPPETS ---");
102
106
  for (const snippet of infoSnippets) {
103
107
  if (snippet.content) {
104
- parts.push("\n" + snippet.content);
108
+ parts.push(`\n${snippet.content}`);
105
109
  }
106
110
  }
107
111
  }
108
112
 
109
- return "Documentation for " + query + ":" + "\n\n" + parts.join("\n");
113
+ return `Documentation for ${query}:\n\n${parts.join("\n")}`;
110
114
  }
111
115
 
112
116
  // -- Extension factory
113
117
 
114
118
  export default function (pi: ExtensionAPI): void {
115
- const authStorage = AuthStorage.create();
116
- // -- /context7_onboard (user-facing command)
117
- pi.registerCommand("context7_onboard", {
118
- description: "Securely save your Context7 API key (input never visible to LLM).",
119
+ // -- /context7_setup (user-facing command)
120
+ pi.registerCommand("context7_setup", {
121
+ description: "Set up or update your Context7 API key (never shown to the agent).",
119
122
  handler: async (_args, ctx) => {
120
- ctx.ui.notify("Context7 Onboarding", "info");
121
-
122
- const existing = await authStorage.getApiKey("context7");
123
- if (existing) {
124
- const overwrite = await confirmInBorderedPopup(ctx, {
125
- title: "Context7 API key already exists, overwrite?",
126
- message: "A Context7 API key is already saved. Overwrite it?",
127
- });
128
- if (!overwrite) {
129
- ctx.ui.notify("Context7 onboarding cancelled.", "warning");
130
- return;
131
- }
132
- }
133
-
134
- const apiKey = await inputInBorderedPopup(ctx, {
135
- title: "Context7 Onboarding",
136
- prompt: "Enter your Context7 API key:",
137
- helpText: "Enter to confirm • Esc = cancel",
138
- });
139
-
140
- if (!apiKey) {
141
- ctx.ui.notify("Context7 onboarding cancelled.", "warning");
142
- return;
143
- }
144
-
145
- authStorage.set("context7", {
146
- type: "api_key" as const,
147
- key: apiKey,
148
- });
149
- authStorage.removeRuntimeApiKey("context7");
150
- const errs = authStorage.drainErrors();
151
- if (errs.length > 0) {
152
- ctx.ui.notify(`onboard ERRORS: ${errs.map((e) => e.message).join("; ")}`, "warning");
153
- }
154
- ctx.ui.notify("Context7 API key saved successfully.", "info");
123
+ const result = await onboardSecret(ctx, { name: "context7", label: "Context7" });
124
+ ctx.ui.notify(result.message, result.ok ? "info" : "warning");
155
125
  },
156
126
  });
157
127
 
@@ -165,97 +135,24 @@ export default function (pi: ExtensionAPI): void {
165
135
  "Always prefer this tool over general web search when you need accurate, version-aware information for coding or development tasks.",
166
136
  parameters: context7SearchSchema,
167
137
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
168
- let apiKey = await authStorage.getApiKey("context7");
169
-
138
+ let apiKey = await resolveSecret("context7");
170
139
  if (!apiKey) {
171
- const entered = await inputInBorderedPopup(ctx, {
172
- title: "Context7 Authentication",
173
- prompt: "Enter your Context7 API key:",
174
- helpText: "Enter to confirm • Esc = cancel",
175
- });
176
- if (!entered) {
177
- ctx.ui.notify("Context7 search cancelled.", "warning");
178
- return {
179
- content: [
180
- {
181
- type: "text",
182
- text: "Search cancelled: no Context7 API key provided.",
183
- },
184
- ],
185
- details: { error: "missing_api_key" },
186
- isError: true,
187
- };
188
- }
189
-
190
- // Always use the entered value for this request (supports raw keys and !op read)
191
- apiKey = entered;
192
-
193
- const savePermanently = await confirmInBorderedPopup(ctx, {
194
- title: "Save API Key?",
195
- message:
196
- "Save this value permanently in auth.json?\n\n" +
197
- "Note: 1Password references (!op read ...) only resolve when saved permanently. " +
198
- "Choosing No will store the resolved secret for this session only.",
199
- });
200
-
201
- if (savePermanently === null) {
202
- // User cancelled the confirmation dialog — abort the entire key entry
203
- ctx.ui.notify("Context7 authentication cancelled.", "warning");
204
- return {
205
- content: [
206
- {
207
- type: "text",
208
- text:
209
- "The user explicitly cancelled Context7 authentication. " +
210
- "Do not attempt to use context7_search or context7_get_docs again in this session " +
211
- "without the user re-initiating the flow.",
212
- },
213
- ],
214
- details: { error: "cancelled" },
215
- isError: true,
216
- };
217
- }
218
-
219
- if (savePermanently) {
220
- authStorage.set("context7", {
221
- type: "api_key" as const,
222
- key: entered,
223
- });
224
- authStorage.removeRuntimeApiKey("context7");
225
- const errs = authStorage.drainErrors();
226
- if (errs.length > 0) {
227
- ctx.ui.notify(
228
- `[context7] search save ERRORS: ${errs.map((e) => e.message).join("; ")}`,
229
- "warning",
230
- );
231
- }
232
- } else {
233
- let keyForRuntime = entered;
234
- if (entered.trim().startsWith("!op read")) {
235
- // Temporarily store the reference so AuthStorage resolves it via the normal path (1Password, etc.)
236
- authStorage.set("context7", { type: "api_key" as const, key: entered });
237
- const resolved = await authStorage.getApiKey("context7");
238
- authStorage.remove("context7");
239
- if (resolved) {
240
- keyForRuntime = resolved;
241
- }
242
- }
243
- authStorage.setRuntimeApiKey("context7", keyForRuntime);
244
- }
245
- apiKey = (await authStorage.getApiKey("context7")) ?? entered;
246
- if (!apiKey) {
247
- return {
248
- content: [
249
- {
250
- type: "text",
251
- text: "Failed to resolve Context7 API key. Check your shell configuration.",
252
- },
253
- ],
254
- details: { error: "missing_api_key" },
255
- isError: true,
256
- };
140
+ const r = await onboardSecret(ctx, { name: "context7", label: "Context7" });
141
+ if (r.ok) {
142
+ apiKey = await resolveSecret("context7");
257
143
  }
258
144
  }
145
+ if (!apiKey) {
146
+ return {
147
+ content: [
148
+ {
149
+ type: "text",
150
+ text: "Search cancelled: no Context7 API key provided.",
151
+ },
152
+ ],
153
+ details: { error: "missing_api_key" },
154
+ };
155
+ }
259
156
 
260
157
  try {
261
158
  const url = new URL("/api/v2/libs/search", CONTEXT7_API_BASE);
@@ -266,7 +163,7 @@ export default function (pi: ExtensionAPI): void {
266
163
 
267
164
  const response = await fetch(url.toString(), {
268
165
  signal,
269
- headers: { Authorization: "Bearer " + apiKey },
166
+ headers: { Authorization: `Bearer ${apiKey}` },
270
167
  });
271
168
 
272
169
  if (!response.ok) {
@@ -277,11 +174,10 @@ export default function (pi: ExtensionAPI): void {
277
174
  type: "text",
278
175
  text:
279
176
  "Context7 API error: 401 Unauthorized. Your Context7 API key " +
280
- "may be missing or invalid. Run /context7_onboard to configure it.",
177
+ "may be missing or invalid. Run /context7_setup to configure it.",
281
178
  },
282
179
  ],
283
180
  details: { status: 401 },
284
- isError: true,
285
181
  };
286
182
  }
287
183
  if (response.status === 429) {
@@ -295,7 +191,6 @@ export default function (pi: ExtensionAPI): void {
295
191
  },
296
192
  ],
297
193
  details: { status: 429 },
298
- isError: true,
299
194
  };
300
195
  }
301
196
 
@@ -314,7 +209,6 @@ export default function (pi: ExtensionAPI): void {
314
209
  },
315
210
  ],
316
211
  details: { status: response.status, body: errorText },
317
- isError: true,
318
212
  };
319
213
  }
320
214
 
@@ -326,7 +220,7 @@ export default function (pi: ExtensionAPI): void {
326
220
  content: [
327
221
  {
328
222
  type: "text",
329
- text: "No libraries found matching " + params.libraryName + ".",
223
+ text: `No libraries found matching ${params.libraryName}.`,
330
224
  },
331
225
  ],
332
226
  details: { libraryName: params.libraryName, raw: data },
@@ -334,9 +228,7 @@ export default function (pi: ExtensionAPI): void {
334
228
  }
335
229
 
336
230
  const formatted = libs
337
- .map(function (lib, i) {
338
- return String(i + 1) + ". " + lib.title + " (ID: " + lib.id + ")";
339
- })
231
+ .map((lib, i) => `${String(i + 1)}. ${lib.title} (ID: ${lib.id})`)
340
232
  .join("\n");
341
233
 
342
234
  return {
@@ -359,11 +251,10 @@ export default function (pi: ExtensionAPI): void {
359
251
  content: [
360
252
  {
361
253
  type: "text",
362
- text: "Error performing Context7 search: " + message,
254
+ text: `Error performing Context7 search: ${message}`,
363
255
  },
364
256
  ],
365
257
  details: { error: message },
366
- isError: true,
367
258
  };
368
259
  }
369
260
  },
@@ -379,97 +270,24 @@ export default function (pi: ExtensionAPI): void {
379
270
  "You should usually call context7_search first to obtain the correct Library ID. Prefer this tool when you need reliable, current technical documentation rather than general explanations.",
380
271
  parameters: context7GetDocsSchema,
381
272
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
382
- let apiKey = await authStorage.getApiKey("context7");
383
-
273
+ let apiKey = await resolveSecret("context7");
384
274
  if (!apiKey) {
385
- const entered = await inputInBorderedPopup(ctx, {
386
- title: "Context7 Authentication",
387
- prompt: "Enter your Context7 API key:",
388
- helpText: "Enter to confirm • Esc = cancel",
389
- });
390
- if (!entered) {
391
- ctx.ui.notify("Context7 documentation retrieval cancelled.", "warning");
392
- return {
393
- content: [
394
- {
395
- type: "text",
396
- text: "Documentation retrieval cancelled: no Context7 API key provided.",
397
- },
398
- ],
399
- details: { error: "missing_api_key" },
400
- isError: true,
401
- };
402
- }
403
-
404
- // Always use the entered value for this request (supports raw keys and !op read)
405
- apiKey = entered;
406
-
407
- const savePermanently = await confirmInBorderedPopup(ctx, {
408
- title: "Save API Key?",
409
- message:
410
- "Save this value permanently in auth.json?\n\n" +
411
- "Note: 1Password references (!op read ...) only resolve when saved permanently. " +
412
- "Choosing No will store the resolved secret for this session only.",
413
- });
414
-
415
- if (savePermanently === null) {
416
- // User cancelled the confirmation dialog — abort the entire key entry
417
- ctx.ui.notify("Context7 authentication cancelled.", "warning");
418
- return {
419
- content: [
420
- {
421
- type: "text",
422
- text:
423
- "The user explicitly cancelled Context7 authentication. " +
424
- "Do not attempt to use context7_search or context7_get_docs again in this session " +
425
- "without the user re-initiating the flow.",
426
- },
427
- ],
428
- details: { error: "cancelled" },
429
- isError: true,
430
- };
431
- }
432
-
433
- if (savePermanently) {
434
- authStorage.set("context7", {
435
- type: "api_key" as const,
436
- key: entered,
437
- });
438
- authStorage.removeRuntimeApiKey("context7");
439
- const errs = authStorage.drainErrors();
440
- if (errs.length > 0) {
441
- ctx.ui.notify(
442
- `get_docs save ERRORS: ${errs.map((e) => e.message).join("; ")}`,
443
- "warning",
444
- );
445
- }
446
- } else {
447
- let keyForRuntime = entered;
448
- if (entered.trim().startsWith("!op read")) {
449
- // Temporarily store the reference so AuthStorage resolves it via the normal path (1Password, etc.)
450
- authStorage.set("context7", { type: "api_key" as const, key: entered });
451
- const resolved = await authStorage.getApiKey("context7");
452
- authStorage.remove("context7");
453
- if (resolved) {
454
- keyForRuntime = resolved;
455
- }
456
- }
457
- authStorage.setRuntimeApiKey("context7", keyForRuntime);
458
- }
459
- apiKey = (await authStorage.getApiKey("context7")) ?? entered;
460
- if (!apiKey) {
461
- return {
462
- content: [
463
- {
464
- type: "text",
465
- text: "Failed to resolve Context7 API key. Check your shell configuration.",
466
- },
467
- ],
468
- details: { error: "missing_api_key" },
469
- isError: true,
470
- };
275
+ const r = await onboardSecret(ctx, { name: "context7", label: "Context7" });
276
+ if (r.ok) {
277
+ apiKey = await resolveSecret("context7");
471
278
  }
472
279
  }
280
+ if (!apiKey) {
281
+ return {
282
+ content: [
283
+ {
284
+ type: "text",
285
+ text: "Documentation retrieval cancelled: no Context7 API key provided.",
286
+ },
287
+ ],
288
+ details: { error: "missing_api_key" },
289
+ };
290
+ }
473
291
 
474
292
  try {
475
293
  const url = new URL("/api/v2/context", CONTEXT7_API_BASE);
@@ -479,7 +297,7 @@ export default function (pi: ExtensionAPI): void {
479
297
 
480
298
  const response = await fetch(url.toString(), {
481
299
  signal,
482
- headers: { Authorization: "Bearer " + apiKey },
300
+ headers: { Authorization: `Bearer ${apiKey}` },
483
301
  });
484
302
 
485
303
  if (!response.ok) {
@@ -490,11 +308,10 @@ export default function (pi: ExtensionAPI): void {
490
308
  type: "text",
491
309
  text:
492
310
  "Context7 API error: 401 Unauthorized. Your Context7 API key " +
493
- "may be missing or invalid. Run /context7_onboard to configure it.",
311
+ "may be missing or invalid. Run /context7_setup to configure it.",
494
312
  },
495
313
  ],
496
314
  details: { status: 401 },
497
- isError: true,
498
315
  };
499
316
  }
500
317
  if (response.status === 429) {
@@ -508,7 +325,6 @@ export default function (pi: ExtensionAPI): void {
508
325
  },
509
326
  ],
510
327
  details: { status: 429 },
511
- isError: true,
512
328
  };
513
329
  }
514
330
 
@@ -527,7 +343,6 @@ export default function (pi: ExtensionAPI): void {
527
343
  },
528
344
  ],
529
345
  details: { status: response.status, body: errorText },
530
- isError: true,
531
346
  };
532
347
  }
533
348
 
@@ -542,11 +357,10 @@ export default function (pi: ExtensionAPI): void {
542
357
  content: [
543
358
  {
544
359
  type: "text",
545
- text: "Error fetching Context7 documentation: " + message,
360
+ text: `Error fetching Context7 documentation: ${message}`,
546
361
  },
547
362
  ],
548
363
  details: { error: message },
549
- isError: true,
550
364
  };
551
365
  }
552
366
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jmcombs/pi-context7",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "private": false,
5
5
  "description": "Real-time documentation for the Pi coding agent via Context7.",
6
6
  "homepage": "https://github.com/jmcombs/pi-extensions/tree/main/packages/context7",
@@ -19,7 +19,6 @@
19
19
  "types": "./index.ts",
20
20
  "files": [
21
21
  "index.ts",
22
- "ui/",
23
22
  "README.md",
24
23
  "LICENSE"
25
24
  ],
@@ -37,9 +36,11 @@
37
36
  "engines": {
38
37
  "node": ">=22.0.0"
39
38
  },
39
+ "dependencies": {
40
+ "@jmcombs/pi-1password": "^1.0.2 || ^2.0.0"
41
+ },
40
42
  "peerDependencies": {
41
43
  "@earendil-works/pi-coding-agent": "*",
42
- "@earendil-works/pi-tui": "*",
43
44
  "typebox": "*"
44
45
  }
45
46
  }
@@ -1,319 +0,0 @@
1
- /**
2
- * Bordered Popup TUI Helpers
3
- *
4
- * These are reusable, self-contained helpers for creating polished,
5
- * consistent bordered popups inside Pi extensions using `ctx.ui.custom({ overlay: true })`.
6
- *
7
- * They were originally developed in the 1Password extension and are provided
8
- * here as a copy-paste starting point for any extension that needs rich
9
- * interactive flows (select lists with live filtering, text input, confirms)
10
- * that look and feel better than the basic `ctx.ui.select / input / confirm`.
11
- *
12
- * ## Usage
13
- *
14
- * ```ts
15
- * import {
16
- * selectInBorderedPopup,
17
- * confirmInBorderedPopup,
18
- * inputInBorderedPopup,
19
- * } from "./ui/bordered-popups.js";
20
- *
21
- * const choice = await selectInBorderedPopup(ctx, {
22
- * title: "Select something",
23
- * items: [...],
24
- * });
25
- *
26
- * const confirmed = await confirmInBorderedPopup(ctx, {
27
- * title: "Are you sure?",
28
- * });
29
- *
30
- * const name = await inputInBorderedPopup(ctx, {
31
- * title: "Enter name",
32
- * prompt: "What should we call it?",
33
- * });
34
- * ```
35
- *
36
- * The helpers automatically handle:
37
- * - Consistent 4-sided borders (╭─╮│╰╯) with stable right edge
38
- * - Proper ANSI-aware padding using Pi's `truncateToWidth`
39
- * - Live filtering on long lists
40
- * - Back navigation ("← Go back") and Esc-to-cancel semantics
41
- * - Theming consistent with the rest of Pi
42
- *
43
- * ## When to use
44
- *
45
- * Use these when you have:
46
- * - Long lists that benefit from filtering
47
- * - Multi-step wizards
48
- * - Situations where the basic Pi UI dialogs feel too plain
49
- *
50
- * For very simple one-off prompts, the built-in `ctx.ui.select/input/confirm`
51
- * are still perfectly acceptable and require less code.
52
- */
53
-
54
- import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
55
-
56
- // No static imports from @earendil-works/pi-tui are used for types.
57
- // We rely on inference for ctx.ui.custom callback parameters (sourced via the
58
- // pi-coding-agent peer) and a minimal local facade for the runtime values
59
- // obtained via dynamic import. This avoids duplicate module declarations
60
- // (pi-tui types nested inside coding-agent vs. direct peer) that would
61
- // otherwise break tsc strict under the project's monorepo layout.
62
-
63
- /** Internal helper to render a consistent bordered box. */
64
- export function renderBorderedBox(
65
- width: number,
66
- title: string,
67
- bodyLines: string[],
68
- footer: string | undefined,
69
- theme: Pick<Theme, "fg" | "bold">,
70
- truncateToWidthFn: (s: string, w: number, e?: string, pad?: boolean) => string,
71
- ): string[] {
72
- const innerWidth = Math.max(20, width - 4);
73
- const top = theme.fg("accent", "╭" + "─".repeat(width - 2) + "╮");
74
- const bottom = theme.fg("accent", "╰" + "─".repeat(width - 2) + "╯");
75
-
76
- const rawTitle = theme.fg("accent", theme.bold(title));
77
- const titlePadded = truncateToWidthFn(rawTitle, innerWidth, "", true);
78
- const borderedTitle = theme.fg("accent", "│ ") + titlePadded + theme.fg("accent", " │");
79
-
80
- const borderedBody = bodyLines.map((line) => {
81
- const padded = truncateToWidthFn(line || "", innerWidth, "", true);
82
- return theme.fg("accent", "│ ") + padded + theme.fg("accent", " │");
83
- });
84
-
85
- const lines = [top, borderedTitle, ...borderedBody];
86
-
87
- if (footer) {
88
- const rawFooter = theme.fg("dim", footer);
89
- const footerPadded = truncateToWidthFn(rawFooter, innerWidth, "", true);
90
- lines.push(theme.fg("accent", "│ ") + footerPadded + theme.fg("accent", " │"));
91
- }
92
-
93
- lines.push(bottom);
94
- return lines;
95
- }
96
-
97
- /**
98
- * High-level helper for a filterable list inside a bordered popup.
99
- * Returns the chosen `.value` or `null` (on cancel / Esc).
100
- */
101
- export async function selectInBorderedPopup<T = string>(
102
- ctx: ExtensionContext,
103
- opts: {
104
- title: string;
105
- items: { value: T; label: string; description?: string }[];
106
- helpText?: string;
107
- maxVisible?: number;
108
- },
109
- ): Promise<T | null> {
110
- const maxVis = opts.maxVisible ?? 14;
111
- const help = opts.helpText ?? "↑↓ • Enter • Esc = cancel • Type to filter";
112
-
113
- // Pure local interface (no `extends` of the real SelectList type) describing
114
- // exactly the surface we use. Avoids pulling in conflicting .d.ts copies of
115
- // pi-tui that exist via the coding-agent transitive dep vs. our direct peer.
116
- interface SelectListHandle {
117
- render(w: number): string[];
118
- invalidate(): void;
119
- handleInput(d: string): void;
120
- onSelect: (item: { value: T }) => void;
121
- onCancel: () => void;
122
- }
123
-
124
- // Let inference provide the exact callback parameter types from the
125
- // ExtensionCommandContext (via coding-agent peer). Explicit annotations
126
- // referencing TUI/KeybindingsManager etc. from pi-tui trigger the
127
- // "separate declarations of private property" tsc error in the monorepo.
128
- return await ctx.ui.custom<T | null>(
129
- async (tui, theme, _kb, done) => {
130
- const piTui = (await import("@earendil-works/pi-tui")) as unknown as {
131
- SelectList: new (
132
- items: { value: T; label: string; description?: string }[],
133
- maxVisible: number,
134
- theme: unknown,
135
- ) => SelectListHandle;
136
- Container: new () => { invalidate(): void };
137
- truncateToWidth: (s: string, w: number, e?: string, pad?: boolean) => string;
138
- };
139
-
140
- const { SelectList, Container, truncateToWidth: truncateToWidthFn } = piTui;
141
-
142
- let currentList: SelectListHandle | null = null;
143
-
144
- function build() {
145
- currentList = new SelectList(
146
- opts.items.map((it) => ({
147
- value: it.value,
148
- label: it.label,
149
- description: it.description,
150
- })),
151
- maxVis,
152
- {
153
- selectedPrefix: (t: string) => theme.fg("accent", t),
154
- selectedText: (t: string) => theme.fg("accent", t),
155
- description: (t: string) => theme.fg("muted", t),
156
- scrollInfo: (t: string) => theme.fg("dim", t),
157
- noMatch: (t: string) => theme.fg("warning", t),
158
- },
159
- );
160
- currentList.onSelect = (item) => {
161
- done(item.value);
162
- };
163
- currentList.onCancel = () => {
164
- done(null);
165
- };
166
- }
167
-
168
- build();
169
-
170
- const container = new Container();
171
-
172
- const popup: {
173
- render(w: number): string[];
174
- invalidate(): void;
175
- handleInput?(d: string): void;
176
- dispose?(): void;
177
- } = {
178
- render(width: number) {
179
- const listLines = currentList ? currentList.render(Math.max(20, width - 4)) : [];
180
- return renderBorderedBox(width, opts.title, listLines, help, theme, truncateToWidthFn);
181
- },
182
- invalidate() {
183
- container.invalidate();
184
- currentList?.invalidate();
185
- },
186
- handleInput(d: string) {
187
- currentList?.handleInput(d);
188
- tui.requestRender();
189
- },
190
- };
191
-
192
- return popup;
193
- },
194
- { overlay: true },
195
- );
196
- }
197
-
198
- /** Yes/No (or custom labels) confirmation inside a bordered popup. */
199
- export async function confirmInBorderedPopup(
200
- ctx: ExtensionContext,
201
- opts: {
202
- title: string;
203
- message?: string;
204
- confirmLabel?: string;
205
- cancelLabel?: string;
206
- },
207
- ): Promise<boolean | null> {
208
- const yes = opts.confirmLabel ?? "Yes";
209
- const no = opts.cancelLabel ?? "No";
210
- const items = [
211
- { value: true, label: yes },
212
- { value: false, label: no },
213
- ];
214
-
215
- const choice = await selectInBorderedPopup(ctx, {
216
- title: opts.title,
217
- items,
218
- helpText: "↑↓ • Enter to confirm • Esc = cancel",
219
- maxVisible: 5,
220
- });
221
-
222
- if (choice === null) return null; // user cancelled the dialog
223
- return choice;
224
- }
225
-
226
- /**
227
- * Bordered popup text input powered by Pi's Editor component.
228
- * Good for free-text entry while staying inside the custom popup aesthetic.
229
- */
230
- export async function inputInBorderedPopup(
231
- ctx: ExtensionContext,
232
- opts: {
233
- title: string;
234
- prompt?: string;
235
- defaultValue?: string;
236
- helpText?: string;
237
- },
238
- ): Promise<string | undefined> {
239
- const help = opts.helpText ?? "Enter to confirm • Esc = cancel";
240
-
241
- // Pure local interface (no extends) for the submit hook.
242
- interface EditorHandle {
243
- render(w: number): string[];
244
- invalidate(): void;
245
- handleInput(d: string): void;
246
- setText(s: string): void;
247
- onSubmit: (value: string) => void;
248
- }
249
-
250
- // Inference for callback params (see selectInBorderedPopup for rationale).
251
- return await ctx.ui.custom<string | undefined>(
252
- async (tui, theme, _kb, done) => {
253
- const piTui = (await import("@earendil-works/pi-tui")) as unknown as {
254
- Editor: new (tui: unknown, theme: unknown) => EditorHandle;
255
- matchesKey: (data: string, key: string) => boolean;
256
- truncateToWidth: (s: string, w: number, e?: string, pad?: boolean) => string;
257
- };
258
-
259
- const { Editor, matchesKey, truncateToWidth: truncateToWidthFn } = piTui;
260
-
261
- const editorTheme = {
262
- borderColor: (s: string) => theme.fg("accent", s),
263
- selectList: {
264
- selectedPrefix: (t: string) => theme.fg("accent", t),
265
- selectedText: (t: string) => theme.fg("accent", t),
266
- description: (t: string) => theme.fg("muted", t),
267
- scrollInfo: (t: string) => theme.fg("dim", t),
268
- noMatch: (t: string) => theme.fg("warning", t),
269
- },
270
- };
271
-
272
- const editor = new Editor(tui, editorTheme);
273
-
274
- if (opts.defaultValue) {
275
- editor.setText(opts.defaultValue);
276
- }
277
-
278
- editor.onSubmit = (value: string) => {
279
- done(value.trim() || undefined);
280
- };
281
-
282
- const popup: {
283
- render(w: number): string[];
284
- invalidate(): void;
285
- handleInput?(d: string): void;
286
- dispose?(): void;
287
- } = {
288
- render(width: number) {
289
- const innerWidth = Math.max(20, width - 4);
290
- const body: string[] = [];
291
-
292
- if (opts.prompt) {
293
- body.push(theme.fg("text", opts.prompt));
294
- body.push("");
295
- }
296
-
297
- const editorLines = editor.render(innerWidth);
298
- body.push(...editorLines);
299
-
300
- return renderBorderedBox(width, opts.title, body, help, theme, truncateToWidthFn);
301
- },
302
- invalidate() {
303
- editor.invalidate();
304
- },
305
- handleInput(data: string) {
306
- if (matchesKey(data, "escape")) {
307
- done(undefined);
308
- return;
309
- }
310
- editor.handleInput(data);
311
- tui.requestRender();
312
- },
313
- };
314
-
315
- return popup;
316
- },
317
- { overlay: true },
318
- );
319
- }