@jmcombs/pi-1password 1.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/LICENSE +21 -0
- package/README.md +143 -0
- package/data/shell-plugins.json +450 -0
- package/index.ts +853 -0
- package/package.json +53 -0
- package/ui/bordered-popups.ts +318 -0
package/index.ts
ADDED
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jmcombs/pi-1password — 1Password integration for the Pi coding agent.
|
|
3
|
+
*
|
|
4
|
+
* Provides tools to read secrets from 1Password and run commands with
|
|
5
|
+
* 1Password credential injection using the `op` CLI.
|
|
6
|
+
*
|
|
7
|
+
* This is especially useful when 1Password shell plugins (e.g. `alias gh="op plugin run -- gh"`)
|
|
8
|
+
* do not work inside Pi's non-interactive bash tool.
|
|
9
|
+
*
|
|
10
|
+
* See:
|
|
11
|
+
* - CONTRIBUTING.md (project conventions)
|
|
12
|
+
* - TEMPLATE.md at the repo root
|
|
13
|
+
* - https://pi.dev/docs/extensions
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { createBashTool, createLocalBashOperations } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { Type, type Static } from "typebox";
|
|
19
|
+
import { promisify } from "node:util";
|
|
20
|
+
import { exec } from "node:child_process";
|
|
21
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
confirmInBorderedPopup,
|
|
29
|
+
inputInBorderedPopup,
|
|
30
|
+
selectInBorderedPopup,
|
|
31
|
+
} from "./ui/bordered-popups.js";
|
|
32
|
+
|
|
33
|
+
const execAsync = promisify(exec);
|
|
34
|
+
|
|
35
|
+
// ── Internal helpers for diagnostics (properly typed) ──────────────────
|
|
36
|
+
|
|
37
|
+
interface OpStatus {
|
|
38
|
+
available: boolean;
|
|
39
|
+
version: string | null;
|
|
40
|
+
signedIn: boolean;
|
|
41
|
+
account: Record<string, unknown> | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface PluginInspection {
|
|
45
|
+
plugin: string;
|
|
46
|
+
output?: string;
|
|
47
|
+
error?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface CuratedPlugin {
|
|
51
|
+
name: string;
|
|
52
|
+
slug: string;
|
|
53
|
+
envVars: string[];
|
|
54
|
+
primaryEnvVar: string | null;
|
|
55
|
+
pageUrl: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Minimal shapes for `op` JSON responses (used by diagnostics + onboarding pickers).
|
|
59
|
+
interface OpVault {
|
|
60
|
+
name: string;
|
|
61
|
+
}
|
|
62
|
+
interface OpItem {
|
|
63
|
+
id: string;
|
|
64
|
+
title: string;
|
|
65
|
+
category?: string;
|
|
66
|
+
}
|
|
67
|
+
interface OpField {
|
|
68
|
+
label: string;
|
|
69
|
+
type?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function getOpStatus(): Promise<OpStatus> {
|
|
73
|
+
try {
|
|
74
|
+
const { stdout } = await execAsync("op --version", { encoding: "utf8" });
|
|
75
|
+
const version = stdout.trim();
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const { stdout: whoamiOut } = await execAsync("op whoami --format json", {
|
|
79
|
+
encoding: "utf8",
|
|
80
|
+
});
|
|
81
|
+
const whoami = JSON.parse(whoamiOut) as Record<string, unknown>;
|
|
82
|
+
return {
|
|
83
|
+
available: true,
|
|
84
|
+
version,
|
|
85
|
+
signedIn: true,
|
|
86
|
+
account: whoami,
|
|
87
|
+
};
|
|
88
|
+
} catch {
|
|
89
|
+
return {
|
|
90
|
+
available: true,
|
|
91
|
+
version,
|
|
92
|
+
signedIn: false,
|
|
93
|
+
account: null,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
return {
|
|
98
|
+
available: false,
|
|
99
|
+
version: null,
|
|
100
|
+
signedIn: false,
|
|
101
|
+
account: null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function inspectPluginIfRelevant(command: string): Promise<PluginInspection | null> {
|
|
107
|
+
const firstWord = command.trim().split(/\s+/)[0] ?? "";
|
|
108
|
+
const knownPlugins = [
|
|
109
|
+
"gh",
|
|
110
|
+
"aws",
|
|
111
|
+
"heroku",
|
|
112
|
+
"npm",
|
|
113
|
+
"pip",
|
|
114
|
+
"docker",
|
|
115
|
+
"doctl",
|
|
116
|
+
"fly",
|
|
117
|
+
"netlify",
|
|
118
|
+
"vercel",
|
|
119
|
+
"stripe",
|
|
120
|
+
"sentry",
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
if (!knownPlugins.includes(firstWord)) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const { stdout } = await execAsync(`op plugin inspect ${firstWord}`, {
|
|
129
|
+
encoding: "utf8",
|
|
130
|
+
timeout: 8000,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return { plugin: firstWord, output: (stdout || "").trim() };
|
|
134
|
+
} catch (e: unknown) {
|
|
135
|
+
const error = e as { stderr?: string; message?: string };
|
|
136
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
137
|
+
const msg = (error.stderr?.trim() ?? error.message) || "Unknown error";
|
|
138
|
+
return { plugin: firstWord, error: msg };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function formatOpStatus(status: OpStatus): string {
|
|
143
|
+
if (!status.available) {
|
|
144
|
+
return "1Password CLI (`op`) is not available in PATH.";
|
|
145
|
+
}
|
|
146
|
+
if (!status.signedIn) {
|
|
147
|
+
return `op ${status.version ?? "unknown"} is installed but you are not signed in.`;
|
|
148
|
+
}
|
|
149
|
+
const acct = status.account ?? {};
|
|
150
|
+
const name =
|
|
151
|
+
(acct.name as string | undefined) ??
|
|
152
|
+
(acct.email as string | undefined) ??
|
|
153
|
+
(acct.account_uuid as string | undefined) ??
|
|
154
|
+
"unknown";
|
|
155
|
+
const url = (acct.url as string | undefined) ?? "unknown account";
|
|
156
|
+
return `op ${status.version ?? "unknown"} — signed in as ${name} (${url})`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Shell env loading from ~/.pi/agent/auth.json (top-level keys per user choice A) ──
|
|
160
|
+
|
|
161
|
+
type AuthJson = Record<string, unknown>;
|
|
162
|
+
|
|
163
|
+
const KNOWN_PROVIDER_KEYS = new Set([
|
|
164
|
+
"anthropic",
|
|
165
|
+
"openai",
|
|
166
|
+
"azure-openai-responses",
|
|
167
|
+
"deepseek",
|
|
168
|
+
"google",
|
|
169
|
+
"mistral",
|
|
170
|
+
"groq",
|
|
171
|
+
"cerebras",
|
|
172
|
+
"cloudflare-ai-gateway",
|
|
173
|
+
"cloudflare-workers-ai",
|
|
174
|
+
"xai",
|
|
175
|
+
"openrouter",
|
|
176
|
+
"vercel-ai-gateway",
|
|
177
|
+
"zai",
|
|
178
|
+
"opencode",
|
|
179
|
+
"opencode-go",
|
|
180
|
+
"huggingface",
|
|
181
|
+
"fireworks",
|
|
182
|
+
"together",
|
|
183
|
+
"kimi-coding",
|
|
184
|
+
"minimax",
|
|
185
|
+
"minimax-cn",
|
|
186
|
+
"xiaomi",
|
|
187
|
+
"xiaomi-token-plan-cn",
|
|
188
|
+
"xiaomi-token-plan-ams",
|
|
189
|
+
"xiaomi-token-plan-sgp",
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
async function resolveShellValue(raw: unknown): Promise<string | null> {
|
|
193
|
+
if (typeof raw !== "string") return null;
|
|
194
|
+
const trimmed = raw.trim();
|
|
195
|
+
|
|
196
|
+
if (trimmed.startsWith("!op read ")) {
|
|
197
|
+
const ref = trimmed.replace(/^!op read\s+/, "").replace(/^['"]|['"]$/g, "");
|
|
198
|
+
try {
|
|
199
|
+
const { stdout } = await execAsync(`op read "${ref}"`, {
|
|
200
|
+
encoding: "utf8",
|
|
201
|
+
maxBuffer: 1024 * 1024,
|
|
202
|
+
timeout: 15000,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
return (stdout || "").trim();
|
|
206
|
+
} catch {
|
|
207
|
+
return null; // fail closed for this key
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (trimmed.startsWith("!")) {
|
|
212
|
+
// Generic shell command (e.g. !security find-generic-password ...)
|
|
213
|
+
// Execute in a minimal non-interactive shell for safety
|
|
214
|
+
try {
|
|
215
|
+
const cmd = trimmed.slice(1).trim();
|
|
216
|
+
const { stdout } = await execAsync(cmd, {
|
|
217
|
+
encoding: "utf8",
|
|
218
|
+
maxBuffer: 1024 * 1024,
|
|
219
|
+
timeout: 15000,
|
|
220
|
+
shell: "/bin/sh",
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return (stdout || "").trim();
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Literal value or env-var name indirection — treat literal as-is for now
|
|
230
|
+
// (If it is exactly an env var name with no value, caller can decide to pull process.env)
|
|
231
|
+
if (trimmed) return trimmed;
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function loadShellEnvMap(): Promise<Record<string, string>> {
|
|
236
|
+
const home = homedir() || "/tmp";
|
|
237
|
+
const authPath = join(home, ".pi", "agent", "auth.json");
|
|
238
|
+
const map: Record<string, string> = {};
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
242
|
+
const raw = await readFile(authPath, "utf8");
|
|
243
|
+
const parsed = JSON.parse(raw) as AuthJson;
|
|
244
|
+
|
|
245
|
+
for (const [key, val] of Object.entries(parsed)) {
|
|
246
|
+
if (KNOWN_PROVIDER_KEYS.has(key)) continue; // don't leak LLM keys by default
|
|
247
|
+
if (typeof key !== "string" || !/^[A-Z0-9_]+$/.exec(key)) continue; // only plausible env var names
|
|
248
|
+
|
|
249
|
+
const resolved = await resolveShellValue(val);
|
|
250
|
+
if (resolved !== null) {
|
|
251
|
+
// eslint-disable-next-line security/detect-object-injection
|
|
252
|
+
map[key] = resolved;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
// File missing or unreadable — no shell env injection this session
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return map;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// In-memory map for the current session (populated on session_start)
|
|
263
|
+
let currentShellEnv: Record<string, string> = {};
|
|
264
|
+
|
|
265
|
+
// Curated shell plugin list (loaded once at startup for /1password_onboard suggestions)
|
|
266
|
+
let curatedPlugins: CuratedPlugin[] = [];
|
|
267
|
+
|
|
268
|
+
/** Returns the *names* of currently injected shell env vars (never the values). Safe for diagnostics / LLM. */
|
|
269
|
+
export function getShellEnvNames(): string[] {
|
|
270
|
+
return Object.keys(currentShellEnv);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── Curated list + auth.json writer (for /1password_onboard) ────────────
|
|
274
|
+
|
|
275
|
+
/** Load the maintained list of 1P shell plugins (generated by scripts/update-1p-shell-plugins.ts). */
|
|
276
|
+
async function loadCuratedPlugins(): Promise<CuratedPlugin[]> {
|
|
277
|
+
try {
|
|
278
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
279
|
+
const __dirnameLocal = dirname(__filename);
|
|
280
|
+
const dataPath = join(__dirnameLocal, "data", "shell-plugins.json");
|
|
281
|
+
const raw = await readFile(dataPath, "utf8");
|
|
282
|
+
return JSON.parse(raw) as CuratedPlugin[];
|
|
283
|
+
} catch {
|
|
284
|
+
// Non-fatal: onboarding still works for custom entries; curated suggestions just won't be available.
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Safely add (or overwrite) a top-level KEY: "!op read 'op://...'" entry
|
|
291
|
+
* to the agent's auth.json using Pi's recommended agent directory.
|
|
292
|
+
* Uses 0600 permissions. Supports optional overwrite.
|
|
293
|
+
*/
|
|
294
|
+
async function addAuthEntry(
|
|
295
|
+
envVar: string,
|
|
296
|
+
opRef: string,
|
|
297
|
+
options: { overwrite?: boolean } = {},
|
|
298
|
+
): Promise<{ success: boolean; message: string; path: string }> {
|
|
299
|
+
const authDir = getAgentDir();
|
|
300
|
+
const authPath = join(authDir, "auth.json");
|
|
301
|
+
|
|
302
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
303
|
+
await mkdir(authDir, { recursive: true });
|
|
304
|
+
|
|
305
|
+
let existing: Record<string, unknown> = {};
|
|
306
|
+
try {
|
|
307
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
308
|
+
const raw = await readFile(authPath, "utf8");
|
|
309
|
+
existing = JSON.parse(raw) as Record<string, unknown>;
|
|
310
|
+
} catch {
|
|
311
|
+
// File missing or invalid JSON → start fresh
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const alreadyExists = existing[envVar] !== undefined;
|
|
315
|
+
|
|
316
|
+
if (alreadyExists && !options.overwrite) {
|
|
317
|
+
return {
|
|
318
|
+
success: false,
|
|
319
|
+
message: `Key "${envVar}" already exists in auth.json. Pick a different env var name or remove the old entry first.`,
|
|
320
|
+
path: authPath,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Convention: single quotes around the op:// ref
|
|
325
|
+
// eslint-disable-next-line security/detect-object-injection
|
|
326
|
+
existing[envVar] = `!op read '${opRef}'`;
|
|
327
|
+
|
|
328
|
+
const content = JSON.stringify(existing, null, 2) + "\n";
|
|
329
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
330
|
+
await writeFile(authPath, content, "utf8");
|
|
331
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
332
|
+
await chmod(authPath, 0o600);
|
|
333
|
+
|
|
334
|
+
return { success: true, message: "Entry added.", path: authPath };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── Tool schemas ───────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
const runSchema = Type.Object({
|
|
340
|
+
command: Type.String({
|
|
341
|
+
description:
|
|
342
|
+
"The command to run with 1Password credential injection (e.g. 'gh auth status' or 'gh repo view owner/repo')",
|
|
343
|
+
}),
|
|
344
|
+
});
|
|
345
|
+
export type RunInput = Static<typeof runSchema>;
|
|
346
|
+
|
|
347
|
+
const diagnoseSchema = Type.Object({});
|
|
348
|
+
export type DiagnoseInput = Static<typeof diagnoseSchema>;
|
|
349
|
+
|
|
350
|
+
// ── Extension factory ──────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
353
|
+
// Load initial shell env (top-level keys from auth.json)
|
|
354
|
+
currentShellEnv = await loadShellEnvMap();
|
|
355
|
+
|
|
356
|
+
// Load curated list for /1password_onboard
|
|
357
|
+
curatedPlugins = await loadCuratedPlugins();
|
|
358
|
+
|
|
359
|
+
// ── Bash tool wrapper with transparent 1P env injection ───────────────
|
|
360
|
+
const cwd = process.cwd();
|
|
361
|
+
const injectedBash = createBashTool(cwd, {
|
|
362
|
+
spawnHook: ({ command, cwd: hookCwd, env }) => ({
|
|
363
|
+
command,
|
|
364
|
+
cwd: hookCwd,
|
|
365
|
+
env: { ...env, ...currentShellEnv },
|
|
366
|
+
}),
|
|
367
|
+
});
|
|
368
|
+
pi.registerTool(injectedBash);
|
|
369
|
+
|
|
370
|
+
pi.on("user_bash", () => {
|
|
371
|
+
return { operations: createLocalBashOperations() };
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
pi.on("session_start", async () => {
|
|
375
|
+
currentShellEnv = await loadShellEnvMap();
|
|
376
|
+
curatedPlugins = await loadCuratedPlugins();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// ── 1p_run ───────────────────────────────────────────────────────────
|
|
380
|
+
pi.registerTool({
|
|
381
|
+
name: "1p_run",
|
|
382
|
+
label: "1Password Run Command",
|
|
383
|
+
description:
|
|
384
|
+
"Run a shell command with 1Password credential injection via `op run -- <command>`. Includes automatic diagnostics on failure.",
|
|
385
|
+
parameters: runSchema,
|
|
386
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
387
|
+
const status = await getOpStatus();
|
|
388
|
+
const pluginInfo = await inspectPluginIfRelevant(params.command);
|
|
389
|
+
|
|
390
|
+
if (!status.available) {
|
|
391
|
+
return {
|
|
392
|
+
content: [{ type: "text", text: `1p_run failed: ${formatOpStatus(status)}` }],
|
|
393
|
+
details: { error: "op not found", opStatus: status },
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
if (!status.signedIn) {
|
|
397
|
+
return {
|
|
398
|
+
content: [
|
|
399
|
+
{
|
|
400
|
+
type: "text",
|
|
401
|
+
text: `1p_run failed: ${formatOpStatus(status)}\n\nPlease run 'op signin' in your terminal.`,
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
details: { error: "not signed in", opStatus: status },
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
const { stdout, stderr } = await execAsync(`op run -- ${params.command}`, {
|
|
410
|
+
encoding: "utf8",
|
|
411
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
412
|
+
timeout: 120000,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const output = (stdout || "").trim();
|
|
416
|
+
const err = (stderr || "").trim();
|
|
417
|
+
|
|
418
|
+
let text = output || "(no stdout)";
|
|
419
|
+
if (err) text += `\n\n[stderr]\n${err}`;
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
content: [{ type: "text", text }],
|
|
423
|
+
details: {
|
|
424
|
+
command: params.command,
|
|
425
|
+
opStatus: status,
|
|
426
|
+
pluginInspection: pluginInfo,
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
} catch (error: unknown) {
|
|
430
|
+
const err = error as { stderr?: string; message?: string };
|
|
431
|
+
const rawError = (err.stderr?.trim() ?? err.message ?? "") || String(error);
|
|
432
|
+
const diagnostic = formatOpStatus(status);
|
|
433
|
+
|
|
434
|
+
let helpful = `Command failed under 1Password injection.\n\n${diagnostic}`;
|
|
435
|
+
|
|
436
|
+
if (pluginInfo) {
|
|
437
|
+
helpful += `\n\nPlugin status for "${pluginInfo.plugin}":\n${pluginInfo.output ?? pluginInfo.error ?? ""}`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
helpful += `\n\nError from command:\n${rawError}`;
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
content: [{ type: "text", text: helpful }],
|
|
444
|
+
details: {
|
|
445
|
+
error: rawError,
|
|
446
|
+
command: params.command,
|
|
447
|
+
opStatus: status,
|
|
448
|
+
pluginInspection: pluginInfo,
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// ── Shared diagnostic logic (used by both 1p_diagnose tool and /1password_diagnose command) ──
|
|
456
|
+
async function get1PasswordDiagnosticReport() {
|
|
457
|
+
const status = await getOpStatus();
|
|
458
|
+
|
|
459
|
+
const commonPlugins = ["gh", "aws", "heroku"];
|
|
460
|
+
const inspections: PluginInspection[] = [];
|
|
461
|
+
|
|
462
|
+
for (const p of commonPlugins) {
|
|
463
|
+
const info = await inspectPluginIfRelevant(p);
|
|
464
|
+
if (info) inspections.push(info);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
let report = formatOpStatus(status) + "\n\n";
|
|
468
|
+
|
|
469
|
+
if (inspections.length > 0) {
|
|
470
|
+
report += "Plugin configuration:\n";
|
|
471
|
+
for (const i of inspections) {
|
|
472
|
+
report += `\n--- ${i.plugin} ---\n${i.output ?? i.error ?? ""}\n`;
|
|
473
|
+
}
|
|
474
|
+
} else {
|
|
475
|
+
report += "No common plugins inspected (or none configured).\n";
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const injectedNames = getShellEnvNames();
|
|
479
|
+
report += "\nShell env injection (transparent for all bash + ! commands):\n";
|
|
480
|
+
if (injectedNames.length > 0) {
|
|
481
|
+
report += `Active vars (names only): ${injectedNames.join(", ")}\n`;
|
|
482
|
+
report += "Source: top-level keys in ~/.pi/agent/auth.json using !op read (or literals).\n";
|
|
483
|
+
report += "These are injected via spawn hook — LLM never sees the values.\n";
|
|
484
|
+
} else {
|
|
485
|
+
report += "No shell env vars currently injected from auth.json.\n";
|
|
486
|
+
report +=
|
|
487
|
+
'Add e.g. "GH_TOKEN": "!op read \'op://Vault/Item/credential\'" to ~/.pi/agent/auth.json (restart or /reload to pick up).\n';
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
report: report.trim(),
|
|
492
|
+
details: {
|
|
493
|
+
opStatus: status,
|
|
494
|
+
pluginInspections: inspections,
|
|
495
|
+
injectedShellEnvNames: injectedNames,
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ── 1p_diagnose (tool for the LLM) ─────────────────────────────────────
|
|
501
|
+
pi.registerTool({
|
|
502
|
+
name: "1p_diagnose",
|
|
503
|
+
label: "1Password Diagnostics",
|
|
504
|
+
description:
|
|
505
|
+
"Check the current status of the 1Password CLI (`op`), sign-in state, plugin configuration, and active shell env injection (from ~/.pi/agent/auth.json). Use this when 1password_onboard or 1password_diagnose are not working as expected, or to verify transparent token injection for bare `gh` / `aws` etc. (uses 1p_run for plugin inspection).",
|
|
506
|
+
parameters: diagnoseSchema,
|
|
507
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
508
|
+
const { report, details } = await get1PasswordDiagnosticReport();
|
|
509
|
+
return {
|
|
510
|
+
content: [{ type: "text", text: report }],
|
|
511
|
+
details,
|
|
512
|
+
};
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// ── /1password_diagnose (user-facing command) ──────────────────────────
|
|
517
|
+
pi.registerCommand("1password_diagnose", {
|
|
518
|
+
description:
|
|
519
|
+
"Run full 1Password diagnostics. Gathers op status, plugin configuration, and active injected variables, then presents a clean report directly (no extra user prompting required).",
|
|
520
|
+
handler: async (_args, ctx) => {
|
|
521
|
+
ctx.ui.notify("Running 1Password diagnostics...", "info");
|
|
522
|
+
|
|
523
|
+
// The command performs the diagnostics directly using privileged access.
|
|
524
|
+
// This guarantees you get a complete, reliable report the moment you run the command.
|
|
525
|
+
const { report } = await get1PasswordDiagnosticReport();
|
|
526
|
+
|
|
527
|
+
ctx.ui.notify(report, "info");
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// TODO (known limitation): /1password_diagnose currently gathers data directly
|
|
532
|
+
// for reliability and presents it. Injecting a prompt via sendUserMessage does not
|
|
533
|
+
// reliably cause the LLM to start a new turn and use 1p_diagnose/1p_run for
|
|
534
|
+
// nicer formatting, because regular command handlers have limited access to
|
|
535
|
+
// the "deliverAs: nextTurn" / sendUserMessage APIs that force LLM reasoning.
|
|
536
|
+
// This should be revisited when better support exists or a different pattern
|
|
537
|
+
// is found. Tracked as a follow-up issue.
|
|
538
|
+
|
|
539
|
+
// Local helper: vault → item → field picker using the custom bordered TUI.
|
|
540
|
+
// Long lists benefit from live filtering. Esc (or picking Cancel) backs out.
|
|
541
|
+
async function pickOpReferenceSimple(ctx: ExtensionCommandContext): Promise<string | null> {
|
|
542
|
+
ctx.ui.setStatus("1p-onboard", "Loading vaults...");
|
|
543
|
+
let vaultNames: string[] = [];
|
|
544
|
+
try {
|
|
545
|
+
const { stdout } = await execAsync(`op vault list --format json`, {
|
|
546
|
+
encoding: "utf8",
|
|
547
|
+
timeout: 20000,
|
|
548
|
+
});
|
|
549
|
+
const parsed = JSON.parse(stdout || "[]") as OpVault[];
|
|
550
|
+
vaultNames = parsed.map((v) => v.name).sort((a, b) => a.localeCompare(b));
|
|
551
|
+
} catch {
|
|
552
|
+
ctx.ui.notify("Failed to load vaults from 1Password.", "error");
|
|
553
|
+
ctx.ui.setStatus("1p-onboard", undefined);
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
ctx.ui.setStatus("1p-onboard", undefined);
|
|
557
|
+
|
|
558
|
+
if (vaultNames.length === 0) {
|
|
559
|
+
ctx.ui.notify("No vaults found in 1Password.", "warning");
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const vaultItems = [
|
|
564
|
+
...vaultNames.map((name) => ({ value: name, label: name })),
|
|
565
|
+
{ value: "__cancel", label: "Cancel" },
|
|
566
|
+
];
|
|
567
|
+
const chosenVault = await selectInBorderedPopup(ctx, {
|
|
568
|
+
title: "Select 1Password vault",
|
|
569
|
+
items: vaultItems,
|
|
570
|
+
helpText: "↑↓ • Enter • Esc = cancel • Type to filter",
|
|
571
|
+
maxVisible: 14,
|
|
572
|
+
});
|
|
573
|
+
if (!chosenVault || chosenVault === "__cancel") return null;
|
|
574
|
+
|
|
575
|
+
ctx.ui.setStatus("1p-onboard", `Loading items from ${chosenVault}...`);
|
|
576
|
+
let items: OpItem[] = [];
|
|
577
|
+
try {
|
|
578
|
+
const cmd = `op item list --vault ${JSON.stringify(chosenVault)} --categories "API Credential,Login,Secure Note,Password" --format json`;
|
|
579
|
+
const { stdout } = await execAsync(cmd, {
|
|
580
|
+
encoding: "utf8",
|
|
581
|
+
timeout: 25000,
|
|
582
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
583
|
+
});
|
|
584
|
+
const parsed = JSON.parse(stdout || "[]") as OpItem[];
|
|
585
|
+
items = parsed.sort((a, b) => (a.title || "").localeCompare(b.title || ""));
|
|
586
|
+
} catch {
|
|
587
|
+
ctx.ui.notify("Failed to load items.", "error");
|
|
588
|
+
ctx.ui.setStatus("1p-onboard", undefined);
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
ctx.ui.setStatus("1p-onboard", undefined);
|
|
592
|
+
|
|
593
|
+
if (items.length === 0) {
|
|
594
|
+
ctx.ui.notify("No matching items in that vault.", "warning");
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const itemItems = [
|
|
599
|
+
...items.map((it) => ({
|
|
600
|
+
value: it.id,
|
|
601
|
+
label: `${it.title}${it.category ? " — " + it.category : ""}`,
|
|
602
|
+
})),
|
|
603
|
+
{ value: "__cancel", label: "Cancel" },
|
|
604
|
+
];
|
|
605
|
+
const chosenItemId = await selectInBorderedPopup(ctx, {
|
|
606
|
+
title: `Select item in ${chosenVault}`,
|
|
607
|
+
items: itemItems,
|
|
608
|
+
helpText: "↑↓ • Enter • Esc = cancel • Type to filter (can be long)",
|
|
609
|
+
maxVisible: 16,
|
|
610
|
+
});
|
|
611
|
+
if (!chosenItemId || chosenItemId === "__cancel") return null;
|
|
612
|
+
|
|
613
|
+
const chosenItem = items.find((it) => it.id === chosenItemId);
|
|
614
|
+
if (!chosenItem) return null;
|
|
615
|
+
|
|
616
|
+
ctx.ui.setStatus("1p-onboard", "Loading fields...");
|
|
617
|
+
let fields: { label: string; type?: string }[] = [];
|
|
618
|
+
try {
|
|
619
|
+
const { stdout } = await execAsync(
|
|
620
|
+
`op item get ${JSON.stringify(chosenItem.id)} --format json`,
|
|
621
|
+
{ encoding: "utf8", timeout: 15000 },
|
|
622
|
+
);
|
|
623
|
+
const full = JSON.parse(stdout || "null") as { fields?: OpField[] } | null;
|
|
624
|
+
fields = full?.fields?.filter(Boolean) ?? [];
|
|
625
|
+
} catch {
|
|
626
|
+
ctx.ui.notify("Failed to load fields.", "error");
|
|
627
|
+
ctx.ui.setStatus("1p-onboard", undefined);
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
ctx.ui.setStatus("1p-onboard", undefined);
|
|
631
|
+
|
|
632
|
+
if (fields.length === 0) {
|
|
633
|
+
ctx.ui.notify("Selected item has no fields.", "warning");
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const fieldItems = [
|
|
638
|
+
...fields.map((f) => ({
|
|
639
|
+
value: f.label,
|
|
640
|
+
label: `${f.label} (${f.type ?? "text"})`,
|
|
641
|
+
})),
|
|
642
|
+
{ value: "__cancel", label: "Cancel" },
|
|
643
|
+
];
|
|
644
|
+
const chosenFieldLabel = await selectInBorderedPopup(ctx, {
|
|
645
|
+
title: `Select field for "${chosenItem.title}"`,
|
|
646
|
+
items: fieldItems,
|
|
647
|
+
helpText: "↑↓ • Enter • Esc = cancel",
|
|
648
|
+
maxVisible: 12,
|
|
649
|
+
});
|
|
650
|
+
if (!chosenFieldLabel || chosenFieldLabel === "__cancel") return null;
|
|
651
|
+
|
|
652
|
+
return `op://${chosenVault}/${chosenItem.title}/${chosenFieldLabel}`;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ── /1password_onboard — fully custom bordered TUI onboarding (curated + manual + vault/item/field)
|
|
656
|
+
pi.registerCommand("1password_onboard", {
|
|
657
|
+
description:
|
|
658
|
+
"Guided setup: pick from supported tools or enter a custom op:// reference, write '!op read ...' entry to ~/.pi/agent/auth.json for transparent injection.",
|
|
659
|
+
handler: async (_args, ctx) => {
|
|
660
|
+
const authPath = join(homedir() || "/tmp", ".pi", "agent", "auth.json");
|
|
661
|
+
|
|
662
|
+
ctx.ui.notify("1Password Onboard — transparent env injection setup", "info");
|
|
663
|
+
|
|
664
|
+
const hasCurated = curatedPlugins.length > 0;
|
|
665
|
+
const firstChoices = [
|
|
666
|
+
hasCurated ? "Pick from a list of supported tools" : "",
|
|
667
|
+
"Enter environment variable + secret reference manually",
|
|
668
|
+
"Cancel",
|
|
669
|
+
].filter(Boolean);
|
|
670
|
+
|
|
671
|
+
const firstItems = firstChoices.map((c) => ({ value: c, label: c }));
|
|
672
|
+
const mode = await selectInBorderedPopup(ctx, {
|
|
673
|
+
title: "1Password Onboard — How would you like to start?",
|
|
674
|
+
items: firstItems,
|
|
675
|
+
helpText: "↑↓ • Enter • Esc = cancel",
|
|
676
|
+
maxVisible: 5,
|
|
677
|
+
});
|
|
678
|
+
if (!mode || mode === "Cancel") {
|
|
679
|
+
ctx.ui.notify("Onboarding cancelled.", "info");
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
let finalEnv: string | null = null;
|
|
684
|
+
let opRef: string | null = null;
|
|
685
|
+
|
|
686
|
+
if (mode.startsWith("Pick from a list of supported tools")) {
|
|
687
|
+
// Rich bordered two-step picker for curated tools (supports multi-env tools
|
|
688
|
+
// such as AWS, Argo CD, etc.). Uses the polished custom TUI (filterable lists,
|
|
689
|
+
// consistent ╭─╮ borders, stable right-edge alignment, live filtering, Esc cancel).
|
|
690
|
+
if (curatedPlugins.length === 0) {
|
|
691
|
+
ctx.ui.notify("No curated tools available right now.", "warning");
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const toolItems = curatedPlugins.map((p) => ({
|
|
696
|
+
value: p.name,
|
|
697
|
+
label: p.name,
|
|
698
|
+
description:
|
|
699
|
+
p.envVars.length > 0
|
|
700
|
+
? `${p.primaryEnvVar ?? p.envVars[0] ?? ""} (+${String(p.envVars.length - 1)} more)`
|
|
701
|
+
: "custom / no standard env var",
|
|
702
|
+
}));
|
|
703
|
+
|
|
704
|
+
const chosenToolName = await selectInBorderedPopup(ctx, {
|
|
705
|
+
title: "Select tool (type to filter)",
|
|
706
|
+
items: toolItems,
|
|
707
|
+
helpText: "↑↓ • Enter • Esc = cancel • Type to filter curated 1P shell plugins",
|
|
708
|
+
maxVisible: 16,
|
|
709
|
+
});
|
|
710
|
+
if (!chosenToolName) {
|
|
711
|
+
ctx.ui.notify("Onboarding cancelled.", "info");
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const tool = curatedPlugins.find((p) => p.name === chosenToolName);
|
|
716
|
+
if (!tool) {
|
|
717
|
+
ctx.ui.notify("Selected tool is no longer available.", "error");
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Two-step: if the chosen tool declares multiple env vars, let the user pick which one.
|
|
722
|
+
if (tool.envVars.length > 1) {
|
|
723
|
+
const envItems = tool.envVars.map((v) => ({
|
|
724
|
+
value: v,
|
|
725
|
+
label: v,
|
|
726
|
+
description: v === tool.primaryEnvVar ? "primary / recommended" : undefined,
|
|
727
|
+
}));
|
|
728
|
+
|
|
729
|
+
const chosenEnv = await selectInBorderedPopup(ctx, {
|
|
730
|
+
title: `Environment variable for ${tool.name}`,
|
|
731
|
+
items: envItems,
|
|
732
|
+
helpText: "↑↓ • Enter to choose which var to inject • Esc = back",
|
|
733
|
+
maxVisible: Math.min(12, tool.envVars.length + 2),
|
|
734
|
+
});
|
|
735
|
+
if (!chosenEnv) {
|
|
736
|
+
ctx.ui.notify("Onboarding cancelled.", "info");
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
finalEnv = chosenEnv;
|
|
740
|
+
} else {
|
|
741
|
+
finalEnv = tool.primaryEnvVar ?? tool.envVars[0] ?? null;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (!finalEnv) {
|
|
745
|
+
ctx.ui.notify("Selected tool has no declared environment variable.", "error");
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
opRef = await pickOpReferenceSimple(ctx);
|
|
750
|
+
if (!opRef) {
|
|
751
|
+
ctx.ui.notify("Onboarding cancelled.", "info");
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
} else {
|
|
755
|
+
// Manual path — also using the custom bordered UI for consistency
|
|
756
|
+
const envVar = await inputInBorderedPopup(ctx, {
|
|
757
|
+
title: "Environment variable name",
|
|
758
|
+
prompt: "Enter the UPPER_SNAKE_CASE name for the secret (e.g. GH_TOKEN)",
|
|
759
|
+
helpText: "Enter to confirm • Esc = cancel",
|
|
760
|
+
});
|
|
761
|
+
if (!envVar) {
|
|
762
|
+
ctx.ui.notify("Onboarding cancelled.", "info");
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
if (!/^[A-Z0-9_]+$/.test(envVar)) {
|
|
766
|
+
ctx.ui.notify("Invalid env var name (must be UPPER_SNAKE_CASE).", "error");
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
finalEnv = envVar;
|
|
770
|
+
|
|
771
|
+
const refMethod = await selectInBorderedPopup(ctx, {
|
|
772
|
+
title: "How do you want to provide the secret location?",
|
|
773
|
+
items: [
|
|
774
|
+
{ value: "manual", label: "Type the op:// reference manually" },
|
|
775
|
+
{ value: "lookup", label: "Look it up in 1Password" },
|
|
776
|
+
],
|
|
777
|
+
helpText: "↑↓ • Enter • Esc = cancel",
|
|
778
|
+
maxVisible: 5,
|
|
779
|
+
});
|
|
780
|
+
if (!refMethod) {
|
|
781
|
+
ctx.ui.notify("Onboarding cancelled.", "info");
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (refMethod === "manual") {
|
|
786
|
+
const manualRef = await inputInBorderedPopup(ctx, {
|
|
787
|
+
title: "op:// Reference",
|
|
788
|
+
prompt: "Enter the full 1Password reference",
|
|
789
|
+
defaultValue: "op://Vault/Item/field",
|
|
790
|
+
helpText: "Must start with op:// • Enter to confirm • Esc = cancel",
|
|
791
|
+
});
|
|
792
|
+
if (!manualRef?.startsWith("op://")) {
|
|
793
|
+
ctx.ui.notify("Invalid reference (must start with op://) or cancelled.", "error");
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
opRef = manualRef;
|
|
797
|
+
} else {
|
|
798
|
+
opRef = await pickOpReferenceSimple(ctx);
|
|
799
|
+
}
|
|
800
|
+
if (!opRef) {
|
|
801
|
+
ctx.ui.notify("Onboarding cancelled.", "info");
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Shared preview + write tail (works for both paths)
|
|
807
|
+
const previewLine = `"${finalEnv}": "!op read '${opRef}'"`;
|
|
808
|
+
const previewMsg =
|
|
809
|
+
`File: ${authPath}\n\n${previewLine}\n\n` +
|
|
810
|
+
"After write: run /reload (or restart Pi). The spawn hook will inject the variable into bash/! commands.\n" +
|
|
811
|
+
"Use /1password_diagnose to verify (names only; values never leave the host).";
|
|
812
|
+
|
|
813
|
+
const confirmed = await confirmInBorderedPopup(ctx, {
|
|
814
|
+
title: "Add this to auth.json?",
|
|
815
|
+
message: previewMsg,
|
|
816
|
+
});
|
|
817
|
+
if (!confirmed) {
|
|
818
|
+
ctx.ui.notify("Cancelled — nothing written.", "info");
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
let writeRes = await addAuthEntry(finalEnv, opRef);
|
|
823
|
+
|
|
824
|
+
if (!writeRes.success && writeRes.message.includes("already exists")) {
|
|
825
|
+
const overwrite = await confirmInBorderedPopup(ctx, {
|
|
826
|
+
title: `Key "${finalEnv}" already exists, overwrite?`,
|
|
827
|
+
message: "Replace the current value in auth.json?",
|
|
828
|
+
});
|
|
829
|
+
if (overwrite) {
|
|
830
|
+
writeRes = await addAuthEntry(finalEnv, opRef, { overwrite: true });
|
|
831
|
+
} else {
|
|
832
|
+
ctx.ui.notify(writeRes.message, "warning");
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (writeRes.success) {
|
|
838
|
+
ctx.ui.notify(`✅ Success! ${finalEnv} added to auth.json (0600).`, "info");
|
|
839
|
+
const doReload = await confirmInBorderedPopup(ctx, {
|
|
840
|
+
title: "Activate now?",
|
|
841
|
+
message: "Run /reload so the spawn hook starts injecting it this session?",
|
|
842
|
+
});
|
|
843
|
+
if (doReload) {
|
|
844
|
+
await ctx.reload();
|
|
845
|
+
} else {
|
|
846
|
+
ctx.ui.notify("Run `/reload` when ready.", "info");
|
|
847
|
+
}
|
|
848
|
+
} else {
|
|
849
|
+
ctx.ui.notify(writeRes.message, "warning");
|
|
850
|
+
}
|
|
851
|
+
},
|
|
852
|
+
});
|
|
853
|
+
}
|