From 7ebb876f3b5a0a6b17137e177d1a4f670b7937cf Mon Sep 17 00:00:00 2001 From: continued-agent <174889979+continued-agent@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:56:24 +0000 Subject: [PATCH 1/2] fix(cli): stop collecting API key in provider picker The provider picker previously prompted for an API key with masked input and then discarded the typed value, writing only a ${{ secrets.NAME }} reference into config.yaml. The user reasonably expects the key they just typed to be saved, but it was thrown away. Replace the apiKey step with a confirm step that shows the env var the user needs to set and waits for any keypress. The matching README, CHANGELOG, and spec/onboarding.md are updated to drop the "enter the API key" wording and document the env-var reminder. Add ink-testing-library coverage for the picker to prevent regressions on the navigation, escape, simple-provider confirm, and apiBase flows. Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- extensions/cli/CHANGELOG.md | 2 +- extensions/cli/README.md | 2 +- extensions/cli/spec/onboarding.md | 43 +++--- .../src/ui/components/ProviderPicker.test.tsx | 131 ++++++++++++++++++ .../cli/src/ui/components/ProviderPicker.tsx | 92 ++++++------ 5 files changed, 201 insertions(+), 69 deletions(-) create mode 100644 extensions/cli/src/ui/components/ProviderPicker.test.tsx diff --git a/extensions/cli/CHANGELOG.md b/extensions/cli/CHANGELOG.md index 8fe4c924437..dd478426efa 100644 --- a/extensions/cli/CHANGELOG.md +++ b/extensions/cli/CHANGELOG.md @@ -3,7 +3,7 @@ ### Features - replace the wide Continue logo with a compact custom ASCII mark sized to fit standard terminal widths; on terminals narrower than the supported logo width `cn` falls back to the compact `CN` mark ([extensions/cli/src/asciiArt.ts](extensions/cli/src/asciiArt.ts)) -- replace the Anthropic-only onboarding prompt with an interactive provider picker (arrow/`j`/`k`, PageUp/PageDown, Enter) covering OpenAI, Anthropic, Gemini, xAI, Mistral, DeepSeek, OpenRouter, Perplexity, LiteLLM, OpenCode Zen, Azure, Bedrock, NVIDIA, Hugging Face and a custom OpenAI-compatible endpoint; the selected API key is stored as a `${{ secrets.NAME }}` reference in `~/.continue/config.yaml` ([extensions/cli/src/ui/components/ProviderPicker.tsx](extensions/cli/src/ui/components/ProviderPicker.tsx)) +- replace the Anthropic-only onboarding prompt with an interactive provider picker (arrow/`j`/`k`, PageUp/PageDown, Enter) covering OpenAI, Anthropic, Gemini, xAI, Mistral, DeepSeek, OpenRouter, Perplexity, LiteLLM, OpenCode Zen, Azure, Bedrock, NVIDIA, Hugging Face and a custom OpenAI-compatible endpoint; the selected model is written to `~/.continue/config.yaml` with a `${{ secrets.NAME }}` reference and the picker reminds the user to set the matching env var before continuing ([extensions/cli/src/ui/components/ProviderPicker.tsx](extensions/cli/src/ui/components/ProviderPicker.tsx)) ### Bug Fixes diff --git a/extensions/cli/README.md b/extensions/cli/README.md index ea7c5d8d026..77772d90ad4 100644 --- a/extensions/cli/README.md +++ b/extensions/cli/README.md @@ -44,7 +44,7 @@ The TUI applies one column of horizontal padding around the chat and input area ### First-run provider setup -On the first interactive launch, `cn` shows a scrollable provider picker instead of requiring Anthropic specifically. Use the arrow keys or `j`/`k`, PageUp/PageDown for longer lists, and Enter to select a provider. The API key is entered with masked input. +On the first interactive launch, `cn` shows a scrollable provider picker instead of requiring Anthropic specifically. Use the arrow keys or `j`/`k`, PageUp/PageDown for longer lists, and Enter to select a provider. For providers that need a base URL, region, or a non-default model, the picker asks for those next. The CLI writes the selected model to `~/.continue/config.yaml` using Continue's `${{ secrets.NAME }}` syntax. Define the matching variable in your shell, `~/.continue/.env`, or the project's `.env` file. For example: diff --git a/extensions/cli/spec/onboarding.md b/extensions/cli/spec/onboarding.md index 58c65132292..95654fad1fe 100644 --- a/extensions/cli/spec/onboarding.md +++ b/extensions/cli/spec/onboarding.md @@ -6,27 +6,32 @@ When a user first runs `cn` in interactive mode, they will be taken through "onb **The onboarding flow runs when the user hasn't completed onboarding before, regardless of whether they have a valid config.yaml file.** -1. If the --config flag is provided, load this config -2. If the CONTINUE_USE_BEDROCK environment variable is set to "1", automatically use AWS Bedrock configuration and skip interactive prompts -3. Present the user with available options: +1. If the `--config` flag is provided, load this config (this short-circuits the rest of onboarding). +2. If the `CONTINUE_USE_BEDROCK` environment variable is set to `"1"`, automatically use AWS Bedrock configuration and skip interactive prompts. +3. Otherwise, present the user with a scrollable provider picker that supports arrow keys, `j`/`k`, PageUp/PageDown, and Enter. The picker covers native Continue providers (OpenAI, Anthropic, Google Gemini, xAI, Mistral, DeepSeek, OpenRouter, AWS Bedrock, NVIDIA, Hugging Face) plus OpenAI-compatible entries (Meta/Llama, Perplexity, LiteLLM, OpenCode Zen, Azure, and a custom endpoint). For providers that require a base URL or region, the picker prompts for those values, then prompts for the model identifier (pre-filled with the provider's default if any). +4. The picker does **not** ask the user to type their API key. It writes a reference into `~/.continue/config.yaml` using Continue's `${{ secrets.NAME }}` syntax, where `NAME` matches the provider's environment variable (e.g. `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`). After confirming the model, the picker shows a short "Set `NAME` in your shell, `~/.continue/.env`, or project `.env`" reminder and waits for any keypress to exit. - - Log in with Continue: log them in, which will automatically create their assistant and then we can load the first assistant from the first org - - Enter your Anthropic API key: let them enter the key, and then either create a ~/.continue/config.yaml with the following contents OR update the existing config.yaml to add the model + Example of the generated config: ```yaml - name: Local Config + name: Main Config version: 1.0.0 schema: v1 - models: - - uses: anthropic/claude-4-sonnet - with: - ANTHROPIC_API_KEY: + - name: OpenAI + provider: openai + model: gpt-5 + apiKey: ${{ secrets.OPENAI_API_KEY }} + roles: [chat, edit, apply] ``` - When CONTINUE_USE_BEDROCK=1 is detected, it will use AWS Bedrock configuration. The user must have AWS credentials configured through the standard AWS credential chain (AWS CLI, environment variables, IAM roles, etc.). + The matching env var must be defined in the user's shell, `~/.continue/.env`, or the project's `.env` file. For example: + + ```bash + export OPENAI_API_KEY=your-key + ``` -When something in the onboarding flow is done automatically, we should tell the user what happened. For example, when CONTINUE_USE_BEDROCK=1 is detected, the CLI displays: "✓ Using AWS Bedrock (CONTINUE_USE_BEDROCK detected)" +When something in the onboarding flow is done automatically, we should tell the user what happened. For example, when `CONTINUE_USE_BEDROCK=1` is detected, the CLI displays: "✓ Using AWS Bedrock (CONTINUE_USE_BEDROCK detected)". ### AWS Bedrock Environment Variable @@ -45,12 +50,16 @@ This will: - Display a confirmation message to the user - Mark onboarding as completed +### Headless ANTHROPIC_API_KEY fast path + +When the CLI is launched in headless mode (`-p`) without a `--config` flag and `ANTHROPIC_API_KEY` is present in the environment, the CLI writes a minimal Anthropic config to `~/.continue/config.yaml` so the run can proceed without interactive prompts. The key is referenced as `${{ secrets.ANTHROPIC_API_KEY }}` — the existing env var is what authenticates the request, not a value stored in `config.yaml`. + ## Normal flow **The normal flow runs when the user has already completed onboarding.** -1. If the --config flag is provided, load this config -2. If the user is logged in, look for the first assistant in the selected org -3. If there are no assistants in that org, then look for a local ~/.continue/config.yaml -4. If there is no config.yaml, look for an ANTHROPIC_API_KEY in the environment and manually construct the config object to include just the claude-4-sonnet model with that API key -5. If none of the above, then bring the user to step 3 of the onboarding flow +1. If the `--config` flag is provided, load this config. +2. If the user is logged in, look for the first assistant in the selected org. +3. If there are no assistants in that org, then look for a local `~/.continue/config.yaml`. +4. If there is no config.yaml, look for an `ANTHROPIC_API_KEY` in the environment and manually construct the config object to include just the Anthropic model with that API key. +5. If none of the above, then bring the user to the provider picker in step 3 of the onboarding flow. diff --git a/extensions/cli/src/ui/components/ProviderPicker.test.tsx b/extensions/cli/src/ui/components/ProviderPicker.test.tsx new file mode 100644 index 00000000000..59695fd2684 --- /dev/null +++ b/extensions/cli/src/ui/components/ProviderPicker.test.tsx @@ -0,0 +1,131 @@ +import { render } from "ink-testing-library"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { ONBOARDING_PROVIDERS } from "../../onboardingProviders.js"; + +import { ProviderPicker } from "./ProviderPicker.js"; + +describe("ProviderPicker", () => { + it("renders the provider list with the first provider selected", () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const { lastFrame, unmount } = render( + , + ); + + try { + const frame = lastFrame() ?? ""; + expect(frame).toContain("Choose your model provider"); + expect(frame).toContain(ONBOARDING_PROVIDERS[0].label); + } finally { + unmount(); + } + }); + + it("navigates down with the j key", () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const { lastFrame, stdin, unmount } = render( + , + ); + + try { + stdin.write("j"); + const frame = lastFrame() ?? ""; + expect(frame).toContain(ONBOARDING_PROVIDERS[1].label); + } finally { + unmount(); + } + }); + + it("cancels when Escape is pressed on the provider step", () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const { stdin, unmount } = render( + , + ); + + try { + stdin.write("\u001B"); // ESC + expect(onCancel).toHaveBeenCalled(); + expect(onSelect).not.toHaveBeenCalled(); + } finally { + unmount(); + } + }); + + it("confirms a simple provider, calls onSelect, and shows the env var reminder", () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const { lastFrame, stdin, unmount, rerender } = render( + , + ); + + try { + // First provider (OpenAI) has a default model and no apiBase/region, + // so pressing Enter on the provider step advances to the model step. + // Pressing Enter again confirms the pre-filled model and fires onSelect. + stdin.write("\r"); + rerender(); + stdin.write("\r"); + rerender(); + + expect(onSelect).toHaveBeenCalledTimes(1); + const config = onSelect.mock.calls[0][0]; + expect(config).toMatchObject({ + name: ONBOARDING_PROVIDERS[0].label, + provider: ONBOARDING_PROVIDERS[0].provider, + apiKeySecret: ONBOARDING_PROVIDERS[0].envVar, + }); + + const frame = lastFrame() ?? ""; + expect(frame).toContain("Configured " + ONBOARDING_PROVIDERS[0].label); + expect(frame).toContain(ONBOARDING_PROVIDERS[0].envVar); + // No more "API key →" input prompt. + expect(frame).not.toContain("API key →"); + } finally { + unmount(); + } + }); + + it("collects an apiBase for providers that need one and passes it to onSelect", () => { + // The LiteLLM provider requires an apiBase. + const litellmIndex = ONBOARDING_PROVIDERS.findIndex( + (p) => p.label === "LiteLLM (OpenAI-compatible)", + ); + expect(litellmIndex).toBeGreaterThanOrEqual(0); + + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const { stdin, unmount, rerender } = render( + , + ); + + try { + // Move down to the LiteLLM row. + for (let i = 0; i < litellmIndex; i++) { + stdin.write("j"); + rerender(); + } + // Enter → apiBase step. + stdin.write("\r"); + rerender(); + // Type the base URL. + stdin.write("https://litellm.example.com"); + rerender(); + // Enter → model step. + stdin.write("\r"); + rerender(); + // Enter → confirm (using the default model). + stdin.write("\r"); + rerender(); + + expect(onSelect).toHaveBeenCalledTimes(1); + const config = onSelect.mock.calls[0][0]; + expect(config.apiBase).toBe("https://litellm.example.com"); + } finally { + unmount(); + } + }); +}); diff --git a/extensions/cli/src/ui/components/ProviderPicker.tsx b/extensions/cli/src/ui/components/ProviderPicker.tsx index 38643e8943d..4f771db9cc6 100644 --- a/extensions/cli/src/ui/components/ProviderPicker.tsx +++ b/extensions/cli/src/ui/components/ProviderPicker.tsx @@ -13,16 +13,13 @@ interface ProviderPickerProps { onCancel: () => void; } -type Step = "provider" | "apiBase" | "region" | "model" | "apiKey"; +type Step = "provider" | "apiBase" | "region" | "model" | "confirm"; type InputKey = Parameters[0]>[1]; type ProviderInputContext = { pageSize: number; provider: OnboardingProvider; setSelectedIndex: React.Dispatch>; - setApiBase: React.Dispatch>; - setRegion: React.Dispatch>; - setModel: React.Dispatch>; setInputValue: React.Dispatch>; setStep: React.Dispatch>; onCancel: () => void; @@ -35,10 +32,8 @@ type FieldInputContext = { inputValue: string; apiBase: string; region: string; - model: string; setApiBase: React.Dispatch>; setRegion: React.Dispatch>; - setModel: React.Dispatch>; setInputValue: React.Dispatch>; setStep: React.Dispatch>; onSelect: (config: SelectedProviderConfig) => void; @@ -46,23 +41,14 @@ type FieldInputContext = { exit: () => void; }; -function InputLine({ - label, - value, - secret, -}: { - label: string; - value: string; - secret?: boolean; -}) { - const rendered = secret ? "•".repeat(value.length) : value; +function InputLine({ label, value }: { label: string; value: string }) { return ( {label} - {rendered} + {value} @@ -78,9 +64,6 @@ function handleProviderInput( pageSize, provider, setSelectedIndex, - setApiBase, - setRegion, - setModel, setInputValue, setStep, onCancel, @@ -94,9 +77,7 @@ function handleProviderInput( return true; } if (key.downArrow || input === "j") { - setSelectedIndex( - (current) => (current + 1) % ONBOARDING_PROVIDERS.length, - ); + setSelectedIndex((current) => (current + 1) % ONBOARDING_PROVIDERS.length); return true; } if (key.pageUp) { @@ -117,16 +98,13 @@ function handleProviderInput( if (!key.return) return false; if (provider.needsApiBase) { - setApiBase(""); setInputValue(""); setStep("apiBase"); } else if (provider.needsRegion) { - setRegion("us-east-1"); setInputValue("us-east-1"); setStep("region"); } else { const defaultModel = provider.defaultModel ?? ""; - setModel(defaultModel); setInputValue(defaultModel); setStep("model"); } @@ -144,10 +122,8 @@ function handleFieldInput( inputValue, apiBase, region, - model, setApiBase, setRegion, - setModel, setInputValue, setStep, onSelect, @@ -155,6 +131,12 @@ function handleFieldInput( exit, } = context; + if (step === "confirm") { + // Selection already happened. Any keypress finalises; ignore input. + exit(); + return; + } + if (key.escape) { onCancel(); exit(); @@ -175,29 +157,24 @@ function handleFieldInput( if (step === "apiBase") { const defaultModel = provider.defaultModel ?? ""; setApiBase(value); - setModel(defaultModel); setInputValue(defaultModel); setStep("model"); } else if (step === "region") { const defaultModel = provider.defaultModel ?? ""; setRegion(value); - setModel(defaultModel); setInputValue(defaultModel); setStep("model"); } else if (step === "model") { - setModel(value); - setInputValue(""); - setStep("apiKey"); - } else { onSelect({ name: provider.label, provider: provider.provider, - model, + model: value, apiKeySecret: provider.envVar, apiBase: provider.needsApiBase ? apiBase : provider.apiBase, env: region ? { region } : undefined, }); - exit(); + setInputValue(""); + setStep("confirm"); } } @@ -209,7 +186,6 @@ export function ProviderPicker({ onSelect, onCancel }: ProviderPickerProps) { const [inputValue, setInputValue] = useState(""); const [apiBase, setApiBase] = useState(""); const [region, setRegion] = useState(""); - const [model, setModel] = useState(""); const provider = ONBOARDING_PROVIDERS[selectedIndex]; const pageSize = Math.max(5, Math.min(12, terminalRows - 8)); @@ -231,9 +207,6 @@ export function ProviderPicker({ onSelect, onCancel }: ProviderPickerProps) { pageSize, provider, setSelectedIndex, - setApiBase, - setRegion, - setModel, setInputValue, setStep, onCancel, @@ -252,10 +225,8 @@ export function ProviderPicker({ onSelect, onCancel }: ProviderPickerProps) { inputValue, apiBase, region, - model, setApiBase, setRegion, - setModel, setInputValue, setStep, onSelect, @@ -264,15 +235,40 @@ export function ProviderPicker({ onSelect, onCancel }: ProviderPickerProps) { }); }); + if (step === "confirm") { + return ( + + + ✓ Configured {provider.label} + + + + Set{" "} + + {provider.envVar} + {" "} + in your shell, + ~/.continue/.env, or your project + .env before running the CLI. + + Press any key to continue. + + ); + } + if (step !== "provider") { const label = step === "apiBase" ? "API base URL" : step === "region" ? "AWS region" - : step === "model" - ? `Model [default: ${provider.defaultModel ?? "enter a model"}]` - : `API key → stored as ${provider.envVar}`; + : `Model [default: ${provider.defaultModel ?? "enter a model"}]`; return ( Enter a value and press Enter. Esc cancels. - + ); } From 739f93b803b2a3f87d092871ac1142783fd97d60 Mon Sep 17 00:00:00 2001 From: continued-agent <174889979+continued-agent@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:56:33 +0000 Subject: [PATCH 2/2] fix(cli): also treat missing stdout TTY as non-interactive runOnboardingFlow only checked process.stdin.isTTY. In some CI/container setups stdin looks like a TTY while stdout is redirected, which would cause Ink to hang on useInput. Add process.stdout.isTTY to the non-interactive heuristic and a comment explaining why the check is conservative, plus a regression test. Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- extensions/cli/src/onboarding.test.ts | 22 ++++++++++++++++++++++ extensions/cli/src/onboarding.ts | 7 ++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/extensions/cli/src/onboarding.test.ts b/extensions/cli/src/onboarding.test.ts index 1fcb7b98361..8d8d531e6c6 100644 --- a/extensions/cli/src/onboarding.test.ts +++ b/extensions/cli/src/onboarding.test.ts @@ -236,4 +236,26 @@ describe("CONTINUE_USE_BEDROCK environment variable", () => { process.stdin.isTTY = originalIsTTY; } }); + + test("should skip interactive onboarding when stdout is not a TTY even if stdin is", async () => { + process.env.CONTINUE_USE_BEDROCK = "0"; + + vi.resetModules(); + const { runOnboardingFlow } = await import("./onboarding.js"); + + const originalStdinTTY = process.stdin.isTTY; + const originalStdoutTTY = process.stdout.isTTY; + process.stdin.isTTY = true; + process.stdout.isTTY = false; + + try { + const result = await runOnboardingFlow(undefined); + // No interactive prompt should be shown; the function should bail out + // and the caller (initializeWithOnboarding) treats it as a no-op. + expect(result).toBe(false); + } finally { + process.stdin.isTTY = originalStdinTTY; + process.stdout.isTTY = originalStdoutTTY; + } + }); }); diff --git a/extensions/cli/src/onboarding.ts b/extensions/cli/src/onboarding.ts index 859dd8b1052..8c8bd601aa2 100644 --- a/extensions/cli/src/onboarding.ts +++ b/extensions/cli/src/onboarding.ts @@ -82,12 +82,17 @@ export async function runOnboardingFlow( return true; } + // Skip interactive prompts in non-interactive contexts. We rely on + // explicit signals (env vars) plus the absence of a TTY. The TTY check + // also covers `cn < script` invocations, which would otherwise hang on + // `useInput` with no input available. const isTestEnv = process.env.NODE_ENV === "test" || process.env.CI === "true" || process.env.VITEST === "true" || process.env.GITHUB_ACTIONS === "true" || - !process.stdin.isTTY; + !process.stdin.isTTY || + !process.stdout.isTTY; if (isTestEnv) { return false;