diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb276f..eb93b08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to qmax-code. Versions follow [Semantic Versioning](https:// ## [Unreleased] +### Fixed +- The Anthropic API key is offered, never demanded. Previously, after a + successful QualityMax login the startup flow asked for an Anthropic key and + then hard-exited with no way to skip, dead-ending users of the Claude Code, + Codex, Antigravity, opencode, Cerebras, and Ollama backends. Now both the + first-run wizard and startup show a paste-or-skip choice (asked once, not + twice), skipping enters the REPL, and an AI turn without any inference + backend prints guidance (`/keys`, `/orch`, `/cerebras`, `/ollama`) instead + of failing with a raw 401. Headless one-shot runs without any inference + still fail fast with the backend alternatives listed. + ## [1.35.2] - 2026-09-14 ### Fixed diff --git a/internal/repl/backend_switch_test.go b/internal/repl/backend_switch_test.go index a31540e..096db0b 100644 --- a/internal/repl/backend_switch_test.go +++ b/internal/repl/backend_switch_test.go @@ -21,3 +21,38 @@ func TestDeactivateEmbeddedBackendsClearsCerebrasAndOllamaMode(t *testing.T) { t.Fatalf("Cerebras client was not cleared") } } + +// TestEmbeddedInferenceAvailable guards the REPL turn pre-check: a skipped +// Anthropic key must be a supported state (turn refused with guidance, not a +// raw 401), while Ollama/Cerebras/key configurations must keep turns running. +func TestEmbeddedInferenceAvailable(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "") + + tests := []struct { + name string + ag *agent.Agent + want bool + }{ + {name: "nil agent", want: false}, + {name: "no backend, no key — skip state", ag: &agent.Agent{}, want: false}, + {name: "anthropic key in agent config", ag: &agent.Agent{Cfg: agent.AgentConfig{AnthropicKey: "sk-ant-x"}}, want: true}, + {name: "cerebras", ag: &agent.Agent{Cerebras: &agent.CerebrasClient{}}, want: true}, + {name: "ollama full mode", ag: &agent.Agent{Ollama: &agent.OllamaClient{}, Mode: agent.OllamaModeFull}, want: true}, + {name: "ollama chat mode", ag: &agent.Agent{Ollama: &agent.OllamaClient{}, Mode: agent.OllamaModeChat}, want: true}, + {name: "ollama configured but off", ag: &agent.Agent{Ollama: &agent.OllamaClient{}, Mode: agent.OllamaModeOff}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := embeddedInferenceAvailable(tt.ag); got != tt.want { + t.Fatalf("embeddedInferenceAvailable() = %v, want %v", got, tt.want) + } + }) + } + + t.Run("env key counts as configured", func(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "sk-ant-from-env") + if !embeddedInferenceAvailable(&agent.Agent{}) { + t.Fatal("ANTHROPIC_API_KEY env var must count as configured inference") + } + }) +} diff --git a/internal/repl/repl.go b/internal/repl/repl.go index 1e373be..959f7c5 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -1272,6 +1272,15 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin // path as before. runWithCLI := cliAgent != nil && len(turnImages) == 0 + // A direct-API turn with no key configured can only fail mid-request + // with a raw 401. Refuse it up front, with the exact commands that + // fix it — the key is offered at startup, never demanded. + if !runWithCLI && !embeddedInferenceAvailable(ag) { + term.PrintError("No inference backend configured — nothing can run this turn.") + term.PrintSystem("Add an Anthropic key with /keys, or switch backends: /orch (Claude Code, Codex, Antigravity, opencode), /cerebras, /ollama.") + continue + } + // Ensure a cloud session exists for this conversation (no-op after first call). startCloudSession() @@ -2459,6 +2468,25 @@ func anthropicBackendAvailable(ag *agent.Agent, term *tui.Terminal) bool { return false } +// embeddedInferenceAvailable reports whether any non-CLI backend can run a +// turn right now: an Anthropic key (agent config or env), an active Ollama +// mode, or Cerebras. CLI backends are evaluated separately via cliAgent — +// the REPL may run with no embedded inference at all while a CLI backend is +// selected, and equally with none of either after the user skips the +// startup key offer (skipping must stay a supported state, not a crash). +func embeddedInferenceAvailable(ag *agent.Agent) bool { + if ag == nil { + return false + } + if ag.Cerebras != nil { + return true + } + if ag.Ollama != nil && ag.Mode != agent.OllamaModeOff { + return true + } + return ag.Cfg.AnthropicKey != "" || os.Getenv("ANTHROPIC_API_KEY") != "" +} + // buildOpenCodeModelEntries resolves the picker rows for the opencode backend: // one per model exposed by each provider the user enabled AND is entitled to. // It queries `opencode models ` at call time so the list is live diff --git a/internal/setup/interactive.go b/internal/setup/interactive.go index 4a0c746..c9b0f4c 100644 --- a/internal/setup/interactive.go +++ b/internal/setup/interactive.go @@ -34,6 +34,10 @@ var ( // promptAPIKey is kept as a seam for the recovery-flow tests. Production // always uses ReadSecret, so API keys remain read without echoing them. promptAPIKey = ReadSecret + + // saveAnthropicKey is a seam for tests: production always writes to the + // OS keychain via api.SaveAnthropicKey, which tests must not touch. + saveAnthropicKey = api.SaveAnthropicKey ) // LoginInteractive prompts the user to paste their API key. @@ -339,34 +343,20 @@ func RunInteractive() (*api.AuthConfig, int, error) { fmt.Println(" generating tests, or set it in ~/.qmax-code/config.json.") } - // Step 3: Anthropic key check + // Step 3: Anthropic key check — offered, never demanded. Skipping is a + // valid outcome: the REPL still starts, and any AI turn explains how to + // configure inference (/keys, /orch, /cerebras, /ollama). This used to + // be a bare paste prompt followed by a hard exit in main with no way to + // skip (QUA: onboarding dead-end for users of other backends). cfg := api.LoadQMaxCodeConfig() anthropicKey := os.Getenv("ANTHROPIC_API_KEY") if anthropicKey == "" { anthropicKey = cfg.AnthropicKey } - if anthropicKey == "" { - fmt.Println() - tui.AnimateMax(tui.MoodThinking, "One more thing...") - fmt.Println() - fmt.Println(" I need an Anthropic API key to think (that's my brain!).") - fmt.Println(" Get one at: https://console.anthropic.com/settings/keys") - fmt.Println() - key := ReadSecret(" Paste your Anthropic key (sk-ant-...): ") - if key != "" { - os.Setenv("ANTHROPIC_API_KEY", key) - // Save to OS keychain - if err := api.SaveAnthropicKey(key); err != nil { - // Fallback: warn but continue - fmt.Printf("\n Note: Could not save to keychain (%s)\n", err) - fmt.Println(" Key is set for this session. Set ANTHROPIC_API_KEY in your shell profile to persist.") - } else { - fmt.Println() - fmt.Println(" Key saved securely to OS keychain") - } - } - } else { + if anthropicKey != "" { os.Setenv("ANTHROPIC_API_KEY", anthropicKey) + } else { + promptAnthropicKey() } // All set! @@ -382,6 +372,42 @@ func RunInteractive() (*api.AuthConfig, int, error) { return auth, projectID, nil } +// promptAnthropicKey offers — never demands — the Anthropic key during +// onboarding. Users of the CLI backends (cc, codex, agy, opencode), Cerebras, +// or Ollama never need one, so skipping must be a first-class choice, not a +// dead end. Returns the pasted key, or "" when skipped or left empty. +func promptAnthropicKey() string { + fmt.Println() + tui.AnimateMax(tui.MoodThinking, "One more thing...") + fmt.Println() + fmt.Println(" I use an Anthropic API key to think (that's my brain!) — but the") + fmt.Println(" Claude Code, Codex, Antigravity, opencode, Cerebras, and Ollama") + fmt.Println(" backends all work without one. You can switch anytime inside the app.") + choice := PromptChoice(" Add an Anthropic key now?", []string{ + "Paste Anthropic key", + "Skip — I'll use another backend or configure it later", + }) + if choice != 0 { + return "" + } + fmt.Println(" Get one at: https://console.anthropic.com/settings/keys") + fmt.Println() + key := promptAPIKey(" Paste your Anthropic key (sk-ant-...): ") + if key == "" { + return "" + } + os.Setenv("ANTHROPIC_API_KEY", key) + if err := saveAnthropicKey(key); err != nil { + // Fallback: warn but continue + fmt.Printf("\n Note: Could not save to keychain (%s)\n", err) + fmt.Println(" Key is set for this session. Set ANTHROPIC_API_KEY in your shell profile to persist.") + } else { + fmt.Println() + fmt.Println(" Key saved securely to OS keychain") + } + return key +} + // recoverFromEmptyKey handles an empty API-key paste: usually "I don't // actually have a key" — offer the standalone exit instead of failing the // whole setup. Returns the retry result, or ErrStandaloneSkip when the diff --git a/internal/setup/interactive_test.go b/internal/setup/interactive_test.go index 122a54e..b7dd38d 100644 --- a/internal/setup/interactive_test.go +++ b/internal/setup/interactive_test.go @@ -405,3 +405,78 @@ func TestRecoverFromEmptyKeyRetryBranchAcceptsValidKey(t *testing.T) { t.Fatalf("auth metadata = %+v", auth) } } + +func TestPromptAnthropicKeySkipBranch(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("ANTHROPIC_API_KEY", "") + + // Keychain writes must never happen when the user skips. + origSave := saveAnthropicKey + saveAnthropicKey = func(string) error { + t.Error("skip branch must not write the keychain") + return nil + } + defer func() { saveAnthropicKey = origSave }() + + // Piped stdin → PromptChoice numeric fallback; "2" selects the skip + // option ("Skip — I'll use another backend or configure it later"). + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + if _, err := w.WriteString("2\n"); err != nil { + t.Fatalf("write: %v", err) + } + w.Close() + + origStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = origStdin }() + + if got := promptAnthropicKey(); got != "" { + t.Fatalf("promptAnthropicKey() = %q, want empty on skip", got) + } + if os.Getenv("ANTHROPIC_API_KEY") != "" { + t.Fatal("skip branch must not set ANTHROPIC_API_KEY") + } +} + +func TestPromptAnthropicKeyPasteBranchSetsEnvAndSaves(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("ANTHROPIC_API_KEY", "") + + // Intercept the OS-keychain write so the test never touches the real + // keychain, and the hidden paste so the key is deterministic. + origSave := saveAnthropicKey + var savedKey string + saveAnthropicKey = func(k string) error { savedKey = k; return nil } + defer func() { saveAnthropicKey = origSave }() + + origPrompt := promptAPIKey + promptAPIKey = func(string) string { return "sk-ant-paste-branch" } + defer func() { promptAPIKey = origPrompt }() + + // "1" selects "Paste Anthropic key" via the numeric fallback. + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + if _, err := w.WriteString("1\n"); err != nil { + t.Fatalf("write: %v", err) + } + w.Close() + + origStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = origStdin }() + + if got := promptAnthropicKey(); got != "sk-ant-paste-branch" { + t.Fatalf("promptAnthropicKey() = %q, want pasted key", got) + } + if savedKey != "sk-ant-paste-branch" { + t.Fatalf("keychain save = %q, want pasted key", savedKey) + } + if os.Getenv("ANTHROPIC_API_KEY") != "sk-ant-paste-branch" { + t.Fatal("paste branch must export ANTHROPIC_API_KEY for this process") + } +} diff --git a/main.go b/main.go index 4534328..f2ed8ef 100644 --- a/main.go +++ b/main.go @@ -267,6 +267,7 @@ func main() { // A leftover `qmax` CLI on PATH must not skip this: the chooser // (browser login / signup / API key / standalone) is how this // binary gets configured, independent of the legacy CLI. + keyOfferedDuringSetup := false if shouldRunInteractiveSetup(localOnly, apiClient != nil) { // Headless one-shot companion to the QUA-580 guard above: onboarding // is interactive (browser login or key paste), so a piped stdin would @@ -305,8 +306,11 @@ func main() { } } else { auth = setupAuth - apiClient = api.NewAPIClient(auth) + apiClient = api.NewAPIClient(setupAuth) appConfig.DefaultProject = setupProjectID + // Onboarding's Step 3 already offered the Anthropic key + // (paste or skip); the startup prompt must not ask twice. + keyOfferedDuringSetup = true if anthropicKey == "" { anthropicKey = os.Getenv("ANTHROPIC_API_KEY") } @@ -427,26 +431,50 @@ func main() { } } - // If connected but missing Anthropic key, prompt for it (skipped in CLI - // backend modes and headless runs — the prompt would eat piped stdin). + // If connected but missing an Anthropic key, offer one — never demand it. + // The old flow re-prompted and then hard-exited with no skip, which + // dead-ended users of the CLI/Cerebras/Ollama backends after a perfectly + // good QualityMax login. Skipping is valid: the REPL starts and any AI + // turn explains how to configure inference. Prompting is skipped in CLI + // backend modes (they bring their own auth), headless runs (the prompt + // would eat piped stdin), local-only runs with Ollama configured (they + // have inference), and when onboarding already offered the key this run. ollamaConfigured := appConfig.OllamaURL != "" && appConfig.OllamaModel != "" - if cliBackend == "" && anthropicKey == "" && !(localOnly && ollamaConfigured) && stdinTTY { - fmt.Println() - fmt.Println(" Anthropic API key needed (this powers the AI).") - fmt.Println(" Get one at: https://console.anthropic.com/settings/keys") - fmt.Println() - key := setup.ReadSecret(" Paste your Anthropic key: ") - if key != "" { - anthropicKey = key - os.Setenv("ANTHROPIC_API_KEY", key) - if err := api.SaveAnthropicKey(key); err == nil { - fmt.Println(" Saved to OS keychain.") + if shouldOfferAnthropicKey(cliBackend, anthropicKey, localOnly, ollamaConfigured, stdinTTY, keyOfferedDuringSetup) { + choice := setup.PromptChoice(" No Anthropic API key found. The direct-API backend needs one to think.", []string{ + "Paste Anthropic API key", + "Skip — use another backend or configure it later", + }) + if choice == 0 { + fmt.Println() + fmt.Println(" Get one at: https://console.anthropic.com/settings/keys") + fmt.Println() + key := setup.ReadSecret(" Paste your Anthropic key: ") + if key != "" { + anthropicKey = key + os.Setenv("ANTHROPIC_API_KEY", key) + if err := api.SaveAnthropicKey(key); err == nil { + fmt.Println(" Saved to OS keychain.") + } + } + } + if anthropicKey == "" { + fmt.Println() + fmt.Println(" Continuing without an Anthropic key.") + fmt.Println(" AI turns will guide you to configure inference: /keys (paste a key),") + fmt.Println(" /orch (Claude Code, Codex, Antigravity, opencode), /cerebras, or /ollama.") + if localOnly { + fmt.Println(" Standalone local mode can also use Ollama:") + fmt.Println(" qmax-code config set ollama_url http://127.0.0.1:11434") + fmt.Println(" qmax-code config set ollama_model ") } } } - - if cliBackend == "" && anthropicKey == "" && !(localOnly && ollamaConfigured) { - fmt.Fprintln(os.Stderr, "\nError: Anthropic API key required.") + // Headless one-shot runs have no interactive way to offer or skip the + // key, and without any inference the run could only fail mid-turn with + // a raw 401 — fail fast with the backend alternatives instead. + if shouldExitWithoutInference(cliBackend, anthropicKey, localOnly, ollamaConfigured, stdinTTY) { + fmt.Fprintln(os.Stderr, "\nError: no inference backend configured (an Anthropic API key is one option).") fmt.Fprintln(os.Stderr, " export ANTHROPIC_API_KEY=sk-ant-...") fmt.Fprintln(os.Stderr, " Or use a CLI backend (no API key needed):") fmt.Fprintln(os.Stderr, " qmax-code config set backend cc # Claude Code login") @@ -702,6 +730,26 @@ func shouldRunInteractiveSetup(localOnly bool, hasAPIClient bool) bool { return !localOnly && !hasAPIClient } +// shouldOfferAnthropicKey reports whether startup should offer the Anthropic +// key. The offer is skippable by design (QUA regression: the old flow had no +// skip and then hard-exited): no offer in CLI backend modes (own auth), none +// headless (would eat piped stdin), none for local-only runs with Ollama +// configured (inference exists), and none when onboarding already offered it +// this process. +func shouldOfferAnthropicKey(cliBackend, anthropicKey string, localOnly, ollamaConfigured, stdinTTY, alreadyOffered bool) bool { + return cliBackend == "" && anthropicKey == "" && + !(localOnly && ollamaConfigured) && stdinTTY && !alreadyOffered +} + +// shouldExitWithoutInference reports whether startup must fail fast: a +// headless run with no inference backend has no interactive way to offer or +// skip the key, and its first AI turn could only fail with a raw 401. +// Interactive runs never exit here anymore — they enter the REPL, where AI +// turns and /keys, /orch, /cerebras, /ollama configure inference. +func shouldExitWithoutInference(cliBackend, anthropicKey string, localOnly, ollamaConfigured, stdinTTY bool) bool { + return cliBackend == "" && anthropicKey == "" && !(localOnly && ollamaConfigured) && !stdinTTY +} + // headlessSetupFallback reports whether interactive onboarding must be // replaced by an ephemeral standalone fallback. It only ever fires for // one-shot runs: pure-REPL non-TTY starts were already rejected by the diff --git a/main_validation_test.go b/main_validation_test.go index 58e4737..c32602e 100644 --- a/main_validation_test.go +++ b/main_validation_test.go @@ -105,6 +105,68 @@ func TestHeadlessSetupFallback(t *testing.T) { } } +// TestShouldOfferAnthropicKey is the regression test for the "no ability to +// skip the Anthropic key" bug: the offer must stay skippable, must not +// re-ask after onboarding already asked, and must stay out of CLI backend +// modes, headless runs, and local-only runs with Ollama configured. +func TestShouldOfferAnthropicKey(t *testing.T) { + tests := []struct { + name string + cliBackend string + anthropicKey string + localOnly bool + ollamaConfigured bool + stdinTTY bool + alreadyOffered bool + want bool + }{ + {name: "interactive api backend without key offers", stdinTTY: true, want: true}, + {name: "key present never offers", anthropicKey: "sk-ant-x", stdinTTY: true, want: false}, + {name: "cli backend never offers", cliBackend: "cc", stdinTTY: true, want: false}, + {name: "headless never offers", want: false}, + {name: "local ollama never offers", localOnly: true, ollamaConfigured: true, stdinTTY: true, want: false}, + {name: "local without ollama still offers", localOnly: true, stdinTTY: true, want: true}, + {name: "onboarding already offered skips re-ask", stdinTTY: true, alreadyOffered: true, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldOfferAnthropicKey(tt.cliBackend, tt.anthropicKey, tt.localOnly, tt.ollamaConfigured, tt.stdinTTY, tt.alreadyOffered); got != tt.want { + t.Fatalf("shouldOfferAnthropicKey(%q, %q, %v, %v, %v, %v) = %v, want %v", + tt.cliBackend, tt.anthropicKey, tt.localOnly, tt.ollamaConfigured, tt.stdinTTY, tt.alreadyOffered, got, tt.want) + } + }) + } +} + +// TestShouldExitWithoutInference pins the post-fix contract: an interactive +// run that skipped the Anthropic key enters the REPL instead of exiting; +// only headless runs without any inference still fail fast. +func TestShouldExitWithoutInference(t *testing.T) { + tests := []struct { + name string + cliBackend string + anthropicKey string + localOnly bool + ollamaConfigured bool + stdinTTY bool + want bool + }{ + {name: "interactive skip no longer exits", stdinTTY: true, want: false}, + {name: "headless one-shot still exits", want: true}, + {name: "headless with key does not exit", anthropicKey: "sk-ant-x", want: false}, + {name: "cli backend never exits", cliBackend: "codex", want: false}, + {name: "local ollama never exits", localOnly: true, ollamaConfigured: true, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldExitWithoutInference(tt.cliBackend, tt.anthropicKey, tt.localOnly, tt.ollamaConfigured, tt.stdinTTY); got != tt.want { + t.Fatalf("shouldExitWithoutInference(%q, %q, %v, %v, %v) = %v, want %v", + tt.cliBackend, tt.anthropicKey, tt.localOnly, tt.ollamaConfigured, tt.stdinTTY, got, tt.want) + } + }) + } +} + func TestShouldUseStreamingBuiltIn(t *testing.T) { tests := []struct { name string