Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions internal/repl/backend_switch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
})
}
28 changes: 28 additions & 0 deletions internal/repl/repl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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 <provider>` at call time so the list is live
Expand Down
70 changes: 48 additions & 22 deletions internal/setup/interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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!
Expand All @@ -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
Expand Down
75 changes: 75 additions & 0 deletions internal/setup/interactive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
82 changes: 65 additions & 17 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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 <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")
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading