Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
kardolus
GitHub Repository: kardolus/chatgpt-cli
Path: blob/main/cmd/chatgpt/main.go
2865 views
1
package main
2
3
import (
4
"context"
5
"errors"
6
"fmt"
7
"io"
8
"os"
9
"strings"
10
"time"
11
12
"github.com/kardolus/chatgpt-cli/api/client"
13
"github.com/kardolus/chatgpt-cli/api/http"
14
"github.com/kardolus/chatgpt-cli/cmd/chatgpt/utils"
15
"github.com/kardolus/chatgpt-cli/internal"
16
"github.com/spf13/pflag"
17
"go.uber.org/zap/zapcore"
18
"gopkg.in/yaml.v3"
19
20
"github.com/chzyer/readline"
21
"github.com/kardolus/chatgpt-cli/config"
22
"github.com/kardolus/chatgpt-cli/history"
23
"github.com/spf13/cobra"
24
"github.com/spf13/viper"
25
"go.uber.org/zap"
26
)
27
28
var (
29
GitCommit string
30
GitVersion string
31
queryMode bool
32
clearHistory bool
33
showHistory bool
34
showVersion bool
35
showDebug bool
36
newThread bool
37
showConfig bool
38
interactiveMode bool
39
listModels bool
40
listThreads bool
41
hasPipe bool
42
useSpeak bool
43
useDraw bool
44
promptFile string
45
roleFile string
46
imageFile string
47
audioFile string
48
outputFile string
49
threadName string
50
ServiceURL string
51
shell string
52
mcpTarget string
53
modelTarget string
54
paramsList []string
55
paramsJSON string
56
cfg config.Config
57
)
58
59
type ConfigMetadata struct {
60
Key string
61
FlagName string
62
DefaultValue interface{}
63
Description string
64
}
65
66
var configMetadata = []ConfigMetadata{
67
{"model", "set-model", "gpt-4o", "Set a new default model by specifying the model name"},
68
{"max_tokens", "set-max-tokens", 4096, "Set a new default max token size"},
69
{"context_window", "set-context-window", 8192, "Set a new default context window size"},
70
{"thread", "set-thread", "default", "Set a new active thread by specifying the thread name"},
71
{"api_key", "set-api-key", "", "Set the API key for authentication"},
72
{"api_key_file", "set-api-key-file", "", "Load the API key from a file"},
73
{"apify_api_key", "set-apify-api-key", "", "Configure Apify API key for MCP"},
74
{"role", "set-role", "You are a helpful assistant.", "Set the role of the AI assistant"},
75
{"url", "set-url", "https://api.openai.com", "Set the API base URL"},
76
{"completions_path", "set-completions-path", "/v1/chat/completions", "Set the completions API endpoint"},
77
{"responses_path", "set-responses-path", "/v1/responses", "Set the responses API endpoint"},
78
{"transcriptions_path", "set-transcriptions-path", "/v1/audio/transcriptions", "Set the transcriptions API endpoint"},
79
{"speech_path", "set-speech-path", "/v1/audio/speech", "Set the speech API endpoint"},
80
{"image_generations_path", "set-image-generations-path", "/v1/images/generations", "Set the image generation API endpoint"},
81
{"image_edits_path", "set-image-edits-path", "/v1/images/edits", "Set the image edits API endpoint"},
82
{"models_path", "set-models-path", "/v1/models", "Set the models API endpoint"},
83
{"auth_header", "set-auth-header", "Authorization", "Set the authorization header"},
84
{"auth_token_prefix", "set-auth-token-prefix", "Bearer ", "Set the authorization token prefix"},
85
{"command_prompt", "set-command-prompt", "[%datetime] [Q%counter] [%usage]", "Set the command prompt format for interactive mode"},
86
{"command_prompt_color", "set-command-prompt-color", "", "Set the command prompt color"},
87
{"output_prompt", "set-output-prompt", "", "Set the output prompt format for interactive mode"},
88
{"output_prompt_color", "set-output-prompt-color", "", "Set the output prompt color"},
89
{"temperature", "set-temperature", 1.0, "Set the sampling temperature"},
90
{"top_p", "set-top-p", 1.0, "Set the top-p value for nucleus sampling"},
91
{"frequency_penalty", "set-frequency-penalty", 0.0, "Set the frequency penalty"},
92
{"presence_penalty", "set-presence-penalty", 0.0, "Set the presence penalty"},
93
{"omit_history", "set-omit-history", false, "Omit history in the conversation"},
94
{"auto_create_new_thread", "set-auto-create-new-thread", true, "Create a new thread for each interactive session"},
95
{"track_token_usage", "set-track-token-usage", true, "Track token usage"},
96
{"skip_tls_verify", "set-skip-tls-verify", false, "Skip TLS certificate verification"},
97
{"multiline", "set-multiline", false, "Enables multiline mode while in interactive mode"},
98
{"seed", "set-seed", 0, "Sets the seed for deterministic sampling (Beta)"},
99
{"name", "set-name", "openai", "The prefix for environment variable overrides"},
100
{"effort", "set-effort", "low", "Set the reasoning effort"},
101
{"voice", "set-voice", "nova", "Set the voice used by tts models"},
102
{"user_agent", "set-user-agent", "chatgpt-cli", "Set the User-Agent in request header"},
103
}
104
105
func init() {
106
internal.SetAllowedLogLevels(zapcore.InfoLevel)
107
}
108
109
func main() {
110
var rootCmd = &cobra.Command{
111
Use: "chatgpt",
112
Short: "ChatGPT CLI Tool",
113
Long: "A powerful ChatGPT client that enables seamless interactions with the GPT model. " +
114
"Provides multiple modes and context management features, including the ability to " +
115
"pipe custom context into the conversation.",
116
RunE: run,
117
SilenceUsage: true,
118
SilenceErrors: true,
119
}
120
121
setCustomHelp(rootCmd)
122
setupFlags(rootCmd)
123
124
// Parse flags early so modelTarget gets filled from `--target`
125
_ = rootCmd.ParseFlags(os.Args[1:])
126
127
sugar := zap.S()
128
129
var err error
130
if cfg, err = initConfig(rootCmd); err != nil {
131
sugar.Fatalf("Config initialization failed: %v", err)
132
}
133
134
if err := rootCmd.Execute(); err != nil {
135
sugar.Fatalln(err)
136
}
137
}
138
139
func run(cmd *cobra.Command, args []string) error {
140
if err := syncFlagsWithViper(cmd); err != nil {
141
return err
142
}
143
144
cfg = createConfigFromViper()
145
146
changedFlags := make(map[string]bool)
147
cmd.Flags().Visit(func(f *pflag.Flag) {
148
changedFlags[f.Name] = true
149
})
150
151
if err := utils.ValidateFlags(cfg.Model, changedFlags); err != nil {
152
return err
153
}
154
155
changedValues := map[string]interface{}{}
156
for _, meta := range configMetadata {
157
if cmd.Flag(meta.FlagName).Changed {
158
changedValues[meta.Key] = viper.Get(meta.Key)
159
}
160
}
161
162
if len(changedValues) > 0 {
163
return saveConfig(changedValues)
164
}
165
166
if cmd.Flag("set-completions").Changed {
167
return config.GenCompletions(cmd, shell)
168
}
169
170
sugar := zap.S()
171
172
if showVersion {
173
if GitCommit != "homebrew" {
174
GitCommit = "commit " + GitCommit
175
}
176
sugar.Infof("ChatGPT CLI version %s (%s)", GitVersion, GitCommit)
177
return nil
178
}
179
180
if cmd.Flag("delete-thread").Changed {
181
cm := config.NewManager(config.NewStore())
182
183
if err := cm.DeleteThread(threadName); err != nil {
184
return err
185
}
186
sugar.Infof("Successfully deleted thread %s", threadName)
187
return nil
188
}
189
190
if listThreads {
191
cm := config.NewManager(config.NewStore())
192
193
threads, err := cm.ListThreads()
194
if err != nil {
195
return err
196
}
197
sugar.Infoln("Available threads:")
198
for _, thread := range threads {
199
sugar.Infoln(thread)
200
}
201
return nil
202
}
203
204
if clearHistory {
205
cm := config.NewManager(config.NewStore())
206
207
if err := cm.DeleteThread(cfg.Thread); err != nil {
208
var fileNotFoundError *config.FileNotFoundError
209
if errors.As(err, &fileNotFoundError) {
210
sugar.Infoln("Thread history does not exist; nothing to clear.")
211
return nil
212
}
213
return err
214
}
215
216
sugar.Infoln("History cleared successfully.")
217
return nil
218
}
219
220
if showHistory {
221
var targetThread string
222
if len(args) > 0 {
223
targetThread = args[0]
224
} else {
225
targetThread = cfg.Thread
226
}
227
228
store, err := history.New()
229
if err != nil {
230
return err
231
}
232
233
h := history.NewHistory(store)
234
235
output, err := h.Print(targetThread)
236
if err != nil {
237
return err
238
}
239
240
sugar.Infoln(output)
241
return nil
242
}
243
244
if showDebug {
245
internal.SetAllowedLogLevels(zapcore.InfoLevel, zapcore.DebugLevel)
246
}
247
248
if cmd.Flag("role-file").Changed {
249
role, err := utils.FileToString(roleFile)
250
if err != nil {
251
return err
252
}
253
cfg.Role = role
254
viper.Set("role", role)
255
}
256
257
if showConfig {
258
allSettings := viper.AllSettings()
259
260
configBytes, err := yaml.Marshal(allSettings)
261
if err != nil {
262
return fmt.Errorf("failed to marshal config: %w", err)
263
}
264
265
sugar.Infoln(string(configBytes))
266
return nil
267
}
268
269
if cfg.APIKey == "" {
270
if cfg.APIKeyFile == "" {
271
return errors.New("API key is required. Provide it via --set-api-key, --set-api-key-file, env var, or config file")
272
} else {
273
content, err := os.ReadFile(cfg.APIKeyFile)
274
if err != nil {
275
return err
276
}
277
cfg.APIKey = strings.TrimSpace(string(content))
278
}
279
}
280
281
ctx := context.Background()
282
283
hs, _ := history.New() // do not error out
284
c := client.New(http.RealCallerFactory, hs, &client.RealTime{}, &client.RealFileReader{}, &client.RealFileWriter{}, cfg, interactiveMode)
285
286
if ServiceURL != "" {
287
c = c.WithServiceURL(ServiceURL)
288
}
289
290
if hs != nil && newThread {
291
slug := internal.GenerateUniqueSlug("cmd_")
292
293
hs.SetThread(slug)
294
295
if err := saveConfig(map[string]interface{}{"thread": slug}); err != nil {
296
return fmt.Errorf("failed to save new thread to config: %w", err)
297
}
298
}
299
300
if cmd.Flag("prompt").Changed {
301
prompt, err := utils.FileToString(promptFile)
302
if err != nil {
303
return err
304
}
305
c.ProvideContext(prompt)
306
}
307
308
if cmd.Flag("image").Changed {
309
ctx = context.WithValue(ctx, internal.ImagePathKey, imageFile)
310
}
311
312
if cmd.Flag("audio").Changed {
313
ctx = context.WithValue(ctx, internal.AudioPathKey, audioFile)
314
}
315
316
if cmd.Flag("transcribe").Changed {
317
text, err := c.Transcribe(audioFile)
318
if err != nil {
319
return err
320
}
321
sugar.Infoln(text)
322
return nil
323
}
324
325
// Check if there is input from the pipe (stdin)
326
var chatContext string
327
stat, _ := os.Stdin.Stat()
328
if (stat.Mode() & os.ModeCharDevice) == 0 {
329
pipeContent, err := io.ReadAll(os.Stdin)
330
if err != nil {
331
return fmt.Errorf("failed to read from pipe: %w", err)
332
}
333
334
isBinary := utils.IsBinary(pipeContent)
335
if isBinary {
336
ctx = context.WithValue(ctx, internal.BinaryDataKey, pipeContent)
337
} else {
338
chatContext = string(pipeContent)
339
340
if strings.Trim(chatContext, "\n ") != "" {
341
hasPipe = true
342
}
343
344
c.ProvideContext(chatContext)
345
}
346
}
347
348
if listModels {
349
models, err := c.ListModels()
350
if err != nil {
351
return err
352
}
353
sugar.Infoln("Available models:")
354
for _, model := range models {
355
sugar.Infoln(model)
356
}
357
return nil
358
}
359
360
if tmp := os.Getenv(internal.ConfigHomeEnv); tmp != "" && !fileExists(viper.ConfigFileUsed()) {
361
sugar.Warnf("Warning: config.yaml doesn't exist in %s, create it\n", tmp)
362
}
363
364
if !client.GetCapabilities(c.Config.Model).SupportsStreaming {
365
queryMode = true
366
}
367
368
if cmd.Flag("mcp").Changed {
369
mcp, err := utils.ParseMCPPlugin(mcpTarget)
370
if err != nil {
371
return err
372
}
373
if cmd.Flag("params").Changed {
374
mcp.Params, err = utils.ParseParams([]string{paramsJSON}...)
375
if err != nil {
376
return err
377
}
378
}
379
if cmd.Flag("param").Changed {
380
newParams, err := utils.ParseParams(paramsList...)
381
if err != nil {
382
return err
383
}
384
385
if len(mcp.Params) > 0 {
386
mergeMaps(mcp.Params, newParams)
387
} else {
388
mcp.Params = newParams
389
}
390
}
391
if err := c.InjectMCPContext(mcp); err != nil {
392
return err
393
}
394
if len(args) == 0 && !hasPipe && !interactiveMode {
395
sugar.Infof("[MCP: %s] Context injected. No query submitted.", mcp.Function)
396
return nil
397
}
398
}
399
400
if interactiveMode {
401
sugar.Infof("Entering interactive mode. Using thread '%s'. Type 'clear' to clear the screen, 'exit' to quit, or press Ctrl+C.\n\n", hs.GetThread())
402
403
var readlineCfg *readline.Config
404
if cfg.OmitHistory || cfg.AutoCreateNewThread || newThread {
405
readlineCfg = &readline.Config{
406
Prompt: "",
407
}
408
} else {
409
store, err := history.New()
410
if err != nil {
411
return err
412
}
413
414
h := history.NewHistory(store)
415
userHistory, err := h.ParseUserHistory(cfg.Thread)
416
if err != nil {
417
return err
418
}
419
420
historyFile, err := utils.CreateHistoryFile(userHistory)
421
if err != nil {
422
return err
423
}
424
readlineCfg = &readline.Config{
425
Prompt: "",
426
HistoryFile: historyFile,
427
}
428
}
429
430
rl, err := readline.NewEx(readlineCfg)
431
if err != nil {
432
return err
433
}
434
435
defer rl.Close()
436
437
commandPrompt := func(counter, usage int) string {
438
return utils.FormatPrompt(c.Config.CommandPrompt, counter, usage, time.Now())
439
}
440
441
cmdColor, cmdReset := utils.ColorToAnsi(c.Config.CommandPromptColor)
442
outputColor, outPutReset := utils.ColorToAnsi(c.Config.OutputPromptColor)
443
444
qNum, usage := 1, 0
445
for {
446
rl.SetPrompt(commandPrompt(qNum, usage))
447
448
fmt.Print(cmdColor)
449
input, err := readInput(rl, cfg.Multiline)
450
fmt.Print(cmdReset)
451
452
if err == io.EOF {
453
sugar.Infoln("Bye!")
454
return nil
455
}
456
457
fmtOutputPrompt := utils.FormatPrompt(c.Config.OutputPrompt, qNum, usage, time.Now())
458
459
if queryMode {
460
result, qUsage, err := c.Query(ctx, input)
461
if err != nil {
462
sugar.Infoln("Error:", err)
463
} else {
464
sugar.Infof("%s%s%s\n\n", outputColor, fmtOutputPrompt+result, outPutReset)
465
usage += qUsage
466
qNum++
467
}
468
} else {
469
fmt.Print(outputColor + fmtOutputPrompt)
470
if err := c.Stream(ctx, input); err != nil {
471
_, _ = fmt.Fprintln(os.Stderr, "Error:", err)
472
} else {
473
sugar.Infoln()
474
qNum++
475
}
476
fmt.Print(outPutReset)
477
}
478
}
479
} else {
480
if len(args) == 0 && !hasPipe {
481
return errors.New("you must specify your query or provide input via a pipe")
482
}
483
484
if cmd.Flag("speak").Changed && cmd.Flag("output").Changed {
485
return c.SynthesizeSpeech(chatContext+strings.Join(args, " "), outputFile)
486
}
487
488
if cmd.Flag("draw").Changed && cmd.Flag("output").Changed {
489
if cmd.Flag("image").Changed {
490
return c.EditImage(chatContext+strings.Join(args, " "), imageFile, outputFile)
491
}
492
return c.GenerateImage(chatContext+strings.Join(args, " "), outputFile)
493
}
494
495
if queryMode {
496
result, usage, err := c.Query(ctx, strings.Join(args, " "))
497
if err != nil {
498
return err
499
}
500
sugar.Infoln(result)
501
502
if c.Config.TrackTokenUsage {
503
sugar.Infof("\n[Token Usage: %d]\n", usage)
504
}
505
} else if err := c.Stream(ctx, strings.Join(args, " ")); err != nil {
506
return err
507
}
508
}
509
return nil
510
}
511
512
func initConfig(rootCmd *cobra.Command) (config.Config, error) {
513
// Set default name for environment variables if no config is loaded yet.
514
viper.SetDefault("name", "openai")
515
516
// Read only the `name` field from the config to determine the environment prefix.
517
configHome, err := internal.GetConfigHome()
518
if err != nil {
519
return config.Config{}, err
520
}
521
522
configName := "config"
523
if modelTarget != "" {
524
configName += "." + modelTarget
525
}
526
527
viper.SetConfigName(configName)
528
viper.SetConfigType("yaml")
529
viper.AddConfigPath(configHome)
530
531
// Attempt to read the configuration file to get the `name` before setting env prefix.
532
if err := viper.ReadInConfig(); err != nil {
533
var configFileNotFoundError viper.ConfigFileNotFoundError
534
if !errors.As(err, &configFileNotFoundError) {
535
return config.Config{}, err
536
}
537
}
538
539
// Retrieve the name from Viper to set the environment prefix.
540
envPrefix := viper.GetString("name")
541
viper.SetEnvPrefix(envPrefix)
542
viper.AutomaticEnv()
543
544
// Bind variables without prefix manually
545
_ = viper.BindEnv("apify_api_key", "APIFY_API_KEY")
546
547
// Now, set up the flags using the fully loaded configuration metadata.
548
for _, meta := range configMetadata {
549
setupConfigFlags(rootCmd, meta)
550
}
551
552
return createConfigFromViper(), nil
553
}
554
555
func readConfigWithComments(configPath string) (*yaml.Node, error) {
556
data, err := os.ReadFile(configPath)
557
if err != nil {
558
return nil, err
559
}
560
561
var rootNode yaml.Node
562
if err := yaml.Unmarshal(data, &rootNode); err != nil {
563
return nil, fmt.Errorf("failed to unmarshal YAML: %w", err)
564
}
565
return &rootNode, nil
566
}
567
568
func readInput(rl *readline.Instance, multiline bool) (string, error) {
569
var lines []string
570
571
sugar := zap.S()
572
if multiline {
573
sugar.Infoln("Multiline mode enabled. Type 'EOF' on a new line to submit your query.")
574
}
575
576
// Custom keybinding to handle backspace in multiline mode
577
rl.Config.SetListener(func(line []rune, pos int, key rune) ([]rune, int, bool) {
578
// Check if backspace is pressed and if multiline mode is enabled
579
if multiline && key == readline.CharBackspace && pos == 0 && len(lines) > 0 {
580
fmt.Print("\033[A") // Move cursor up one line
581
582
// Print the last line without clearing
583
lastLine := lines[len(lines)-1]
584
fmt.Print(lastLine)
585
586
// Remove the last line from the slice
587
lines = lines[:len(lines)-1]
588
589
// Set the cursor at the end of the previous line
590
return []rune(lastLine), len(lastLine), true
591
}
592
return line, pos, false // Default behavior for other keys
593
})
594
595
for {
596
line, err := rl.Readline()
597
if errors.Is(err, readline.ErrInterrupt) || err == io.EOF {
598
return "", io.EOF
599
}
600
601
switch line {
602
case "clear":
603
fmt.Print("\033[H\033[2J") // ANSI escape code to clear the screen
604
continue
605
case "exit", "/q":
606
return "", io.EOF
607
}
608
609
if multiline {
610
if line == "EOF" {
611
break
612
}
613
lines = append(lines, line)
614
} else {
615
return line, nil
616
}
617
}
618
619
// Join and return all accumulated lines as a single string
620
return strings.Join(lines, "\n"), nil
621
}
622
623
func updateConfig(node *yaml.Node, changes map[string]interface{}) error {
624
// If the node is not a document or has no content, create an empty mapping node.
625
if node.Kind != yaml.DocumentNode || len(node.Content) == 0 {
626
node.Kind = yaml.DocumentNode
627
node.Content = []*yaml.Node{
628
{
629
Kind: yaml.MappingNode,
630
Content: []*yaml.Node{},
631
},
632
}
633
}
634
635
// Assume the root is now a mapping node.
636
mapNode := node.Content[0]
637
if mapNode.Kind != yaml.MappingNode {
638
return errors.New("expected a mapping node at the root of the YAML document")
639
}
640
641
// Update the values in the mapNode.
642
for i := 0; i < len(mapNode.Content); i += 2 {
643
keyNode := mapNode.Content[i]
644
valueNode := mapNode.Content[i+1]
645
646
key := keyNode.Value
647
if newValue, ok := changes[key]; ok {
648
newValueStr := fmt.Sprintf("%v", newValue)
649
valueNode.Value = newValueStr
650
}
651
}
652
653
// Add any new keys that don't exist in the current mapNode.
654
for key, value := range changes {
655
if !keyExistsInNode(mapNode, key) {
656
mapNode.Content = append(mapNode.Content, &yaml.Node{
657
Kind: yaml.ScalarNode,
658
Value: key,
659
}, &yaml.Node{
660
Kind: yaml.ScalarNode,
661
Value: fmt.Sprintf("%v", value),
662
})
663
}
664
}
665
666
return nil
667
}
668
669
func keyExistsInNode(mapNode *yaml.Node, key string) bool {
670
for i := 0; i < len(mapNode.Content); i += 2 {
671
if mapNode.Content[i].Value == key {
672
return true
673
}
674
}
675
return false
676
}
677
678
func saveConfigWithComments(configPath string, node *yaml.Node) error {
679
out, err := yaml.Marshal(node)
680
if err != nil {
681
return fmt.Errorf("failed to marshal YAML: %w", err)
682
}
683
return os.WriteFile(configPath, out, 0644)
684
}
685
686
func saveConfig(changedValues map[string]interface{}) error {
687
configFile := viper.ConfigFileUsed()
688
configHome, err := internal.GetConfigHome()
689
if err != nil {
690
return fmt.Errorf("failed to get config home: %w", err)
691
}
692
693
// If the config file is not specified, assume it's supposed to be in the default location.
694
if configFile == "" {
695
configFile = fmt.Sprintf("%s/config.yaml", configHome)
696
}
697
698
// Check if the config directory exists.
699
if _, err := os.Stat(configHome); os.IsNotExist(err) {
700
return fmt.Errorf("config directory does not exist: %s", configHome)
701
}
702
703
// Check if the config file itself exists, and create it if it doesn't.
704
if _, err := os.Stat(configFile); os.IsNotExist(err) {
705
file, err := os.Create(configFile)
706
if err != nil {
707
return fmt.Errorf("failed to create config file: %w", err)
708
}
709
defer file.Close()
710
}
711
712
// Read the existing config with comments.
713
rootNode, err := readConfigWithComments(configFile)
714
if err != nil {
715
return fmt.Errorf("failed to read config with comments: %w", err)
716
}
717
718
// Update the config with the new values.
719
if err := updateConfig(rootNode, changedValues); err != nil {
720
return fmt.Errorf("failed to update config: %w", err)
721
}
722
723
// Write back the updated config with preserved comments.
724
return saveConfigWithComments(configFile, rootNode)
725
}
726
727
func setCustomHelp(rootCmd *cobra.Command) {
728
sugar := zap.S()
729
rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
730
sugar.Infoln("ChatGPT CLI - A powerful client for interacting with GPT models.")
731
732
sugar.Infoln("\nUsage:")
733
sugar.Infof(" chatgpt [flags]\n")
734
735
sugar.Infoln("General Flags:")
736
printFlagWithPadding("-q, --query", "Use query mode instead of stream mode")
737
printFlagWithPadding("-i, --interactive", "Use interactive mode")
738
printFlagWithPadding("-p, --prompt", "Provide a prompt file for context")
739
printFlagWithPadding("-n, --new-thread", "Create a new thread with a random name and target it")
740
printFlagWithPadding("-c, --config", "Display the configuration")
741
printFlagWithPadding("-v, --version", "Display the version information")
742
printFlagWithPadding("-l, --list-models", "List available models")
743
printFlagWithPadding("--list-threads", "List available threads")
744
printFlagWithPadding("--delete-thread", "Delete the specified thread (supports wildcards)")
745
printFlagWithPadding("--clear-history", "Clear the history of the current thread")
746
printFlagWithPadding("--show-history [thread]", "Show the human-readable conversation history")
747
printFlagWithPadding("--image", "Upload an image from the specified local path or URL")
748
printFlagWithPadding("--audio", "Upload an audio file (mp3 or wav)")
749
printFlagWithPadding("--transcribe", "Transcribe an audio file")
750
printFlagWithPadding("--speak", "Use text-to-speech")
751
printFlagWithPadding("--draw", "Draw an image")
752
printFlagWithPadding("--output", "The output audio file for text-to-speech")
753
printFlagWithPadding("--role-file", "Set the system role from the specified file")
754
printFlagWithPadding("--debug", "Print debug messages")
755
printFlagWithPadding("--target", "Load configuration from config.<target>.yaml")
756
printFlagWithPadding("--mcp", "Specify the MCP plugin in the form <provider>/<plugin>@<version>")
757
printFlagWithPadding("--param", "Key-value pair as key=value. Can be specified multiple times")
758
printFlagWithPadding("--params", "Provide parameters as a raw JSON string")
759
printFlagWithPadding("--set-completions", "Generate autocompletion script for your current shell")
760
sugar.Infoln()
761
762
sugar.Infoln("Persistent Configuration Setters:")
763
cmd.Flags().VisitAll(func(f *pflag.Flag) {
764
if strings.HasPrefix(f.Name, "set-") && !isNonConfigSetter(f.Name) {
765
printFlagWithPadding("--"+f.Name, f.Usage)
766
}
767
})
768
769
sugar.Infoln("\nRuntime Value Overrides:")
770
cmd.Flags().VisitAll(func(f *pflag.Flag) {
771
if isConfigAlias(f.Name) {
772
printFlagWithPadding("--"+f.Name, "Override value for "+strings.ReplaceAll(f.Name, "_", "-"))
773
}
774
})
775
776
sugar.Infoln("\nEnvironment Variables:")
777
sugar.Infoln(" You can also use environment variables to set config values. For example:")
778
sugar.Infof(" %s_API_KEY=your_api_key chatgpt --query 'Hello'", strings.ToUpper(viper.GetEnvPrefix()))
779
780
configHome, _ := internal.GetConfigHome()
781
782
sugar.Infoln("\nConfiguration File:")
783
sugar.Infoln(" All configuration changes made with the setters will be saved in the config.yaml file.")
784
sugar.Infof(" The config.yaml file is located in the following path: %s/config.yaml", configHome)
785
sugar.Infoln(" You can edit this file manually to change configuration settings as well.")
786
})
787
}
788
789
func setupFlags(rootCmd *cobra.Command) {
790
rootCmd.PersistentFlags().BoolVarP(&interactiveMode, "interactive", "i", false, "Use interactive mode")
791
rootCmd.PersistentFlags().BoolVarP(&queryMode, "query", "q", false, "Use query mode instead of stream mode")
792
rootCmd.PersistentFlags().BoolVar(&clearHistory, "clear-history", false, "Clear all prior conversation context for the current thread")
793
rootCmd.PersistentFlags().BoolVarP(&showConfig, "config", "c", false, "Display the configuration")
794
rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "Display the version information")
795
rootCmd.PersistentFlags().BoolVarP(&showDebug, "debug", "", false, "Enable debug mode")
796
rootCmd.PersistentFlags().BoolVarP(&newThread, "new-thread", "n", false, "Create a new thread with a random name and target it")
797
rootCmd.PersistentFlags().BoolVarP(&listModels, "list-models", "l", false, "List available models")
798
rootCmd.PersistentFlags().BoolVarP(&useSpeak, "speak", "", false, "Use text-to-speak")
799
rootCmd.PersistentFlags().BoolVarP(&useDraw, "draw", "", false, "Draw an image")
800
rootCmd.PersistentFlags().StringVarP(&promptFile, "prompt", "p", "", "Provide a prompt file")
801
rootCmd.PersistentFlags().StringVarP(&roleFile, "role-file", "", "", "Provide a role file")
802
rootCmd.PersistentFlags().StringVarP(&imageFile, "image", "", "", "Provide an image from a local path or URL")
803
rootCmd.PersistentFlags().StringVarP(&outputFile, "output", "", "", "Provide an output file for text-to-speech")
804
rootCmd.PersistentFlags().StringVarP(&audioFile, "audio", "", "", "Provide an audio file from a local path")
805
rootCmd.PersistentFlags().StringVarP(&audioFile, "transcribe", "", "", "Provide an audio file from a local path")
806
rootCmd.PersistentFlags().BoolVarP(&listThreads, "list-threads", "", false, "List available threads")
807
rootCmd.PersistentFlags().StringVar(&threadName, "delete-thread", "", "Delete the specified thread")
808
rootCmd.PersistentFlags().BoolVar(&showHistory, "show-history", false, "Show the human-readable conversation history")
809
rootCmd.PersistentFlags().StringVar(&shell, "set-completions", "", "Generate autocompletion script for your current shell")
810
rootCmd.PersistentFlags().StringVar(&modelTarget, "target", "", "Specify the model to target")
811
rootCmd.PersistentFlags().StringVar(&mcpTarget, "mcp", "", "Specify the MCP plugin in the form <provider>/<plugin>@<version>")
812
rootCmd.PersistentFlags().StringArrayVar(&paramsList, "param", []string{}, "Key-value pair as key=value. Can be specified multiple times")
813
rootCmd.PersistentFlags().StringVar(&paramsJSON, "params", "", "Provide parameters as a raw JSON string")
814
}
815
816
func setupConfigFlags(rootCmd *cobra.Command, meta ConfigMetadata) {
817
aliasFlagName := strings.ReplaceAll(meta.Key, "_", "-")
818
819
switch meta.DefaultValue.(type) {
820
case string:
821
rootCmd.PersistentFlags().String(meta.FlagName, viper.GetString(meta.Key), meta.Description)
822
rootCmd.PersistentFlags().String(aliasFlagName, viper.GetString(meta.Key), fmt.Sprintf("Alias for setting %s", meta.Key))
823
case int:
824
rootCmd.PersistentFlags().Int(meta.FlagName, viper.GetInt(meta.Key), meta.Description)
825
rootCmd.PersistentFlags().Int(aliasFlagName, viper.GetInt(meta.Key), fmt.Sprintf("Alias for setting %s", meta.Key))
826
case bool:
827
rootCmd.PersistentFlags().Bool(meta.FlagName, viper.GetBool(meta.Key), meta.Description)
828
rootCmd.PersistentFlags().Bool(aliasFlagName, viper.GetBool(meta.Key), fmt.Sprintf("Alias for setting %s", meta.Key))
829
case float64:
830
rootCmd.PersistentFlags().Float64(meta.FlagName, viper.GetFloat64(meta.Key), meta.Description)
831
rootCmd.PersistentFlags().Float64(aliasFlagName, viper.GetFloat64(meta.Key), fmt.Sprintf("Alias for setting %s", meta.Key))
832
}
833
834
// Bind the flags directly to Viper keys
835
_ = viper.BindPFlag(meta.Key, rootCmd.PersistentFlags().Lookup(meta.FlagName))
836
_ = viper.BindPFlag(meta.Key, rootCmd.PersistentFlags().Lookup(aliasFlagName))
837
viper.SetDefault(meta.Key, meta.DefaultValue)
838
}
839
840
func isNonConfigSetter(name string) bool {
841
return name == "set-completions"
842
}
843
844
func isGeneralFlag(name string) bool {
845
var generalFlags = map[string]bool{
846
"query": true,
847
"interactive": true,
848
"config": true,
849
"version": true,
850
"new-thread": true,
851
"list-models": true,
852
"list-threads": true,
853
"clear-history": true,
854
"delete-thread": true,
855
"show-history": true,
856
"prompt": true,
857
"set-completions": true,
858
"help": true,
859
"role-file": true,
860
"image": true,
861
"audio": true,
862
"speak": true,
863
"draw": true,
864
"output": true,
865
"transcribe": true,
866
"param": true,
867
"params": true,
868
"mcp": true,
869
"target": true,
870
}
871
872
return generalFlags[name]
873
}
874
875
func isConfigAlias(name string) bool {
876
return !strings.HasPrefix(name, "set-") && !isGeneralFlag(name)
877
}
878
879
func printFlagWithPadding(name, description string) {
880
sugar := zap.S()
881
padding := 30
882
sugar.Infof(" %-*s %s", padding, name, description)
883
}
884
885
func syncFlagsWithViper(cmd *cobra.Command) error {
886
for _, meta := range configMetadata {
887
aliasFlagName := strings.ReplaceAll(meta.Key, "_", "-")
888
if err := syncFlag(cmd, meta, aliasFlagName); err != nil {
889
return err
890
}
891
}
892
return nil
893
}
894
895
func syncFlag(cmd *cobra.Command, meta ConfigMetadata, alias string) error {
896
var value interface{}
897
var err error
898
899
if cmd.Flag(meta.FlagName).Changed || cmd.Flag(alias).Changed {
900
switch meta.DefaultValue.(type) {
901
case string:
902
value = cmd.Flag(meta.FlagName).Value.String()
903
if cmd.Flag(alias).Changed {
904
value = cmd.Flag(alias).Value.String()
905
}
906
case int:
907
value, err = cmd.Flags().GetInt(meta.FlagName)
908
if cmd.Flag(alias).Changed {
909
value, err = cmd.Flags().GetInt(alias)
910
}
911
case bool:
912
value, err = cmd.Flags().GetBool(meta.FlagName)
913
if cmd.Flag(alias).Changed {
914
value, err = cmd.Flags().GetBool(alias)
915
}
916
case float64:
917
value, err = cmd.Flags().GetFloat64(meta.FlagName)
918
if cmd.Flag(alias).Changed {
919
value, err = cmd.Flags().GetFloat64(alias)
920
}
921
default:
922
return fmt.Errorf("unsupported type for %s", meta.FlagName)
923
}
924
925
if err != nil {
926
return fmt.Errorf("failed to parse value for %s: %w", meta.FlagName, err)
927
}
928
929
viper.Set(meta.Key, value)
930
}
931
return nil
932
}
933
934
func createConfigFromViper() config.Config {
935
return config.Config{
936
Name: viper.GetString("name"),
937
APIKey: viper.GetString("api_key"),
938
APIKeyFile: viper.GetString("api_key_file"),
939
ApifyAPIKey: viper.GetString("apify_api_key"),
940
Model: viper.GetString("model"),
941
MaxTokens: viper.GetInt("max_tokens"),
942
ContextWindow: viper.GetInt("context_window"),
943
Role: viper.GetString("role"),
944
Temperature: viper.GetFloat64("temperature"),
945
TopP: viper.GetFloat64("top_p"),
946
FrequencyPenalty: viper.GetFloat64("frequency_penalty"),
947
PresencePenalty: viper.GetFloat64("presence_penalty"),
948
Thread: viper.GetString("thread"),
949
OmitHistory: viper.GetBool("omit_history"),
950
URL: viper.GetString("url"),
951
CompletionsPath: viper.GetString("completions_path"),
952
ResponsesPath: viper.GetString("responses_path"),
953
TranscriptionsPath: viper.GetString("transcriptions_path"),
954
SpeechPath: viper.GetString("speech_path"),
955
ImageGenerationsPath: viper.GetString("image_generations_path"),
956
ImageEditsPath: viper.GetString("image_edits_path"),
957
ModelsPath: viper.GetString("models_path"),
958
AuthHeader: viper.GetString("auth_header"),
959
AuthTokenPrefix: viper.GetString("auth_token_prefix"),
960
CommandPrompt: viper.GetString("command_prompt"),
961
CommandPromptColor: viper.GetString("command_prompt_color"),
962
OutputPrompt: viper.GetString("output_prompt"),
963
OutputPromptColor: viper.GetString("output_prompt_color"),
964
AutoCreateNewThread: viper.GetBool("auto_create_new_thread"),
965
TrackTokenUsage: viper.GetBool("track_token_usage"),
966
SkipTLSVerify: viper.GetBool("skip_tls_verify"),
967
Multiline: viper.GetBool("multiline"),
968
Seed: viper.GetInt("seed"),
969
Effort: viper.GetString("effort"),
970
Voice: viper.GetString("voice"),
971
UserAgent: viper.GetString("user_agent"),
972
CustomHeaders: viper.GetStringMapString("custom_headers"),
973
}
974
}
975
976
func fileExists(filename string) bool {
977
_, err := os.Stat(filename)
978
if os.IsNotExist(err) {
979
return false
980
}
981
return err == nil
982
}
983
984
func mergeMaps(m1, m2 map[string]interface{}) map[string]interface{} {
985
for k, v := range m2 {
986
m1[k] = v
987
}
988
return m1
989
}
990
991