diff --git a/packages/extension/package.json b/packages/extension/package.json index 5600ef48..bcd1b9bf 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -70,6 +70,10 @@ ] }, "commands": [ + { + "command": "amicode.onboarding.open", + "title": "Amicode: Open Onboarding" + }, { "command": "amicode.openChat", "title": "Amicode: Open Chat", @@ -157,6 +161,10 @@ { "command": "amicode.openAmicodeTerminal", "title": "Amicode: Open Amicode Terminal (vendored opencode, fleet-aware)" + }, + { + "command": "amicode.redoOnboarding", + "title": "Amicode: Redo Onboarding" } ], "configuration": { @@ -301,6 +309,10 @@ "type": "string", "default": "https://bld42qbgsn7gu6y44v4kd6a32e0hprmy.lambda-url.us-east-1.on.aws", "description": "Base URL of the run-corpus ingest endpoint (no trailing slash; opencode appends /v1/traces and /v1/logs). Defaults to the PRODUCTION corpus. Empty = capture stays dormant even with consent given. Auth uses your per-user Amico cloud token (~/.amico/cloud.json, set up via \"Amico: Connect Cloud\") — never a separate ingest key. The token MUST be minted in the same AWS account as this endpoint: each account's credentials table is independent, so a token from the other account is rejected on every batch (401) while capture still looks enabled. This default is therefore account-coupled with DEFAULT_CLOUD_URL in cloud_key.ts — change both together or neither." + }, + "amicode.redoOnboarding": { + "type": "null", + "markdownDescription": "**[Redo Onboarding](command:amicode.redoOnboarding)** — Reset onboarding state and re-run the setup flow. Your model/provider config is preserved." } } }, diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index 0c31a00b..d1469af7 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -225,6 +225,12 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean return true; } + // Redo Onboarding: reset state and open the onboarding panel (#433/#438). + if (msg.kind === "redo-onboarding") { + void vscode.commands.executeCommand("amicode.redoOnboarding"); + return true; + } + // Developer Tools settings: validate paths, write VS Code settings, restart // server / prompt reload as appropriate. The app posts on blur and on toggle. if (msg.kind === "dev-tools-update") { diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index eb8a52de..f083c107 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -270,7 +270,7 @@ export class ChatPanel { replyClipboardImage(d.nonce); return; } - if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove")) { + if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove")) { vscode.postMessage(d); } return; @@ -338,4 +338,12 @@ export class ChatPanel { ChatPanel.live.delete(this); if (ChatPanel.current === this) ChatPanel.current = undefined; } + + /** Close the current singleton chat panel (if one exists). Used by redo-onboarding + * to clear the view before opening the onboarding webview. */ + static disposeCurrent(): void { + if (ChatPanel.current) { + ChatPanel.current.panel.dispose(); + } + } } diff --git a/packages/extension/src/credential_scanner.ts b/packages/extension/src/credential_scanner.ts new file mode 100644 index 00000000..5601a7f2 --- /dev/null +++ b/packages/extension/src/credential_scanner.ts @@ -0,0 +1,327 @@ +// Credential Scanner — Auto-Import Credentials (#449) +// +// Scans flat-file credential sources in priority order, deduplicates by provider, +// and returns detected credentials. Keys NEVER leave the extension host process. +// +// Source priority (first hit per provider wins): +// 1. ~/.local/share/opencode/account.json (v2) +// 2. ~/.local/share/opencode/auth.json (v1) +// 3. process.env +// 4. Shell RC files (~/.zshrc, ~/.bashrc, ~/.zprofile, ~/.bash_profile) +// 5. ~/.claude/.credentials.json (type: "api" only) + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +import { PROVIDER_MODELS, writeOnboardingConfig } from "./onboarding_panel"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface DetectedCredential { + provider: string; + key: string; + source: string; +} + +export interface ScanOptions { + accountJsonPath: string; + authJsonPath: string; + env: Record; + rcPaths: string[]; + claudeCredPath: string; +} + +export interface ScanResult { + credentials: DetectedCredential[]; +} + +/** Webview-safe representation — NO key material. */ +export interface SafeCredential { + provider: string; + source: string; + model: string; +} + +// ─── Env var → provider mapping ────────────────────────────────────────────── + +const ENV_TO_PROVIDER: Record = { + ANTHROPIC_API_KEY: "anthropic", + OPENAI_API_KEY: "openai", + GOOGLE_API_KEY: "google", + OPENROUTER_API_KEY: "openrouter", + OPENCODE_API_KEY: "opencode", +}; + +/** Env vars to scan in process.env and shell RC files. */ +const SCANNABLE_ENV_VARS = Object.keys(ENV_TO_PROVIDER); + +// ─── Provider ID normalization ─────────────────────────────────────────────── + +const PROVIDER_ALIASES: Record = { + "opencode-go": "opencode", +}; + +function normalizeProviderId(raw: string): string { + return PROVIDER_ALIASES[raw] ?? raw; +} + +// ─── Provider → env var (for config writing) ───────────────────────────────── + +const PROVIDER_ENV_VAR: Record = { + anthropic: "ANTHROPIC_API_KEY", + openai: "OPENAI_API_KEY", + google: "GOOGLE_API_KEY", + opencode: "OPENCODE_API_KEY", + openrouter: "OPENROUTER_API_KEY", +}; + +// ─── Scanner ───────────────────────────────────────────────────────────────── + +/** Default scan options using standard paths. */ +export function defaultScanOptions(): ScanOptions { + const home = os.homedir(); + const dataDir = path.join(home, ".local", "share", "opencode"); + return { + accountJsonPath: path.join(dataDir, "account.json"), + authJsonPath: path.join(dataDir, "auth.json"), + env: process.env as Record, + rcPaths: [ + path.join(home, ".zshrc"), + path.join(home, ".bashrc"), + path.join(home, ".zprofile"), + path.join(home, ".bash_profile"), + ], + claudeCredPath: path.join(home, ".claude", ".credentials.json"), + }; +} + +/** + * Scan for existing API credentials across all configured sources. + * Sources are checked in priority order; first hit per provider wins. + * Unreadable or malformed sources are skipped silently. + */ +export async function scanCredentials(options: ScanOptions): Promise { + const seen = new Set(); + const credentials: DetectedCredential[] = []; + + function add(provider: string, key: string, source: string): void { + const normalized = normalizeProviderId(provider); + if (seen.has(normalized)) return; + if (!key || key.trim() === "") return; + seen.add(normalized); + credentials.push({ provider: normalized, key: key.trim(), source }); + } + + // 1. opencode account.json (v2) + scanAccountJson(options.accountJsonPath, add); + + // 2. opencode auth.json (v1) + scanAuthJson(options.authJsonPath, add); + + // 3. Environment variables + scanEnv(options.env, add); + + // 4. Shell RC files + for (const rcPath of options.rcPaths) { + scanRcFile(rcPath, add); + } + + // 5. Claude Code .credentials.json + scanClaudeCredentials(options.claudeCredPath, add); + + return { credentials }; +} + +// ─── Source scanners ───────────────────────────────────────────────────────── + +type AddFn = (provider: string, key: string, source: string) => void; + +function scanAccountJson(filePath: string, add: AddFn): void { + try { + const raw = fs.readFileSync(filePath, "utf8"); + const data = JSON.parse(raw); + if (typeof data !== "object" || data === null) return; + + // v2 format: { version: 2, accounts: { : { serviceID, credential: { type, key } } }, active: { : } } + if (data.version === 2 && typeof data.accounts === "object" && data.accounts !== null) { + for (const [, entry] of Object.entries(data.accounts)) { + if (typeof entry !== "object" || entry === null) continue; + const acct = entry as { serviceID?: string; credential?: { type?: string; key?: string } }; + if (!acct.serviceID) continue; + if (acct.credential?.type === "api" && typeof acct.credential.key === "string") { + add(acct.serviceID, acct.credential.key, "opencode (account)"); + } + } + return; + } + + // Legacy flat format: { : { token: "..." } } + for (const [serviceId, entry] of Object.entries(data)) { + if (typeof entry === "object" && entry !== null && "token" in entry) { + const token = (entry as { token: unknown }).token; + if (typeof token === "string") { + add(serviceId, token, "opencode (account)"); + } + } + } + } catch { + // Skip unreadable/malformed files silently + } +} + +function scanAuthJson(filePath: string, add: AddFn): void { + try { + const raw = fs.readFileSync(filePath, "utf8"); + const data = JSON.parse(raw); + if (typeof data !== "object" || data === null) return; + + // v1 format: flat { : { type: "api", key: "..." } } + for (const [serviceId, entry] of Object.entries(data)) { + if (typeof entry !== "object" || entry === null) continue; + const cred = entry as { type?: string; key?: string }; + if (cred.type === "api" && typeof cred.key === "string") { + add(serviceId, cred.key, "opencode (auth)"); + } + } + } catch { + // Skip unreadable/malformed files silently + } +} + +function scanEnv(env: Record, add: AddFn): void { + for (const varName of SCANNABLE_ENV_VARS) { + const value = env[varName]; + if (typeof value === "string" && value.trim() !== "") { + add(ENV_TO_PROVIDER[varName], value, "environment"); + } + } +} + +/** + * Parse shell RC files using strict regex — NO eval, NO child_process, NO subshell. + * Matches: export VAR_NAME="value", export VAR_NAME='value', export VAR_NAME=value + * Skips commented lines and lines with subshell expansion $(...) or backticks. + */ +function scanRcFile(filePath: string, add: AddFn): void { + try { + const content = fs.readFileSync(filePath, "utf8"); + const basename = path.basename(filePath); + + for (const line of content.split("\n")) { + const trimmed = line.trim(); + // Skip comments + if (trimmed.startsWith("#")) continue; + + // Strict regex: export VAR=value (with optional quotes) + const match = trimmed.match(/^export\s+([\w]+)=["']?([^"'\s]*)["']?/); + if (!match) continue; + + const [, varName, value] = match; + if (!SCANNABLE_ENV_VARS.includes(varName)) continue; + if (!value || value.trim() === "") continue; + + // Skip lines with subshell expansion (security: never execute) + if (value.includes("$(") || value.includes("`")) continue; + + add(ENV_TO_PROVIDER[varName], value, basename); + } + } catch { + // Skip unreadable files silently + } +} + +function scanClaudeCredentials(filePath: string, add: AddFn): void { + try { + const raw = fs.readFileSync(filePath, "utf8"); + const data = JSON.parse(raw); + if (!Array.isArray(data)) return; + + for (const entry of data) { + if (typeof entry !== "object" || entry === null) continue; + // Only import type: "api" entries — NEVER OAuth tokens + if (entry.type !== "api") continue; + const provider = entry.provider; + const key = entry.key; + if (typeof provider === "string" && typeof key === "string") { + add(provider, key, "Claude Code"); + } + } + } catch { + // Skip unreadable/malformed files silently + } +} + +// ─── Webview-safe output (AC8) ─────────────────────────────────────────────── + +/** + * Convert detected credentials to a webview-safe format. + * Keys are STRIPPED — only provider names, sources, and default model IDs are included. + */ +export function webviewSafeResults(credentials: DetectedCredential[]): SafeCredential[] { + return credentials.map((c) => { + const models = PROVIDER_MODELS[c.provider]; + const defaultModel = models?.[0]?.id ?? `${c.provider}/unknown`; + return { + provider: c.provider, + source: c.source, + model: defaultModel, + }; + }); +} + +// ─── Batch config writing (AC7) ────────────────────────────────────────────── + +/** + * Write all detected providers to opencode.json in one pass. + * The `activeProvider` becomes the active `model` (using its first model entry). + * Uses the same schema as writeOnboardingConfig: provider..options.apiKey, env as string[]. + */ +export function writeBatchConfig( + credentials: DetectedCredential[], + activeProvider: string, + configPath?: string, +): void { + const targetPath = configPath ?? path.join(os.homedir(), ".config", "opencode", "opencode.json"); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + + // Read existing config to merge + let existing: Record = {}; + try { + if (fs.existsSync(targetPath)) { + existing = JSON.parse(fs.readFileSync(targetPath, "utf8")); + } + } catch { + // Start fresh if parsing fails + } + + // Build provider entries + const providerEntry: Record = { + ...(existing.provider as Record ?? {}), + }; + + for (const cred of credentials) { + const entry: Record = {}; + if (cred.key) { + entry.options = { apiKey: cred.key }; + } + const envVar = PROVIDER_ENV_VAR[cred.provider]; + if (envVar) { + entry.env = [envVar]; + } + providerEntry[cred.provider] = entry; + } + + // Determine active model + const activeModels = PROVIDER_MODELS[activeProvider]; + const activeModel = activeModels?.[0]?.id ?? `${activeProvider}/unknown`; + + const result = { + ...existing, + $schema: "https://opencode.ai/config.json", + provider: providerEntry, + model: activeModel, + }; + + fs.writeFileSync(targetPath, JSON.stringify(result, null, 2) + "\n"); +} diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 95760c3e..05b681fd 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -39,6 +39,8 @@ import { writeStopFile, savePulseTo, catalogPulsesDir, stopPlan, forceStop, runL import { watchSolverMode, applyEntitlementForMode, readSolverModeState } from "./solver_mode"; import { runSetCloudKeyCommand } from "./cloud_key"; import { amicodeOpsDir } from "./substrate/vault_store"; +import { registerOnboardingPanel, onOnboardingComplete, onOnboardingCancelled } from "./onboarding_panel"; +import { isModelConfigured, writeWelcomeShown } from "./onboarding_routing"; import { stagePasqalConnector } from "./pasqal_assets"; import { needsProvision, pasqalVenvDir, provisionPasqalPython } from "./pasqal_python"; import { createLocalPersonalVault, sanitizeVaultName, suggestVaultName } from "./substrate/vault_setup"; @@ -345,9 +347,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }, }); - // 1. UI surfaces + // 1. UI surfaces const trees = registerTrees(ctx); registerCatalogCard(ctx); // #47 dev scaffold — card opens via the save-to-catalog flow + registerOnboardingPanel(ctx); // #433 — Stage 0 model-setup webview ctx.subscriptions.push( // #47 session catalog: record the save (workspaceState + tree), then open // the card. Both prompts (demo replay, live promote) route through here. @@ -833,9 +836,21 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeReadyUrl = url; statusBar?.setServerReady(true); sseClient?.connect(url); - // Open the chat as soon as the server is up (amicode.chat.autoOpen, - // default on) — the chat IS the product's front door. - if (vscode.workspace.getConfiguration("amicode").get("chat.autoOpen", true)) { + // Onboarding gate: if no model is configured, open the Stage 0 webview + // instead of chat. The webview will fire onOnboardingComplete when done, + // which then opens chat. + if (!isModelConfigured() && vscode.workspace.getConfiguration("amicode").get("chat.autoOpen", true)) { + void vscode.commands.executeCommand("amicode.onboarding.open"); + // Wire: when onboarding completes, auto-open chat + onOnboardingComplete(() => { + ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir); + }); + // Wire: when onboarding is cancelled (X), open chat normally + onOnboardingCancelled(() => { + ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir); + }); + } else if (vscode.workspace.getConfiguration("amicode").get("chat.autoOpen", true)) { + // Normal path: model configured → open chat directly ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir); } // Surface ONE explicit LLM-provider signal at boot, read from opencode's @@ -1725,6 +1740,24 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }, }); + // Redo Onboarding (developer tool): reset onboarding state and re-open the + // Stage 0 webview. For existing users, preserves current model config. + ctx.subscriptions.push( + vscode.commands.registerCommand("amicode.redoOnboarding", async () => { + // Reset onboarding state files + const onboardDir = path.join(amicodeOpsDir(), "onboarding"); + const eventsFile = path.join(onboardDir, "events.jsonl"); + const stateFile = path.join(amicodeOpsDir(), "onboarding_state.json"); + try { fs.unlinkSync(eventsFile); } catch { /* may not exist */ } + try { fs.unlinkSync(stateFile); } catch { /* may not exist */ } + try { fs.unlinkSync(path.join(os.homedir(), ".amico", "profile.json")); } catch { /* may not exist */ } + // Close the chat panel so the onboarding panel is visible + ChatPanel.disposeCurrent(); + // Open the onboarding panel + void vscode.commands.executeCommand("amicode.onboarding.open"); + }), + ); + opencodeChannel.appendLine(`[boot] activated; runsRoot=${runsRoot}; amicoRunBinDir=${amicoRunBinDir ?? "(none)"}`); } diff --git a/packages/extension/src/onboarding_panel.ts b/packages/extension/src/onboarding_panel.ts index 5beda0ef..61b8c6ea 100644 --- a/packages/extension/src/onboarding_panel.ts +++ b/packages/extension/src/onboarding_panel.ts @@ -12,6 +12,14 @@ import * as path from "node:path"; import * as os from "node:os"; import * as vscode from "vscode"; +import { + scanCredentials, + defaultScanOptions, + webviewSafeResults, + writeBatchConfig, + type DetectedCredential, +} from "./credential_scanner"; + // ─── Provider → Model data (data-driven, not hard-coded conditionals) ──────── export interface ModelEntry { @@ -20,26 +28,55 @@ export interface ModelEntry { } /** Data-driven provider→model map. Each provider key is the opencode provider id; - * models are the provider/model-id pairs opencode expects in `config.model`. */ + * models are the provider/model-id pairs opencode expects in `config.model`. + * "github-copilot" uses OAuth (no key); "custom" uses a base URL + model text. */ export const PROVIDER_MODELS: Record = { + "github-copilot": [ + { id: "github-copilot/claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { id: "github-copilot/gpt-4o", name: "GPT-4o" }, + { id: "github-copilot/o3-mini", name: "o3-mini" }, + ], + opencode: [ + { id: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { id: "anthropic/claude-opus-4-6", name: "Claude Opus 4.6" }, + { id: "openai/gpt-4.1", name: "GPT-4.1" }, + ], anthropic: [ - { id: "anthropic/claude-sonnet-4-20250514", name: "Claude Sonnet 4" }, - { id: "anthropic/claude-opus-4-20250514", name: "Claude Opus 4" }, - { id: "anthropic/claude-haiku-3-5-20241022", name: "Claude 3.5 Haiku" }, + { id: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { id: "anthropic/claude-opus-4-6", name: "Claude Opus 4.6" }, + { id: "anthropic/claude-haiku-4-5", name: "Claude Haiku 4.5" }, ], openai: [ - { id: "openai/gpt-4o", name: "GPT-4o" }, - { id: "openai/gpt-4o-mini", name: "GPT-4o Mini" }, + { id: "openai/gpt-4.1", name: "GPT-4.1" }, + { id: "openai/gpt-4.1-mini", name: "GPT-4.1 Mini" }, { id: "openai/o3-mini", name: "o3-mini" }, ], google: [ { id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro" }, { id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash" }, ], - "amazon-bedrock": [ - { id: "amazon-bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", name: "Claude Sonnet 4 (Bedrock)" }, - { id: "amazon-bedrock/us.anthropic.claude-opus-4-20250514-v1:0", name: "Claude Opus 4 (Bedrock)" }, + openrouter: [ + { id: "openrouter/anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { id: "openrouter/openai/gpt-4.1", name: "GPT-4.1" }, + { id: "openrouter/google/gemini-2.5-pro", name: "Gemini 2.5 Pro" }, ], + vercel: [ + { id: "vercel/anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { id: "vercel/openai/gpt-4.1", name: "GPT-4.1" }, + ], + custom: [], +}; + +/** Human-readable display names for the provider dropdown. */ +export const PROVIDER_DISPLAY_NAMES: Record = { + "github-copilot": "GitHub Copilot (Free)", + opencode: "OpenCode", + anthropic: "Anthropic", + openai: "OpenAI", + google: "Google", + openrouter: "OpenRouter", + vercel: "Vercel", + custom: "Custom (OpenAI-compatible)", }; // ─── Config types and writing ──────────────────────────────────────────────── @@ -76,13 +113,20 @@ export function writeOnboardingConfig( // Provider-specific key env var name const envVarName = providerKeyEnvVar(config.provider); - // Build the provider entry + // Build the provider entry per opencode schema: + // provider..options.apiKey (NOT provider..apiKey) + // provider..env = string[] (NOT a bare string) + const providerConfig: Record = {}; + if (config.apiKey) { + providerConfig.options = { apiKey: config.apiKey }; + } + if (envVarName) { + providerConfig.env = [envVarName]; + } + const providerEntry: Record = { ...(existing.provider as Record ?? {}), - [config.provider]: { - apiKey: config.apiKey, - ...(envVarName ? { env: envVarName } : {}), - }, + [config.provider]: providerConfig, }; const result = { @@ -101,7 +145,8 @@ function providerKeyEnvVar(provider: string): string | undefined { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", google: "GOOGLE_API_KEY", - "amazon-bedrock": "AWS_ACCESS_KEY_ID", + opencode: "OPENCODE_API_KEY", + openrouter: "OPENROUTER_API_KEY", }; return map[provider]; } @@ -118,11 +163,14 @@ const PROVIDER_TEST_ENDPOINTS: Record = { anthropic: "https://api.anthropic.com/v1/messages", openai: "https://api.openai.com/v1/chat/completions", google: "https://generativelanguage.googleapis.com/v1beta/models", - "amazon-bedrock": "https://bedrock-runtime.us-east-1.amazonaws.com", + opencode: "https://api.opencode.ai/v1/models", + openrouter: "https://openrouter.ai/api/v1/chat/completions", + vercel: "https://api.vercel.ai/v1/chat/completions", }; /** Test the connection by making exactly one minimal LLM API call. * Returns ok:true on success, ok:false with error message on failure. + * Providers without a known test endpoint return ok:true (untestable, not failed). * The API key is NEVER included in the return value. */ export async function testConnection( config: OnboardingConfig, @@ -130,7 +178,11 @@ export async function testConnection( ): Promise { const endpoint = PROVIDER_TEST_ENDPOINTS[config.provider]; if (!endpoint) { - return { ok: false, error: `Unknown provider: ${config.provider}` }; + // Provider has no test endpoint — treat as untestable (pass), not unknown + if (config.provider === "unknown-provider" || config.provider === "") { + return { ok: false, error: `Unknown provider: ${config.provider}` }; + } + return { ok: true }; } try { @@ -174,7 +226,7 @@ function buildTestRequest( }; } - if (config.provider === "openai") { + if (config.provider === "openai" || config.provider === "openrouter" || config.provider === "vercel") { return { url: endpoint, options: { @@ -184,7 +236,7 @@ function buildTestRequest( Authorization: `Bearer ${config.apiKey}`, }, body: JSON.stringify({ - model: config.model.replace("openai/", ""), + model: config.model.replace(/^[^/]+\//, ""), max_tokens: 1, messages: [{ role: "user", content: "hi" }], }), @@ -206,6 +258,7 @@ function buildTestRequest( type OnCompleteListener = () => void; const completionListeners: OnCompleteListener[] = []; +const cancelListeners: OnCompleteListener[] = []; /** Register a listener for when onboarding completes successfully. * Downstream wiring (Slice 2) observes this to auto-open chat. */ @@ -217,6 +270,16 @@ export function onOnboardingComplete(listener: OnCompleteListener): vscode.Dispo }); } +/** Register a listener for when onboarding is cancelled (X button). + * Downstream opens chat normally (skip onboarding). */ +export function onOnboardingCancelled(listener: OnCompleteListener): vscode.Disposable { + cancelListeners.push(listener); + return new vscode.Disposable(() => { + const idx = cancelListeners.indexOf(listener); + if (idx >= 0) cancelListeners.splice(idx, 1); + }); +} + function fireOnboardingComplete(): void { for (const listener of completionListeners) { try { @@ -227,6 +290,16 @@ function fireOnboardingComplete(): void { } } +function fireOnboardingCancelled(): void { + for (const listener of cancelListeners) { + try { + listener(); + } catch { + // Don't let a listener failure crash the flow + } + } +} + // ─── WebviewPanel host ─────────────────────────────────────────────────────── let currentPanel: vscode.WebviewPanel | undefined; @@ -262,15 +335,11 @@ export function registerOnboardingPanel(ctx: vscode.ExtensionContext): void { ); currentPanel = panel; - panel.onDidDispose( - () => { - currentPanel = undefined; - }, - null, - ctx.subscriptions, - ); - // Handle messages from the webview + let heldCredentials: DetectedCredential[] = []; + const testResults = new Map(); // provider -> passed + let scanAborted = false; + panel.webview.onDidReceiveMessage( async (msg: { type: string; payload?: unknown }) => { if (msg.type === "test-connection") { @@ -282,12 +351,105 @@ export function registerOnboardingPanel(ctx: vscode.ExtensionContext): void { writeOnboardingConfig(payload); panel.dispose(); fireOnboardingComplete(); + // Open chat as fallback (in case no completion listener is wired) + void vscode.commands.executeCommand("amicode.openChat"); + } else if (msg.type === "cancel") { + // User cancelled onboarding — close panel, re-open chat + panel.dispose(); + fireOnboardingCancelled(); + // Also directly open chat as fallback (in case no listener is wired) + void vscode.commands.executeCommand("amicode.openChat"); + } else if (msg.type === "scan-credentials") { + // Auto-import: scan for existing credentials + scanAborted = false; + heldCredentials = []; + panel.webview.postMessage({ + type: "scan-status", + payload: { state: "searching" }, + }); + + try { + const scanResult = await scanCredentials(defaultScanOptions()); + if (scanAborted) return; // Panel was closed mid-scan + heldCredentials = scanResult.credentials; + + if (heldCredentials.length === 0) { + panel.webview.postMessage({ + type: "scan-status", + payload: { state: "empty" }, + }); + } else { + panel.webview.postMessage({ + type: "scan-status", + payload: { state: "found", count: heldCredentials.length }, + }); + // Send webview-safe results (no key material) + panel.webview.postMessage({ + type: "scan-results", + payload: { providers: webviewSafeResults(heldCredentials) }, + }); + + // Run connection tests in parallel (AC12) + const testPromises = heldCredentials.map(async (cred) => { + const models = PROVIDER_MODELS[cred.provider]; + const model = models?.[0]?.id ?? `${cred.provider}/unknown`; + const result = await testConnection({ + provider: cred.provider, + model, + apiKey: cred.key, + }); + testResults.set(cred.provider, result.ok); + if (!scanAborted) { + panel.webview.postMessage({ + type: "test-status-update", + payload: { provider: cred.provider, ok: result.ok, error: result.error }, + }); + } + }); + // Fire all tests in parallel, don't await sequentially + void Promise.allSettled(testPromises); + } + } catch { + if (!scanAborted) { + panel.webview.postMessage({ + type: "scan-status", + payload: { state: "failed", error: "Scan failed unexpectedly" }, + }); + } + } + } else if (msg.type === "confirm-import") { + // User confirmed the import — write only selected providers that passed + const payload = msg.payload as { activeProvider: string; includedProviders?: string[] }; + const included = new Set(payload.includedProviders ?? heldCredentials.map((c) => c.provider)); + const passedCredentials = heldCredentials.filter( + (c) => included.has(c.provider) && testResults.get(c.provider) !== false, + ); + if (passedCredentials.length > 0) { + writeBatchConfig(passedCredentials, payload.activeProvider); + } + heldCredentials = []; + testResults.clear(); + panel.dispose(); + fireOnboardingComplete(); + // Open chat as fallback (in case no completion listener is wired) + void vscode.commands.executeCommand("amicode.openChat"); } }, null, ctx.subscriptions, ); + // On panel close, abort scan and drop credentials (AC13, AC14) + panel.onDidDispose( + () => { + scanAborted = true; + heldCredentials = []; + currentPanel = undefined; + }, + null, + ctx.subscriptions, + ); + // Render the webview HTML const uri = (...p: string[]) => panel.webview.asWebviewUri(vscode.Uri.joinPath(ctx.extensionUri, ...p)); @@ -310,14 +472,27 @@ function buildWebviewHtml( +
- + `; } diff --git a/packages/extension/src/onboarding_routing.ts b/packages/extension/src/onboarding_routing.ts index b99f5a5e..fc238f01 100644 --- a/packages/extension/src/onboarding_routing.ts +++ b/packages/extension/src/onboarding_routing.ts @@ -49,23 +49,33 @@ export function resolveOnboardingAction(flags: OnboardingFlags): OnboardingActio // ─── Model-presence check ──────────────────────────────────────────────────── /** Check if the opencode config has a model/provider configured. - * Reads the config file at the given path (default: ~/.config/opencode/opencode.json). + * Reads the config file at the given path (default: ~/.config/opencode/opencode.json[c]). * Returns true if there's at least one provider entry. */ export function isModelConfigured( - configPath: string = defaultConfigPath(), + configPath?: string, ): boolean { - try { - if (!fs.existsSync(configPath)) return false; - const content = fs.readFileSync(configPath, "utf8"); - // Strip single-line comments for JSONC tolerance - const stripped = content.replace(/^\s*\/\/.*$/gm, ""); - const config = JSON.parse(stripped) as Record; - const provider = config.provider; - if (!provider || typeof provider !== "object") return false; - return Object.keys(provider as object).length > 0; - } catch { - return false; + const paths = configPath + ? [configPath] + : [ + path.join(os.homedir(), ".config", "opencode", "opencode.json"), + path.join(os.homedir(), ".config", "opencode", "opencode.jsonc"), + ]; + + for (const p of paths) { + try { + if (!fs.existsSync(p)) continue; + const content = fs.readFileSync(p, "utf8"); + // Strip single-line comments for JSONC tolerance + const stripped = content.replace(/^\s*\/\/.*$/gm, ""); + const config = JSON.parse(stripped) as Record; + const provider = config.provider; + if (!provider || typeof provider !== "object") continue; + if (Object.keys(provider as object).length > 0) return true; + } catch { + continue; + } } + return false; } /** Also check secondary locations where env-based providers resolve: @@ -170,6 +180,4 @@ export class OnboardingLauncher { // ─── Helpers ───────────────────────────────────────────────────────────────── -function defaultConfigPath(): string { - return path.join(os.homedir(), ".config", "opencode", "opencode.json"); -} +// (defaultConfigPath removed — isModelConfigured checks both .json and .jsonc) diff --git a/packages/extension/src/onboarding_webview.ts b/packages/extension/src/onboarding_webview.ts index b3c29ec4..f411b9b8 100644 --- a/packages/extension/src/onboarding_webview.ts +++ b/packages/extension/src/onboarding_webview.ts @@ -5,6 +5,7 @@ // // Contract: // window.__PROVIDERS__: Record +// window.__PROVIDER_NAMES__: Record // host → webview: { type: "test-result", payload: { ok: boolean, error?: string } } // webview → host: { type: "test-connection", payload: OnboardingConfig } // webview → host: { type: "config-success", payload: OnboardingConfig } @@ -13,11 +14,13 @@ declare function acquireVsCodeApi(): { postMessage(msg: unknown): void }; declare global { interface Window { __PROVIDERS__: Record; + __PROVIDER_NAMES__: Record; } } const vscodeApi = acquireVsCodeApi(); const providers = window.__PROVIDERS__; +const providerNames = window.__PROVIDER_NAMES__ ?? {}; // ─── Animation ─────────────────────────────────────────────────────────────── @@ -33,37 +36,330 @@ function playWelcomeAnimation(): void { } animationPlayed = true; - // Brand animation: logo fade-in → hold → dissolve (~2.5s total) + // Brand animation: Amico drops in whole, bounces onto his feet, then idles + // — breathing, springing, blinking, and winking at you on a loop. + // + // Geometry is the DETAILED mark (amicode media/amico.svg, mirrored as + // MarkDetailed in the fork's logo.tsx) — correct here because the brand rule + // is "small -> reduced bracket, large -> detailed". The rotate(-180) + // transforms in the source SVG are Illustrator no-ops and are dropped so each + // glyph can be grouped and animated. viewBox is cropped to the glyph's own + // bounds plus room for the tilt and bob. + // + // Colour follows media/brand.css, NOT the app design system — VS Code webviews + // are their own stack. The mark takes the INK role (--color-accent-ink): lemon + // on dark, neutral theme foreground on light. Yellow is never a foreground on + // a light ground, and every mark the extension ships already resolves this way. + // + // Every animated property sits on its own nested group so nothing fights over + // the transform property: breathe wraps jump wraps enter, and each eye owns + // only its own lid. The lean sits on its own group too, synced to the winks. animationEl.innerHTML = ` -