@jmcombs/pi-context7 1.0.0 → 2.0.1

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,23 @@
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 { JsonObject, JsonValue } from "@earendil-works/pi-ai";
20
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
+ import { onboardSecret, resolveSecret } from "@jmcombs/pi-1password";
22
+ import { type Static, Type } from "typebox";
18
23
 
19
24
  const CONTEXT7_API_BASE = "https://context7.com/api/v2";
20
25
 
@@ -29,22 +34,6 @@ interface InfoSnippet {
29
34
  content?: string;
30
35
  }
31
36
 
32
- interface Context7SearchResult {
33
- id: string;
34
- title: string;
35
- [key: string]: unknown;
36
- }
37
-
38
- interface Context7SearchResponse {
39
- results?: Context7SearchResult[];
40
- }
41
-
42
- interface Context7DocsResponse {
43
- codeSnippets?: CodeSnippet[];
44
- infoSnippets?: InfoSnippet[];
45
- [key: string]: unknown;
46
- }
47
-
48
37
  // -- Tool parameter schemas
49
38
 
50
39
  const context7SearchSchema = Type.Object({
@@ -71,11 +60,60 @@ export type Context7GetDocsInput = Static<typeof context7GetDocsSchema>;
71
60
 
72
61
  // -- Helpers
73
62
 
74
- function formatDocs(data: Context7DocsResponse, query: string): string {
75
- const { codeSnippets = [], infoSnippets = [] } = data;
63
+ export function isJsonValue(value: unknown): value is JsonValue {
64
+ if (value === null) return true;
65
+ switch (typeof value) {
66
+ case "boolean":
67
+ case "number":
68
+ case "string":
69
+ return true;
70
+ case "object": {
71
+ if (Array.isArray(value)) return value.every(isJsonValue);
72
+ const nested: unknown[] = Object.values(value);
73
+ return nested.every(isJsonValue);
74
+ }
75
+ default:
76
+ return false;
77
+ }
78
+ }
79
+
80
+ function asJsonObject(value: JsonValue): JsonObject | undefined {
81
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
82
+ return value as JsonObject;
83
+ }
84
+
85
+ function formatDocs(data: JsonValue, query: string): string {
86
+ const obj = asJsonObject(data);
87
+ const codeSnippetsRaw = obj && Array.isArray(obj.codeSnippets) ? obj.codeSnippets : [];
88
+ const infoSnippetsRaw = obj && Array.isArray(obj.infoSnippets) ? obj.infoSnippets : [];
89
+ const codeSnippets: CodeSnippet[] = [];
90
+ for (const snippet of codeSnippetsRaw) {
91
+ const item = asJsonObject(snippet);
92
+ if (!item) continue;
93
+ const next: CodeSnippet = {};
94
+ if (typeof item.codeTitle === "string") next.codeTitle = item.codeTitle;
95
+ if (Array.isArray(item.codeList)) {
96
+ next.codeList = item.codeList.flatMap((entry) => {
97
+ const codeItem = asJsonObject(entry);
98
+ if (!codeItem) return [];
99
+ const language = typeof codeItem.language === "string" ? codeItem.language : undefined;
100
+ const code = typeof codeItem.code === "string" ? codeItem.code : undefined;
101
+ return [{ language, code }];
102
+ });
103
+ }
104
+ codeSnippets.push(next);
105
+ }
106
+ const infoSnippets: InfoSnippet[] = [];
107
+ for (const snippet of infoSnippetsRaw) {
108
+ const item = asJsonObject(snippet);
109
+ if (!item) continue;
110
+ const next: InfoSnippet = {};
111
+ if (typeof item.content === "string") next.content = item.content;
112
+ infoSnippets.push(next);
113
+ }
76
114
 
77
115
  if (codeSnippets.length === 0 && infoSnippets.length === 0) {
78
- return "No documentation snippets found for " + query + ".";
116
+ return `No documentation snippets found for ${query}.`;
79
117
  }
80
118
 
81
119
  const parts: string[] = [];
@@ -84,13 +122,13 @@ function formatDocs(data: Context7DocsResponse, query: string): string {
84
122
  parts.push("--- CODE SNIPPETS ---");
85
123
  for (const snippet of codeSnippets) {
86
124
  if (snippet.codeTitle) {
87
- parts.push("\n## " + snippet.codeTitle);
125
+ parts.push(`\n## ${snippet.codeTitle}`);
88
126
  }
89
127
  if (snippet.codeList && snippet.codeList.length > 0) {
90
128
  for (const item of snippet.codeList) {
91
129
  if (item.code) {
92
130
  const lang = item.language ?? "typescript";
93
- parts.push("```" + lang + "\n" + item.code + "\n```\n");
131
+ parts.push(`\`\`\`${lang}\n${item.code}\n\`\`\`\n`);
94
132
  }
95
133
  }
96
134
  }
@@ -101,57 +139,23 @@ function formatDocs(data: Context7DocsResponse, query: string): string {
101
139
  parts.push("\n--- INFO SNIPPETS ---");
102
140
  for (const snippet of infoSnippets) {
103
141
  if (snippet.content) {
104
- parts.push("\n" + snippet.content);
142
+ parts.push(`\n${snippet.content}`);
105
143
  }
106
144
  }
107
145
  }
108
146
 
109
- return "Documentation for " + query + ":" + "\n\n" + parts.join("\n");
147
+ return `Documentation for ${query}:\n\n${parts.join("\n")}`;
110
148
  }
111
149
 
112
150
  // -- Extension factory
113
151
 
114
152
  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).",
153
+ // -- /context7_setup (user-facing command)
154
+ pi.registerCommand("context7_setup", {
155
+ description: "Set up or update your Context7 API key (never shown to the agent).",
119
156
  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");
157
+ const result = await onboardSecret(ctx, { name: "context7", label: "Context7" });
158
+ ctx.ui.notify(result.message, result.ok ? "info" : "warning");
155
159
  },
156
160
  });
157
161
 
@@ -165,97 +169,24 @@ export default function (pi: ExtensionAPI): void {
165
169
  "Always prefer this tool over general web search when you need accurate, version-aware information for coding or development tasks.",
166
170
  parameters: context7SearchSchema,
167
171
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
168
- let apiKey = await authStorage.getApiKey("context7");
169
-
172
+ let apiKey = await resolveSecret("context7");
170
173
  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
- };
174
+ const r = await onboardSecret(ctx, { name: "context7", label: "Context7" });
175
+ if (r.ok) {
176
+ apiKey = await resolveSecret("context7");
257
177
  }
258
178
  }
179
+ if (!apiKey) {
180
+ return {
181
+ content: [
182
+ {
183
+ type: "text",
184
+ text: "Search cancelled: no Context7 API key provided.",
185
+ },
186
+ ],
187
+ details: { error: "missing_api_key" },
188
+ };
189
+ }
259
190
 
260
191
  try {
261
192
  const url = new URL("/api/v2/libs/search", CONTEXT7_API_BASE);
@@ -266,7 +197,7 @@ export default function (pi: ExtensionAPI): void {
266
197
 
267
198
  const response = await fetch(url.toString(), {
268
199
  signal,
269
- headers: { Authorization: "Bearer " + apiKey },
200
+ headers: { Authorization: `Bearer ${apiKey}` },
270
201
  });
271
202
 
272
203
  if (!response.ok) {
@@ -277,11 +208,10 @@ export default function (pi: ExtensionAPI): void {
277
208
  type: "text",
278
209
  text:
279
210
  "Context7 API error: 401 Unauthorized. Your Context7 API key " +
280
- "may be missing or invalid. Run /context7_onboard to configure it.",
211
+ "may be missing or invalid. Run /context7_setup to configure it.",
281
212
  },
282
213
  ],
283
214
  details: { status: 401 },
284
- isError: true,
285
215
  };
286
216
  }
287
217
  if (response.status === 429) {
@@ -295,7 +225,6 @@ export default function (pi: ExtensionAPI): void {
295
225
  },
296
226
  ],
297
227
  details: { status: 429 },
298
- isError: true,
299
228
  };
300
229
  }
