/** * /vault command family. * * Subcommands: * (no args) -- open settings panel * show -- backend status and managed providers * verify -- check backend health * providers -- list managed vs unmanaged * setup -- guided first-time setup * import -- import credentials from auth.json * export [target]-- export credentials to another backend or rewrite current * path -- show config and vault file locations * help -- usage summary */ import type { ExtensionAPI, ExtensionCommandContext, } from "@mariozechner/pi-coding-agent"; import type { VaultController } from "../config/controller.js"; import type { BackendType } from "../types.js"; import { generateAgeIdentity } from "../backends/age-backend.js"; import { PassthroughBackend } from "../backends/passthrough-backend.js"; import { openVaultSettingsPanel } from "../config/modal.js"; import { parseVaultArgs, getSubcommands, getSubcommandDescription, } from "./parse-args.js"; import { transferCredentials, createTargetBackend, isValidExportTarget, VALID_EXPORT_TARGETS, } from "../services/credential-transfer.js"; /** * Provider state getter. Resolved at command execution time * to avoid stale captured-array snapshots. */ export type ProviderStateGetter = () => readonly string[]; export function registerVaultCommand( pi: ExtensionAPI, controller: VaultController, getOverriddenProviders: ProviderStateGetter, ): void { const subcommands = getSubcommands(); pi.registerCommand("vault", { description: "Credential vault management", getArgumentCompletions(prefix: string) { const tokens = prefix.trim().split(/\s+/); const first = tokens[0] ?? ""; // Second-level completions for export if (tokens.length >= 2 && first === "export") { const targetPrefix = tokens[1] ?? ""; return VALID_EXPORT_TARGETS.filter((t) => t.startsWith(targetPrefix), ).map((t) => ({ value: `export ${t}`, label: t, description: `Export to ${t} backend`, })); } // First-level completions if (!first) { return subcommands.map((cmd) => ({ value: cmd, label: cmd, description: getSubcommandDescription(cmd), })); } return subcommands .filter((cmd) => cmd.startsWith(first)) .map((cmd) => ({ value: cmd, label: cmd, description: getSubcommandDescription(cmd), })); }, async handler(args: string, ctx: ExtensionCommandContext) { const parsed = parseVaultArgs(args); if (!parsed) { // No subcommand or unrecognized -- open settings or warn const raw = args.trim(); if (raw.length === 0) { return openVaultSettingsPanel(controller, ctx); } ctx.ui.notify( `Unknown subcommand: ${raw}. Use /vault help.`, "warning", ); return; } switch (parsed.subcommand) { case "show": return handleShow(controller, getOverriddenProviders, ctx); case "verify": return handleVerify(controller, ctx); case "providers": return handleProviders( controller, getOverriddenProviders, ctx, ); case "setup": return handleSetup(controller, ctx); case "import": return handleImport(controller, ctx); case "export": return handleExport( controller, parsed.args, ctx, ); case "path": return handlePath(controller, ctx); case "help": return handleHelp(ctx); } }, }); } // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- async function handleShow( controller: VaultController, getOverriddenProviders: ProviderStateGetter, ctx: ExtensionCommandContext, ): Promise { const config = controller.getConfig(); const status = await controller.getBackendStatus(); const overriddenProviders = getOverriddenProviders(); const statusLabel = status.available ? "available" : "UNAVAILABLE"; const lines = [`Backend: ${config.backend} [${statusLabel}]`]; if (status.available) { const backend = controller.getBackend(); const stored = await backend.list(); lines.push(`Stored credentials: ${stored.length}`); } if (status.detail) { lines.push(` ${status.detail}`); } if (status.error) { lines.push(` Error: ${status.error}`); } lines.push(""); lines.push(`Managed providers: ${overriddenProviders.length}`); if (overriddenProviders.length > 0) { for (const id of overriddenProviders) { lines.push(` + ${id}`); } } const excluded = config.excludeProviders ?? []; if (excluded.length > 0) { lines.push(`Excluded: ${excluded.length}`); for (const id of excluded) { lines.push(` - ${id}`); } } ctx.ui.notify(lines.join("\n"), status.available ? "info" : "warning"); } async function handleVerify( controller: VaultController, ctx: ExtensionCommandContext, ): Promise { const config = controller.getConfig(); const status = await controller.getBackendStatus(); if (status.available) { const backend = controller.getBackend(); const providers = await backend.list(); const lines = [ `Backend: ${config.backend} [healthy]`, `Credentials stored: ${providers.length}`, ]; if (providers.length > 0) { lines.push(`Providers: ${providers.join(", ")}`); } if (status.detail) { lines.push(status.detail); } ctx.ui.notify(lines.join("\n"), "info"); } else { const lines = [ `Backend: ${config.backend} [UNAVAILABLE]`, `Error: ${status.error ?? "unknown error"}`, ]; if (status.detail) { lines.push(status.detail); } lines.push(""); lines.push("Providers are using native auth.json fallback."); lines.push("Run /vault setup to configure the backend."); ctx.ui.notify(lines.join("\n"), "warning"); } } function handleProviders( controller: VaultController, getOverriddenProviders: ProviderStateGetter, ctx: ExtensionCommandContext, ): void { const config = controller.getConfig(); const overriddenProviders = getOverriddenProviders(); const excluded = config.excludeProviders ?? []; const lines: string[] = []; if (overriddenProviders.length > 0) { lines.push("Vault-managed providers:"); for (const id of overriddenProviders) { lines.push(` + ${id}`); } } else { lines.push("No providers are currently vault-managed."); } if (excluded.length > 0) { lines.push("Excluded (managed by other extensions or native Pi):"); for (const id of excluded) { lines.push(` - ${id}`); } } const scope = config.managedProviders === "all" ? "all (minus excluded)" : config.managedProviders.join(", "); lines.push(`Provider scope: ${scope}`); ctx.ui.notify(lines.join("\n"), "info"); } async function handleSetup( controller: VaultController, ctx: ExtensionCommandContext, ): Promise { const config = controller.getConfig(); if (config.backend !== "age") { ctx.ui.notify( `Setup is currently only supported for the age backend. Current: ${config.backend}`, "info", ); return; } // Check if identity already exists const status = await controller.getBackendStatus(); if (status.available) { ctx.ui.notify("Age backend is already set up and working.", "info"); return; } // Generate a new age identity const proceed = await ctx.ui.confirm( "Generate age identity", "Generate a new age encryption identity for credential storage?", ); if (!proceed) { return; } try { const identityPath = config.age?.identityPath; const recipient = await generateAgeIdentity(identityPath); ctx.ui.notify( `Age identity generated.\nPublic key: ${recipient}\nShare this key to add recipients on other machines.`, "info", ); } catch (err) { const message = err instanceof Error ? err.message : String(err); ctx.ui.notify(`Failed to generate identity: ${message}`, "warning"); } } async function handleImport( controller: VaultController, ctx: ExtensionCommandContext, ): Promise { const status = await controller.getBackendStatus(); if (!status.available) { ctx.ui.notify( `Backend "${controller.getConfig().backend}" is not available. Run /vault setup first.`, "warning", ); return; } // Read existing credentials from auth.json via passthrough backend const passthrough = new PassthroughBackend(); const providers = await passthrough.list(); if (providers.length === 0) { ctx.ui.notify("No credentials found in auth.json to import.", "info"); return; } const backend = controller.getBackend(); const alreadyStored = await backend.list(); const toImport = providers.filter((p) => !alreadyStored.includes(p)); if (toImport.length === 0) { ctx.ui.notify( `All ${providers.length} credential(s) from auth.json are already in the vault.`, "info", ); return; } const proceed = await ctx.ui.confirm( "Import credentials", `Import ${toImport.length} credential(s) from auth.json into ${controller.getConfig().backend} backend?\n\nProviders: ${toImport.join(", ")}`, ); if (!proceed) { return; } let imported = 0; let failed = 0; for (const providerId of toImport) { try { const entry = await passthrough.get(providerId); if (entry) { await backend.set(providerId, entry); imported++; } } catch { failed++; } } const parts = [`Imported ${imported} credential(s).`]; if (failed > 0) { parts.push(`${failed} failed.`); } ctx.ui.notify(parts.join(" "), failed > 0 ? "warning" : "info"); } async function handleExport( controller: VaultController, args: readonly string[], ctx: ExtensionCommandContext, ): Promise { const config = controller.getConfig(); const status = await controller.getBackendStatus(); if (!status.available) { ctx.ui.notify( `Active backend "${config.backend}" is not available. Run /vault verify for details.`, "warning", ); return; } // Determine target backend let targetType: BackendType; if (args.length > 0 && args[0]) { const candidate = args[0].toLowerCase(); if (!isValidExportTarget(candidate)) { ctx.ui.notify( `Invalid export target: "${args[0]}". Valid targets: ${VALID_EXPORT_TARGETS.join(", ")}`, "warning", ); return; } targetType = candidate; } else { // No target specified -- prompt from available targets const candidates = VALID_EXPORT_TARGETS.filter( (t) => t !== config.backend, ); const choices = [config.backend, ...candidates]; // Use the first non-current backend as default suggestion const message = [ `Export from ${config.backend} to which backend?`, "", `Available: ${choices.join(", ")}`, `Same-backend export (${config.backend}) re-encrypts/rewrites all credentials.`, ].join("\n"); const proceed = await ctx.ui.confirm("Export credentials", message); if (!proceed) { return; } // Default to the first non-current backend when confirmed without // a specific target. If only the current backend is available, use it. targetType = candidates[0] ?? config.backend; } const isSameBackend = targetType === config.backend; const sourceBackend = controller.getBackend(); // For same-backend rewrite, source and target are the same instance // which still works -- re-read and re-write forces re-encryption const targetBackend = isSameBackend ? sourceBackend : createTargetBackend(targetType, config); if (!targetBackend) { ctx.ui.notify( `Failed to create target backend "${targetType}". Check configuration.`, "warning", ); return; } // Check target backend health for cross-backend exports if (!isSameBackend) { const targetStatus = await targetBackend.check(); if (!targetStatus.available) { ctx.ui.notify( `Target backend "${targetType}" is not available: ${targetStatus.error ?? "unknown error"}`, "warning", ); return; } } // Count credentials to export const providers = await sourceBackend.list(); if (providers.length === 0) { ctx.ui.notify("No credentials to export.", "info"); return; } const confirmMessage = isSameBackend ? `Rewrite ${providers.length} credential(s) in ${config.backend}?\n\nThis re-encrypts all entries with current recipients.` : `Export ${providers.length} credential(s) from ${config.backend} to ${targetType}?\n\nSource data is not deleted.`; const proceed = await ctx.ui.confirm( isSameBackend ? "Rewrite credentials" : "Export credentials", confirmMessage, ); if (!proceed) { return; } const result = await transferCredentials(sourceBackend, targetBackend); const lines = [ `${isSameBackend ? "Rewrite" : "Export"} complete.`, `Copied: ${result.copied}`, ]; if (result.skipped > 0) { lines.push(`Skipped: ${result.skipped}`); } if (result.failed > 0) { lines.push(`Failed: ${result.failed}`); for (const f of result.failures) { lines.push(` ${f.provider}: ${f.error}`); } } const level = result.failed > 0 ? "warning" : "info"; ctx.ui.notify(lines.join("\n"), level as "info" | "warning"); } function handlePath( controller: VaultController, ctx: ExtensionCommandContext, ): void { const config = controller.getConfig(); const lines = [`Backend: ${config.backend}`]; if (config.backend === "age") { const vaultPath = config.age?.vaultPath ?? "~/.pi/agent/vault.age.json"; const identityPath = config.age?.identityPath ?? "~/.config/pi-vault/age.txt"; lines.push(`Vault file: ${vaultPath}`); lines.push(`Identity: ${identityPath}`); const recipients = config.age?.recipients ?? []; if (recipients.length > 0) { lines.push(`Extra recipients: ${recipients.length}`); } } else if (config.backend === "keychain") { const service = config.keychain?.service ?? "pi-credential-vault"; lines.push(`Keychain service: ${service}`); } else { lines.push("Path: ~/.pi/agent/auth.json"); } ctx.ui.notify(lines.join("\n"), "info"); } function handleHelp(ctx: ExtensionCommandContext): void { const help = [ "/vault Open settings", "/vault show Backend status and managed providers", "/vault verify Check backend health and credential count", "/vault providers List managed vs excluded providers", "/vault setup Guided first-time setup (age identity)", "/vault import Import existing auth.json credentials into vault", "/vault export [tgt] Export credentials to target backend or rewrite current", "/vault path Show config and vault file locations", "/vault help This message", ].join("\n"); ctx.ui.notify(help, "info"); }