返回 DeepSeek-Reasonix
setup_manager.go
根目录 / internal / cli / setup_manager.go
1 package cli
2
3 import (
4 "bufio"
5 "bytes"
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "sort"
12 "strings"
13 "time"
14
15 "reasonix/internal/config"
16 "reasonix/internal/i18n"
17 "reasonix/internal/netclient"
18 )
19
20 type providerSetupSession struct {
21 cfg *config.Config
22 originalProviders map[string]config.ProviderEntry
23 originalDefault string
24 pendingCredentials map[string]string
25 // Credentials are owned by selected connections, never by their previous
26 // shared environment variable. The legacy map above is summary-only.
27 pendingConnectionCredentials map[string]string
28 typedKeyEnv map[string]string // provider -> api_key_env the user typed; its pending key is saved there
29 removed map[string]bool
30 accessDeclared bool
31 projectScoped bool
32 declaredProviders []string
33 operations []providerSetupOperation
34 }
35
36 const setupManagerContinue = 2
37
38 type providerSetupOperationKind uint8
39
40 const (
41 setupOpProvider providerSetupOperationKind = iota
42 setupOpDefaultModel
43 setupOpLanguage
44 setupOpMaterializeAccess
45 setupOpAccessMembership
46 )
47
48 type providerSetupOperation struct {
49 kind providerSetupOperationKind
50 providerName string
51 beforeProvider *config.ProviderEntry
52 afterProvider *config.ProviderEntry
53 beforeString string
54 afterString string
55 accessName string
56 projectScoped bool
57 beforeBool bool
58 afterBool bool
59 }
60
61 type providerSetupConflictError struct {
62 field string
63 }
64
65 type providerSetupFileSnapshot struct {
66 exists bool
67 body []byte
68 }
69
70 func (e *providerSetupConflictError) Error() string {
71 return e.field
72 }
73
74 func providerSetupEntryPtr(entry config.ProviderEntry) *config.ProviderEntry {
75 copy := config.ProviderEntryConfigSnapshot(entry)
76 return &copy
77 }
78
79 func readProviderSetupFileSnapshot(path string) (providerSetupFileSnapshot, error) {
80 body, err := os.ReadFile(path)
81 if err != nil {
82 if os.IsNotExist(err) {
83 return providerSetupFileSnapshot{}, nil
84 }
85 return providerSetupFileSnapshot{}, err
86 }
87 return providerSetupFileSnapshot{exists: true, body: body}, nil
88 }
89
90 func providerSetupFileSnapshotEqual(a, b providerSetupFileSnapshot) bool {
91 return a.exists == b.exists && bytes.Equal(a.body, b.body)
92 }
93
94 func newProviderSetupSession(cfg *config.Config) *providerSetupSession {
95 s := &providerSetupSession{
96 cfg: cfg,
97 originalProviders: make(map[string]config.ProviderEntry, len(cfg.Providers)),
98 originalDefault: cfg.DefaultModel,
99 pendingCredentials: map[string]string{},
100 pendingConnectionCredentials: map[string]string{},
101 typedKeyEnv: map[string]string{},
102 removed: map[string]bool{},
103 }
104 for _, p := range cfg.Providers {
105 s.originalProviders[p.Name] = p
106 }
107 return s
108 }
109
110 func newProviderSetupSessionForPath(cfg *config.Config, path string) *providerSetupSession {
111 s := newProviderSetupSession(cfg)
112 s.projectScoped = !config.IsUserConfigPath(path)
113 declarations, err := config.InspectConfigFileDeclarations(path)
114 if err != nil {
115 // LoadForEdit already reports malformed/unreadable config and falls back;
116 // keep the conservative policy here so setup never enables hidden siblings.
117 s.accessDeclared = true
118 return s
119 }
120 s.accessDeclared = declarations.DesktopProviderAccessDeclared
121 s.declaredProviders = declarations.ProviderNames
122 return s
123 }
124
125 func (s *providerSetupSession) recordProviderMutation(name string, before, after *config.ProviderEntry) {
126 s.operations = append(s.operations, providerSetupOperation{
127 kind: setupOpProvider,
128 providerName: name,
129 beforeProvider: before,
130 afterProvider: after,
131 })
132 }
133
134 func (s *providerSetupSession) setLanguage(language string) {
135 if s.cfg.Language == language {
136 return
137 }
138 s.operations = append(s.operations, providerSetupOperation{
139 kind: setupOpLanguage,
140 beforeString: s.cfg.Language,
141 afterString: language,
142 })
143 s.cfg.Language = language
144 }
145
146 func (s *providerSetupSession) applyDeepSeekOfficialDefaultPricing() {
147 before := make(map[string]config.ProviderEntry, len(s.cfg.Providers))
148 for _, provider := range s.cfg.Providers {
149 before[provider.Name] = provider
150 }
151 s.cfg.ApplyDeepSeekOfficialDefaultPricing()
152 for i := range s.cfg.Providers {
153 after := s.cfg.Providers[i]
154 previous, existed := before[after.Name]
155 if existed && config.ProviderEntriesConfigEqual(previous, after) {
156 delete(before, after.Name)
157 continue
158 }
159 var previousPtr *config.ProviderEntry
160 if existed {
161 previousPtr = providerSetupEntryPtr(previous)
162 }
163 s.recordProviderMutation(after.Name, previousPtr, providerSetupEntryPtr(after))
164 delete(before, after.Name)
165 }
166 for name, previous := range before {
167 s.recordProviderMutation(name, providerSetupEntryPtr(previous), nil)
168 }
169 }
170
171 func (s *providerSetupSession) resetProviderSummaryBaseline() {
172 s.originalProviders = make(map[string]config.ProviderEntry, len(s.cfg.Providers))
173 for _, provider := range s.cfg.Providers {
174 s.originalProviders[provider.Name] = provider
175 }
176 }
177
178 func (s *providerSetupSession) upsert(entries []config.ProviderEntry) error {
179 for _, entry := range entries {
180 var before *config.ProviderEntry
181 if current, ok := s.cfg.Provider(entry.Name); ok {
182 before = providerSetupEntryPtr(*current)
183 }
184 if err := s.cfg.UpsertProvider(entry); err != nil {
185 return err
186 }
187 current, _ := s.cfg.Provider(entry.Name)
188 if before == nil || !config.ProviderEntriesConfigEqual(*before, *current) {
189 s.recordProviderMutation(entry.Name, before, providerSetupEntryPtr(*current))
190 }
191 delete(s.removed, entry.Name)
192 s.repairDanglingDefaultFor(*current)
193 }
194 return nil
195 }
196
197 // repairDanglingDefaultFor re-points default_model at the provider's own default
198 // when an edit or model refresh dropped the exact model the ref named, mirroring
199 // the repair RemoveProvider performs on removal.
200 func (s *providerSetupSession) repairDanglingDefaultFor(p config.ProviderEntry) {
201 if !config.ModelRefsProvider(s.cfg.DefaultModel, p.Name) || len(p.ModelList()) == 0 {
202 return
203 }
204 if _, ok := s.cfg.ResolveModel(s.cfg.DefaultModel); ok {
205 return
206 }
207 if err := s.setDefaultModel(p.Name); err != nil {
208 fmt.Fprintln(os.Stderr, err)
209 }
210 }
211
212 func (s *providerSetupSession) add(entries []config.ProviderEntry) error {
213 seen := make(map[string]bool, len(s.cfg.Providers)+len(entries))
214 for _, provider := range s.cfg.Providers {
215 seen[provider.Name] = true
216 }
217 for _, entry := range entries {
218 if seen[entry.Name] {
219 return fmt.Errorf(i18n.M.SetupProviderExistsFmt, entry.Name)
220 }
221 seen[entry.Name] = true
222 }
223 return s.upsert(entries)
224 }
225
226 func (s *providerSetupSession) remove(name string) error {
227 current, ok := s.cfg.Provider(name)
228 if !ok {
229 return fmt.Errorf("remove provider: no provider %q", name)
230 }
231 before := providerSetupEntryPtr(*current)
232 if err := s.cfg.RemoveProvider(name); err != nil {
233 return err
234 }
235 s.recordProviderMutation(name, before, nil)
236 delete(s.typedKeyEnv, name)
237 s.removeProviderAccess(name)
238 if _, existed := s.originalProviders[name]; existed {
239 s.removed[name] = true
240 }
241 return nil
242 }
243
244 func (s *providerSetupSession) addProviderAccess(entries []config.ProviderEntry) {
245 if len(entries) == 0 {
246 return
247 }
248 before := append([]string(nil), s.cfg.Desktop.ProviderAccess...)
249 // Preserve the legacy "undeclared means infer all configured providers"
250 // behavior before turning provider_access into an explicit list. Project
251 // setup only seeds providers declared by that project; cfg also contains
252 // built-in defaults, which must not override the user's global access policy.
253 if !s.accessDeclared && len(s.cfg.Desktop.ProviderAccess) == 0 {
254 if s.projectScoped {
255 for _, name := range s.declaredProviders {
256 provider, ok := s.cfg.Provider(name)
257 if ok && provider.Configured() && len(provider.ModelList()) > 0 {
258 s.cfg.Desktop.ProviderAccess = append(s.cfg.Desktop.ProviderAccess, name)
259 }
260 }
261 } else {
262 config.NormalizeLegacyDesktopProviderAccess(s.cfg)
263 }
264 s.accessDeclared = true
265 s.operations = append(s.operations, providerSetupOperation{
266 kind: setupOpMaterializeAccess,
267 projectScoped: s.projectScoped,
268 })
269 before = append([]string(nil), s.cfg.Desktop.ProviderAccess...)
270 }
271 seen := make(map[string]bool, len(s.cfg.Desktop.ProviderAccess)+len(entries))
272 for _, name := range s.cfg.Desktop.ProviderAccess {
273 name = strings.TrimSpace(name)
274 if name != "" {
275 seen[name] = true
276 }
277 }
278 for _, entry := range entries {
279 name := strings.TrimSpace(entry.Name)
280 if name == "" || seen[name] {
281 continue
282 }
283 s.cfg.Desktop.ProviderAccess = append(s.cfg.Desktop.ProviderAccess, name)
284 seen[name] = true
285 }
286 s.accessDeclared = true
287 s.recordAccessTransition(before)
288 }
289
290 func (s *providerSetupSession) removeProviderAccess(name string) {
291 name = strings.TrimSpace(name)
292 if name == "" || len(s.cfg.Desktop.ProviderAccess) == 0 {
293 return
294 }
295 before := append([]string(nil), s.cfg.Desktop.ProviderAccess...)
296 out := s.cfg.Desktop.ProviderAccess[:0]
297 for _, current := range s.cfg.Desktop.ProviderAccess {
298 if strings.TrimSpace(current) != name {
299 out = append(out, current)
300 }
301 }
302 s.cfg.Desktop.ProviderAccess = out
303 s.recordAccessTransition(before)
304 }
305
306 func (s *providerSetupSession) recordAccessTransition(before []string) {
307 beforeSet := make(map[string]bool, len(before))
308 afterSet := make(map[string]bool, len(s.cfg.Desktop.ProviderAccess))
309 var order []string
310 seen := map[string]bool{}
311 for _, names := range [][]string{before, s.cfg.Desktop.ProviderAccess} {
312 for _, name := range names {
313 name = strings.TrimSpace(name)
314 if name == "" {
315 continue
316 }
317 if !seen[name] {
318 seen[name] = true
319 order = append(order, name)
320 }
321 }
322 }
323 for _, name := range before {
324 name = strings.TrimSpace(name)
325 if name != "" {
326 beforeSet[name] = true
327 }
328 }
329 for _, name := range s.cfg.Desktop.ProviderAccess {
330 name = strings.TrimSpace(name)
331 if name != "" {
332 afterSet[name] = true
333 }
334 }
335 for _, name := range order {
336 if beforeSet[name] == afterSet[name] {
337 continue
338 }
339 s.operations = append(s.operations, providerSetupOperation{
340 kind: setupOpAccessMembership,
341 accessName: name,
342 beforeBool: beforeSet[name],
343 afterBool: afterSet[name],
344 })
345 }
346 }
347
348 func (s *providerSetupSession) setCredential(key, value string) error {
349 var names []string
350 for _, entry := range s.cfg.Providers {
351 if entry.APIKeyEnv == strings.TrimSpace(key) {
352 names = append(names, entry.Name)
353 }
354 }
355 return s.setCredentialForProviders(names, key, value)
356 }
357
358 func (s *providerSetupSession) setCredentialForProviders(names []string, key, value string) error {
359 key = strings.TrimSpace(key)
360 if !config.IsValidCredentialKey(key) {
361 return fmt.Errorf("invalid API key variable name %q", key)
362 }
363 if strings.ContainsAny(value, "\r\n") {
364 return fmt.Errorf("API key for %s contains a newline", key)
365 }
366 s.pendingCredentials[key] = value
367 if s.pendingConnectionCredentials == nil {
368 s.pendingConnectionCredentials = map[string]string{}
369 }
370 for _, name := range names {
371 if name = strings.TrimSpace(name); name != "" {
372 entry, ok := s.cfg.Provider(name)
373 if !ok {
374 return fmt.Errorf("unknown provider %q", name)
375 }
376 // A credential-only edit still has a config precondition. Replay it
377 // at the same point as other edits so concurrent rotations conflict.
378 s.recordProviderMutation(name, providerSetupEntryPtr(*entry), providerSetupEntryPtr(*entry))
379 s.pendingConnectionCredentials[name] = value
380 }
381 }
382 return nil
383 }
384
385 func (s *providerSetupSession) setDefaultModel(model string) error {
386 before := s.cfg.DefaultModel
387 if err := s.cfg.SetDefaultModel(model); err != nil {
388 return err
389 }
390 if before != s.cfg.DefaultModel {
391 s.operations = append(s.operations, providerSetupOperation{
392 kind: setupOpDefaultModel,
393 beforeString: before,
394 afterString: s.cfg.DefaultModel,
395 })
396 }
397 return nil
398 }
399
400 // providerUsable reports whether the provider would be selectable once this
401 // session saves: it lists models and either needs no key or has one resolvable
402 // from the credential store or staged in this session.
403 func (s *providerSetupSession) providerUsable(p *config.ProviderEntry) bool {
404 if p == nil || len(p.ModelList()) == 0 {
405 return false
406 }
407 return p.Configured() || s.pendingConnectionCredentials[p.Name] != ""
408 }
409
410 // defaultModelUsable reports whether default_model resolves to a provider the
411 // user could actually run once this session saves.
412 func (s *providerSetupSession) defaultModelUsable() bool {
413 entry, ok := s.cfg.ResolveModel(s.cfg.DefaultModel)
414 return ok && s.providerUsable(entry)
415 }
416
417 // promoteDefaultToNewProviders keeps the wizard's first-run contract: when the
418 // current default_model cannot run (unresolvable, or its key is neither stored
419 // nor staged), point it at the first usable provider the user just added, so a
420 // first run that only configures a custom provider boots on that provider
421 // instead of failing on the built-in default's missing key. A usable default is
422 // never hijacked.
423 func (s *providerSetupSession) promoteDefaultToNewProviders(entries []config.ProviderEntry) {
424 if s.defaultModelUsable() {
425 return
426 }
427 for _, entry := range entries {
428 current, ok := s.cfg.Provider(entry.Name)
429 if !ok || !s.providerUsable(current) {
430 continue
431 }
432 if err := s.setDefaultModel(current.Name); err == nil {
433 return
434 }
435 }
436 }
437
438 func (s *providerSetupSession) credentialLines() []string {
439 keys := make([]string, 0, len(s.pendingCredentials))
440 for key := range s.pendingCredentials {
441 keys = append(keys, key)
442 }
443 sort.Strings(keys)
444 lines := make([]string, 0, len(keys))
445 for _, key := range keys {
446 lines = append(lines, key+"="+s.pendingCredentials[key])
447 }
448 return lines
449 }
450
451 func (s *providerSetupSession) summary() []string {
452 var added, edited []string
453 for _, p := range s.cfg.Providers {
454 old, existed := s.originalProviders[p.Name]
455 switch {
456 case !existed:
457 added = append(added, p.Name)
458 case !providerSetupEqual(old, p):
459 edited = append(edited, p.Name)
460 }
461 }
462 var out []string
463 if len(added) > 0 {
464 out = append(out, fmt.Sprintf(i18n.M.SetupSummaryAddedFmt, strings.Join(added, ", ")))
465 }
466 if len(edited) > 0 {
467 out = append(out, fmt.Sprintf(i18n.M.SetupSummaryEditedFmt, strings.Join(edited, ", ")))
468 }
469 if len(s.removed) > 0 {
470 names := make([]string, 0, len(s.removed))
471 for name := range s.removed {
472 names = append(names, name)
473 }
474 sort.Strings(names)
475 out = append(out, fmt.Sprintf(i18n.M.SetupSummaryRemovedFmt, strings.Join(names, ", ")))
476 }
477 if s.cfg.DefaultModel != s.originalDefault {
478 out = append(out, fmt.Sprintf(i18n.M.SetupSummaryDefaultFmt, s.cfg.DefaultModel))
479 }
480 if len(s.pendingCredentials) > 0 {
481 out = append(out, fmt.Sprintf(i18n.M.SetupSummaryKeysFmt, len(s.pendingCredentials)))
482 }
483 if len(out) == 0 {
484 out = append(out, i18n.M.SetupSummaryNoChanges)
485 }
486 return out
487 }
488
489 func providerSetupEqual(a, b config.ProviderEntry) bool {
490 // Render-level equality is unnecessary here: the manager only changes these
491 // fields, while advanced provider fields are preserved by editing a copy.
492 return a.Name == b.Name && a.Kind == b.Kind && a.BaseURL == b.BaseURL &&
493 a.Model == b.Model && strings.Join(a.Models, "\x00") == strings.Join(b.Models, "\x00") &&
494 a.Default == b.Default && a.APIKeyEnv == b.APIKeyEnv
495 }
496
497 func runProviderSetupManager(s *providerSetupSession, configPath, envPath string) int {
498 cfg := s.cfg
499 repaired, repairs := repairInvalidProviderKeyEnvs(cfg.Providers)
500 for i := range repaired {
501 if config.ProviderEntriesConfigEqual(cfg.Providers[i], repaired[i]) {
502 continue
503 }
504 before := providerSetupEntryPtr(cfg.Providers[i])
505 cfg.Providers[i] = repaired[i]
506 s.recordProviderMutation(repaired[i].Name, before, providerSetupEntryPtr(repaired[i]))
507 }
508 for _, repair := range repairs {
509 fmt.Fprintf(os.Stderr, " %s\n", dim(fmt.Sprintf(i18n.M.RepairedAPIKeyEnvFmt, repair.provider, repair.old, repair.new)))
510 }
511 for {
512 items := providerManagerItems(s)
513 idx, err := selectOne(i18n.M.SetupManagerTitle, items)
514 if err != nil {
515 fmt.Fprintln(os.Stderr, "\n"+i18n.M.SetupCancelled)
516 return 1
517 }
518 providerCount := len(cfg.Providers)
519 switch idx {
520 case providerCount:
521 if !addProviderToSession(s, false) {
522 continue
523 }
524 case providerCount + 1:
525 if !addProviderToSession(s, true) {
526 continue
527 }
528 case providerCount + 2:
529 rc := saveProviderSetupSession(s, configPath, envPath)
530 if rc == setupManagerContinue {
531 continue
532 }
533 return rc
534 case providerCount + 3:
535 fmt.Println(i18n.M.SetupCancelled)
536 return 1
537 default:
538 manageProvider(s, idx)
539 }
540 }
541 }
542
543 func providerManagerItems(s *providerSetupSession) []menuItem {
544 cfg := s.cfg
545 items := make([]menuItem, 0, len(cfg.Providers)+4)
546 for _, p := range cfg.Providers {
547 models := p.ModelList()
548 keyStatus := i18n.M.SetupKeyMissing
549 if p.APIKeyEnv == "" || config.CredentialIsSet(p.APIKeyEnv) || s.pendingConnectionCredentials[p.Name] != "" {
550 keyStatus = i18n.M.SetupKeySet
551 }
552 desc := fmt.Sprintf("%s · %d %s · %s", p.Kind, len(models), i18n.M.SetupModelsUnit, keyStatus)
553 if cfg.DefaultModel == p.Name || config.ModelRefsProvider(cfg.DefaultModel, p.Name) {
554 desc += " · " + i18n.M.SetupDefaultBadge
555 }
556 items = append(items, menuItem{name: p.Name, desc: desc})
557 }
558 return append(items,
559 menuItem{name: i18n.M.SetupAddOpenAI, desc: i18n.M.CustomProviderDesc},
560 menuItem{name: i18n.M.SetupAddAnthropic, desc: i18n.M.AnthropicProviderDesc},
561 menuItem{name: i18n.M.SetupSaveExit, desc: i18n.M.SetupSaveExitDesc},
562 menuItem{name: i18n.M.SetupCancel, desc: i18n.M.SetupCancelDesc},
563 )
564 }
565
566 func addProviderToSession(s *providerSetupSession, anthropic bool) bool {
567 var result providerPromptResult
568 var err error
569 var proxy netclient.ProxySpec
570 if s != nil && s.cfg != nil {
571 proxy = s.cfg.NetworkProxySpec()
572 }
573 if anthropic {
574 result, err = promptAnthropicProvider(proxy)
575 } else {
576 result, err = promptCustomProvider(proxy)
577 }
578 if err != nil {
579 if !errors.Is(err, errCancelled) {
580 fmt.Fprintln(os.Stderr, err)
581 }
582 return false
583 }
584 return s.addPrompted(bufio.NewScanner(os.Stdin), os.Stdout, result)
585 }
586
587 func manageProvider(s *providerSetupSession, providerIndex int) {
588 if providerIndex < 0 || providerIndex >= len(s.cfg.Providers) {
589 return
590 }
591 p := s.cfg.Providers[providerIndex]
592 idx, err := selectOne(fmt.Sprintf(i18n.M.SetupProviderActionsFmt, p.Name), []menuItem{
593 {name: i18n.M.SetupEditProvider},
594 {name: i18n.M.SetupUpdateKey},
595 {name: i18n.M.SetupTestRefresh},
596 {name: i18n.M.SetupSetDefault},
597 {name: i18n.M.SetupRemoveProvider},
598 {name: i18n.M.SetupBack},
599 })
600 if err != nil || idx == 5 {
601 return
602 }
603 switch idx {
604 case 0:
605 editProvider(s, p)
606 case 1:
607 updateProviderKey(s, p)
608 case 2:
609 testAndRefreshProvider(s, p)
610 case 3:
611 setDefaultProvider(s, p)
612 case 4:
613 removeProviderFromSession(s, p)
614 }
615 }
616
617 func editProvider(s *providerSetupSession, current config.ProviderEntry) {
618 in := bufio.NewScanner(os.Stdin)
619 edited := current
620 edited.BaseURL = ask(in, os.Stdout, i18n.M.CustomPromptBaseURL, current.BaseURL)
621 models := ask(in, os.Stdout, i18n.M.SetupPromptModels, strings.Join(current.ModelList(), ","))
622 edited.Models = splitModels(models)
623 if len(edited.Models) == 1 {
624 edited.Model = edited.Models[0]
625 } else {
626 edited.Model = ""
627 }
628 if len(edited.Models) > 0 && !containsString(edited.Models, edited.Default) {
629 edited.Default = edited.Models[0]
630 }
631 edited.APIKeyEnv = promptOptionalAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, current.APIKeyEnv)
632 if !confirmSharedCredential(s.cfg, edited, current.Name) {
633 return
634 }
635 if err := s.upsert([]config.ProviderEntry{edited}); err != nil {
636 fmt.Fprintln(os.Stderr, err)
637 }
638 }
639
640 func promptOptionalAPIKeyEnvName(in *bufio.Scanner, w io.Writer, label, def string) string {
641 for {
642 key := ask(in, w, label, def)
643 if key == "" || config.IsValidCredentialKey(key) {
644 return key
645 }
646 fmt.Fprintf(w, i18n.M.InvalidAPIKeyEnvFmt+"\n", key)
647 }
648 }
649
650 func splitModels(raw string) []string {
651 seen := map[string]bool{}
652 var models []string
653 for model := range strings.SplitSeq(raw, ",") {
654 model = strings.TrimSpace(model)
655 if model != "" && !seen[model] {
656 seen[model] = true
657 models = append(models, model)
658 }
659 }
660 return models
661 }
662
663 func confirmSharedCredential(cfg *config.Config, candidate config.ProviderEntry, ignoreName string) bool {
664 if candidate.APIKeyEnv == "" {
665 return true
666 }
667 for _, p := range cfg.Providers {
668 if p.Name == ignoreName || p.Name == candidate.Name || p.APIKeyEnv != candidate.APIKeyEnv || p.BaseURL == candidate.BaseURL {
669 continue
670 }
671 in := bufio.NewScanner(os.Stdin)
672 answer := ask(in, os.Stdout, fmt.Sprintf(i18n.M.SetupSharedKeyWarningFmt, candidate.APIKeyEnv, p.Name, p.BaseURL), "y/N")
673 return answer == "y" || answer == "Y"
674 }
675 return true
676 }
677
678 func updateProviderKey(s *providerSetupSession, p config.ProviderEntry) {
679 in := bufio.NewScanner(os.Stdin)
680 keyEnvChanged, typed := false, false
681 if p.APIKeyEnv == "" {
682 draft := apiKeyEnvFromProviderName(p.Name)
683 if p.APIKeyEnv, typed = promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, draft); typed {
684 p.APIKeyEnv, typed = s.settleTypedKeyEnv(in, os.Stdout, i18n.M.CustomPromptKeyEnv, []string{p.Name}, p.APIKeyEnv, draft)
685 }
686 keyEnvChanged = true
687 }
688 if !confirmSharedCredential(s.cfg, p, p.Name) {
689 return
690 }
691 if keyEnvChanged {
692 if err := s.upsert([]config.ProviderEntry{p}); err != nil {
693 fmt.Fprintln(os.Stderr, err)
694 return
695 }
696 }
697 value := askSecret(in, os.Stdout, fmt.Sprintf(i18n.M.SetupPromptAPIKeyFmt, p.APIKeyEnv))
698 if value == "" {
699 return
700 }
701 if err := s.setCredentialForProviders([]string{p.Name}, p.APIKeyEnv, value); err != nil {
702 fmt.Fprintln(os.Stderr, err)
703 return
704 }
705 if typed {
706 s.typedKeyEnv[p.Name] = p.APIKeyEnv
707 }
708 }
709
710 func testAndRefreshProvider(s *providerSetupSession, p config.ProviderEntry) {
711 restore := temporarilySetCredential(p.APIKeyEnv, s.pendingConnectionCredentials[p.Name])
712 defer restore()
713 p.ResolveAPIKeyFromProcessEnvForProbe()
714 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
715 defer cancel()
716 var proxy netclient.ProxySpec
717 if s.cfg != nil {
718 proxy = s.cfg.NetworkProxySpec()
719 }
720 models, err := p.FetchModelsWithProxy(ctx, proxy)
721 if err != nil {
722 fmt.Fprintf(os.Stderr, i18n.M.FetchModelsFailedFmt+"\n", p.Name, err)
723 return
724 }
725 if len(models) == 0 {
726 fmt.Fprintln(os.Stderr, i18n.M.CustomFetchEmpty)
727 return
728 }
729 items := make([]menuItem, len(models))
730 for i, model := range models {
731 items[i] = menuItem{name: model}
732 }
733 idxs, err := selectMany(fmt.Sprintf(i18n.M.SelectModelsLabel, p.Name), items)
734 if err != nil || len(idxs) == 0 {
735 return
736 }
737 selected := make([]string, 0, len(idxs))
738 for _, idx := range idxs {
739 selected = append(selected, models[idx])
740 }
741 p.Models = selected
742 p.Model = ""
743 if !containsString(selected, p.Default) {
744 p.Default = selected[0]
745 }
746 if err := s.upsert([]config.ProviderEntry{p}); err != nil {
747 fmt.Fprintln(os.Stderr, err)
748 return
749 }
750 fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.FetchModelsSuccessFmt, len(models), p.Name)))
751 }
752
753 func temporarilySetCredential(key, value string) func() {
754 if key == "" || value == "" {
755 return func() {}
756 }
757 old, existed := os.LookupEnv(key)
758 _ = os.Setenv(key, value)
759 return func() {
760 if existed {
761 _ = os.Setenv(key, old)
762 } else {
763 _ = os.Unsetenv(key)
764 }
765 }
766 }
767
768 func setDefaultProvider(s *providerSetupSession, p config.ProviderEntry) {
769 models := p.ModelList()
770 if len(models) == 0 {
771 return
772 }
773 items := make([]menuItem, len(models))
774 for i, model := range models {
775 items[i] = menuItem{name: model}
776 }
777 idx, err := selectOne(i18n.M.SetupSelectDefaultModel, items)
778 if err != nil {
779 return
780 }
781 if err := s.setDefaultModel(p.Name + "/" + models[idx]); err != nil {
782 fmt.Fprintln(os.Stderr, err)
783 }
784 }
785
786 func removeProviderFromSession(s *providerSetupSession, p config.ProviderEntry) {
787 in := bufio.NewScanner(os.Stdin)
788 answer := ask(in, os.Stdout, fmt.Sprintf(i18n.M.SetupConfirmRemoveFmt, p.Name), "y/N")
789 if answer != "y" && answer != "Y" {
790 return
791 }
792 if err := s.remove(p.Name); err != nil {
793 fmt.Fprintln(os.Stderr, err)
794 }
795 }
796
797 func (s *providerSetupSession) replayOperations(cfg *config.Config, accessDeclared *bool, declaredProviders []string) error {
798 for _, operation := range s.operations {
799 switch operation.kind {
800 case setupOpProvider:
801 current, exists := cfg.Provider(operation.providerName)
802 if operation.beforeProvider == nil {
803 if exists {
804 return &providerSetupConflictError{field: fmt.Sprintf("provider %q", operation.providerName)}
805 }
806 } else if !exists || !config.ProviderEntriesConfigEqual(*current, *operation.beforeProvider) {
807 return &providerSetupConflictError{field: fmt.Sprintf("provider %q", operation.providerName)}
808 }
809 if operation.afterProvider == nil {
810 if err := cfg.RemoveProvider(operation.providerName); err != nil {
811 return fmt.Errorf("replay remove provider %q: %w", operation.providerName, err)
812 }
813 } else if err := cfg.UpsertProviderPreservingRuntime(*operation.afterProvider); err != nil {
814 return fmt.Errorf("replay provider %q: %w", operation.providerName, err)
815 }
816 case setupOpDefaultModel:
817 if cfg.DefaultModel != operation.beforeString {
818 return &providerSetupConflictError{field: "default_model"}
819 }
820 if err := cfg.SetDefaultModel(operation.afterString); err != nil {
821 return fmt.Errorf("replay default_model: %w", err)
822 }
823 case setupOpLanguage:
824 if cfg.Language != operation.beforeString {
825 return &providerSetupConflictError{field: "language"}
826 }
827 cfg.Language = operation.afterString
828 case setupOpMaterializeAccess:
829 if *accessDeclared {
830 return &providerSetupConflictError{field: "desktop.provider_access"}
831 }
832 cfg.Desktop.ProviderAccess = nil
833 if operation.projectScoped {
834 for _, name := range declaredProviders {
835 provider, ok := cfg.Provider(name)
836 if ok && provider.Configured() && len(provider.ModelList()) > 0 {
837 cfg.Desktop.ProviderAccess = append(cfg.Desktop.ProviderAccess, name)
838 }
839 }
840 } else {
841 config.NormalizeLegacyDesktopProviderAccess(cfg)
842 }
843 *accessDeclared = true
844 if cfg.Desktop.ProviderAccess == nil {
845 cfg.Desktop.ProviderAccess = []string{}
846 }
847 case setupOpAccessMembership:
848 current := providerSetupAccessContains(cfg.Desktop.ProviderAccess, operation.accessName)
849 if current != operation.beforeBool {
850 return &providerSetupConflictError{field: fmt.Sprintf("desktop.provider_access[%q]", operation.accessName)}
851 }
852 if operation.afterBool {
853 cfg.Desktop.ProviderAccess = append(cfg.Desktop.ProviderAccess, operation.accessName)
854 } else {
855 out := cfg.Desktop.ProviderAccess[:0]
856 for _, name := range cfg.Desktop.ProviderAccess {
857 if strings.TrimSpace(name) != operation.accessName {
858 out = append(out, name)
859 }
860 }
861 cfg.Desktop.ProviderAccess = out
862 }
863 default:
864 return fmt.Errorf("unknown provider setup operation %d", operation.kind)
865 }
866 }
867 return nil
868 }
869
870 func providerSetupAccessContains(names []string, want string) bool {
871 want = strings.TrimSpace(want)
872 for _, name := range names {
873 if strings.TrimSpace(name) == want {
874 return true
875 }
876 }
877 return false
878 }
879
880 func commitProviderSetupSession(s *providerSetupSession, configPath string) (bool, error) {
881 if len(s.operations) == 0 && len(s.pendingConnectionCredentials) == 0 {
882 return false, nil
883 }
884 unlock, err := config.LockConfigFileEdits(configPath)
885 if err != nil {
886 return false, err
887 }
888 defer unlock()
889 unlockCredentials, err := config.LockUserCredentialEdits()
890 if err != nil {
891 return false, err
892 }
893 defer unlockCredentials()
894
895 before, err := readProviderSetupFileSnapshot(configPath)
896 if err != nil {
897 return false, err
898 }
899 declarations, err := config.InspectConfigFileDeclarations(configPath)
900 if err != nil {
901 return false, err
902 }
903 fresh, err := config.LoadForEditReadOnlyStrict(configPath)
904 if err != nil {
905 return false, err
906 }
907 if err := fresh.BeginModelCredentialCommitLocked(configPath, "cli-setup"); err != nil {
908 return false, err
909 }
910 defer fresh.CleanupStagedModelCredentialsLocked(configPath)
911 baseline := fresh.ModelSettingsBaseline()
912 accessDeclared := declarations.DesktopProviderAccessDeclared
913 if err := s.replayOperations(fresh, &accessDeclared, declarations.ProviderNames); err != nil {
914 return false, err
915 }
916 for name, value := range s.pendingConnectionCredentials {
917 entry, ok := fresh.Provider(name)
918 if !ok {
919 return false, &providerSetupConflictError{field: fmt.Sprintf("provider %q", name)}
920 }
921 var slot string
922 if typed := s.typedKeyEnv[name]; typed != "" && typed == entry.APIKeyEnv {
923 slot, err = fresh.StageNamedModelCredentialLocked(typed, name, value)
924 } else {
925 slot, err = fresh.RotateModelCredentialLocked([]string{name}, value)
926 }
927 if err != nil {
928 return false, err
929 }
930 updated := *entry
931 updated.APIKeyEnv = slot
932 if err := fresh.UpsertProvider(updated); err != nil {
933 return false, err
934 }
935 }
936 current, err := readProviderSetupFileSnapshot(configPath)
937 if err != nil {
938 return false, err
939 }
940 if !providerSetupFileSnapshotEqual(before, current) {
941 return false, &providerSetupConflictError{field: "configuration file"}
942 }
943 err = fresh.SaveModelSettingsTo(configPath, baseline)
944 if err != nil {
945 return false, err
946 }
947 if err := fresh.MarkModelCredentialConfigCommittedLocked(configPath); err != nil {
948 return false, err
949 }
950 if _, err := config.LoadForEditReadOnlyStrict(configPath); err != nil {
951 return false, err
952 }
953 if err := fresh.CompleteModelCredentialCommitLocked(); err != nil {
954 return false, err
955 }
956 return true, nil
957 }
958
959 func saveProviderSetupSession(s *providerSetupSession, configPath, envPath string) int {
960 fmt.Println()
961 fmt.Println(i18n.M.SetupSummaryTitle)
962 for _, line := range s.summary() {
963 fmt.Println(" " + line)
964 }
965 in := bufio.NewScanner(os.Stdin)
966 answer := ask(in, os.Stdout, i18n.M.SetupConfirmSave, "Y/n")
967 if answer == "n" || answer == "N" {
968 return setupManagerContinue
969 }
970 configWritten, err := commitProviderSetupSession(s, configPath)
971 if err != nil {
972 var conflict *providerSetupConflictError
973 if errors.As(err, &conflict) {
974 fmt.Fprintf(os.Stderr, i18n.M.SetupConcurrentChangeFmt+"\n", conflict.field)
975 } else if errors.Is(err, config.ErrCredentialKeyInUse) {
976 fmt.Fprintln(os.Stderr, i18n.M.WriteConfigErr, keyEnvInUseText(err))
977 } else {
978 fmt.Fprintln(os.Stderr, i18n.M.WriteConfigErr, err)
979 }
980 return 1
981 }
982 if configWritten {
983 fmt.Printf("\n%s %s\n", green("✓"), fmt.Sprintf(i18n.M.WroteFileFmt, displayPath(configPath)))
984 }
985 if len(s.pendingCredentials) > 0 {
986 target := config.CredentialsTargetDescription()
987 if target == "" {
988 target = envPath
989 }
990 fmt.Printf("%s %s\n", green("✓"), fmt.Sprintf(i18n.M.WroteFileFmt, displayPath(target)))
991 }
992 fmt.Printf("\n%s %s\n", accent("◆"), i18n.M.SetupComplete)
993 return 0
994 }
995
995 lines GO