diff --git a/config_command.go b/config_command.go index bac5a36..eb7095f 100644 --- a/config_command.go +++ b/config_command.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strconv" + "strings" "github.com/qualitymax/qmax-code/internal/agent" "github.com/qualitymax/qmax-code/internal/api" @@ -158,8 +159,17 @@ func printConfig() { } else { fmt.Print(" (WARNING: CEREBRAS_API_KEY not set)") } + case "opencode": + if bin := agent.FindOpenCode(); bin != "" { + fmt.Printf(" (opencode found: %s; model: %s)", bin, cfg.ModelOverride) + } else { + fmt.Print(" (WARNING: opencode binary not found in PATH)") + } } fmt.Println() + if len(cfg.EnabledProviders) > 0 { + fmt.Printf(" providers = %s (opencode; manage with /providers)\n", strings.Join(cfg.EnabledProviders, ", ")) + } } // setConfigField writes the given value into the Config. Empty value @@ -236,10 +246,10 @@ func setConfigField(key, value string) error { case "backend": switch value { - case "", "api", "cc", "codex", "cerebras": + case "", "api", "cc", "codex", "cerebras", "opencode": cfg.Backend = value default: - return fmt.Errorf("invalid backend %q; allowed: api, cc, codex, cerebras", value) + return fmt.Errorf("invalid backend %q; allowed: api, cc, codex, cerebras, opencode", value) } case "cerebras_key": diff --git a/internal/agent/opencode_agent.go b/internal/agent/opencode_agent.go new file mode 100644 index 0000000..c2c247a --- /dev/null +++ b/internal/agent/opencode_agent.go @@ -0,0 +1,308 @@ +package agent + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/qualitymax/qmax-code/internal/api" + "github.com/qualitymax/qmax-code/internal/tui" +) + +// OpenCodeAgent orchestrates an opencode CLI subprocess for LLM inference. +// Inference runs through whichever provider the user opted into (Z.AI, Groq, +// OpenRouter, …) using the user's own key — qmax-code consumes no QM-held +// tokens. qmax tools are exposed to opencode via an MCP server entry in the +// managed opencode config, so opencode can call them natively. +// +// opencode supports native session resume (--session) and a rich NDJSON event +// stream (opencode run --format json), so this mirrors the CCAgent design +// rather than CodexAgent's self-managed history. +// +// Per-message flow: +// 1. qmax-code writes ~/.qmax-code/opencode.json (provider blocks + qmax MCP) +// 2. qmax-code spawns: opencode run --format json --model / +// [--session ] [--auto] -- "msg" with OPENCODE_CONFIG + key env set +// 3. opencode picks up the MCP config and spawns qmax-code serve --mcp +// 4. opencode runs the turn on the user's provider, using qmax tools via MCP +// 5. qmax-code parses opencode's NDJSON and renders it; session id → --session +type OpenCodeAgent struct { + openCodeBin string + modelID string // "provider/model"; "" lets opencode use its default + effort string // "low" | "medium" | "high" + outputVerbose bool + permissionMode string // "standard" | "unattended" (--auto) + sessionID string // opencode session id, for --session resume + cfg *api.Config + sctx *api.SessionContext + lastToolName string + mu sync.Mutex + runMu sync.Mutex + runCancel context.CancelFunc // non-nil while Run() is active +} + +// FindOpenCode returns the path to the opencode CLI binary, or "" if not found. +func FindOpenCode() string { + if path, err := exec.LookPath("opencode"); err == nil { + return path + } + for _, p := range []string{ + filepath.Join(os.Getenv("HOME"), ".opencode/bin/opencode"), + "/usr/local/bin/opencode", + "/opt/homebrew/bin/opencode", + filepath.Join(os.Getenv("HOME"), ".local/bin/opencode"), + } { + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +// NewOpenCodeAgent creates an opencode subprocess orchestrator. +// modelID is the full "provider/model" string selected via the picker. +// effort is "low" | "medium" | "high" (empty defaults to "high"). +// permissionMode is "standard" or "unattended" (adds --auto). +func NewOpenCodeAgent(bin, modelID, effort, permissionMode string, outputVerbose bool, cfg *api.Config, sctx *api.SessionContext) *OpenCodeAgent { + if effort == "" { + effort = "high" + } + if permissionMode == "" { + permissionMode = "standard" + } + return &OpenCodeAgent{ + openCodeBin: bin, + modelID: modelID, + effort: effort, + outputVerbose: outputVerbose, + permissionMode: permissionMode, + cfg: cfg, + sctx: sctx, + } +} + +// validOpenCodeSessionID guards the --session argument. opencode session ids +// look like "ses_0a91c2141ffe8FiFOZVFulDUUM": a "ses_" prefix followed by +// alphanumeric characters. +func validOpenCodeSessionID(id string) bool { + if !strings.HasPrefix(id, "ses_") || len(id) > 64 { + return false + } + rest := id[len("ses_"):] + if rest == "" { + return false + } + for _, r := range rest { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) { + return false + } + } + return true +} + +// Run executes one conversation turn through an opencode subprocess. +func (a *OpenCodeAgent) Run(userMsg string, term *tui.Terminal) (string, error) { + // Regenerate the managed config each turn so newly enabled/disabled + // providers (and the permission policy) take effect without a restart. + configPath, err := WriteOpenCodeConfig(a.cfg, a.sctx, a.permissionMode) + if err != nil { + return "", fmt.Errorf("opencode config: %w", err) + } + + safeUserMsg, err := sanitizeCCUserPrompt(userMsg) + if err != nil { + return "", err + } + + a.mu.Lock() + sessionID := a.sessionID + a.mu.Unlock() + + // On the first turn of a session, prepend the QA system prompt + effort/output + // directives. opencode persists conversation state per session, so later turns + // resume via --session and don't need it re-injected. + message := safeUserMsg + if sessionID == "" { + message = codexQASystemPrompt + effortDirective(a.effort) + outputStyleDirective(a.outputVerbose) + "\n\n" + safeUserMsg + } + + args := []string{"run", "--format", "json"} + if a.modelID != "" { + args = append(args, "--model", a.modelID) + } + // --auto auto-approves anything not explicitly denied. In standard mode the + // managed config denies edits + destructive shell (openCodeStandardPermission), + // so --auto is safe there too; unattended has no denies (full autonomy). Both + // need --auto because `opencode run` is non-interactive — without it, tools + // that would prompt simply block. + args = append(args, "--auto") + if sessionID != "" && validOpenCodeSessionID(sessionID) { + args = append(args, "--session", sessionID) + } + // "--" terminates flag parsing so a message starting with "-" is treated as + // the positional prompt rather than an unknown flag. + args = append(args, "--", message) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + + a.runMu.Lock() + a.runCancel = cancel + a.runMu.Unlock() + defer func() { + a.runMu.Lock() + a.runCancel = nil + a.runMu.Unlock() + }() + + cmd := exec.CommandContext(ctx, a.openCodeBin, args...) + cmd.Stdin = strings.NewReader("") + cmd.Stderr = os.Stderr + cmd.Env = append(os.Environ(), "OPENCODE_CONFIG="+configPath) + for k, v := range OpenCodeProviderEnv(a.cfg) { + cmd.Env = append(cmd.Env, k+"="+v) + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", fmt.Errorf("stdout pipe: %w", err) + } + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("start opencode: %w", err) + } + + result := a.parseStream(stdout, term) + + if err := cmd.Wait(); err != nil { + // Intentional cancel (user pressed Enter to interrupt) — not an error. + if ctx.Err() != nil { + return result, nil + } + if result == "" { + return "", fmt.Errorf("opencode exited with error: %w", err) + } + } + return result, nil +} + +// --- NDJSON stream parsing --- + +type ocEvent struct { + Type string `json:"type"` + SessionID string `json:"sessionID"` + Part ocPart `json:"part"` +} + +type ocPart struct { + ID string `json:"id"` + Type string `json:"type"` + Text string `json:"text"` + Tool string `json:"tool"` +} + +// parseStream reads opencode's NDJSON output, renders it, captures the session +// id for --session resume, and returns the full text of the final response. +func (a *OpenCodeAgent) parseStream(stdout interface{ Read([]byte) (int, error) }, term *tui.Terminal) string { + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 1<<20), 1<<20) + + textByPart := map[string]string{} // part id → latest full text + var order []string // text part ids in first-seen order + seenTool := map[string]bool{} // tool part ids already announced + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var ev ocEvent + if err := json.Unmarshal(line, &ev); err != nil { + continue + } + + if ev.SessionID != "" && validOpenCodeSessionID(ev.SessionID) { + a.mu.Lock() + a.sessionID = ev.SessionID + a.mu.Unlock() + } + + switch { + case ev.Type == "text" || ev.Part.Type == "text": + id := ev.Part.ID + text := ev.Part.Text + if text == "" { + continue + } + prev, seen := textByPart[id] + if !seen { + order = append(order, id) + } + // opencode may re-emit a growing snapshot for the same part id; + // stream only the delta to avoid duplication. + if strings.HasPrefix(text, prev) { + if delta := text[len(prev):]; delta != "" { + term.StreamText(delta) + } + } else { + term.StreamText(text) + } + textByPart[id] = text + + case ev.Part.Type == "tool" || ev.Type == "tool": + if ev.Part.Tool == "" || seenTool[ev.Part.ID] { + continue + } + seenTool[ev.Part.ID] = true + displayName := stripMCPPrefix(ev.Part.Tool) + a.mu.Lock() + a.lastToolName = displayName + a.mu.Unlock() + term.PrintToolIcon(displayName) + if !a.outputVerbose { + fmt.Println() + } + } + } + + var sb strings.Builder + for _, id := range order { + sb.WriteString(textByPart[id]) + } + finalResult := sb.String() + term.FinishMarkdown(finalResult) + return finalResult +} + +// ClearSession forgets the opencode session id so the next turn starts fresh +// (used when the user types /clear). +func (a *OpenCodeAgent) ClearSession() { + a.mu.Lock() + a.sessionID = "" + a.mu.Unlock() +} + +func (a *OpenCodeAgent) SetOutputVerbose(verbose bool) { + a.mu.Lock() + a.outputVerbose = verbose + a.mu.Unlock() +} + +// Cancel interrupts a Run call that is in progress. Safe to call from any goroutine. +func (a *OpenCodeAgent) Cancel() { + a.runMu.Lock() + if a.runCancel != nil { + a.runCancel() + } + a.runMu.Unlock() +} + +// Cleanup is a no-op: the managed opencode config is persistent and syncable, +// not a per-session temp file. +func (a *OpenCodeAgent) Cleanup() {} diff --git a/internal/agent/opencode_config.go b/internal/agent/opencode_config.go new file mode 100644 index 0000000..0d65f21 --- /dev/null +++ b/internal/agent/opencode_config.go @@ -0,0 +1,263 @@ +package agent + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/qualitymax/qmax-code/internal/api" + "github.com/qualitymax/qmax-code/internal/sysutil" +) + +// userOpenCodeConfigDir returns the directory opencode reads its own global +// config from. opencode uses $XDG_CONFIG_HOME/opencode (falling back to +// ~/.config/opencode) on every OS — NOT os.UserConfigDir(), which differs on +// macOS. This is the user's own file, separate from qmax's managed config. +func userOpenCodeConfigDir() string { + base := os.Getenv("XDG_CONFIG_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + base = filepath.Join(home, ".config") + } + return filepath.Join(base, "opencode") +} + +// literalAPIKeyRe matches an "apiKey": "" pair. A value referencing +// opencode's {env:...} / {file:...} substitution is safe; any other non-empty +// literal is a plaintext key sitting on disk. +var literalAPIKeyRe = regexp.MustCompile(`"apiKey"\s*:\s*"([^"]*)"`) + +// PlaintextKeyInUserOpenCodeConfig reports whether the user's OWN opencode +// config files contain a plaintext apiKey (not an {env:}/{file:} reference), +// and the path where one was found. Used to warn a user — when they move a +// provider under qmax's keychain-backed management — that a live key is still +// sitting in cleartext in their opencode config and should be rotated/removed. +func PlaintextKeyInUserOpenCodeConfig() (path string, found bool) { + dir := userOpenCodeConfigDir() + if dir == "" { + return "", false + } + for _, name := range []string{"opencode.jsonc", "opencode.json", "config.json"} { + p := filepath.Join(dir, name) + data, err := os.ReadFile(p) + if err != nil { + continue + } + for _, m := range literalAPIKeyRe.FindAllStringSubmatch(string(data), -1) { + v := strings.TrimSpace(m[1]) + if v == "" || strings.HasPrefix(v, "{env:") || strings.HasPrefix(v, "{file:") { + continue + } + return p, true + } + } + return "", false +} + +// OpenCodeConfigPath is the qmax-managed opencode config file. It is pointed at +// the opencode subprocess via the OPENCODE_CONFIG env var, which opencode MERGES +// on top of the user's own ~/.config/opencode/opencode.jsonc — so we never +// clobber the user's config. Kept under ~/.qmax-code so it lives beside the rest +// of qmax's state and is easy to sync. +func OpenCodeConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + return filepath.Join(home, api.QmaxCodeConfigDir, "opencode.json") +} + +// openCodeStandardPermission is the permission policy written into the managed +// config for "standard" mode. opencode runs are launched with --auto (which +// auto-approves anything NOT explicitly denied), so denying file edits and +// destructive shell here reproduces the intent of CC's standard allowlist: +// reads / search / test runners run freely, but the model cannot mutate files +// or run destructive commands. This is a coarser model than CC's default-deny +// allowlist (unknown non-destructive bash still auto-approves), which is why +// Unattended remains the mode for full autonomy. +func openCodeStandardPermission() map[string]interface{} { + return map[string]interface{}{ + "edit": "deny", // covers edit/write/patch — no file mutation in standard + "webfetch": "allow", + "bash": map[string]interface{}{ + "*": "allow", + "rm": "deny", + "rm *": "deny", + "rmdir *": "deny", + "sudo *": "deny", + "chmod *": "deny", + "chown *": "deny", + "dd *": "deny", + "mkfs*": "deny", + "git push*": "deny", + "git reset*": "deny", + "git clean*": "deny", + "> *": "deny", + }, + } +} + +// WriteOpenCodeConfig regenerates the managed opencode config from the user's +// enabled providers, the qmax MCP server entry, and the permission policy for +// permissionMode ("standard" | "unattended"), and returns its path. +// +// Secrets never land in this file: custom providers reference their key via +// opencode's "{env:VAR}" substitution, and the real key is injected into the +// subprocess environment at launch (see OpenCodeProviderEnv). The file is +// therefore safe to sync while no plaintext key touches disk. +func WriteOpenCodeConfig(cfg *api.Config, sctx *api.SessionContext, permissionMode string) (string, error) { + self, err := os.Executable() + if err != nil { + self = QmaxMCPCommand + } + + // MCP server entry so opencode can call qmax's tools natively (mirrors the + // env hand-off used for the cc/codex backends). + mcpEnv := map[string]string{} + if sctx != nil { + if sctx.ProjectID > 0 { + mcpEnv["QMAX_PROJECT_ID"] = fmt.Sprintf("%d", sctx.ProjectID) + } + if sctx.LiveFeed { + mcpEnv["QMAX_LIVE_FEED"] = "1" + } + } + if path := sysutil.LiveURLFilePath(); path != "" { + mcpEnv["QMAX_LIVE_URL_FILE"] = path + } + if path := sysutil.ExecIDFilePath(); path != "" { + mcpEnv["QMAX_EXEC_ID_FILE"] = path + } + + root := map[string]interface{}{ + "$schema": "https://opencode.ai/config.json", + "mcp": map[string]interface{}{ + "qmax": map[string]interface{}{ + "type": "local", + "command": []string{self, "serve", "--mcp"}, + "enabled": true, + "environment": mcpEnv, + }, + }, + } + + // Custom providers (not on models.dev) need a full openai-compatible block. + // Known providers (groq/openrouter) are discovered by opencode via models.dev + // once their standard env var is set, so they get no block here. + providers := map[string]interface{}{} + for _, p := range cfg.ActiveProviders() { + if !p.Custom { + continue + } + models := map[string]interface{}{} + for _, m := range p.Models { + models[m.ID] = map[string]interface{}{"name": m.Name} + } + apiKeyRef := "" + if len(p.KeyEnvVars) > 0 { + apiKeyRef = "{env:" + p.KeyEnvVars[0] + "}" + } + providers[p.ID] = map[string]interface{}{ + "npm": "@ai-sdk/openai-compatible", + "options": map[string]interface{}{ + "baseURL": p.BaseURL, + "apiKey": apiKeyRef, + }, + "models": models, + } + } + if len(providers) > 0 { + root["provider"] = providers + } + + // Standard mode gets an explicit deny policy (see openCodeStandardPermission). + // Unattended relies on --auto with no denies. Anything else (e.g. listing-only + // regen with "") writes no policy. + if permissionMode == "standard" { + root["permission"] = openCodeStandardPermission() + } + + data, err := json.MarshalIndent(root, "", " ") + if err != nil { + return "", err + } + + path := OpenCodeConfigPath() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return "", err + } + if err := os.WriteFile(path, data, 0600); err != nil { + return "", err + } + return path, nil +} + +// OpenCodeProviderEnv returns the environment variables that carry each enabled +// provider's API key into the opencode subprocess. For custom providers this is +// the "{env:VAR}" name referenced in the config block; for known providers it's +// opencode's standard var (GROQ_API_KEY, …). Keys are read from the OS keychain +// at launch — never written to the config file. +func OpenCodeProviderEnv(cfg *api.Config) map[string]string { + env := map[string]string{} + for _, p := range cfg.ActiveProviders() { + key := api.LoadProviderKey(p.ID) + if key == "" { + continue + } + for _, name := range p.KeyEnvVars { + env[name] = key + } + } + return env +} + +// OpenCodeModels lists the models opencode exposes for one provider, as +// "provider/model" strings. It shells out to `opencode models ` +// with OPENCODE_CONFIG pointed at the managed config so custom providers +// (whose models come from our seeded block) resolve too. providerEnv MUST carry +// the provider API keys (from OpenCodeProviderEnv): opencode 1.17 reports +// "Provider not found" for known providers like groq/openrouter when their key +// env var is absent, so without it those providers would never reach the +// picker. Returns nil on error. +func OpenCodeModels(bin, configPath string, providerEnv map[string]string, providerID string) []string { + if bin == "" || providerID == "" { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, bin, "models", providerID) + cmd.Env = append(os.Environ(), "OPENCODE_CONFIG="+configPath) + for k, v := range providerEnv { + cmd.Env = append(cmd.Env, k+"="+v) + } + var out bytes.Buffer + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + return nil + } + + var models []string + scanner := bufio.NewScanner(&out) + scanner.Buffer(make([]byte, 1<<16), 1<<20) + prefix := providerID + "/" + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || !strings.HasPrefix(line, prefix) { + continue + } + models = append(models, line) + } + return models +} diff --git a/internal/agent/opencode_config_test.go b/internal/agent/opencode_config_test.go new file mode 100644 index 0000000..40cc50e --- /dev/null +++ b/internal/agent/opencode_config_test.go @@ -0,0 +1,177 @@ +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/qualitymax/qmax-code/internal/api" +) + +func TestWriteOpenCodeConfigProducesProviderBlockAndMCP(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + // Custom provider key via env so no keychain is touched. + t.Setenv("QMAX_PC_ZAI_CODING_PLAN", "zai-secret") + + cfg := &api.Config{EnabledProviders: []string{"zai-coding-plan", "groq"}} + path, err := WriteOpenCodeConfig(cfg, &api.SessionContext{ProjectID: 7}, "standard") + if err != nil { + t.Fatalf("WriteOpenCodeConfig: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read config: %v", err) + } + var root map[string]any + if err := json.Unmarshal(data, &root); err != nil { + t.Fatalf("config is not valid JSON: %v", err) + } + + // MCP server entry present. + mcp, ok := root["mcp"].(map[string]any) + if !ok || mcp["qmax"] == nil { + t.Fatalf("expected mcp.qmax entry, got %v", root["mcp"]) + } + + // Custom provider (zai) gets a block; known provider (groq) does not. + prov, ok := root["provider"].(map[string]any) + if !ok { + t.Fatalf("expected provider map, got %v", root["provider"]) + } + if prov["groq"] != nil { + t.Error("known provider groq should not get a provider block") + } + zai, ok := prov["zai-coding-plan"].(map[string]any) + if !ok { + t.Fatalf("expected zai-coding-plan block, got %v", prov["zai-coding-plan"]) + } + opts, _ := zai["options"].(map[string]any) + if opts["apiKey"] != "{env:QMAX_PC_ZAI_CODING_PLAN}" { + t.Errorf("apiKey should reference env, got %v", opts["apiKey"]) + } + if opts["apiKey"] == "zai-secret" { + t.Error("plaintext key must never be written to the config file") + } + + // No plaintext secret anywhere in the file. + if containsSecret := string(data); len(containsSecret) > 0 { + if contains(containsSecret, "zai-secret") { + t.Error("config file leaked the plaintext key") + } + } +} + +func TestWriteOpenCodeConfigStandardWritesPermissionPolicy(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + // Standard mode → deny policy present. + path, err := WriteOpenCodeConfig(&api.Config{}, nil, "standard") + if err != nil { + t.Fatalf("WriteOpenCodeConfig: %v", err) + } + var root map[string]any + data, _ := os.ReadFile(path) + _ = json.Unmarshal(data, &root) + perm, ok := root["permission"].(map[string]any) + if !ok { + t.Fatalf("standard mode should write a permission policy, got %v", root["permission"]) + } + if perm["edit"] != "deny" { + t.Errorf("standard should deny edits, got %v", perm["edit"]) + } + + // Unattended mode → no deny policy (full autonomy via --auto). + path2, _ := WriteOpenCodeConfig(&api.Config{}, nil, "unattended") + var root2 map[string]any + data2, _ := os.ReadFile(path2) + _ = json.Unmarshal(data2, &root2) + if root2["permission"] != nil { + t.Errorf("unattended should not write a permission policy, got %v", root2["permission"]) + } +} + +// TestOpenCodeModelsPassesProviderEnv is a regression test for the bug where +// `opencode models ` was invoked without the provider's key env var, +// so known providers (groq/openrouter) reported "Provider not found" and never +// reached the picker. A stub binary echoes back the key it received; the test +// asserts it was passed through. +func TestOpenCodeModelsPassesProviderEnv(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("stub uses a shell script") + } + dir := t.TempDir() + stub := filepath.Join(dir, "opencode-stub") + // Prints "groq/model-" so a missing env var is detectable. + script := "#!/bin/sh\necho \"$2/model-$GROQ_API_KEY\"\n" + if err := os.WriteFile(stub, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + got := OpenCodeModels(stub, filepath.Join(dir, "cfg.json"), map[string]string{"GROQ_API_KEY": "gsk_regress"}, "groq") + want := "groq/model-gsk_regress" + if len(got) != 1 || got[0] != want { + t.Fatalf("OpenCodeModels did not pass provider env: got %v, want [%s]", got, want) + } +} + +func TestOpenCodeProviderEnvInjectsKeys(t *testing.T) { + t.Setenv("QMAX_PC_ZAI_CODING_PLAN", "zai-secret") + t.Setenv("GROQ_API_KEY", "gsk_secret") + cfg := &api.Config{EnabledProviders: []string{"zai-coding-plan", "groq"}} + env := OpenCodeProviderEnv(cfg) + if env["QMAX_PC_ZAI_CODING_PLAN"] != "zai-secret" { + t.Errorf("zai env not injected: %v", env) + } + if env["GROQ_API_KEY"] != "gsk_secret" { + t.Errorf("groq env not injected: %v", env) + } +} + +func TestPlaintextKeyInUserOpenCodeConfig(t *testing.T) { + tmp := t.TempDir() + cfgDir := filepath.Join(tmp, "opencode") + if err := os.MkdirAll(cfgDir, 0755); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_CONFIG_HOME", tmp) + + // No config → nothing found. + if _, found := PlaintextKeyInUserOpenCodeConfig(); found { + t.Fatal("no config should mean no plaintext key") + } + + // {env:...} reference is safe → not flagged. + safe := `{ "provider": { "zai": { "options": { "apiKey": "{env:QMAX_PC_ZAI}" } } } }` + if err := os.WriteFile(filepath.Join(cfgDir, "opencode.jsonc"), []byte(safe), 0600); err != nil { + t.Fatal(err) + } + if _, found := PlaintextKeyInUserOpenCodeConfig(); found { + t.Fatal("{env:...} reference must not be flagged as plaintext") + } + + // Literal key → flagged, with the path. + leak := `{ "provider": { "zai": { "options": { "apiKey": "sk-literal-secret" } } } }` + if err := os.WriteFile(filepath.Join(cfgDir, "opencode.jsonc"), []byte(leak), 0600); err != nil { + t.Fatal(err) + } + path, found := PlaintextKeyInUserOpenCodeConfig() + if !found { + t.Fatal("literal apiKey should be flagged") + } + if path == "" { + t.Error("should return the offending file path") + } +} + +func contains(haystack, needle string) bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} diff --git a/internal/api/config.go b/internal/api/config.go index e779913..350ea2c 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -52,9 +52,20 @@ type Config struct { // "cerebras" → Cerebras OpenAI-compatible API (requires CEREBRAS_API_KEY). // Drives the native qmax agent loop with the full tool set via // native function calling — fast and low-cost. - // In both CLI modes qmax tools are served to the CLI via an embedded MCP server. + // "opencode" → opencode CLI subprocess. Inherits opencode's broad provider + // support (Z.AI, Groq, OpenRouter, …); the specific model is + // ModelOverride in "provider/model" form. Providers are opted + // into per-user via EnabledProviders + the /providers command. + // In the CLI modes (cc/codex/opencode) qmax tools are served via an embedded MCP server. Backend string `json:"backend,omitempty"` + // EnabledProviders is the per-user opt-in set of opencode provider IDs + // (e.g. "zai-coding-plan", "groq", "openrouter"). Empty by default — + // nothing is enabled for everyone. A provider only appears in this user's + // model picker once they turn it on via /providers and supply a key. See + // internal/api/providers.go for the registry and the entitlement seam. + EnabledProviders []string `json:"enabled_providers,omitempty"` + // ModelOverride is the specific model ID selected via the /orch TUI picker. // Empty means "let the CLI pick its default". Used with CC and Codex backends. ModelOverride string `json:"model_override,omitempty"` diff --git a/internal/api/providers.go b/internal/api/providers.go new file mode 100644 index 0000000..40401ea --- /dev/null +++ b/internal/api/providers.go @@ -0,0 +1,222 @@ +package api + +import ( + "fmt" + "os" + "sort" + "strings" + "unicode" +) + +func getenvTrimmed(name string) string { + return strings.TrimSpace(os.Getenv(name)) +} + +// Provider describes a user-selectable, opt-in inference provider reachable +// through the opencode backend. These are OpenAI-compatible endpoints (Z.AI's +// coding plan, Groq, OpenRouter, …) that a user turns on individually via the +// /providers command. Nothing here is enabled for everyone by default: a +// provider only appears in a user's model picker once THAT user opts in and +// supplies a key. +// +// Two kinds of provider: +// - Custom (Custom=true): not known to opencode/models.dev, so we write a full +// provider block into the managed opencode config (npm openai-compatible +// adapter + baseURL + a seeded model list). Z.AI's coding plan is the case. +// - Known (Custom=false): recognised by opencode via models.dev (Groq, +// OpenRouter). We don't define a block — supplying the standard provider +// env var is enough for opencode to auto-discover the live model catalogue. +type Provider struct { + ID string // opencode provider id, e.g. "zai-coding-plan", "groq" + DisplayName string // human label shown in /providers and the picker + SignupURL string // where a user gets an API key + + Custom bool // true → write an openai-compatible provider block + BaseURL string // custom only: OpenAI-compatible endpoint + Models []ProviderModel // custom only: seed models exposed to opencode + + // KeyEnvVars are the environment variable names to set to the API key when + // launching the opencode subprocess. For custom providers this is our own + // substitution var referenced as "{env:...}" in the provider block. For + // known providers these are opencode's standard vars (e.g. GROQ_API_KEY), + // which opencode reads directly. + KeyEnvVars []string + + KeyPrefix string // optional soft-validation hint (e.g. "gsk_" for Groq) +} + +// ProviderModel is one model exposed by a custom provider block. +type ProviderModel struct { + ID string // model id opencode uses after the "provider/" prefix + Name string // display name in the opencode config models map +} + +// builtinProviders is the static catalogue of opt-in providers. Cerebras is +// deliberately absent: it keeps its first-class native backend (see the +// "cerebras" path in config.go / agent) and is surfaced in /providers as a +// note, not routed through opencode. +var builtinProviders = []Provider{ + { + ID: "zai-coding-plan", + DisplayName: "Z.AI Coding Plan", + SignupURL: "https://z.ai", + Custom: true, + BaseURL: "https://open.bigmodel.cn/api/coding/paas/v4", + Models: []ProviderModel{ + {ID: "glm-4.6", Name: "GLM 4.6"}, + {ID: "glm-4.5", Name: "GLM 4.5"}, + {ID: "glm-4.5-air", Name: "GLM 4.5 Air"}, + }, + KeyEnvVars: []string{"QMAX_PC_ZAI_CODING_PLAN"}, + }, + { + ID: "groq", + DisplayName: "Groq", + SignupURL: "https://console.groq.com/keys", + KeyEnvVars: []string{"GROQ_API_KEY"}, + KeyPrefix: "gsk_", + }, + { + ID: "openrouter", + DisplayName: "OpenRouter", + SignupURL: "https://openrouter.ai/keys", + KeyEnvVars: []string{"OPENROUTER_API_KEY"}, + KeyPrefix: "sk-or-", + }, +} + +// BuiltinProviders returns the catalogue of opt-in opencode providers. +func BuiltinProviders() []Provider { + out := make([]Provider, len(builtinProviders)) + copy(out, builtinProviders) + return out +} + +// ProviderByID looks up a builtin provider definition. +func ProviderByID(id string) (Provider, bool) { + for _, p := range builtinProviders { + if p.ID == id { + return p, true + } + } + return Provider{}, false +} + +// ProviderAllowed is the entitlement seam. Today it returns true for every +// builtin provider — enablement is purely a local, per-user opt-in. Later this +// is where a cloud check (QualityMax auth / server-driven entitlement) plugs +// in so an org can gate which providers a given user may turn on, WITHOUT any +// change to the /providers UI or the picker: both consult this function, and +// the visible set is always (entitled ∧ enabled). Keep it the single chokepoint. +func ProviderAllowed(id string) bool { + _, ok := ProviderByID(id) + return ok +} + +// providerKeychainAccount is the OS-keychain account name for a provider's key. +func providerKeychainAccount(id string) string { + return "provider_" + id + "_key" +} + +// SaveProviderKey stores a provider's API key in the OS keychain. +func SaveProviderKey(id, key string) error { + if _, ok := ProviderByID(id); !ok { + return fmt.Errorf("unknown provider %q", id) + } + return SaveToKeychain(providerKeychainAccount(id), key) +} + +// LoadProviderKey retrieves a provider's API key from the OS keychain, falling +// back to any of the provider's standard environment variables. +func LoadProviderKey(id string) string { + if key, err := LoadFromKeychain(providerKeychainAccount(id)); err == nil && key != "" { + return key + } + if p, ok := ProviderByID(id); ok { + for _, ev := range p.KeyEnvVars { + if v := getenvTrimmed(ev); v != "" { + return v + } + } + } + return "" +} + +// DeleteProviderKey removes a provider's API key from the OS keychain. +func DeleteProviderKey(id string) error { + return DeleteFromKeychain(providerKeychainAccount(id)) +} + +// ProviderKeySet reports whether a usable key exists for the provider. +func ProviderKeySet(id string) bool { + return LoadProviderKey(id) != "" +} + +// ValidateProviderKey applies the same lenient sanity check used for the +// Cerebras key: reject empty, whitespace, or non-printable pastes (the common +// no-echo mistakes) while accepting any single opaque token. The bool reports +// whether the key matches the provider's expected prefix — a soft signal, not +// a hard failure, so future key formats keep working. +func ValidateProviderKey(id, key string) (looksLikeKey bool, err error) { + if key == "" { + return false, fmt.Errorf("key is empty") + } + for _, r := range key { + if unicode.IsSpace(r) { + return false, fmt.Errorf("key contains whitespace — looks like a pasted command or multiple values, not a single API key") + } + if !unicode.IsPrint(r) { + return false, fmt.Errorf("key contains a non-printable character") + } + } + if p, ok := ProviderByID(id); ok && p.KeyPrefix != "" { + return strings.HasPrefix(key, p.KeyPrefix), nil + } + return true, nil +} + +// IsProviderEnabled reports whether the user opted this provider in. +func (c *Config) IsProviderEnabled(id string) bool { + for _, e := range c.EnabledProviders { + if e == id { + return true + } + } + return false +} + +// EnableProviderID adds a provider to the user's opt-in set (idempotent, sorted). +func (c *Config) EnableProviderID(id string) { + if c.IsProviderEnabled(id) { + return + } + c.EnabledProviders = append(c.EnabledProviders, id) + sort.Strings(c.EnabledProviders) +} + +// DisableProviderID removes a provider from the user's opt-in set. +func (c *Config) DisableProviderID(id string) { + out := c.EnabledProviders[:0] + for _, e := range c.EnabledProviders { + if e != id { + out = append(out, e) + } + } + c.EnabledProviders = out +} + +// ActiveProviders returns the providers that are BOTH enabled by the user AND +// allowed by the entitlement seam — i.e. exactly what should be visible in the +// picker and driven through opencode. +func (c *Config) ActiveProviders() []Provider { + var out []Provider + for _, id := range c.EnabledProviders { + if !ProviderAllowed(id) { + continue + } + if p, ok := ProviderByID(id); ok { + out = append(out, p) + } + } + return out +} diff --git a/internal/api/providers_test.go b/internal/api/providers_test.go new file mode 100644 index 0000000..d48c463 --- /dev/null +++ b/internal/api/providers_test.go @@ -0,0 +1,85 @@ +package api + +import "testing" + +func TestBuiltinProvidersHaveRequiredFields(t *testing.T) { + for _, p := range BuiltinProviders() { + if p.ID == "" || p.DisplayName == "" { + t.Errorf("provider missing id/name: %+v", p) + } + if len(p.KeyEnvVars) == 0 { + t.Errorf("provider %q has no KeyEnvVars", p.ID) + } + if p.Custom && p.BaseURL == "" { + t.Errorf("custom provider %q missing BaseURL", p.ID) + } + if p.Custom && len(p.Models) == 0 { + t.Errorf("custom provider %q has no seed Models", p.ID) + } + } +} + +func TestProviderByIDAndAllowed(t *testing.T) { + if _, ok := ProviderByID("groq"); !ok { + t.Fatal("groq should be a builtin provider") + } + if _, ok := ProviderByID("nope"); ok { + t.Fatal("unknown provider should not resolve") + } + if !ProviderAllowed("groq") { + t.Error("groq should be allowed by the entitlement seam today") + } + if ProviderAllowed("nope") { + t.Error("unknown provider should not be allowed") + } +} + +func TestEnableDisableProviderID(t *testing.T) { + cfg := &Config{} + cfg.EnableProviderID("groq") + cfg.EnableProviderID("groq") // idempotent + cfg.EnableProviderID("openrouter") + if !cfg.IsProviderEnabled("groq") || !cfg.IsProviderEnabled("openrouter") { + t.Fatal("providers should be enabled") + } + if len(cfg.EnabledProviders) != 2 { + t.Fatalf("expected 2 enabled, got %v", cfg.EnabledProviders) + } + cfg.DisableProviderID("groq") + if cfg.IsProviderEnabled("groq") { + t.Fatal("groq should be disabled") + } + if len(cfg.EnabledProviders) != 1 { + t.Fatalf("expected 1 enabled, got %v", cfg.EnabledProviders) + } +} + +func TestActiveProvidersRespectsEntitlementAndEnable(t *testing.T) { + cfg := &Config{EnabledProviders: []string{"groq", "bogus-unentitled"}} + active := cfg.ActiveProviders() + if len(active) != 1 || active[0].ID != "groq" { + t.Fatalf("ActiveProviders should filter to allowed+enabled, got %+v", active) + } +} + +func TestValidateProviderKey(t *testing.T) { + if _, err := ValidateProviderKey("groq", ""); err == nil { + t.Error("empty key should error") + } + if _, err := ValidateProviderKey("groq", "has space"); err == nil { + t.Error("whitespace key should error") + } + if looks, err := ValidateProviderKey("groq", "gsk_abc123"); err != nil || !looks { + t.Errorf("valid groq key: looks=%v err=%v", looks, err) + } + if looks, err := ValidateProviderKey("groq", "abc123"); err != nil || looks { + t.Errorf("wrong-prefix key should be soft-flagged: looks=%v err=%v", looks, err) + } +} + +func TestLoadProviderKeyEnvFallback(t *testing.T) { + t.Setenv("GROQ_API_KEY", "gsk_fromenv") + if got := LoadProviderKey("groq"); got != "gsk_fromenv" { + t.Errorf("LoadProviderKey env fallback = %q", got) + } +} diff --git a/internal/repl/repl.go b/internal/repl/repl.go index 44833f0..45b1a26 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -208,6 +208,9 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin continue case input == "/clear": ag.ClearHistory() + if oc, ok := cliAgent.(*agent.OpenCodeAgent); ok { + oc.ClearSession() + } term.PrintSystem("Conversation cleared.") continue case strings.HasPrefix(input, "/project "): @@ -315,6 +318,9 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin case input == "/config": printConfigInfo(ag.AppConfig, term) continue + case input == "/providers" || strings.HasPrefix(input, "/providers "): + handleProviders(input, ag, term) + continue case input == "/skills": setup.PrintSkillsStatus(term) continue @@ -380,15 +386,20 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin currentEffort = "low" } } + if len(cfg.ActiveProviders()) > 0 && agent.FindOpenCode() != "" { + term.PrintSystem("Querying opencode models for your enabled providers…") + } result := tui.ShowModelPicker(tui.ModelPickerOpts{ - CurrentBackend: currentBackend, - CurrentModelID: currentModelID, - Effort: currentEffort, - OllamaURL: cfg.OllamaURL, - OllamaModel: cfg.OllamaModel, - CCInstalled: agent.FindClaudeCode() != "", - CodexInstalled: agent.FindCodex() != "", - CerebrasKeySet: cfg.CerebrasKey != "", + CurrentBackend: currentBackend, + CurrentModelID: currentModelID, + Effort: currentEffort, + OllamaURL: cfg.OllamaURL, + OllamaModel: cfg.OllamaModel, + CCInstalled: agent.FindClaudeCode() != "", + CodexInstalled: agent.FindCodex() != "", + CerebrasKeySet: cfg.CerebrasKey != "", + OpenCodeInstalled: agent.FindOpenCode() != "", + OpenCodeModels: buildOpenCodeModelEntries(cfg, ag.Cfg.Context), }) if !result.Confirmed { continue @@ -489,6 +500,12 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin term.PrintSystem(" npm install -g @openai/codex") continue } + case "opencode": + if agent.FindOpenCode() == "" { + term.PrintError("opencode CLI ('opencode') not found.") + term.PrintSystem(" curl -fsSL https://opencode.ai/install | bash") + continue + } } // Consent gate: required before activating CC/Codex (autonomous shell + edits). @@ -527,6 +544,13 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin } cliAgent = ca term.PrintSystem(fmt.Sprintf("Backend: Codex model: %s effort: %s", result.ModelID, result.Effort)) + case "opencode": + oc := agent.NewOpenCodeAgent(agent.FindOpenCode(), result.ModelID, result.Effort, cfg.OrchPermissionMode, cfg.OutputVerbose, cfg, ag.Cfg.Context) + if _, err := agent.WriteOpenCodeConfig(cfg, ag.Cfg.Context, cfg.OrchPermissionMode); err != nil { + term.PrintSystem(fmt.Sprintf("Warning: opencode config: %v", err)) + } + cliAgent = oc + term.PrintSystem(fmt.Sprintf("Backend: opencode model: %s effort: %s", result.ModelID, result.Effort)) default: term.PrintSystem(fmt.Sprintf("Backend: Anthropic API model: %s", result.ModelID)) } @@ -581,7 +605,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin } continue - case input == "/cc", input == "/codex", input == "/api": + case input == "/cc", input == "/codex", input == "/api", input == "/opencode": // Instant backend switching — no restart needed. cfg := ag.AppConfig if cfg == nil { @@ -595,6 +619,8 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin wantBackend = "cc" case "/codex": wantBackend = "codex" + case "/opencode": + wantBackend = "opencode" case "/api": wantBackend = "" } @@ -604,6 +630,35 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin wantBackend = "" } + // opencode pre-flight — validate BEFORE tearing down the active agent + // so a failed switch leaves the current backend intact. Also resolves + // a valid provider/model: the global ModelOverride may hold a model + // from a different backend (e.g. gpt-5.6 from Codex), which must never + // be passed to opencode. + if wantBackend == "opencode" { + if agent.FindOpenCode() == "" { + term.PrintError("'opencode' CLI not found.") + term.PrintSystem(" curl -fsSL https://opencode.ai/install | bash") + continue + } + if len(cfg.ActiveProviders()) == 0 { + term.PrintSystem("No opencode providers enabled yet. Turn one on with /providers, then pick a model in /orch.") + continue + } + model, ok := resolveOpenCodeModelOverride(cfg, ag.Cfg.Context) + if !ok { + term.PrintError("No opencode models available for your enabled providers. Check keys with /providers or pick a model in /orch.") + continue + } + consent := setup.PromptOrchConsent(cfg, "opencode") + if !consent.Proceed { + term.PrintSystem("Backend not changed.") + continue + } + cfg.OrchPermissionMode = consent.PermissionMode + cfg.ModelOverride = model + } + // Tear down current CLI agent. if cliAgent != nil { cliAgent.Cleanup() @@ -666,6 +721,19 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin _ = cfg.Save() term.PrintSystem(fmt.Sprintf("Backend → Codex (%s) · %s mode", bin, cfg.OrchPermissionMode)) + case "opencode": + // Pre-flight above already validated the CLI, providers, model, and + // consent (and set cfg.ModelOverride / cfg.OrchPermissionMode). + bin := agent.FindOpenCode() + oc := agent.NewOpenCodeAgent(bin, cfg.ModelOverride, cfg.Effort, cfg.OrchPermissionMode, cfg.OutputVerbose, cfg, ag.Cfg.Context) + if _, err := agent.WriteOpenCodeConfig(cfg, ag.Cfg.Context, cfg.OrchPermissionMode); err != nil { + term.PrintSystem(fmt.Sprintf("Warning: opencode config: %v", err)) + } + cliAgent = oc + cfg.Backend = "opencode" + _ = cfg.Save() + term.PrintSystem(fmt.Sprintf("Backend → opencode (%s) · %s · model %s", bin, cfg.OrchPermissionMode, cfg.ModelOverride)) + default: cfg.Backend = "" _ = cfg.Save() @@ -1301,6 +1369,8 @@ Commands: /cloudsync Toggle cloud session sync (enabled/disabled) /cc Switch to Claude Code backend (no QM API key; Agent SDK credit) /codex Switch to Codex CLI backend (OpenAI subscription, no API tokens) + /opencode Switch to opencode backend (bring-your-own Z.AI / Groq / OpenRouter) + /providers Enable/disable opencode providers per-user (/providers enable groq) /api Switch back to direct Anthropic API /gemma [none|low|medium|high|off] Activate Gemma 4 31B on Cerebras (multimodal + reasoning) @@ -1713,6 +1783,183 @@ func deactivateEmbeddedBackends(ag *agent.Agent) { ag.Cerebras = nil } +// 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 +// (models.dev-backed for Groq/OpenRouter, seeded config for custom providers). +// Returns nil when opencode isn't installed or no providers are active. +func buildOpenCodeModelEntries(cfg *api.Config, sctx *api.SessionContext) []tui.OpenCodeModelEntry { + bin := agent.FindOpenCode() + if bin == "" { + return nil + } + active := cfg.ActiveProviders() + if len(active) == 0 { + return nil + } + path, err := agent.WriteOpenCodeConfig(cfg, sctx, cfg.OrchPermissionMode) + if err != nil { + return nil + } + // Provider keys must be present in the env or `opencode models ` + // reports "Provider not found" for known providers (groq/openrouter). + env := agent.OpenCodeProviderEnv(cfg) + var entries []tui.OpenCodeModelEntry + for _, p := range active { + for _, full := range agent.OpenCodeModels(bin, path, env, p.ID) { + label := full + if i := strings.Index(full, "/"); i >= 0 { + label = full[i+1:] + } + entries = append(entries, tui.OpenCodeModelEntry{ + ProviderID: p.ID, + ProviderName: p.DisplayName, + ModelID: full, + Label: label, + }) + } + } + return entries +} + +// resolveOpenCodeModelOverride returns a valid "provider/model" for the current +// enabled providers. It keeps the existing ModelOverride if it's a real opencode +// model for an active provider; otherwise it falls back to the first available +// model (so a stale override from another backend is never sent to opencode). +// Returns false when no models are available at all. +func resolveOpenCodeModelOverride(cfg *api.Config, sctx *api.SessionContext) (string, bool) { + entries := buildOpenCodeModelEntries(cfg, sctx) + if len(entries) == 0 { + return "", false + } + for _, e := range entries { + if e.ModelID == cfg.ModelOverride { + return cfg.ModelOverride, true + } + } + return entries[0].ModelID, true +} + +// handleProviders implements the /providers command: list opt-in opencode +// providers, or enable/disable one for this user. Enabling prompts for the key +// (stored in the OS keychain) and regenerates the managed opencode config so +// the provider's models appear in /orch. +func handleProviders(input string, ag *agent.Agent, term *tui.Terminal) { + cfg := ag.AppConfig + if cfg == nil { + term.PrintError("api.Config not loaded.") + return + } + fields := strings.Fields(strings.TrimSpace(strings.TrimPrefix(input, "/providers"))) + if len(fields) == 0 { + printProvidersList(cfg, term) + return + } + switch fields[0] { + case "enable", "on", "add": + if len(fields) < 2 { + term.PrintError("Usage: /providers enable (e.g. /providers enable groq)") + return + } + enableProvider(cfg, fields[1], ag, term) + case "disable", "off", "remove", "rm": + if len(fields) < 2 { + term.PrintError("Usage: /providers disable ") + return + } + disableProvider(cfg, fields[1], ag, term) + default: + term.PrintError(fmt.Sprintf("Unknown subcommand %q. Use /providers, /providers enable , or /providers disable .", fields[0])) + } +} + +func printProvidersList(cfg *api.Config, term *tui.Terminal) { + term.PrintSystem("Opt-in providers (opencode backend). Enable one, then pick a model in /orch:") + for _, p := range api.BuiltinProviders() { + status := "○ available" + switch { + case !api.ProviderAllowed(p.ID): + status = "🔒 not entitled" + case cfg.IsProviderEnabled(p.ID) && api.ProviderKeySet(p.ID): + status = "● enabled (key set)" + case cfg.IsProviderEnabled(p.ID): + status = "● enabled (no key — re-enable)" + } + term.PrintSystem(fmt.Sprintf(" %-16s %-30s %s", p.ID, status, p.DisplayName)) + } + term.PrintSystem("") + term.PrintSystem(" /providers enable · /providers disable ") + term.PrintSystem(" Cerebras is available natively — select it directly in /orch.") +} + +func enableProvider(cfg *api.Config, id string, ag *agent.Agent, term *tui.Terminal) { + p, ok := api.ProviderByID(id) + if !ok { + term.PrintError(fmt.Sprintf("Unknown provider %q. See /providers for the list.", id)) + return + } + if !api.ProviderAllowed(id) { + term.PrintError(fmt.Sprintf("Provider %q is not available to your account.", id)) + return + } + if !api.ProviderKeySet(id) { + term.PrintSystem(fmt.Sprintf("%s needs an API key (%s).", p.DisplayName, p.SignupURL)) + key := setup.ReadSecret(fmt.Sprintf(" Paste your %s key (blank to cancel): ", p.DisplayName)) + if key == "" { + term.PrintSystem("No key entered — provider not enabled.") + return + } + looks, verr := api.ValidateProviderKey(id, key) + if verr != nil { + term.PrintError(fmt.Sprintf("That doesn't look like an API key (%v). Not enabled.", verr)) + return + } + if !looks && p.KeyPrefix != "" { + term.PrintSystem(fmt.Sprintf(" Note: key doesn't start with %q — saving anyway.", p.KeyPrefix)) + } + if err := api.SaveProviderKey(id, key); err != nil { + term.PrintError(fmt.Sprintf("Could not save key: %v", err)) + return + } + term.PrintSystem(" Saved to OS keychain.") + } + cfg.EnableProviderID(id) + if err := cfg.Save(); err != nil { + term.PrintError(fmt.Sprintf("Could not save config: %v", err)) + return + } + if _, err := agent.WriteOpenCodeConfig(cfg, ag.Cfg.Context, cfg.OrchPermissionMode); err != nil { + term.PrintSystem(fmt.Sprintf("Warning: opencode config: %v", err)) + } + term.PrintSystem(fmt.Sprintf("Enabled %s. Pick a model in /orch (opencode section).", p.DisplayName)) + if agent.FindOpenCode() == "" { + term.PrintSystem(" Heads-up: the opencode CLI isn't installed yet — curl -fsSL https://opencode.ai/install | bash") + } + // qmax now holds this provider's key in the OS keychain and injects it at + // launch. If the user's own opencode config still has a plaintext key, it's + // live and redundant — flag it so they can rotate/remove it. + if path, found := agent.PlaintextKeyInUserOpenCodeConfig(); found { + term.PrintSystem(fmt.Sprintf(" Security: a plaintext API key is still in %s.", path)) + term.PrintSystem(" qmax keeps provider keys in your OS keychain — consider rotating that key and removing the literal value (use \"{env:...}\" instead).") + } +} + +func disableProvider(cfg *api.Config, id string, ag *agent.Agent, term *tui.Terminal) { + if _, ok := api.ProviderByID(id); !ok { + term.PrintError(fmt.Sprintf("Unknown provider %q.", id)) + return + } + cfg.DisableProviderID(id) + if err := cfg.Save(); err != nil { + term.PrintError(fmt.Sprintf("Could not save config: %v", err)) + return + } + if _, err := agent.WriteOpenCodeConfig(cfg, ag.Cfg.Context, cfg.OrchPermissionMode); err != nil { + term.PrintSystem(fmt.Sprintf("Warning: opencode config: %v", err)) + } + term.PrintSystem(fmt.Sprintf("Disabled %s. (Its key stays in the keychain; re-enable to reuse.)", id)) +} + // sdkCreditCutover is the date Anthropic moves `claude --print` / // Agent SDK calls off the regular Pro/Max subscription pool onto a // separate per-user monthly credit. Reference: diff --git a/internal/setup/consent.go b/internal/setup/consent.go index 5d8ed78..68c45be 100644 --- a/internal/setup/consent.go +++ b/internal/setup/consent.go @@ -29,6 +29,10 @@ func PromptOrchConsent(cfg *api.Config, backend string) ConsentResult { cliName = "Codex" globalConfigPath = "~/.codex/config.toml" } + if backend == "opencode" { + cliName = "opencode" + globalConfigPath = "~/.qmax-code/opencode.json" + } res := ConsentResult{ PermissionMode: cfg.OrchPermissionMode, @@ -77,8 +81,10 @@ func PromptOrchConsent(cfg *api.Config, backend string) ConsentResult { } } - // Second prompt: global install. Only ask if not previously consented and not already done. - if !res.GlobalInstall && !IsOrchInstalled(backend) { + // Second prompt: global install. Only ask if not previously consented and not + // already done. Skipped for opencode: qmax manages its own opencode config + // (~/.qmax-code/opencode.json) directly, so there is no external global install. + if backend != "opencode" && !res.GlobalInstall && !IsOrchInstalled(backend) { fmt.Println() fmt.Printf(" Optional: register qmax tools globally in %s\n", globalConfigPath) fmt.Printf(" so they appear in every `%s` session, not just qmax-code.\n", strings.ToLower(cliName)) diff --git a/internal/tui/input.go b/internal/tui/input.go index 083d672..f24fa33 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -26,7 +26,9 @@ var slashMenuItems = []SlashMenuItem{ {"/feed", "Open the most recent live browser feed (after a test/crawl with /live on)"}, {"/cc", "Switch to Claude Code backend"}, {"/codex", "Switch to Codex CLI backend"}, + {"/opencode", "Switch to opencode backend (Z.AI / Groq / OpenRouter)"}, {"/api", "Switch to direct Anthropic API"}, + {"/providers", "Enable/disable opencode providers (per-user opt-in)"}, {"/connect", "Log in to QualityMax (browser)"}, {"/disconnect", "Log out"}, {"/reconnect", "Restore active MCP transport"}, diff --git a/internal/tui/tui_backend.go b/internal/tui/tui_backend.go index fb32ec2..6a80714 100644 --- a/internal/tui/tui_backend.go +++ b/internal/tui/tui_backend.go @@ -47,6 +47,9 @@ var ( pickerIconCerebras = lipgloss.NewStyle(). Foreground(lipgloss.Color("208")) // orange — Cerebras ◆ + pickerIconOpenCode = lipgloss.NewStyle(). + Foreground(lipgloss.Color("170")) // magenta — opencode ◈ + pickerDotGreen = lipgloss.NewStyle().Foreground(lipgloss.Color("82")) pickerDotRed = lipgloss.NewStyle().Foreground(lipgloss.Color("160")) @@ -164,6 +167,17 @@ var cerebrasModels = []pickerEntry{ {backend: "cerebras", modelID: "gemma-4-31b", label: "Gemma 4 31B", subLabel: "vision · preview", external: true}, } +// OpenCodeModelEntry is one model offered by an enabled opencode provider. The +// caller resolves these dynamically (via `opencode models `) and +// passes only enabled+entitled providers, so the picker shows exactly what the +// user opted into — never the full opencode catalogue. +type OpenCodeModelEntry struct { + ProviderID string // e.g. "groq" + ProviderName string // e.g. "Groq" + ModelID string // full "provider/model" passed via --model + Label string // display name (model portion) +} + var effortLevels = []string{"low", "medium", "high"} // probeOllamaReachable returns true if the Ollama base URL responds within 2s. @@ -210,8 +224,12 @@ type modelPickerModel struct { // Backend availability (resolved by caller before constructing picker so // the View doesn't shell out to LookPath on every frame). - ccInstalled bool - codexInstalled bool + ccInstalled bool + codexInstalled bool + openCodeInstalled bool + + // hasOpenCode is true when at least one enabled provider contributed a model. + hasOpenCode bool // Ollama metadata ollamaURL string @@ -224,13 +242,24 @@ type modelPickerModel struct { chosen *pickerEntry } -func newModelPickerModel(currentBackend, currentModelID, effort, ollamaURL, ollamaModel string, ccInstalled, codexInstalled, cerebrasKeySet bool) modelPickerModel { - entries := make([]pickerEntry, 0, len(ccModels)+len(codexModels)+len(apiModels)+len(cerebrasModels)+1) +func newModelPickerModel(currentBackend, currentModelID, effort, ollamaURL, ollamaModel string, ccInstalled, codexInstalled, cerebrasKeySet, openCodeInstalled bool, openCodeModels []OpenCodeModelEntry) modelPickerModel { + entries := make([]pickerEntry, 0, len(ccModels)+len(codexModels)+len(apiModels)+len(cerebrasModels)+len(openCodeModels)+1) entries = append(entries, ccModels...) entries = append(entries, codexModels...) entries = append(entries, apiModels...) entries = append(entries, cerebrasModels...) + // Append opencode entries (one per enabled-provider model) after Cerebras. + for _, m := range openCodeModels { + entries = append(entries, pickerEntry{ + backend: "opencode", + modelID: m.ModelID, + label: m.Label, + subLabel: m.ProviderName, + external: true, + }) + } + // Append Ollama entry if configured. if ollamaURL != "" && ollamaModel != "" { entries = append(entries, pickerEntry{ @@ -258,15 +287,17 @@ func newModelPickerModel(currentBackend, currentModelID, effort, ollamaURL, olla } return modelPickerModel{ - allEntries: entries, - cursor: cursor, - effort: effort, - currentBackend: currentBackend, - currentModelID: currentModelID, - ollamaURL: ollamaURL, - ccInstalled: ccInstalled, - codexInstalled: codexInstalled, - cerebrasKeySet: cerebrasKeySet, + allEntries: entries, + cursor: cursor, + effort: effort, + currentBackend: currentBackend, + currentModelID: currentModelID, + ollamaURL: ollamaURL, + ccInstalled: ccInstalled, + codexInstalled: codexInstalled, + cerebrasKeySet: cerebrasKeySet, + openCodeInstalled: openCodeInstalled, + hasOpenCode: len(openCodeModels) > 0, } } @@ -431,6 +462,29 @@ func (m modelPickerModel) View() string { b.WriteByte('\n') } + // ── opencode section ───────────────────────────────────────────── + if m.hasOpenCode { + b.WriteString(pickerDivider.Render(strings.Repeat("─", 52))) + b.WriteByte('\n') + ocDot := pickerDotGreen.Render("●") + ocStatus := "enabled providers" + if !m.openCodeInstalled { + ocDot = pickerDotRed.Render("●") + ocStatus = "opencode not installed" + } + sectionLabelOC := fmt.Sprintf("%s opencode %s %s", + pickerIconOpenCode.Render("◈"), ocDot, pickerBadgeExt.Render(ocStatus)) + b.WriteString(pickerSectionHeader.Render(sectionLabelOC)) + b.WriteByte('\n') + for i, e := range m.allEntries { + if e.backend != "opencode" { + continue + } + b.WriteString(m.renderRow(i, e, "opencode")) + b.WriteByte('\n') + } + } + // ── Ollama section ─────────────────────────────────────────────── hasOllama := false for _, e := range m.allEntries { @@ -499,6 +553,8 @@ func (m modelPickerModel) renderRow(idx int, e pickerEntry, backend string) stri icon = pickerIconOllama.Render("⬡") case "cerebras": icon = pickerIconCerebras.Render("◆") + case "opencode": + icon = pickerIconOpenCode.Render("◈") default: icon = pickerIconAPI.Render("○") } @@ -584,6 +640,8 @@ func (m modelPickerModel) renderStatusBar() string { icon = pickerStatusBar.Render("⬡") case "cerebras": icon = pickerStatusBar.Render("◆") + case "opencode": + icon = pickerStatusBar.Render("◈") default: icon = pickerStatusIcon.Render("○") } @@ -741,12 +799,15 @@ type ModelPickerOpts struct { CCInstalled bool // pre-resolved (don't shell out from picker.View per frame) CodexInstalled bool CerebrasKeySet bool // true when a Cerebras API key is configured (drives the section status dot) + + OpenCodeInstalled bool // true when the opencode CLI is present + OpenCodeModels []OpenCodeModelEntry // models from enabled+entitled providers; empty hides the section } // ShowModelPicker opens the unified model + effort TUI. // Returns the result; Confirmed=false means the user cancelled. func ShowModelPicker(opts ModelPickerOpts) ModelPickerResult { - m := newModelPickerModel(opts.CurrentBackend, opts.CurrentModelID, opts.Effort, opts.OllamaURL, opts.OllamaModel, opts.CCInstalled, opts.CodexInstalled, opts.CerebrasKeySet) + m := newModelPickerModel(opts.CurrentBackend, opts.CurrentModelID, opts.Effort, opts.OllamaURL, opts.OllamaModel, opts.CCInstalled, opts.CodexInstalled, opts.CerebrasKeySet, opts.OpenCodeInstalled, opts.OpenCodeModels) p := tea.NewProgram(m) result, err := p.Run() if err != nil { diff --git a/internal/tui/tui_backend_cc_test.go b/internal/tui/tui_backend_cc_test.go index de6882b..3c62d49 100644 --- a/internal/tui/tui_backend_cc_test.go +++ b/internal/tui/tui_backend_cc_test.go @@ -7,7 +7,7 @@ import ( ) func TestPickerIncludesClaudeCodeFableAndSonnet5(t *testing.T) { - m := newModelPickerModel("cc", "", "high", "", "", true, true, false) + m := newModelPickerModel("cc", "", "high", "", "", true, true, false, false, nil) seen := map[string]pickerEntry{} for _, e := range m.allEntries { @@ -40,7 +40,7 @@ func TestPickerIncludesClaudeCodeFableAndSonnet5(t *testing.T) { } func TestPickerClaudeCodeDefaultCursorOnSonnet5(t *testing.T) { - m := newModelPickerModel("cc", "", "high", "", "", true, true, false) + m := newModelPickerModel("cc", "", "high", "", "", true, true, false, false, nil) cur := m.allEntries[m.cursor] if cur.backend != "cc" || cur.modelID != api.ModelSonnet5 { t.Errorf("cursor on %s/%s, want cc/%s", cur.backend, cur.modelID, api.ModelSonnet5) @@ -48,7 +48,7 @@ func TestPickerClaudeCodeDefaultCursorOnSonnet5(t *testing.T) { } func TestPickerIncludesCodexGPT56Variants(t *testing.T) { - m := newModelPickerModel("codex", "", "high", "", "", true, true, false) + m := newModelPickerModel("codex", "", "high", "", "", true, true, false, false, nil) seen := map[string]pickerEntry{} for _, e := range m.allEntries { diff --git a/internal/tui/tui_backend_cerebras_test.go b/internal/tui/tui_backend_cerebras_test.go index 4e7ec77..a0a6190 100644 --- a/internal/tui/tui_backend_cerebras_test.go +++ b/internal/tui/tui_backend_cerebras_test.go @@ -6,7 +6,7 @@ import ( ) func TestPickerIncludesCerebrasEntries(t *testing.T) { - m := newModelPickerModel("", "", "high", "", "", true, true, false) + m := newModelPickerModel("", "", "high", "", "", true, true, false, false, nil) var got []string for _, e := range m.allEntries { if e.backend == "cerebras" { @@ -23,7 +23,7 @@ func TestPickerIncludesCerebrasEntries(t *testing.T) { func TestPickerCerebrasSectionRenders(t *testing.T) { // No key configured → status should advertise the inline prompt. - m := newModelPickerModel("", "", "high", "", "", true, true, false) + m := newModelPickerModel("", "", "high", "", "", true, true, false, false, nil) view := m.View() if !strings.Contains(view, "Cerebras") { t.Error("picker view missing Cerebras section header") @@ -33,7 +33,7 @@ func TestPickerCerebrasSectionRenders(t *testing.T) { } // Key configured → status should say so. - m2 := newModelPickerModel("", "", "high", "", "", true, true, true) + m2 := newModelPickerModel("", "", "high", "", "", true, true, true, false, nil) if !strings.Contains(m2.View(), "key set") { t.Error("picker should show 'key set' status when CerebrasKeySet is true") } @@ -42,7 +42,7 @@ func TestPickerCerebrasSectionRenders(t *testing.T) { func TestPickerCerebrasCursorOnCurrent(t *testing.T) { // When cerebras is the active backend, the cursor should land on the // matching model entry. - m := newModelPickerModel("cerebras", "zai-glm-4.7", "high", "", "", true, true, true) + m := newModelPickerModel("cerebras", "zai-glm-4.7", "high", "", "", true, true, true, false, nil) cur := m.allEntries[m.cursor] if cur.backend != "cerebras" || cur.modelID != "zai-glm-4.7" { t.Errorf("cursor on %s/%s, want cerebras/zai-glm-4.7", cur.backend, cur.modelID) diff --git a/main.go b/main.go index fbccd48..f506339 100644 --- a/main.go +++ b/main.go @@ -36,7 +36,7 @@ func main() { professional := flag.Bool("professional", false, "Disable cat personality, be direct and professional") quiet := flag.Bool("q", false, "Quiet mode — no banner, minimal output (for CI)") showVersion := flag.Bool("version", false, "Show version") - backendFlag := flag.String("backend", "", "Orchestration backend: cc, codex, cerebras, or api (overrides saved config)") + backendFlag := flag.String("backend", "", "Orchestration backend: cc, codex, cerebras, opencode, or api (overrides saved config)") flag.Parse() _ = quiet // reserved for future CI mode @@ -157,14 +157,14 @@ func main() { // --backend flag overrides saved config for this session only. if *backendFlag != "" { switch *backendFlag { - case "cc", "codex", "cerebras", "api", "": + case "cc", "codex", "cerebras", "opencode", "api", "": if *backendFlag == "api" { appConfig.Backend = "" } else { appConfig.Backend = *backendFlag } default: - fmt.Fprintf(os.Stderr, "Error: --backend must be cc, codex, cerebras, or api\n") + fmt.Fprintf(os.Stderr, "Error: --backend must be cc, codex, cerebras, opencode, or api\n") os.Exit(2) } } @@ -300,6 +300,24 @@ func main() { os.Exit(1) } anthropicKey = "__cerebras_mode__" // skip Anthropic key gate below + } else if cliBackend == "opencode" { + openCodeBin := agent.FindOpenCode() + if openCodeBin == "" { + fmt.Fprintln(os.Stderr, "\nError: backend=opencode but 'opencode' CLI was not found.") + fmt.Fprintln(os.Stderr, " Install opencode: https://opencode.ai (curl -fsSL https://opencode.ai/install | bash)") + fmt.Fprintln(os.Stderr, " Or switch backend: qmax-code config set backend api") + os.Exit(1) + } + consent := setup.PromptOrchConsent(appConfig, "opencode") + if !consent.Proceed { + fmt.Fprintln(os.Stderr, " opencode backend not activated. Falling back to direct API.") + cliBackend = "" + appConfig.Backend = "" + } else { + appConfig.OrchPermissionMode = consent.PermissionMode + _ = appConfig.Save() + anthropicKey = "__opencode_mode__" // skip Anthropic key gate below + } } // If connected but missing Anthropic key, prompt for it (skipped in CLI backend modes). @@ -413,6 +431,12 @@ func main() { fmt.Fprintf(os.Stderr, "Warning: could not write Codex MCP config: %v\n", err) } cliAgent = ca + case "opencode": + oc := agent.NewOpenCodeAgent(agent.FindOpenCode(), appConfig.ModelOverride, appConfig.Effort, appConfig.OrchPermissionMode, appConfig.OutputVerbose, appConfig, ctx) + if _, err := agent.WriteOpenCodeConfig(appConfig, ctx, appConfig.OrchPermissionMode); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not write opencode config: %v\n", err) + } + cliAgent = oc } // One-shot mode and positional-arg mode honour the configured CLI backend.