Repository navigation
Expand file tree
/
Copy pathmain.go
More file actions
777 lines (724 loc) · 30.3 KB
/
Copy pathmain.go
File metadata and controls
777 lines (724 loc) · 30.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"strings"
"sync"
xterm "golang.org/x/term"
"github.com/Quality-Max/qmax-code/internal/agent"
"github.com/Quality-Max/qmax-code/internal/api"
"github.com/Quality-Max/qmax-code/internal/mcp"
"github.com/Quality-Max/qmax-code/internal/repl"
"github.com/Quality-Max/qmax-code/internal/session"
"github.com/Quality-Max/qmax-code/internal/setup"
"github.com/Quality-Max/qmax-code/internal/sysutil"
"github.com/Quality-Max/qmax-code/internal/tui"
)
// Version is set at build time via -ldflags "-X main.Version=x.y.z"
var Version = "1.40.1"
const Name = "qmax-code"
func main() {
tui.EnsureTTYNewlines()
// Flags
projectID := flag.Int("project-id", 0, "Default project ID for this session")
model := flag.String("model", "", "Model: auto, fable, sonnet, opus, haiku, or full Claude/Codex ID")
anthropicAPIKey := flag.String("anthropic-api-key", "", "Anthropic API key (or set ANTHROPIC_API_KEY)")
cloudURL := flag.String("cloud-url", "", "QualityMax cloud URL (or use qmax login)")
oneShot := flag.String("p", "", "Run a single prompt and exit (non-interactive)")
resumeID := flag.String("resume", "", "Resume a previous session by ID (or 'last')")
listSessions := flag.Bool("list-sessions", false, "List recent sessions and exit")
saveSession := flag.Bool("save-session", false, "Save this session on exit (overrides auto-save setting)")
verbose := flag.Bool("verbose", false, "Show tool calls and raw responses")
professional := flag.Bool("professional", false, "Disable cat personality, be direct and professional")
quiet := flag.Bool("q", false, "Reserved for future quiet/CI mode")
showVersion := flag.Bool("version", false, "Show version")
backendFlag := flag.String("backend", "", "Orchestration backend: cc, codex, agy, cerebras, opencode, or api (overrides saved config)")
localFlag := flag.Bool("local", false, "Standalone local-only mode: skip QualityMax login and expose only local workspace tools")
flag.Parse()
_ = quiet // reserved for future CI mode
if *showVersion {
fmt.Printf("%s v%s\n", Name, Version)
return
}
// Exposure Receipt: record every outbound request this session makes (LLM
// prompts, cloud-API calls, integration connects) into a signed, offline-
// verifiable manifest under ~/.qmax-code/receipts/. Installed before any
// subcommand dispatch so MCP-serve, login, cc/codex and the interactive flow
// are all covered; written at exit only if the session actually egressed.
initReceiptPaths()
sessionRec := beginSessionReceipt(receiptKind(os.Args))
// sync.Once makes the finalizer idempotent: signal-based exits call it via
// repl.Run's saveAndExit (before os.Exit), normal exits call it via this
// defer — whichever fires first wins, the other is a no-op.
var receiptOnce sync.Once
finalizeReceipt := func() {
receiptOnce.Do(func() { finalizeSessionReceipt(sessionRec) })
}
// The receipt must remain active while telemetry flushes: that flush is an
// outbound HTTP request when opt-in error reporting is enabled.
finalizeForExit := func() {
sysutil.FlushErrorReporting()
finalizeReceipt()
}
configureExitFinalizer(finalizeForExit)
defer finalizeReceipt()
// Initialize error reporting only when explicitly enabled. These defers are
// registered after the receipt finalizer, therefore they run first on a
// normal return (LIFO) and are included in the session receipt.
sysutil.InitErrorReporting(Version)
defer sysutil.FlushErrorReporting()
defer sysutil.RecoverPanic()
// The local PR gate runs before authentication, setup, or backend
// initialization. It is deterministic, read-only, and backend-independent.
if len(os.Args) > 1 && os.Args[1] == "gate" {
if code := runGateCommand(context.Background(), os.Args[2:], os.Stdout, os.Stderr); code != 0 {
exitWithReceipt(code)
}
return
}
// `receipt` inspects prior manifests offline and does no egress of its own.
if len(os.Args) > 1 && os.Args[1] == "receipt" {
handleReceiptCommand(os.Args[2:])
return
}
// Handle "serve --mcp" subcommand: start MCP server for Claude Code integration.
// CC spawns this automatically when qmax-code is listed as an MCP server.
if len(os.Args) > 1 && os.Args[1] == "serve" {
if len(os.Args) > 2 && os.Args[2] == "--mcp" {
mcp.RunServer(Version)
return
}
fmt.Fprintln(os.Stderr, "Usage: qmax-code serve --mcp")
exitWithReceipt(2)
}
// Handle "config" subcommand — lets users change persisted settings
// without hand-editing ~/.qmax-code/config.json.
//
// qmax-code config → show current config
// qmax-code config set default_framework rust_cargo
// qmax-code config unset default_framework
if len(os.Args) > 1 && os.Args[1] == "config" {
handleConfigCommand(os.Args[2:])
return
}
// Attach a fresh local Codex login to the authenticated QualityMax user.
if len(os.Args) > 1 && os.Args[1] == "codex" {
if err := handleCodexCommand(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
exitWithReceipt(1)
}
return
}
// Attach a fresh local Claude Code login to the authenticated QualityMax user.
if len(os.Args) > 1 && os.Args[1] == "cc" {
if err := handleCCCommand(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
exitWithReceipt(1)
}
return
}
// Handle "login" subcommand before flag parsing
if len(os.Args) > 1 && os.Args[1] == "login" {
loginFlags := flag.NewFlagSet("login", flag.ExitOnError)
qualityMaxAPIKey := loginFlags.String("api-key", "", "QualityMax API key")
_ = loginFlags.Parse(os.Args[2:])
var cfg *api.AuthConfig
var err error
args := loginFlags.Args()
if *qualityMaxAPIKey != "" {
cfg, err = api.LoginWithAPIKey(*qualityMaxAPIKey)
} else if len(args) > 0 && strings.HasPrefix(args[0], "qm-") {
cfg, err = api.LoginWithAPIKey(args[0])
} else {
// Browser-based login (Railway-style)
tui.AnimateMax(tui.MoodWaving, "Let's get you logged in!")
cfg, err = setup.LoginViaBrowser()
}
if err != nil {
tui.AnimateMax(tui.MoodSad, "Login failed: "+err.Error())
fmt.Fprintf(os.Stderr, "\n Try: qmax-code login --api-key qm-YOUR-API-KEY\n")
exitWithReceipt(1)
}
tui.AnimateMax(tui.MoodHappy, fmt.Sprintf("Logged in as %s", cfg.Email))
return
}
if *listSessions {
sessions, err := session.ListSessions(20)
if err != nil || len(sessions) == 0 {
fmt.Println("No sessions found.")
exitWithReceipt(0)
}
fmt.Printf("%-10s %-18s %-6s %-8s %s\n", "ID", "Updated", "Turns", "Tokens", "Project")
for _, s := range sessions {
fmt.Printf("%-10s %-18s %-6d %-8d #%d\n",
s.ID, s.UpdatedAt.Format("2006-01-02 15:04"), s.Turns, s.Tokens, s.ProjectID)
}
return
}
// QUA-580: refuse interactive startup when stdin is not a terminal.
// This must happen before any setup, consent, or API-key prompt, because
// those paths are interactive too and can block forever when stdin is a
// pipe. One-shot prompts (-p or positional args) remain valid headless use.
stdinTTY := xterm.IsTerminal(int(os.Stdin.Fd()))
isOneShot := *oneShot != "" || len(flag.Args()) > 0
if !isOneShot && !stdinTTY {
fmt.Fprintln(os.Stderr, "qmax-code: stdin is not a terminal.")
fmt.Fprintln(os.Stderr, " For non-interactive use, pass a prompt:")
fmt.Fprintln(os.Stderr, " qmax-code -p \"<your prompt>\"")
fmt.Fprintln(os.Stderr, " qmax-code \"<your prompt>\"")
exitWithReceipt(2)
}
// Load persistent user config
appConfig := api.LoadQMaxCodeConfig()
localOnly := resolveLocalOnly(*localFlag, appConfig.LocalOnly, sysutil.EnvEnabled(api.LocalOnlyEnv))
if localOnly {
// CLI backends spawn qmax-code serve --mcp in a descendant process.
// The explicit hand-off preserves an ephemeral --local choice even when
// local_only is false in the persisted config.
_ = os.Setenv(api.LocalOnlyEnv, "1")
}
// --save-session is an explicit per-run opt-in. It must override a
// persisted auto_save=false setting without changing that setting on disk.
applySaveSessionFlag(appConfig, *saveSession)
// Apply color theme before constructing any UI components.
tui.ApplyTheme(tui.ThemeByName(appConfig.Theme))
// Apply --professional flag (CLI flag overrides saved config)
if *professional {
appConfig.Professional = true
}
// --backend flag overrides saved config for this session only.
if *backendFlag != "" {
switch *backendFlag {
case "cc", "codex", "agy", "cerebras", "opencode", "api", "":
if *backendFlag == "api" {
appConfig.Backend = ""
} else {
appConfig.Backend = *backendFlag
}
default:
fmt.Fprintf(os.Stderr, "Error: --backend must be cc, codex, agy, cerebras, opencode, or api\n")
exitWithReceipt(2)
}
}
// Resolve the model for the active backend before authentication or setup.
effectiveModel, modelErr := resolveSessionModel(appConfig, *model)
if modelErr != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", modelErr)
exitWithReceipt(2)
}
// Resolve Anthropic API key: flag > env > keychain
anthropicKey := *anthropicAPIKey
if anthropicKey == "" {
anthropicKey = os.Getenv("ANTHROPIC_API_KEY")
}
if anthropicKey == "" && appConfig.AnthropicKey != "" {
anthropicKey = appConfig.AnthropicKey
}
// QualityMax state is deliberately not loaded in standalone local-only
// mode. This prevents stale saved auth or a legacy qmax CLI from silently
// re-enabling cloud tools.
var auth *api.AuthConfig
var qmaxCfg api.QMaxConfig
var qmaxBin string
var apiClient *api.APIClient
if !localOnly {
auth = api.LoadAuth()
qmaxCfg = api.LoadQMaxConfig()
if *cloudURL != "" {
qmaxCfg.CloudURL = *cloudURL
}
qmaxBin = api.DiscoverQMaxBinary()
if auth != nil && auth.IsAuthenticated() {
apiClient = api.NewAPIClient(auth)
}
}
// First-run onboarding when qmax-code itself has no credentials.
// 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
// either hang on the poll loop or consume the piped bytes as menu
// input. Fall back to ephemeral standalone for this run only — no
// config mutation — and tell the user how to connect properly.
if headlessSetupFallback(stdinTTY) {
localOnly = true
_ = os.Setenv(api.LocalOnlyEnv, "1")
auth = nil
apiClient = nil
qmaxCfg = api.QMaxConfig{}
fmt.Fprintln(os.Stderr, "qmax-code: no QualityMax credentials found; running this command in standalone local mode.")
fmt.Fprintln(os.Stderr, " Connect once interactively: qmax-code login")
fmt.Fprintln(os.Stderr, " Or persist standalone: qmax-code config set local_only true")
} else {
setupAuth, setupProjectID, setupErr := setup.RunInteractive()
if setupErr != nil {
if errors.Is(setupErr, setup.ErrStandaloneSkip) {
// The user chose standalone local mode during onboarding.
// setup already persisted local_only=true; mirror the
// startup path so descendants (MCP serve, CLI backends)
// inherit the choice for this process too. Mutate the
// in-memory config rather than reloading from disk — a
// reload would discard per-run overrides (--backend,
// --professional, --save-session) applied earlier.
localOnly = true
_ = os.Setenv(api.LocalOnlyEnv, "1")
auth = nil
apiClient = nil
qmaxCfg = api.QMaxConfig{}
appConfig.LocalOnly = true
} else {
fmt.Fprintln(os.Stderr, "Setup failed:", setupErr)
exitWithReceipt(1)
}
} else {
auth = setupAuth
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")
}
}
}
}
// CLI backend mode: route all LLM inference through a local CLI subprocess.
// Neither a QM-held Anthropic API key nor an OpenAI key is required. In cc
// mode, `claude --print` uses Claude Code's configured authentication and
// billing (subscription login or API/provider credentials).
// qmax tools are served to the CLI via the embedded MCP server.
var cliAgent agent.CLIAgent
cliBackend := appConfig.Backend // "cc" | "codex" | "" (API)
if cliBackend == "cc" {
claudeBin := agent.FindClaudeCode()
if claudeBin == "" {
fmt.Fprintln(os.Stderr, "\nError: backend=cc but 'claude' CLI was not found.")
fmt.Fprintln(os.Stderr, " Install Claude Code: https://claude.ai/download")
fmt.Fprintln(os.Stderr, " Or switch backend: qmax-code config set backend api")
exitWithReceipt(1)
}
consent := setup.PromptOrchConsent(appConfig, "cc")
if !consent.Proceed {
fmt.Fprintln(os.Stderr, " CC backend not activated. Falling back to direct API.")
cliBackend = ""
appConfig.Backend = ""
} else {
appConfig.OrchPermissionMode = consent.PermissionMode
appConfig.OrchGlobalInstall = consent.GlobalInstall
_ = appConfig.Save()
}
} else if cliBackend == "codex" {
codexBin := agent.FindCodex()
if codexBin == "" {
fmt.Fprintln(os.Stderr, "\nError: backend=codex but 'codex' CLI was not found.")
fmt.Fprintln(os.Stderr, " Install Codex CLI: npm install -g @openai/codex")
fmt.Fprintln(os.Stderr, " Or switch backend: qmax-code config set backend api")
exitWithReceipt(1)
}
consent := setup.PromptOrchConsent(appConfig, "codex")
if !consent.Proceed {
fmt.Fprintln(os.Stderr, " Codex backend not activated. Falling back to direct API.")
cliBackend = ""
appConfig.Backend = ""
} else {
appConfig.OrchPermissionMode = consent.PermissionMode
appConfig.OrchGlobalInstall = consent.GlobalInstall
_ = appConfig.Save()
}
} else if cliBackend == "agy" {
agyBin := agent.FindAgy()
if agyBin == "" {
fmt.Fprintln(os.Stderr, "\nError: backend=agy but 'agy' CLI was not found.")
fmt.Fprintln(os.Stderr, " Install Antigravity CLI: curl -fsSL https://antigravity.google/cli/install.sh | bash")
fmt.Fprintln(os.Stderr, " Then sign in with Google: run `agy` and complete the browser login")
fmt.Fprintln(os.Stderr, " Or switch backend: qmax-code config set backend api")
exitWithReceipt(1)
}
consent := setup.PromptOrchConsent(appConfig, "agy")
if !consent.Proceed {
fmt.Fprintln(os.Stderr, " Antigravity backend not activated. Falling back to direct API.")
cliBackend = ""
appConfig.Backend = ""
} else {
appConfig.OrchPermissionMode = consent.PermissionMode
appConfig.OrchGlobalInstall = consent.GlobalInstall
_ = appConfig.Save()
setup.PromptAgyGoogleLogin(agyBin)
}
} else if cliBackend == "cerebras" {
// Cerebras drives the native qmax agent loop (mode-filtered tool set, native
// function calling) via its OpenAI-compatible API. No Anthropic key,
// no external CLI, no MCP subprocess — qmax owns the loop directly.
if appConfig.CerebrasKey == "" {
fmt.Println()
fmt.Println(" Cerebras API key needed (powers fast, low-cost inference).")
fmt.Println(" Get one at: https://cloud.cerebras.ai")
fmt.Println()
key := setup.ReadSecret(" Paste your Cerebras key: ")
if key != "" {
if looks, verr := api.ValidateCerebrasKey(key); verr != nil {
fmt.Fprintf(os.Stderr, " That doesn't look like an API key: %v\n", verr)
} else {
if !looks {
fmt.Println(" Note: key doesn't start with \"csk-\" — saving anyway.")
}
appConfig.CerebrasKey = key
if err := api.SaveCerebrasKey(key); err == nil {
fmt.Println(" Saved to OS keychain.")
}
}
}
}
if appConfig.CerebrasKey == "" {
fmt.Fprintln(os.Stderr, "\nError: Cerebras API key required for backend=cerebras.")
fmt.Fprintln(os.Stderr, " export CEREBRAS_API_KEY=csk-...")
fmt.Fprintln(os.Stderr, " Or switch backend: qmax-code config set backend api")
exitWithReceipt(1)
}
} 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")
exitWithReceipt(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()
}
}
// 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 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>")
}
}
}
// 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")
fmt.Fprintln(os.Stderr, " qmax-code config set backend codex # OpenAI/Codex subscription")
fmt.Fprintln(os.Stderr, " qmax-code config set backend agy # Google Antigravity (Google OAuth)")
if localOnly {
fmt.Fprintln(os.Stderr, " qmax-code config set ollama_url http://127.0.0.1:11434")
fmt.Fprintln(os.Stderr, " qmax-code config set ollama_model <model>")
}
exitWithReceipt(1)
}
// Detect project from cwd if not set via flag; fall back to saved config
detectedProjectID := *projectID
var projectFile string
if !localOnly && detectedProjectID == 0 {
detectedProjectID, projectFile = api.DetectProjectFromCwd()
}
if !localOnly && detectedProjectID == 0 && appConfig.DefaultProject > 0 {
detectedProjectID = appConfig.DefaultProject
}
if localOnly {
detectedProjectID = 0
}
// Build session context
ctx := &api.SessionContext{
ProjectID: detectedProjectID,
LocalOnly: localOnly,
QMaxCfg: qmaxCfg,
QMaxBin: qmaxBin,
QMaxInfo: api.ProbeQMaxStatus(qmaxBin),
GitInfo: api.DetectGitInfo(),
ProjectFile: projectFile,
API: apiClient,
Auth: auth,
Backend: appConfig.Backend,
LiveFeed: appConfig.LiveFeed && !localOnly,
}
// Build agent with smart model routing
autoRoute := effectiveModel == "auto"
var baseModel, chatModel string
if autoRoute {
baseModel = api.ModelSonnet
chatModel = api.ModelHaiku
} else {
baseModel = resolveModel(effectiveModel)
chatModel = baseModel
}
ag := agent.NewAgent(agent.AgentConfig{
AnthropicKey: anthropicKey,
Model: baseModel,
ChatModel: chatModel,
Verbose: *verbose,
OutputVerbose: appConfig.OutputVerbose,
Context: ctx,
AutoRoute: autoRoute,
Professional: appConfig.Professional,
})
defer ag.CleanupConversation()
ag.AppConfig = appConfig
ag.Ollama = agent.NewOllamaClient(appConfig)
// Cerebras backend takes precedence: when selected it owns every turn
// (native function calling over the full tool set), so leave Ollama mode off.
if appConfig.Backend == "cerebras" {
ag.Cerebras = agent.NewCerebrasClient(appConfig)
} else if ag.Ollama != nil {
ag.Mode = agent.OllamaModeFull // default to full when configured
}
// Build the CLI agent if a CLI backend was selected and consented to above.
// Global MCP install (~/.claude/settings.json or ~/.codex/config.toml) is
// performed only when the user opted into it during the consent prompt.
switch cliBackend {
case "cc":
if appConfig.OrchGlobalInstall && !setup.IsOrchInstalled("cc") {
if res, err := setup.InstallCC(); err == nil && !res.AlreadyHadMCP {
fmt.Printf(" qmax MCP entry added to %s\n", res.MCPPath)
}
}
// Skills sync every launch (idempotent) so an upgraded catalog reaches
// users who already have the MCP installed. Independent of IsOrchInstalled.
if appConfig.OrchGlobalInstall {
_, _ = setup.InstallSkills("cc")
}
cliAgent = agent.NewCCAgent(agent.FindClaudeCode(), appConfig.ModelOverride, appConfig.Effort, appConfig.OrchPermissionMode, appConfig.OutputVerbose, appConfig.NarrateToolCalls, ctx)
case "codex":
if appConfig.OrchGlobalInstall && !setup.IsOrchInstalled("codex") {
if res, err := setup.InstallCodex(); err == nil && !res.AlreadyHadMCP {
fmt.Printf(" qmax MCP entry added to %s\n", res.MCPPath)
}
}
// Skills sync every launch (idempotent), independent of IsOrchInstalled.
if appConfig.OrchGlobalInstall {
_, _ = setup.InstallSkills("codex")
}
ca := agent.NewCodexAgent(agent.FindCodex(), appConfig.CodexModel, appConfig.Effort, appConfig.OutputVerbose, appConfig.NarrateToolCalls, ctx)
if err := ca.WriteMCPConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not write Codex MCP config: %v\n", err)
}
cliAgent = ca
case "agy":
if appConfig.OrchGlobalInstall && !setup.IsOrchInstalled("agy") {
if res, err := setup.InstallAgy(); err == nil && !res.AlreadyHadMCP {
fmt.Printf(" qmax MCP entry added to %s\n", res.MCPPath)
}
}
if appConfig.OrchGlobalInstall {
_, _ = setup.InstallSkills("agy")
}
aa := agent.NewAgyAgent(agent.FindAgy(), appConfig.ModelOverride, appConfig.Effort, appConfig.OrchPermissionMode, appConfig.OutputVerbose, appConfig.NarrateToolCalls, ctx)
if err := aa.WriteMCPConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not write Antigravity MCP config: %v\n", err)
}
cliAgent = aa
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)
}
// Skills sync every launch (idempotent). opencode has no global-install
// prompt — qmax manages its config directly — so this is unconditional,
// unlike the OrchGlobalInstall-gated cc/codex sync above.
if _, err := setup.InstallSkills("opencode"); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not install opencode skills: %v\n", err)
}
cliAgent = oc
}
// Handle --resume flag
if *resumeID != "" {
var sess *session.Session
var loadErr error
if *resumeID == "last" {
sess, loadErr = session.LoadLastSession()
} else {
sess, loadErr = session.LoadSession(*resumeID)
}
if loadErr != nil {
fmt.Fprintf(os.Stderr, "Cannot resume session %q: %v\n", *resumeID, loadErr)
exitWithReceipt(1)
}
ag.RestoreConversation(sess.Messages, sess.Conversation)
ag.SessionID = sess.ID
ag.Usage = sess.Usage
if !ag.Cfg.Context.LocalOnly && sess.ProjectID > 0 {
ag.Cfg.Context.ProjectID = sess.ProjectID
}
fmt.Printf("Resumed session %s (%d turns)\n", sess.ID, sess.Turns)
}
// All backends share the portable session, including one-shot invocations.
oneShotSessionID := ag.SessionID
if !session.IsValidSessionID(oneShotSessionID) {
oneShotSessionID = session.GenerateSessionID()
}
ag.SessionID = oneShotSessionID
saveOneShotSession := func() {
if !shouldSaveOneShotSession(appConfig.AutoSave, ag.History) {
return
}
if err := session.SaveSession(oneShotSessionID, ag.History, ag.Cfg.Context.ProjectID, ag.Usage, ag.Cfg.Model, ag.Conversation); err != nil {
fmt.Fprintf(os.Stderr, "Failed to save session: %v\n", err)
}
}
runOneShot := func(prompt string) error {
if cliAgent != nil {
// CLI backends stream their own output (tool icons, glamour-rendered
// text) via the Terminal; FinishMarkdown handles final rendering.
// Build a Terminal here since main never created one (repl.Run owns
// the interactive Terminal instance).
term := tui.NewTerminal()
defer term.Close()
defer cliAgent.Cleanup()
_, err := ag.RunCLI(cliAgent, prompt, term)
saveOneShotSession()
return err
}
if shouldUseStreamingBuiltIn(ag) {
// Cerebras and Ollama run the native streaming tool loop.
// repl.Run owns the Logger in interactive mode; create one here so the
// one-shot tool loop (which logs each tool call) doesn't nil-panic.
if ag.Logger == nil {
ag.Logger = sysutil.NewLogger("oneshot")
defer ag.Logger.Close()
}
term := tui.NewTerminal()
defer term.Close()
_, err := ag.RunStreaming(prompt, term)
saveOneShotSession()
return err
}
// Direct-API path: non-streaming. Print the returned text ourselves.
result, err := ag.Run(prompt)
saveOneShotSession()
if err != nil {
return err
}
fmt.Println(result)
return nil
}
if *oneShot != "" {
if err := runOneShot(*oneShot); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
exitWithReceipt(1)
}
return
}
// Also handle positional args as a prompt: qmax-code "test the login flow"
if remaining := flag.Args(); len(remaining) > 0 {
if err := runOneShot(strings.Join(remaining, " ")); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
exitWithReceipt(1)
}
return
}
// Clean up expired sessions (legacy >7 days, portable transcripts >90 days)
if removed := session.CleanupOldSessions(); removed > 0 && *verbose {
fmt.Printf("[cleanup] Removed %d old sessions\n", removed)
}
// Sweep orphaned MCP config files left by crashed previous instances.
agent.CleanupStaleMCPConfigs()
// Interactive REPL
repl.Run(ag, cliAgent, *quiet, Version, finalizeForExit)
}
// resolveModel expands shorthand model names to full model IDs.
func resolveModel(m string) string {
return api.ResolveClaudeModel(m)
}
// applySaveSessionFlag applies the explicit per-run save-session override.
// Keeping this separate makes the flag behavior testable without starting the
// interactive terminal or loading credentials.
func applySaveSessionFlag(cfg *api.Config, enabled bool) {
if enabled {
cfg.AutoSave = true
}
}
func resolveLocalOnly(flagEnabled, persisted, envEnabled bool) bool {
return flagEnabled || persisted || envEnabled
}
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
// QUA-580 guard, so reaching setup without a TTY means -p/positional args.
func headlessSetupFallback(stdinIsTTY bool) bool {
return !stdinIsTTY
}
func shouldUseStreamingBuiltIn(ag *agent.Agent) bool {
return ag != nil && (ag.Cerebras != nil || (ag.Ollama != nil && ag.Mode != agent.OllamaModeOff))
}
// shouldSaveOneShotSession gates the one-shot session write: only persist
// when auto-save is on (--save-session forces this via applySaveSessionFlag)
// and there's actually a conversation to save.
func shouldSaveOneShotSession(autoSave bool, history []api.Message) bool {
return autoSave && len(history) > 0
}
// isValidModelName reports whether m is a recognized model identifier.
// QUA-579: pre-fix, any string was forwarded to the API and produced a
// confusing 401/400. Now we fail fast with a list of valid choices.
func isValidModelName(m string) bool {
return api.IsValidClaudeModelName(m)
}