301
230
 
@@ -314,19 +243,34 @@ export default function (pi: ExtensionAPI): void {
314
243
  },
315
244
  ],
316
245
  details: { status: response.status, body: errorText },
317
- isError: true,
318
246
  };
319
247
  }
320
248
 
321
- const data = (await response.json()) as Context7SearchResponse;
322
- const libs = data.results ?? [];
249
+ const parsed: unknown = await response.json();
250
+ if (!isJsonValue(parsed)) {
251
+ return {
252
+ content: [{ type: "text", text: "Context7 API returned invalid JSON." }],
253
+ details: { error: "invalid_json" },
254
+ };
255
+ }
256
+ const data = parsed;
257
+ const obj = asJsonObject(data);
258
+ const libsRaw = obj && Array.isArray(obj.results) ? obj.results : [];
259
+ const libs: { id: string; title: string }[] = [];
260
+ for (const lib of libsRaw) {
261
+ const item = asJsonObject(lib);
262
+ if (!item) continue;
263
+ if (typeof item.id === "string" && typeof item.title === "string") {
264
+ libs.push({ id: item.id, title: item.title });
265
+ }
266
+ }
323
267
 
324
268
  if (libs.length === 0) {
325
269
  return {
326
270
  content: [
327
271
  {
328
272
  type: "text",
329
- text: "No libraries found matching " + params.libraryName + ".",
273
+ text: `No libraries found matching ${params.libraryName}.`,
330
274
  },
331
275
  ],
332
276
  details: { libraryName: params.libraryName, raw: data },
@@ -334,9 +278,7 @@ export default function (pi: ExtensionAPI): void {
334
278
  }
335
279
 
336
280
  const formatted = libs
337
- .map(function (lib, i) {
338
- return String(i + 1) + ". " + lib.title + " (ID: " + lib.id + ")";
339
- })
281
+ .map((lib, i) => `${String(i + 1)}. ${lib.title} (ID: ${lib.id})`)
340
282
  .join("\n");
341
283
 
342
284
  return {
@@ -359,11 +301,10 @@ export default function (pi: ExtensionAPI): void {
359
301
  content: [
360
302
  {
361
303
  type: "text",
362
- text: "Error performing Context7 search: " + message,
304
+ text: `Error performing Context7 search: ${message}`,
363
305
  },
364
306
  ],
365
307
  details: { error: message },
366
- isError: true,
367
308
  };
368
309
  }
369
310
  },
@@ -379,97 +320,24 @@ export default function (pi: ExtensionAPI): void {
379
320
  "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
321
  parameters: context7GetDocsSchema,
381
322
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
382
- let apiKey = await authStorage.getApiKey("context7");
383
-
323
+ let apiKey = await resolveSecret("context7");
384
324
  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
- };
325
+ const r = await onboardSecret(ctx, { name: "context7", label: "Context7" });
326
+ if (r.ok) {
327
+ apiKey = await resolveSecret("context7");
471
328
  }
472
329
  }
