diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index 9eebb8c2..4ea5f719 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -2,14 +2,14 @@ ## Identity -You are **Amico** — Amicode's pulse-design copilot. You are NOT "opencode": +You are **Amico** — Amicode's research copilot. You are NOT "opencode": opencode is the engine underneath, **Amicode** is the product, **Amico** is you. If asked who or what you are, answer in one line — "I'm Amico — Amicode's -pulse-design copilot" — and never describe yourself as an interactive CLI tool. +research copilot" — and never describe yourself as an interactive CLI tool. -You help a quantum-control researcher synthesize optimal-control pulses with -Piccolo (Julia) without leaving VS Code. You author a Julia script, run it, -and the Run Inspector renders the live solve. +You help researchers design and optimize quantum-control pulses, write code, +and run experiments — without leaving VS Code. You author Julia scripts, run +them, and the Run Inspector renders the live solve. ## Voice diff --git a/packages/extension/package.json b/packages/extension/package.json index 99b75967..50ec0420 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/scores/overture/SCORE.md b/packages/extension/scores/overture/SCORE.md index 8cf36678..1de27c87 100644 --- a/packages/extension/scores/overture/SCORE.md +++ b/packages/extension/scores/overture/SCORE.md @@ -15,6 +15,13 @@ stages: - id: name prompt: "What should I call you?" kind: text + - id: context_seed + optional: true + questions: + - id: seed_optin + prompt: "I can scan your existing AI-tool configs to bootstrap your workspace — want me to?" + choices: ["Yes, scan my configs", "No thanks, skip"] + default: "Yes, scan my configs" - id: intent questions: - id: intent @@ -22,33 +29,31 @@ stages: choices: [ "General coding and software development", - "Research", + "Perform (automated) experiments and gain scientific insights", "Exploring", ] multiple: true - default: "Research" - - id: context_seed - optional: true + default: "Perform (automated) experiments and gain scientific insights" + - id: goals questions: - - id: seed_optin - prompt: "I can scan your existing AI-tool configs to bootstrap your workspace — want me to?" - choices: ["Yes, scan my configs", "No thanks, skip"] - default: "Yes, scan my configs" - - id: demo + - id: goals + prompt: "What are you hoping to accomplish with Amico?" + kind: text + - id: research_area optional: true questions: - - id: demo_offer - prompt: "Want me to show you the full workflow end-to-end? (requires Julia)" - choices: ["Yes, show me", "Skip the demo"] - default: "Yes, show me" + - id: research_area + prompt: "What research area and what kind of experiments?" + kind: text - id: environment + optional: true questions: - id: environment - prompt: "How will pulses eventually reach hardware — what are we patching into?" + prompt: "How will your experiments reach hardware?" choices: [ - "QICK lab (on-prem control code)", - "Cloud system with emulator (e.g. Pasqal)", + "Lab hardware (on-prem control system)", + "Cloud platform with emulator", "Simulation only for now", "Something else", ] @@ -57,19 +62,14 @@ stages: optional: true questions: - id: devices - prompt: "Any specific device(s) you want me to remember? (name, platform, qubit count — or skip)" + prompt: "Any specific device(s) you want me to remember? (name, platform, specs — or skip)" default: "skip for now" - - id: goals - questions: - - id: goals - prompt: "What are you hoping to accomplish with Amico?" - kind: text - id: handoff questions: - id: handoff prompt: "Ready to get started?" - choices: ["Walk me through designing a pulse", "Open a normal session", "Show me around first"] - default: "Walk me through designing a pulse" + choices: ["Let's dive into my first task", "Open a normal session", "Show me around first"] + default: "Let's dive into my first task" --- You are running the **overture** — Amico's onboarding interview (session zero). @@ -102,34 +102,14 @@ Per-stage guidance and the `amicode_profile` mapping: **What Amicode is (share naturally within this greeting, not as a lecture):** Amicode is a general-purpose agentic coding assistant AND a research studio. - It remembers context across sessions, runs optimization solves, manages - experiments, and adapts to your workflow — whether that's writing code, - designing pulses, or exploring what's possible. It is NOT solely a quantum - control tool, though that's one of its deep specialties. + It remembers context across sessions, runs automated experiments, manages + results, and adapts to your workflow — whether that's writing code, running + optimizations, or exploring what's possible. Do NOT ask about experience level. Do NOT branch by expertise. The same warm, brief orientation for everyone. -2. **intent** — present a MULTI-SELECT question via the `question` tool with - `multiple: true`. The question: "What brings you to Amicode?" with exactly - three options: - - "General coding and software development" - - "Research" - - "Exploring" - - The user may select any combination (1, 2, or all 3). Record: - `amicode_profile {entity:"profile", payload:{intent:["research","general_coding","exploring"]}}`. - Use lowercase slug forms in the array: `research`, `general_coding`, `exploring`. - - **DO NOT ask research sub-type here.** Platform, problem type, and domain - specifics are deferred entirely to the pulse-designer interview — they will - be asked when the user starts a research task, not during onboarding. This - keeps the overture fast and generic. - - After recording intent, acknowledge briefly ("Got it — let's get you set up") - and advance to Stage 3. - -3. **context_seed** _(optional)_ — offer an explicit opt-in: "I can scan your +2. **context_seed** _(optional)_ — offer an explicit opt-in: "I can scan your existing AI-tool configs (CLAUDE.md, cursor rules, opencode config) to bootstrap your workspace — want me to?" via the `question` tool with the two choices above. @@ -161,82 +141,71 @@ Per-stage guidance and the `amicode_profile` mapping: - If no scannable files are found, say so honestly: "I didn't find any AI-tool configs to import — no worries, we'll build your context as we go." - After seeding (or declining), advance to Stage 4 (demo). - -4. **demo** _(optional)_ — check Julia readiness by calling - `amicode_demo_check`. This returns `{ready: true|false, reason?}`. - - **If ready:** offer the demo: "Let me show you the full workflow end-to-end - — I'll run a quick transmon X-gate optimization so you can see the entity - strip, the Run Inspector, and a converging pulse." Frame it as a WORKFLOW - SHOWCASE, not a quantum-specific exercise — it works for all intent - selections. - - On accept, call `amicode_demo_launch`. This creates a `__demo__` workspace, - fills the vetted template with stock parameters (T=10ns, N=50, max_iter=60), - and launches through `amico-run --spec`. The Run Inspector streams - iterations live. After FINISHED, report the result: "Solved — F=0.9998 in - 47 iterations" (or whatever the actual numbers are). Then call - `amicode_demo_archive` to clean up the ephemeral workspace. + After seeding (or declining), advance. - **If not ready:** explain honestly: "Julia environment isn't set up yet — - {reason}. No worries, we'll skip the demo. You can always run one later - from the command palette." Advance without blocking. +3. **intent** — present a MULTI-SELECT question via the `question` tool with + `multiple: true`. The question: "What brings you to Amicode?" with exactly + three options: + - "General coding and software development" + - "Perform (automated) experiments and gain scientific insights" + - "Exploring" - **If the user DECLINES the demo:** say "No problem" and advance. + The user may select any combination (1, 2, or all 3). Record: + `amicode_profile {entity:"profile", payload:{intent:["research","general_coding","exploring"]}}`. + Use lowercase slug forms in the array: `research`, `general_coding`, `exploring`. - **If the demo FAILS** (Julia error, convergence failure): report honestly - and continue. A failed demo never blocks onboarding. + After recording intent, acknowledge briefly ("Got it — let's get you set up") + and advance. - **Constraints:** - - The demo MUST use the vetted template — never free-tier. - - The demo MUST NOT create vault artifacts (no problem card, no pulse bank entry). - - If `isDemoCompleted()` is true (archive marker exists), skip — don't re-offer. +4. **goals** — free-text question via `question` tool with `kind: "text"`: + "What are you hoping to accomplish with Amico?" No pre-fill (goals are + personal, not inferrable from configs). - After the demo (or skipping), advance to Stage 5. + Record: `amicode_profile {entity:"profile", payload:{goals:"..."}}`. -5. **environment** — ask how pulses will reach hardware. **Pre-fill from - seeds:** call `amicode_profile {entity:"status"}` and check if an - environment is already recorded from the context-seed (Stage 3). If so, - present it as a confirmation: "I found you use {archetype} — confirm, or - change?" via the `question` tool. If no seed, ask the standard choice - question with the options above. +5. **research_area** _(optional — only if user selected the experiments intent)_ — + ask via the `question` tool with `kind: "text"`: "What research area and what + kind of experiments?" This is free-form — the user can say anything from + "quantum optimal control for transmon gates" to "protein folding simulations" + to "materials science DFT sweeps." Record whatever they say: + `amicode_profile {entity:"profile", payload:{research_area:"..."}}`. + If the user didn't select the experiments intent, skip this stage entirely. + +6. **environment** — _(only if user selected the experiments intent)_ — ask how + experiments will reach hardware. **Pre-fill from seeds:** call + `amicode_profile {entity:"status"}` and check if an environment is already + recorded from the context-seed (Stage 2). If so, present it as a + confirmation: "I found you use {archetype} — confirm, or change?" via the + `question` tool. If no seed, ask the standard choice question with the + options above. Record: `amicode_profile {entity:"environment", payload:{slug, archetype}}`. - Follow up on details per archetype if confirmed (QICK: tProc version, - repo pointer; cloud-Pasqal: which provider, emulator access; etc.). + Follow up on details per archetype if confirmed. -6. **devices** _(optional)_ — same pre-fill pattern: if a device was seeded, - confirm it. Otherwise ask: "Any specific device(s) you want me to remember?" +7. **devices** _(optional, only if user selected the experiments intent)_ — + same pre-fill pattern: if a device was seeded, confirm it. Otherwise ask: + "Any specific device(s) you want me to remember?" This stage is ALWAYS skippable — "none" or "skip" is a valid answer. - Record: `amicode_profile {entity:"device", payload:{name, platform, qubits}}`. + Record: `amicode_profile {entity:"device", payload:{name, platform, specs}}`. If skipped, move on without recording. -7. **goals** — free-text question via `question` tool with `kind: "text"`: - "What are you hoping to accomplish with Amico?" No pre-fill (goals are - personal, not inferrable from configs). +8. **handoff** — the terminal stage. FIRST, **auto-generate a description** from + what you've learned (name, goals, research_area, intent, environment) — a + concise 1–2 sentence summary of the user written in third person, suitable + for the "About you" card. Example: "Aaron is a quantum-control researcher + focused on high-fidelity transmon gates, working in simulation." Record: + `amicode_profile {entity:"profile", payload:{description:"..."}}`. - Record: `amicode_profile {entity:"profile", payload:{goals:"..."}}`. - -8. **handoff** — the terminal stage. FIRST, record the completion marker: + Then record the completion marker: `amicode_profile {entity:"onboarding_completed"}` (exactly once — this is what lets Amico remember them next time and triggers the distiller to materialize the vault). - Then route by the user's intent selections (from Stage 2 — read from the - events stream, do NOT re-ask): - - - **Research** selected (alone or combined) → "Let's design your first - pulse" → continue straight into the **pulse-designer interview** in this - same session. Use everything learned (platform, environment, device) to - skip pulse-design questions already answered. - - **Research + General coding** → same as above, but acknowledge: "I'm also - your general coding companion — you can switch modes any time." - - **General coding only** (no Research) → open a normal session: "You're all - set — I'll remember your context across sessions. Ask me anything." - Highlight memory + vault features briefly. - - **Exploring only** → "Welcome aboard — want a quick tour of what I can do, - or just dive in?" Offer a brief orientation tour. - - The handoff does NOT re-ask intent — it reads what was recorded and routes. + Then tell the user onboarding is complete: "You're all set — your About You + card on the dashboard is now populated with what you told me. To see it, + reload the window (Cmd+Shift+P → 'Reload Window', or Cmd+R). After that, + start a new session anytime to explore what Amico can do." + + Do NOT auto-chain into another interview or open a new session. The + onboarding ends here. The user is in control of what happens next. 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..bc92a164 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -46,6 +46,12 @@ export class ChatPanel { * staged skill set after every session prep; the composer button renders * only when the report-a-bug skill is there to answer it. */ private static bugReportAvailable = false; + /** One-shot flag: when true, the next openOrReveal posts a navigate message + * to start a new session with the onboarding greeting auto-sent. Cleared + * after use. Set by the onboarding panel after config-success/confirm-import. */ + private static pendingOnboardingGreeting = false; + /** Callbacks fired when the app signals ready (app-ready message from iframe). */ + private static appReadyCallbacks: Array<() => void> = []; private readonly disposables: vscode.Disposable[] = []; private constructor( @@ -54,8 +60,11 @@ export class ChatPanel { opencodeUrl: URL, authToken?: string, hideProjectDir?: string, + withSplash?: boolean, ) { - this.panel.webview.html = this.renderHtml(opencodeUrl, authToken, hideProjectDir); + this.panel.webview.html = withSplash + ? this.renderTransitionHtml(opencodeUrl, authToken, hideProjectDir) + : this.renderHtml(opencodeUrl, authToken, hideProjectDir); ChatPanel.live.add(this); this.panel.onDidDispose(() => this.dispose(), null, this.disposables); // #351: register this panel as an inspector poster — RunsManager / device @@ -80,6 +89,14 @@ export class ChatPanel { const serverUrl = opencodeUrl.origin; this.panel.webview.onDidReceiveMessage( (msg) => { + // app-ready: the SolidJS app has mounted and is rendering. Fire + // any registered callbacks (one-shot) and clear the list. + if (msg && msg.source === "amicode" && msg.kind === "app-ready") { + const cbs = ChatPanel.appReadyCallbacks.slice(); + ChatPanel.appReadyCallbacks = []; + for (const cb of cbs) cb(); + return; + } // iframe → extension bridge: the outer webview relay (renderHtml) // forwards the framed app's envelopes here; the shared handler owns the // strict allowlists (chat_bridge.ts, also used by the deck's panes). @@ -114,12 +131,67 @@ export class ChatPanel { setTimeout(() => void this.panel.webview.postMessage(envelope), 1500); } + /** Post a navigate message to open a new session with the onboarding + * prompt auto-sent. Waits for the app-ready signal before posting (the app + * must be mounted to handle the navigate). Falls back to a timeout if + * app-ready never fires. */ + postOnboardingGreeting(timeoutMs = 10_000): void { + const prompt = encodeURIComponent("Let's begin onboarding."); + const path = `/new-session?prompt=${prompt}&autoSend=1`; + const envelope = { source: "amicode", kind: "navigate", path }; + let sent = false; + const send = () => { + if (sent) return; + sent = true; + void this.panel.webview.postMessage(envelope); + }; + ChatPanel.onAppReady(send); + // Fallback: if app-ready never fires (server hung, iframe broken), + // post after timeout so the user isn't stuck on the splash forever. + setTimeout(send, timeoutMs); + } + + /** Post an arbitrary message to the webview (relayed to the iframe). */ + postMessage(msg: unknown): Thenable { + return this.panel.webview.postMessage(msg); + } + /** AC5's gate setter — called after each session prep with * bugReportSkillStaged(project.skillPaths). */ static setBugReportAvailable(available: boolean): void { ChatPanel.bugReportAvailable = available; } + /** Set by the onboarding panel after config is written — the next + * openOrReveal will post a navigate message to auto-send the greeting. */ + static setPendingOnboardingGreeting(pending: boolean): void { + ChatPanel.pendingOnboardingGreeting = pending; + } + + /** Clear the pending greeting flag (test cleanup / manual reset). */ + static clearPendingOnboardingGreeting(): void { + ChatPanel.pendingOnboardingGreeting = false; + } + + /** Register a one-shot callback for when the app signals ready. + * All registered callbacks fire once on the first app-ready message, + * then the list is cleared. */ + static onAppReady(cb: () => void): void { + ChatPanel.appReadyCallbacks.push(cb); + } + + /** Clear app-ready callbacks (test cleanup). */ + static clearAppReadyCallbacks(): void { + ChatPanel.appReadyCallbacks = []; + } + + /** Consume and clear the pending greeting flag. Returns true if it was set. */ + static consumePendingOnboardingGreeting(): boolean { + if (!ChatPanel.pendingOnboardingGreeting) return false; + ChatPanel.pendingOnboardingGreeting = false; + return true; + } + /** The primary panel if one is live (never creates) — the down lane's * fallback when the server is mid-restart and no ready URL exists. */ static peek(): ChatPanel | undefined { @@ -191,6 +263,29 @@ export class ChatPanel { return ChatPanel.current; } + /** Adopt an existing WebviewPanel (e.g. the onboarding panel) as the chat + * singleton. Swaps its HTML to the chat iframe with a splash overlay on top, + * wires message relay + bridge, and registers it as the primary ChatPanel. + * No new panel is created — zero tab switching. */ + static adopt( + panel: vscode.WebviewPanel, + ctx: vscode.ExtensionContext, + opencodeUrl: URL, + authToken?: string, + hideProjectDir?: string, + ): ChatPanel { + // If there's already a ChatPanel singleton, dispose it (shouldn't happen in normal flow) + if (ChatPanel.current) { + ChatPanel.current.dispose(); + } + const title = "Amicode Chat"; + const instance = new ChatPanel(panel, title, opencodeUrl, authToken, hideProjectDir, true); + ChatPanel.current = instance; + panel.title = title; + panel.iconPath = tabIconPath(ctx); + return instance; + } + /** Side-by-side sessions: ALWAYS a fresh tab beside the active editor — the * caller pins the tab's session scope via the URL (e.g. the app's * /new-session draft route), so each tab owns its conversation while sharing @@ -270,7 +365,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" || d.kind === "app-ready")) { vscode.postMessage(d); } return; @@ -279,7 +374,7 @@ export class ChatPanel { // (webview-internal origin, never the opencode origin). Forward only // our own envelopes, pinned to the opencode origin. #351 adds // run:*/device:* envelopes for the Work Column inspector tabs. - if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image")) { + if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image")) { var f = document.querySelector("iframe"); if (f && f.contentWindow) f.contentWindow.postMessage(d, ${origin}); } @@ -328,6 +423,183 @@ export class ChatPanel { `; } + /** Render the chat iframe HTML with a splash overlay on top. + * Used by adopt() — the overlay fades out when app-ready fires, revealing + * the fully-loaded chat underneath. Zero tab switching, pure CSS transition. */ + private renderTransitionHtml(opencodeUrl: URL, authToken?: string, hideProjectDir?: string): string { + const nonce = randomBytes(16).toString("base64"); + const csp = [ + "default-src 'none'", + "style-src 'unsafe-inline'", + `script-src 'nonce-${nonce}'`, + `frame-src ${opencodeUrl.origin}`, + "connect-src 'self'", + ].join("; "); + const origin = JSON.stringify(opencodeUrl.origin); + const framed = new URL(opencodeUrl.href); + framed.searchParams.set("colorScheme", themeKindToScheme(vscode.window.activeColorTheme.kind)); + if (authToken) framed.searchParams.set("auth_token", authToken); + if (hideProjectDir) framed.searchParams.set("amicode_hide_project", hideProjectDir); + if (ChatPanel.bugReportAvailable) framed.searchParams.set("amicode_bug_report", "1"); + return /* html */ ` + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Getting Amico ready...
+
+ + + +`; + } + dispose(): void { for (const d of this.disposables) { try { @@ -338,4 +610,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..bb72b15a --- /dev/null +++ b/packages/extension/src/credential_scanner.ts @@ -0,0 +1,414 @@ +// 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, + }; + }); +} + +// ─── Key validation (#455) ─────────────────────────────────────────────────── + +/** Known placeholder keys that should never be persisted to config. */ +const PLACEHOLDER_KEYS = new Set(["sk-test"]); + +/** + * Returns true if the API key is valid for writing to config. + * Rejects: empty strings, known placeholders, and keys shorter than 10 chars. + * Empty string is allowed ONLY when the caller explicitly passes it (OAuth + * providers like github-copilot don't use API keys at all — they pass empty + * and the entry is written without options.apiKey). This function is called + * only when a key IS present (non-empty), so empty returns false here. + */ +export function isValidApiKey(key: string): boolean { + if (!key || key.trim() === "") return false; + if (PLACEHOLDER_KEYS.has(key.trim())) return false; + if (key.trim().length < 10) return false; + return true; +} + +// ─── 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[]. + * Credentials with placeholder or invalid keys are silently skipped (#455). + */ +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 — replaces the entire provider section + // (on redo, user's selection is the canonical set; old entries don't persist) + const providerEntry: Record = {}; + + for (const cred of credentials) { + // Skip credentials with invalid/placeholder keys (#455) + if (!isValidApiKey(cred.key)) continue; + + 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"); +} + +// ─── Disconnect excluded providers from auth stores ────────────────────────── + +/** + * Remove credentials for excluded providers from opencode's auth stores. + * After a server restart, excluded providers will no longer auto-connect. + */ +export function disconnectProviders( + providers: string[], + options?: { accountJsonPath?: string; authJsonPath?: string }, +): void { + const home = os.homedir(); + const dataDir = path.join(home, ".local", "share", "opencode"); + const accountPath = options?.accountJsonPath ?? path.join(dataDir, "account.json"); + const authPath = options?.authJsonPath ?? path.join(dataDir, "auth.json"); + + // Build the set of serviceIDs to remove, including aliases + const excludeSet = new Set(providers); + if (excludeSet.has("opencode")) excludeSet.add("opencode-go"); + + // Remove from account.json (v2) + try { + const raw = fs.readFileSync(accountPath, "utf8"); + const data = JSON.parse(raw); + if (data.version === 2 && typeof data.accounts === "object" && data.accounts !== null) { + let modified = false; + for (const [id, entry] of Object.entries(data.accounts)) { + const acct = entry as { serviceID?: string }; + if (acct.serviceID && excludeSet.has(acct.serviceID)) { + delete data.accounts[id]; + if (data.active && acct.serviceID in data.active) { + delete data.active[acct.serviceID]; + } + modified = true; + } + } + if (modified) { + fs.writeFileSync(accountPath, JSON.stringify(data, null, 2) + "\n"); + } + } + } catch { + // Skip if file doesn't exist or is malformed + } + + // Remove from auth.json (v1) + try { + const raw = fs.readFileSync(authPath, "utf8"); + const data = JSON.parse(raw); + if (typeof data === "object" && data !== null) { + let modified = false; + for (const serviceId of excludeSet) { + if (serviceId in data) { + delete data[serviceId]; + modified = true; + } + } + if (modified) { + fs.writeFileSync(authPath, JSON.stringify(data, null, 2) + "\n"); + } + } + } catch { + // Skip if file doesn't exist or is malformed + } +} diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 95760c3e..2176389e 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, onOnboardingCancelled, getOnboardingPanel, releaseOnboardingPanel } 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"; @@ -204,6 +206,39 @@ async function refreshDeviceInspector(channel: vscode.OutputChannel): Promise(); +/** Create a session and arm it with "Let's begin onboarding." via the server API. + * Bypasses the UI model gate (the server resolves its own default model). + * Returns the session ID on success, undefined on failure. */ +async function armOnboardingSession( + serverUrl: URL, + authHeaders: Record, + projectDir?: string, +): Promise { + try { + const collectionUrl = new URL("/session", serverUrl); + if (projectDir) collectionUrl.searchParams.set("directory", projectDir); + const createRes = await fetch(collectionUrl.toString(), { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders }, + body: JSON.stringify({ title: "Onboarding" }), + }); + if (!createRes.ok) return undefined; + const { id } = (await createRes.json()) as { id?: string }; + if (!id) return undefined; + + const commandUrl = new URL(`/session/${id}/command`, serverUrl); + const commandRes = await fetch(commandUrl.toString(), { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders }, + body: JSON.stringify({ command: "Let's begin onboarding.", arguments: "" }), + }); + if (!commandRes.ok) return undefined; + return id; + } catch { + return undefined; + } +} + export async function activate(ctx: vscode.ExtensionContext): Promise { const opencodeChannel = vscode.window.createOutputChannel("Amicode — opencode"); const runsChannel = vscode.window.createOutputChannel("Amicode — runs"); @@ -345,9 +380,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,10 +869,37 @@ 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)) { - ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir); + // 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, the server restarts and the + // onReady handler (else-if branch below) opens the chat panel. + // We do NOT open chat here — that would race the server restart + // and show behind the transition splash. + // 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 + // Post-onboarding: adopt the onboarding panel as the chat panel (zero + // tab switching — the splash overlay fades out revealing the chat). + if (ChatPanel.consumePendingOnboardingGreeting()) { + const onboardPanel = getOnboardingPanel(); + if (onboardPanel) { + releaseOnboardingPanel(); // detach from onboarding lifecycle + const panel = ChatPanel.adopt(onboardPanel, ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir); + panel.postOnboardingGreeting(); + } else { + // Fallback: no onboarding panel alive (user closed it manually) + const panel = ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir); + panel.postOnboardingGreeting(); + } + } else { + ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir); + } } // Surface ONE explicit LLM-provider signal at boot, read from opencode's // OWN resolution (its live /config/providers) — not a silent hang at the @@ -1725,6 +1788,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..653c01ac 100644 --- a/packages/extension/src/onboarding_panel.ts +++ b/packages/extension/src/onboarding_panel.ts @@ -12,6 +12,17 @@ import * as path from "node:path"; import * as os from "node:os"; import * as vscode from "vscode"; +import { + scanCredentials, + defaultScanOptions, + webviewSafeResults, + writeBatchConfig, + disconnectProviders, + isValidApiKey, + type DetectedCredential, +} from "./credential_scanner"; +import { ChatPanel } from "./chat_panel"; + // ─── Provider → Model data (data-driven, not hard-coded conditionals) ──────── export interface ModelEntry { @@ -20,26 +31,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 ──────────────────────────────────────────────── @@ -56,7 +96,8 @@ function defaultConfigPath(): string { } /** Write the onboarding config to the opencode config file. - * Creates parent directories if needed. Merges with existing config if present. */ + * Creates parent directories if needed. Merges with existing config if present. + * Rejects placeholder/invalid API keys — the provider entry is not written (#455). */ export function writeOnboardingConfig( config: OnboardingConfig, configPath: string = defaultConfigPath(), @@ -73,16 +114,36 @@ export function writeOnboardingConfig( // If parsing fails, start fresh } + // Reject placeholder/invalid keys (#455) — but allow empty keys (OAuth providers) + if (config.apiKey && !isValidApiKey(config.apiKey)) { + // Key is non-empty but invalid — don't write this provider, just preserve existing config + const result = { + ...existing, + $schema: "https://opencode.ai/config.json", + provider: existing.provider ?? {}, + model: config.model, + }; + fs.writeFileSync(configPath, JSON.stringify(result, null, 2) + "\n"); + return; + } + // 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 +162,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 +180,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 +195,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 +243,7 @@ function buildTestRequest( }; } - if (config.provider === "openai") { + if (config.provider === "openai" || config.provider === "openrouter" || config.provider === "vercel") { return { url: endpoint, options: { @@ -184,7 +253,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 +275,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 +287,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 +307,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; @@ -239,6 +329,93 @@ export function _resetForTesting(): void { currentPanel = undefined; } +/** Dismiss the onboarding panel (dispose it). Called by the extension host + * after the chat panel's app signals ready — ends the transition splash. */ +export function dismissOnboardingPanel(): void { + if (currentPanel) { + currentPanel.dispose(); + } +} + +/** Return the live onboarding WebviewPanel (if one exists). Used by the + * transition flow: the extension swaps its HTML and adopts it as the chat + * panel — zero tab switching. */ +export function getOnboardingPanel(): vscode.WebviewPanel | undefined { + return currentPanel; +} + +/** Detach the onboarding panel from this module's lifecycle tracking WITHOUT + * disposing it. Called when ChatPanel.adopt() takes ownership. After this, + * dismissOnboardingPanel() is a no-op and re-opening creates a fresh panel. */ +export function releaseOnboardingPanel(): void { + currentPanel = undefined; +} + +/** Static splash HTML — the happy robot + "Getting Amico ready..." on a plain + * background. Used as an immediate visual while the server restarts. The exact + * same SVG + CSS appears in ChatPanel.renderTransitionHtml's overlay, so when + * adopt() fires there's no visible flash (same pixels). */ +function splashHtml(): string { + return ` + + + + + + + + + + + + + + + + + + + + + + + + + +
Getting Amico ready...
+`; +} + /** Register the onboarding panel command. Call from extension.ts activate(). */ export function registerOnboardingPanel(ctx: vscode.ExtensionContext): void { ctx.subscriptions.push( @@ -262,15 +439,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") { @@ -280,14 +453,127 @@ export function registerOnboardingPanel(ctx: vscode.ExtensionContext): void { } else if (msg.type === "config-success") { const payload = msg.payload as OnboardingConfig; writeOnboardingConfig(payload); + // Swap the panel HTML directly to the splash (same as confirm-import) + panel.webview.html = splashHtml(); + // Signal that the next chat panel open should auto-send the onboarding greeting + ChatPanel.setPendingOnboardingGreeting(true); + fireOnboardingComplete(); + // Restart server so it picks up the new provider config. + // Chat opens via the onReady-gated listener in extension.ts. + void vscode.commands.executeCommand("amicode.restartServer"); + } 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); + } + // If user excluded 'opencode', disconnect it from the auth store. + // This is the only provider that needs file-level removal (it's a + // built-in integration, not in the connections seam). + if (!included.has("opencode") && heldCredentials.some((c) => c.provider === "opencode")) { + disconnectProviders(["opencode"]); + } + heldCredentials = []; + testResults.clear(); + // Swap the panel HTML directly to the splash — no webview-side + // DOM manipulation, so there's no flash when adopt() fires later + // (adopt's overlay uses the exact same SVG + CSS). + panel.webview.html = splashHtml(); + // Signal that the next chat panel open should auto-send the onboarding greeting + ChatPanel.setPendingOnboardingGreeting(true); fireOnboardingComplete(); + // Restart server so it picks up the new provider config. + // Chat opens via the onReady-gated listener in extension.ts. + void vscode.commands.executeCommand("amicode.restartServer"); + } else if (msg.type === "transition-complete") { + // The extension signals that the chat panel is ready — dispose the + // splash now. This is posted by the extension host after app-ready. + panel.dispose(); } }, 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 +596,28 @@ 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 c6f0ff24..cb0ec327 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 } @@ -16,11 +17,13 @@ export {}; declare global { interface Window { __PROVIDERS__: Record; + __PROVIDER_NAMES__: Record; } } const vscodeApi = acquireVsCodeApi(); const providers = window.__PROVIDERS__; +const providerNames = window.__PROVIDER_NAMES__ ?? {}; // ─── Animation ─────────────────────────────────────────────────────────────── @@ -36,37 +39,307 @@ 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 = ` -