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