330
+ if (!apiKey) {
331
+ return {
332
+ content: [
333
+ {
334
+ type: "text",
335
+ text: "Documentation retrieval cancelled: no Context7 API key provided.",
336
+ },
337
+ ],
338
+ details: { error: "missing_api_key" },
339
+ };
340
+ }
473
341
 
474
342
  try {
475
343
  const url = new URL("/api/v2/context", CONTEXT7_API_BASE);
@@ -479,7 +347,7 @@ export default function (pi: ExtensionAPI): void {
479
347
 
480
348
  const response = await fetch(url.toString(), {
481
349
  signal,
482
- headers: { Authorization: "Bearer " + apiKey },
350
+ headers: { Authorization: `Bearer ${apiKey}` },
483
351
  });
484
352
 
485
353
  if (!response.ok) {
@@ -490,11 +358,10 @@ export default function (pi: ExtensionAPI): void {
490
358
  type: "text",
491
359
  text:
492
360
  "Context7 API error: 401 Unauthorized. Your Context7 API key " +
493
- "may be missing or invalid. Run /context7_onboard to configure it.",
361
+ "may be missing or invalid. Run /context7_setup to configure it.",
494
362
  },
495
363
  ],
496
364
  details: { status: 401 },
497
- isError: true,
498
365
  };
499
366
  }
500
367
  if (response.status === 429) {
@@ -508,7 +375,6 @@ export default function (pi: ExtensionAPI): void {
508
375
  },
509
376
  ],
510
377
  details: { status: 429 },
511
- isError: true,
512
378
  };
513
379
  }
514
380
 
@@ -527,11 +393,17 @@ export default function (pi: ExtensionAPI): void {
527
393
  },
528
394
  ],
529
395
  details: { status: response.status, body: errorText },
530
- isError: true,
531
396
  };
532
397
  }
533
398
 
534
- const data = (await response.json()) as Context7DocsResponse;
399
+ const parsed: unknown = await response.json();
400
+ if (!isJsonValue(parsed)) {
401
+ return {
402
+ content: [{ type: "text", text: "Context7 API returned invalid JSON." }],
403
+ details: { error: "invalid_json" },
404
+ };
405
+ }
406
+ const data = parsed;
535
407
  return {
536
408
  content: [{ type: "text", text: formatDocs(data, params.query) }],
537
409
  details: { libraryId: params.libraryId, query: params.query, raw: data },
@@ -542,11 +414,10 @@ export default function (pi: ExtensionAPI): void {
542
414
  content: [
543
415
  {
544
416
  type: "text",
545
- text: "Error fetching Context7 documentation: " + message,
417
+ text: `Error fetching Context7 documentation: ${message}`,
546
418
  },
547
419
  ],
548
420
  details: { error: message },
549
- isError: true,
550
421
  };
551
422
  }
552
423
  },
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.1",
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
  ],
@@ -35,11 +34,13 @@
35
34
  "image": "https://raw.githubusercontent.com/jmcombs/pi-extensions/main/assets/context7/preview.png"
36
35
  },
37
36
  "engines": {
38
- "node": ">=22.0.0"
37
+ "node": ">=22.19.0"
38
+ },
39
+ "dependencies": {
40
+ "@jmcombs/pi-1password": "^1.0.2 || ^2.0.0"
39
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
- }