@jmcombs/pi-context7 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jeremy Combs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # @jmcombs/pi-EXTENSION_NAME
2
+
3
+ > TODO: One-paragraph description of what this extension does and who it is
4
+ > for. Mention the tools and/or commands it provides.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ # Globally (recommended)
10
+ pi install npm:@jmcombs/pi-EXTENSION_NAME
11
+
12
+ # For a single session, without installing
13
+ pi -e npm:@jmcombs/pi-EXTENSION_NAME
14
+ ```
15
+
16
+ See the [Pi packages documentation](https://pi.dev/docs/packages) for git, local
17
+ path, project-scoped install, and filtering options.
18
+
19
+ ## What It Adds
20
+
21
+ - **Tool**: `example_echo` — TODO: describe the tool, its parameters, and what
22
+ the LLM uses it for.
23
+ - **Command**: `/example-hello [name]` — TODO: describe the command and any
24
+ arguments.
25
+
26
+ ## Configuration
27
+
28
+ <!-- Delete this section if your extension does not need any configuration. -->
29
+
30
+ ### API Keys / Secrets
31
+
32
+ If this extension calls a third-party service, store the credential using one
33
+ of Pi's recommended auth storage methods. **Never** hard-code API keys or commit
34
+ them to source control.
35
+
36
+ #### Option 1 — Environment variable
37
+
38
+ ```bash
39
+ export EXAMPLE_API_KEY="…"
40
+ ```
41
+
42
+ #### Option 2 — `~/.pi/agent/auth.json`
43
+
44
+ ```json
45
+ {
46
+ "example": {
47
+ "type": "api_key",
48
+ "key": "EXAMPLE_API_KEY"
49
+ }
50
+ }
51
+ ```
52
+
53
+ #### Option 3 — Shell-resolved secret (macOS Keychain, 1Password, pass, etc.)
54
+
55
+ ```json
56
+ {
57
+ "example": {
58
+ "type": "api_key",
59
+ "key": "!security find-generic-password -ws 'example'"
60
+ }
61
+ }
62
+ ```
63
+
64
+ ```json
65
+ {
66
+ "example": {
67
+ "type": "api_key",
68
+ "key": "!op read 'op://Personal/example/credential'"
69
+ }
70
+ }
71
+ ```
72
+
73
+ The extension reads the key with:
74
+
75
+ ```ts
76
+ import { AuthStorage } from "@earendil-works/pi-coding-agent";
77
+ const auth = AuthStorage.create();
78
+ const apiKey = (await auth.getApiKey("example")) ?? process.env.EXAMPLE_API_KEY;
79
+ ```
80
+
81
+ ## Requirements
82
+
83
+ - Pi `>= TODO: minimum tested pi version`
84
+ - Node `>= 20.6.0`
85
+
86
+ ## Development
87
+
88
+ This package lives in the [pi-extensions monorepo](https://github.com/jmcombs/pi-extensions).
89
+ See `CONTRIBUTING.md` at the repo root for project conventions.
90
+
91
+ ```bash
92
+ # From the repo root
93
+ npm ci
94
+ npm run check # full quality gate
95
+ npm run test # this package's smoke test
96
+ ```
97
+
98
+ To try local changes against a real Pi session:
99
+
100
+ ```bash
101
+ pi -e ./packages/EXTENSION_NAME
102
+ ```
103
+
104
+ ## License
105
+
106
+ [MIT](./LICENSE) © Jeremy Combs
package/index.ts ADDED
@@ -0,0 +1,554 @@
1
+ /**
2
+ * @jmcombs/pi-context7 — Real-time documentation for the Pi coding agent via Context7.
3
+ *
4
+ * Registers `context7_search` and `context7_get_docs` tools that let the LLM
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`.
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
13
+ */
14
+
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";
18
+
19
+ const CONTEXT7_API_BASE = "https://context7.com/api/v2";
20
+
21
+ // -- Context7 API response types
22
+
23
+ interface CodeSnippet {
24
+ codeTitle?: string;
25
+ codeList?: { language?: string; code?: string }[];
26
+ }
27
+
28
+ interface InfoSnippet {
29
+ content?: string;
30
+ }
31
+
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
+ // -- Tool parameter schemas
49
+
50
+ const context7SearchSchema = Type.Object({
51
+ libraryName: Type.String({
52
+ description: "The name of the library (e.g., 'next.js', 'supabase').",
53
+ }),
54
+ query: Type.Optional(
55
+ Type.String({
56
+ description: "A specific question/topic to refine search results.",
57
+ }),
58
+ ),
59
+ });
60
+ export type Context7SearchInput = Static<typeof context7SearchSchema>;
61
+
62
+ const context7GetDocsSchema = Type.Object({
63
+ libraryId: Type.String({
64
+ description: "The Context7 Library ID (e.g., '/vercel/next.js').",
65
+ }),
66
+ query: Type.String({
67
+ description: "The specific technical question or implementation pattern requested.",
68
+ }),
69
+ });
70
+ export type Context7GetDocsInput = Static<typeof context7GetDocsSchema>;
71
+
72
+ // -- Helpers
73
+
74
+ function formatDocs(data: Context7DocsResponse, query: string): string {
75
+ const { codeSnippets = [], infoSnippets = [] } = data;
76
+
77
+ if (codeSnippets.length === 0 && infoSnippets.length === 0) {
78
+ return "No documentation snippets found for " + query + ".";
79
+ }
80
+
81
+ const parts: string[] = [];
82
+
83
+ if (codeSnippets.length > 0) {
84
+ parts.push("--- CODE SNIPPETS ---");
85
+ for (const snippet of codeSnippets) {
86
+ if (snippet.codeTitle) {
87
+ parts.push("\n## " + snippet.codeTitle);
88
+ }
89
+ if (snippet.codeList && snippet.codeList.length > 0) {
90
+ for (const item of snippet.codeList) {
91
+ if (item.code) {
92
+ const lang = item.language ?? "typescript";
93
+ parts.push("```" + lang + "\n" + item.code + "\n```\n");
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ if (infoSnippets.length > 0) {
101
+ parts.push("\n--- INFO SNIPPETS ---");
102
+ for (const snippet of infoSnippets) {
103
+ if (snippet.content) {
104
+ parts.push("\n" + snippet.content);
105
+ }
106
+ }
107
+ }
108
+
109
+ return "Documentation for " + query + ":" + "\n\n" + parts.join("\n");
110
+ }
111
+
112
+ // -- Extension factory
113
+
114
+ 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
+ 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");
155
+ },
156
+ });
157
+
158
+ // -- context7_search
159
+ pi.registerTool({
160
+ name: "context7_search",
161
+ label: "Context7: Find Library ID",
162
+ description:
163
+ "Use this tool to search Context7 for the correct library ID of a programming language, framework, or library. " +
164
+ "Call this when the user needs up-to-date documentation, code examples, configuration guidance, or implementation details for something like Supabase, React, Rust, Tailwind, Prisma, or any other programming language, framework, or library. " +
165
+ "Always prefer this tool over general web search when you need accurate, version-aware information for coding or development tasks.",
166
+ parameters: context7SearchSchema,
167
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
168
+ let apiKey = await authStorage.getApiKey("context7");
169
+
170
+ 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
+ };
257
+ }
258
+ }
259
+
260
+ try {
261
+ const url = new URL("/api/v2/libs/search", CONTEXT7_API_BASE);
262
+ url.searchParams.set("libraryName", params.libraryName);
263
+ if (params.query) {
264
+ url.searchParams.set("query", params.query);
265
+ }
266
+
267
+ const response = await fetch(url.toString(), {
268
+ signal,
269
+ headers: { Authorization: "Bearer " + apiKey },
270
+ });
271
+
272
+ if (!response.ok) {
273
+ if (response.status === 401) {
274
+ return {
275
+ content: [
276
+ {
277
+ type: "text",
278
+ text:
279
+ "Context7 API error: 401 Unauthorized. Your Context7 API key " +
280
+ "may be missing or invalid. Run /context7_onboard to configure it.",
281
+ },
282
+ ],
283
+ details: { status: 401 },
284
+ isError: true,
285
+ };
286
+ }
287
+ if (response.status === 429) {
288
+ return {
289
+ content: [
290
+ {
291
+ type: "text",
292
+ text:
293
+ "Context7 API error: 429 Too Many Requests. You are being rate " +
294
+ "limited — please wait a moment and try again.",
295
+ },
296
+ ],
297
+ details: { status: 429 },
298
+ isError: true,
299
+ };
300
+ }
301
+
302
+ const errorText = await response.text();
303
+ return {
304
+ content: [
305
+ {
306
+ type: "text",
307
+ text:
308
+ "Context7 API error: " +
309
+ String(response.status) +
310
+ " " +
311
+ response.statusText +
312
+ "\n" +
313
+ errorText,
314
+ },
315
+ ],
316
+ details: { status: response.status, body: errorText },
317
+ isError: true,
318
+ };
319
+ }
320
+
321
+ const data = (await response.json()) as Context7SearchResponse;
322
+ const libs = data.results ?? [];
323
+
324
+ if (libs.length === 0) {
325
+ return {
326
+ content: [
327
+ {
328
+ type: "text",
329
+ text: "No libraries found matching " + params.libraryName + ".",
330
+ },
331
+ ],
332
+ details: { libraryName: params.libraryName, raw: data },
333
+ };
334
+ }
335
+
336
+ const formatted = libs
337
+ .map(function (lib, i) {
338
+ return String(i + 1) + ". " + lib.title + " (ID: " + lib.id + ")";
339
+ })
340
+ .join("\n");
341
+
342
+ return {
343
+ content: [
344
+ {
345
+ type: "text",
346
+ text:
347
+ "Context7 library search results for " +
348
+ params.libraryName +
349
+ ":\n\n" +
350
+ formatted +
351
+ "\n\nUse context7_get_docs with a Library ID to retrieve documentation.",
352
+ },
353
+ ],
354
+ details: { libraryName: params.libraryName, raw: data },
355
+ };
356
+ } catch (error) {
357
+ const message = error instanceof Error ? error.message : String(error);
358
+ return {
359
+ content: [
360
+ {
361
+ type: "text",
362
+ text: "Error performing Context7 search: " + message,
363
+ },
364
+ ],
365
+ details: { error: message },
366
+ isError: true,
367
+ };
368
+ }
369
+ },
370
+ });
371
+
372
+ // -- context7_get_docs
373
+ pi.registerTool({
374
+ name: "context7_get_docs",
375
+ label: "Context7: Query Documentation",
376
+ description:
377
+ "Use this tool to retrieve detailed, version-specific documentation and real code examples from Context7 for a programming language, framework, or library. " +
378
+ "Call this when the user needs implementation details, code snippets, configuration examples, best practices, or answers to technical questions about a specific language, framework, or library. " +
379
+ "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
+ parameters: context7GetDocsSchema,
381
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
382
+ let apiKey = await authStorage.getApiKey("context7");
383
+
384
+ 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
+ };
471
+ }
472
+ }
473
+
474
+ try {
475
+ const url = new URL("/api/v2/context", CONTEXT7_API_BASE);
476
+ url.searchParams.set("libraryId", params.libraryId);
477
+ url.searchParams.set("query", params.query);
478
+ url.searchParams.set("type", "json");
479
+
480
+ const response = await fetch(url.toString(), {
481
+ signal,
482
+ headers: { Authorization: "Bearer " + apiKey },
483
+ });
484
+
485
+ if (!response.ok) {
486
+ if (response.status === 401) {
487
+ return {
488
+ content: [
489
+ {
490
+ type: "text",
491
+ text:
492
+ "Context7 API error: 401 Unauthorized. Your Context7 API key " +
493
+ "may be missing or invalid. Run /context7_onboard to configure it.",
494
+ },
495
+ ],
496
+ details: { status: 401 },
497
+ isError: true,
498
+ };
499
+ }
500
+ if (response.status === 429) {
501
+ return {
502
+ content: [
503
+ {
504
+ type: "text",
505
+ text:
506
+ "Context7 API error: 429 Too Many Requests. You are being rate " +
507
+ "limited — please wait a moment and try again.",
508
+ },
509
+ ],
510
+ details: { status: 429 },
511
+ isError: true,
512
+ };
513
+ }
514
+
515
+ const errorText = await response.text();
516
+ return {
517
+ content: [
518
+ {
519
+ type: "text",
520
+ text:
521
+ "Context7 API error: " +
522
+ String(response.status) +
523
+ " " +
524
+ response.statusText +
525
+ "\n" +
526
+ errorText,
527
+ },
528
+ ],
529
+ details: { status: response.status, body: errorText },
530
+ isError: true,
531
+ };
532
+ }
533
+
534
+ const data = (await response.json()) as Context7DocsResponse;
535
+ return {
536
+ content: [{ type: "text", text: formatDocs(data, params.query) }],
537
+ details: { libraryId: params.libraryId, query: params.query, raw: data },
538
+ };
539
+ } catch (error) {
540
+ const message = error instanceof Error ? error.message : String(error);
541
+ return {
542
+ content: [
543
+ {
544
+ type: "text",
545
+ text: "Error fetching Context7 documentation: " + message,
546
+ },
547
+ ],
548
+ details: { error: message },
549
+ isError: true,
550
+ };
551
+ }
552
+ },
553
+ });
554
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@jmcombs/pi-context7",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Real-time documentation for the Pi coding agent via Context7.",
6
+ "homepage": "https://github.com/jmcombs/pi-extensions/tree/main/packages/context7",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/jmcombs/pi-extensions.git",
10
+ "directory": "packages/context7"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/jmcombs/pi-extensions/issues"
14
+ },
15
+ "license": "MIT",
16
+ "author": "Jeremy Combs",
17
+ "type": "module",
18
+ "main": "./index.ts",
19
+ "types": "./index.ts",
20
+ "files": [
21
+ "index.ts",
22
+ "ui/",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "keywords": [
27
+ "pi-package",
28
+ "context7",
29
+ "documentation"
30
+ ],
31
+ "pi": {
32
+ "extensions": [
33
+ "./index.ts"
34
+ ],
35
+ "image": "https://raw.githubusercontent.com/jmcombs/pi-extensions/main/assets/context7/preview.png"
36
+ },
37
+ "engines": {
38
+ "node": ">=22.0.0"
39
+ },
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-coding-agent": "*",
42
+ "@earendil-works/pi-tui": "*",
43
+ "typebox": "*"
44
+ }
45
+ }
@@ -0,0 +1,319 @@
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
+ }