Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
kardolus
GitHub Repository: kardolus/chatgpt-cli
Path: blob/main/cmd/chatgpt/main.go
3447 views
1
package main
2
3
import (
4
"context"
5
"errors"
6
"fmt"
7
"github.com/kardolus/chatgpt-cli/agent/core"
8
"github.com/kardolus/chatgpt-cli/agent/factory"
9
"github.com/kardolus/chatgpt-cli/agent/planexec"
10
"github.com/kardolus/chatgpt-cli/agent/tools"
11
"github.com/kardolus/chatgpt-cli/agent/types"
12
"github.com/kardolus/chatgpt-cli/api"
13
"github.com/kardolus/chatgpt-cli/cache"
14
"github.com/kardolus/chatgpt-cli/internal/fsio"
15
"io"
16
"os"
17
"path/filepath"
18
"strings"
19
"time"
20
21
"github.com/kardolus/chatgpt-cli/api/client"
22
"github.com/kardolus/chatgpt-cli/api/http"
23
"github.com/kardolus/chatgpt-cli/cmd/chatgpt/utils"
24
"github.com/kardolus/chatgpt-cli/internal"
25
"github.com/spf13/pflag"
26
"go.uber.org/zap/zapcore"
27
"gopkg.in/yaml.v3"
28
29
"github.com/chzyer/readline"
30
"github.com/kardolus/chatgpt-cli/config"
31
"github.com/kardolus/chatgpt-cli/history"
32
"github.com/spf13/cobra"
33
"github.com/spf13/viper"
34
"go.uber.org/zap"
35
)
36
37
var (
38
GitCommit string
39
GitVersion string
40
queryMode bool
41
clearHistory bool
42
showHistory bool
43
showVersion bool
44
showDebug bool
45
newThread bool
46
showConfig bool
47
interactiveMode bool
48
listModels bool
49
listThreads bool
50
hasPipe bool
51
useSpeak bool
52
useDraw bool
53
agentMode string
54
agentEnabled bool
55
promptFile string
56
roleFile string
57
imageFile string
58
audioFile string
59
outputFile string
60
threadName string
61
ServiceURL string
62
shell string
63
mcpEndpoint string
64
mcpTool string
65
mcpHeaders []string
66
modelTarget string
67
paramsList []string
68
paramsJSON string
69
cfg config.Config
70
)
71
72
type ConfigMetadata struct {
73
Key string
74
FlagName string
75
DefaultValue interface{}
76
Description string
77
}
78
79
var configMetadata = []ConfigMetadata{
80
{"model", "set-model", "gpt-4o", "Set a new default model by specifying the model name"},
81
{"max_tokens", "set-max-tokens", 4096, "Set a new default max token size"},
82
{"context_window", "set-context-window", 8192, "Set a new default context window size"},
83
{"thread", "set-thread", "default", "Set a new active thread by specifying the thread name"},
84
{"api_key", "set-api-key", "", "Set the API key for authentication"},
85
{"api_key_file", "set-api-key-file", "", "Load the API key from a file"},
86
{"role", "set-role", "You are a helpful assistant.", "Set the role of the AI assistant"},
87
{"url", "set-url", "https://api.openai.com", "Set the API base URL"},
88
{"completions_path", "set-completions-path", "/v1/chat/completions", "Set the completions API endpoint"},
89
{"responses_path", "set-responses-path", "/v1/responses", "Set the responses API endpoint"},
90
{"transcriptions_path", "set-transcriptions-path", "/v1/audio/transcriptions", "Set the transcriptions API endpoint"},
91
{"speech_path", "set-speech-path", "/v1/audio/speech", "Set the speech API endpoint"},
92
{"image_generations_path", "set-image-generations-path", "/v1/images/generations", "Set the image generation API endpoint"},
93
{"image_edits_path", "set-image-edits-path", "/v1/images/edits", "Set the image edits API endpoint"},
94
{"models_path", "set-models-path", "/v1/models", "Set the models API endpoint"},
95
{"auth_header", "set-auth-header", "Authorization", "Set the authorization header"},
96
{"auth_token_prefix", "set-auth-token-prefix", "Bearer ", "Set the authorization token prefix"},
97
{"command_prompt", "set-command-prompt", "[%datetime] [Q%counter] [%usage]", "Set the command prompt format for interactive mode"},
98
{"command_prompt_color", "set-command-prompt-color", "", "Set the command prompt color"},
99
{"output_prompt", "set-output-prompt", "", "Set the output prompt format for interactive mode"},
100
{"output_prompt_color", "set-output-prompt-color", "", "Set the output prompt color"},
101
{"temperature", "set-temperature", 1.0, "Set the sampling temperature"},
102
{"top_p", "set-top-p", 1.0, "Set the top-p value for nucleus sampling"},
103
{"frequency_penalty", "set-frequency-penalty", 0.0, "Set the frequency penalty"},
104
{"presence_penalty", "set-presence-penalty", 0.0, "Set the presence penalty"},
105
{"omit_history", "set-omit-history", false, "Omit history in the conversation"},
106
{"auto_create_new_thread", "set-auto-create-new-thread", false, "Create a new thread for each interactive session"},
107
{"auto_shell_title", "set-auto-shell-title", false, "Set the title of the shell to the name of the current thread"},
108
{"track_token_usage", "set-track-token-usage", false, "Track token usage"},
109
{"skip_tls_verify", "set-skip-tls-verify", false, "Skip TLS certificate verification"},
110
{"multiline", "set-multiline", false, "Enables multiline mode while in interactive mode"},
111
{"seed", "set-seed", 0, "Sets the seed for deterministic sampling (Beta)"},
112
{"name", "set-name", "openai", "The prefix for environment variable overrides"},
113
{"effort", "set-effort", "low", "Set the reasoning effort"},
114
{"web", "set-web", false, "Enable web search"},
115
{"web_context_size", "set-web-context-size", "low", "Set the context size for web search"},
116
{"voice", "set-voice", "nova", "Set the voice used by tts models"},
117
{"agent.mode", "set-agent-mode", "react", "Default agent mode (react|plan)"},
118
{"agent.max_steps", "set-agent-max-steps", 10, "Max steps (plan mode)"},
119
{"agent.max_iterations", "set-agent-max-iterations", 10, "Max iterations (react mode)"},
120
{"agent.max_wall_time", "set-agent-max-wall-time", 0, "Max wall time in seconds (0=unlimited)"},
121
{"agent.max_shell_calls", "set-agent-max-shell-calls", 0, "Max shell calls (0=unlimited)"},
122
{"agent.max_llm_calls", "set-agent-max-llm-calls", 10, "Max LLM calls (0=unlimited)"},
123
{"agent.max_file_ops", "set-agent-max-file-ops", 0, "Max file ops (0=unlimited)"},
124
{"agent.max_llm_tokens", "set-agent-max-llm-tokens", 0, "Max LLM tokens (0=unlimited)"},
125
{"agent.allowed_tools", "set-agent-allowed-tools", []string{"shell", "llm", "files"}, "Allowed tools for agent"},
126
{"agent.denied_shell_commands", "set-agent-denied-shell-commands", []string{"rm", "sudo", "dd", "mkfs", "shutdown", "reboot"}, "Denied shell commands"},
127
{"agent.allowed_file_ops", "set-agent-allowed-file-ops", []string{"read", "write"}, "Allowed file ops"},
128
{"agent.restrict_files_to_work_dir", "set-agent-restrict-files-to-work-dir", true, "Restrict file ops to workdir"},
129
{"agent.write_plan_json", "set-agent-write-plan-json", true, "Write plan.json in plan mode"},
130
{"agent.plan_json_path", "set-agent-plan-json-path", "", "Override plan.json path"},
131
{"agent.work_dir", "set-agent-work-dir", ".", "Agent working directory (default: .)"},
132
{"agent.dry_run", "set-agent-dry-run", false, "Agent dry-run (no side effects)"},
133
{"user_agent", "set-user-agent", "chatgpt-cli", "Set the User-Agent in request header"},
134
}
135
136
func init() {
137
internal.SetAllowedLogLevels(zapcore.InfoLevel)
138
}
139
140
func main() {
141
var rootCmd = &cobra.Command{
142
Use: "chatgpt",
143
Short: "ChatGPT CLI Tool",
144
Long: "A powerful ChatGPT client that enables seamless interactions with the GPT model. " +
145
"Provides multiple modes and context management features, including the ability to " +
146
"pipe custom context into the conversation.",
147
RunE: run,
148
SilenceUsage: true,
149
SilenceErrors: true,
150
}
151
152
setCustomHelp(rootCmd)
153
setupFlags(rootCmd)
154
155
// Parse flags early so modelTarget gets filled from `--target`
156
_ = rootCmd.ParseFlags(os.Args[1:])
157
158
sugar := zap.S()
159
160
var err error
161
if cfg, err = initConfig(rootCmd); err != nil {
162
sugar.Fatalf("Config initialization failed: %v", err)
163
}
164
165
if err := rootCmd.Execute(); err != nil {
166
sugar.Fatalln(err)
167
}
168
}
169
170
func run(cmd *cobra.Command, args []string) error {
171
if err := syncFlagsWithViper(cmd); err != nil {
172
return err
173
}
174
175
cfg = createConfigFromViper()
176
177
changedFlags := make(map[string]bool)
178
cmd.Flags().Visit(func(f *pflag.Flag) {
179
changedFlags[f.Name] = true
180
})
181
182
if err := utils.ValidateFlags(cfg.Model, changedFlags); err != nil {
183
return err
184
}
185
186
changedValues := map[string]interface{}{}
187
for _, meta := range configMetadata {
188
if cmd.Flag(meta.FlagName).Changed {
189
changedValues[meta.Key] = viper.Get(meta.Key)
190
}
191
}
192
193
if len(changedValues) > 0 {
194
return saveConfig(changedValues)
195
}
196
197
if cmd.Flag("set-completions").Changed {
198
return config.GenCompletions(cmd, shell)
199
}
200
201
sugar := zap.S()
202
203
if showVersion {
204
if GitCommit != "homebrew" {
205
GitCommit = "commit " + GitCommit
206
}
207
sugar.Infof("ChatGPT CLI version %s (%s)", GitVersion, GitCommit)
208
return nil
209
}
210
211
if cmd.Flag("delete-thread").Changed {
212
cm := config.NewManager(config.NewStore())
213
214
if err := cm.DeleteThread(threadName); err != nil {
215
return err
216
}
217
sugar.Infof("Successfully deleted thread %s", threadName)
218
return nil
219
}
220
221
if listThreads {
222
cm := config.NewManager(config.NewStore())
223
224
threads, err := cm.ListThreads()
225
if err != nil {
226
return err
227
}
228
sugar.Infoln("Available threads:")
229
for _, thread := range threads {
230
sugar.Infoln(thread)
231
}
232
return nil
233
}
234
235
if clearHistory {
236
cm := config.NewManager(config.NewStore())
237
238
if err := cm.DeleteThread(cfg.Thread); err != nil {
239
var fileNotFoundError *config.FileNotFoundError
240
if errors.As(err, &fileNotFoundError) {
241
sugar.Infoln("Thread history does not exist; nothing to clear.")
242
return nil
243
}
244
return err
245
}
246
247
sugar.Infoln("History cleared successfully.")
248
return nil
249
}
250
251
if showHistory {
252
var targetThread string
253
if len(args) > 0 {
254
targetThread = args[0]
255
} else {
256
targetThread = cfg.Thread
257
}
258
259
store, err := history.New()
260
if err != nil {
261
return err
262
}
263
264
h := history.NewHistory(store)
265
266
output, err := h.Print(targetThread)
267
if err != nil {
268
return err
269
}
270
271
sugar.Infoln(output)
272
return nil
273
}
274
275
if showDebug {
276
internal.SetAllowedLogLevels(zapcore.InfoLevel, zapcore.DebugLevel)
277
}
278
279
if cmd.Flag("role-file").Changed {
280
role, err := utils.FileToString(roleFile)
281
if err != nil {
282
return err
283
}
284
cfg.Role = role
285
viper.Set("role", role)
286
}
287
288
if showConfig {
289
allSettings := viper.AllSettings()
290
291
configBytes, err := yaml.Marshal(allSettings)
292
if err != nil {
293
return fmt.Errorf("failed to marshal config: %w", err)
294
}
295
296
sugar.Infoln(string(configBytes))
297
return nil
298
}
299
300
if cfg.APIKey == "" {
301
if cfg.APIKeyFile == "" {
302
return errors.New("API key is required. Provide it via --set-api-key, --set-api-key-file, env var, or config file")
303
}
304
305
key, err := config.ReadAPIKeyFile(cfg.APIKeyFile)
306
if err != nil {
307
return err
308
}
309
cfg.APIKey = key
310
}
311
312
ctx := context.Background()
313
314
hs, _ := history.New() // do not error out
315
316
if hs != nil {
317
slug, writeConfig := utils.GenerateThreadName(cfg, interactiveMode, newThread)
318
319
hs.SetThread(slug)
320
321
if writeConfig {
322
if err := saveConfig(map[string]interface{}{"thread": slug}); err != nil {
323
return fmt.Errorf("failed to save new thread to config: %w", err)
324
}
325
}
326
327
if cfg.AutoShellTitle {
328
if err := setShellTitle(slug); err != nil {
329
return err
330
}
331
}
332
}
333
334
c := client.New(http.RealCallerFactory, hs, &client.RealTime{}, fsio.NewRealReader(fsio.DefaultBufferSize), &fsio.RealWriter{}, cfg)
335
336
if ServiceURL != "" {
337
c = c.WithServiceURL(ServiceURL)
338
}
339
340
if cmd.Flag("prompt").Changed {
341
prompt, err := utils.FileToString(promptFile)
342
if err != nil {
343
return err
344
}
345
c.ProvideContext(prompt)
346
}
347
348
if cmd.Flag("image").Changed {
349
ctx = context.WithValue(ctx, internal.ImagePathKey, imageFile)
350
}
351
352
if cmd.Flag("audio").Changed {
353
ctx = context.WithValue(ctx, internal.AudioPathKey, audioFile)
354
}
355
356
if cmd.Flag("transcribe").Changed {
357
text, err := c.Transcribe(audioFile)
358
if err != nil {
359
return err
360
}
361
sugar.Infoln(text)
362
return nil
363
}
364
365
// Check if there is input from the pipe (stdin)
366
var chatContext string
367
stat, _ := os.Stdin.Stat()
368
if (stat.Mode() & os.ModeCharDevice) == 0 {
369
pipeContent, err := io.ReadAll(os.Stdin)
370
if err != nil {
371
return fmt.Errorf("failed to read from pipe: %w", err)
372
}
373
374
isBinary := utils.IsBinary(pipeContent)
375
if isBinary {
376
ctx = context.WithValue(ctx, internal.BinaryDataKey, pipeContent)
377
} else {
378
chatContext = string(pipeContent)
379
380
if strings.Trim(chatContext, "\n ") != "" {
381
hasPipe = true
382
}
383
384
c.ProvideContext(chatContext)
385
}
386
}
387
388
if listModels {
389
models, err := c.ListModels()
390
if err != nil {
391
return err
392
}
393
sugar.Infoln("Available models:")
394
for _, model := range models {
395
sugar.Infoln(model)
396
}
397
return nil
398
}
399
400
if tmp := os.Getenv(internal.ConfigHomeEnv); tmp != "" && !fileExists(viper.ConfigFileUsed()) {
401
sugar.Warnf("Warning: config.yaml doesn't exist in %s, create it\n", tmp)
402
}
403
404
if !client.GetCapabilities(c.Config.Model).SupportsStreaming {
405
queryMode = true
406
}
407
408
if cmd.Flag("mcp").Changed {
409
if mcpEndpoint == "" {
410
return errors.New("--mcp is required")
411
}
412
if mcpTool == "" {
413
return errors.New("--mcp-tool is required when using --mcp")
414
}
415
416
headers, err := utils.ParseMCPHeaders(mcpHeaders)
417
if err != nil {
418
return err
419
}
420
421
mcp := api.MCPRequest{
422
Endpoint: mcpEndpoint,
423
Headers: headers,
424
Tool: mcpTool,
425
Params: map[string]interface{}{},
426
}
427
428
if cmd.Flag("mcp-params").Changed {
429
mcp.Params, err = utils.ParseMCPParams([]string{paramsJSON}...)
430
if err != nil {
431
return err
432
}
433
}
434
435
if cmd.Flag("mcp-param").Changed {
436
newParams, err := utils.ParseMCPParams(paramsList...)
437
if err != nil {
438
return err
439
}
440
if len(mcp.Params) > 0 {
441
mergeMaps(mcp.Params, newParams)
442
} else {
443
mcp.Params = newParams
444
}
445
}
446
447
base, err := client.NewMCPTransport(mcp.Endpoint, c.Caller, mcp.Headers)
448
if err != nil {
449
return err
450
}
451
452
cacheHome, err := internal.GetCacheHome()
453
if err != nil {
454
return err
455
}
456
457
sessionsDir := filepath.Join(cacheHome, "mcp", "sessions")
458
459
store := cache.NewFileStore(sessionsDir)
460
sessionStore := cache.New(store)
461
462
transport := client.NewSessionTransport(base, sessionStore)
463
464
c = c.WithTransport(transport)
465
466
if err := c.InjectMCPContext(mcp); err != nil {
467
return err
468
}
469
470
if len(args) == 0 && !hasPipe && !interactiveMode && !agentEnabled {
471
sugar.Infof("[MCP: %s] Context injected. No query submitted.", mcp.Tool)
472
return nil
473
}
474
}
475
476
if agentEnabled {
477
mode, err := resolveAgentMode(agentMode, cfg.Agent.Mode)
478
if err != nil {
479
return err
480
}
481
482
goal, err := buildAgentGoal(chatContext, args)
483
if err != nil {
484
return err
485
}
486
487
answer, err := runAgent(ctx, c, cfg, mode, goal)
488
if err != nil {
489
return err
490
}
491
492
// write ONE history interaction (you said you already created the helper)
493
if hs != nil && !cfg.OmitHistory {
494
if err := appendAgentRunToHistory(hs, cfg.Role, goal, answer); err != nil {
495
return err
496
}
497
}
498
499
return nil
500
}
501
502
if interactiveMode {
503
sugar.Infof(
504
"Entering interactive mode. Using thread '%s'. Multiline mode is %s.\n"+
505
"Commands: 'clear' (clear screen), 'multiline' (toggle multiline input), 'exit' or Ctrl+C (quit).\n\n",
506
hs.GetThread(),
507
boolToOnOff(cfg.Multiline),
508
)
509
510
var readlineCfg *readline.Config
511
if cfg.OmitHistory || cfg.AutoCreateNewThread || newThread {
512
readlineCfg = &readline.Config{
513
Prompt: "",
514
}
515
} else {
516
store, err := history.New()
517
if err != nil {
518
return err
519
}
520
521
h := history.NewHistory(store)
522
userHistory, err := h.ParseUserHistory(cfg.Thread)
523
if err != nil {
524
return err
525
}
526
527
historyFile, err := utils.CreateHistoryFile(userHistory)
528
if err != nil {
529
return err
530
}
531
readlineCfg = &readline.Config{
532
Prompt: "",
533
HistoryFile: historyFile,
534
}
535
}
536
537
rl, err := readline.NewEx(readlineCfg)
538
if err != nil {
539
return err
540
}
541
542
defer rl.Close()
543
544
commandPrompt := func(counter, usage int) string {
545
return utils.FormatPrompt(c.Config.CommandPrompt, counter, usage, time.Now())
546
}
547
548
cmdColor, cmdReset := utils.ColorToAnsi(c.Config.CommandPromptColor)
549
outputColor, outPutReset := utils.ColorToAnsi(c.Config.OutputPromptColor)
550
551
multiline := cfg.Multiline
552
553
qNum, usage := 1, 0
554
for {
555
rl.SetPrompt(commandPrompt(qNum, usage))
556
557
fmt.Print(cmdColor)
558
input, err := readInput(rl, &multiline)
559
fmt.Print(cmdReset)
560
561
if err == io.EOF {
562
sugar.Infoln("Bye!")
563
return nil
564
}
565
566
fmtOutputPrompt := utils.FormatPrompt(c.Config.OutputPrompt, qNum, usage, time.Now())
567
568
if queryMode {
569
result, qUsage, err := c.Query(ctx, input)
570
if err != nil {
571
sugar.Infoln("Error:", err)
572
} else {
573
sugar.Infof("%s%s%s\n\n", outputColor, fmtOutputPrompt+result, outPutReset)
574
usage += qUsage
575
qNum++
576
}
577
} else {
578
fmt.Print(outputColor + fmtOutputPrompt)
579
if err := c.Stream(ctx, input); err != nil {
580
_, _ = fmt.Fprintln(os.Stderr, "Error:", err)
581
} else {
582
sugar.Infoln()
583
qNum++
584
}
585
fmt.Print(outPutReset)
586
}
587
}
588
} else {
589
if len(args) == 0 && !hasPipe {
590
return errors.New("you must specify your query or provide input via a pipe")
591
}
592
593
if cmd.Flag("speak").Changed && cmd.Flag("output").Changed {
594
return c.SynthesizeSpeech(chatContext+strings.Join(args, " "), outputFile)
595
}
596
597
if cmd.Flag("draw").Changed && cmd.Flag("output").Changed {
598
if cmd.Flag("image").Changed {
599
return c.EditImage(chatContext+strings.Join(args, " "), imageFile, outputFile)
600
}
601
return c.GenerateImage(chatContext+strings.Join(args, " "), outputFile)
602
}
603
604
if queryMode {
605
result, usage, err := c.Query(ctx, strings.Join(args, " "))
606
if err != nil {
607
return err
608
}
609
sugar.Infoln(result)
610
611
if c.Config.TrackTokenUsage {
612
sugar.Infof("\n[Token Usage: %d]\n", usage)
613
}
614
} else if err := c.Stream(ctx, strings.Join(args, " ")); err != nil {
615
return err
616
}
617
}
618
return nil
619
}
620
621
func boolToOnOff(b bool) string {
622
if b {
623
return "ON"
624
}
625
return "OFF"
626
}
627
628
func initConfig(rootCmd *cobra.Command) (config.Config, error) {
629
// Set default name for environment variables if no config is loaded yet.
630
viper.SetDefault("name", "openai")
631
632
// Read only the `name` field from the config to determine the environment prefix.
633
configHome, err := internal.GetConfigHome()
634
if err != nil {
635
return config.Config{}, err
636
}
637
638
configName := "config"
639
if modelTarget != "" {
640
configName += "." + modelTarget
641
}
642
643
viper.SetConfigName(configName)
644
viper.SetConfigType("yaml")
645
viper.AddConfigPath(configHome)
646
647
// Attempt to read the configuration file to get the `name` before setting env prefix.
648
if err := viper.ReadInConfig(); err != nil {
649
var configFileNotFoundError viper.ConfigFileNotFoundError
650
if !errors.As(err, &configFileNotFoundError) {
651
return config.Config{}, err
652
}
653
}
654
655
// Retrieve the name from Viper to set the environment prefix.
656
envPrefix := viper.GetString("name")
657
viper.SetEnvPrefix(envPrefix)
658
viper.AutomaticEnv()
659
660
// Now, set up the flags using the fully loaded configuration metadata.
661
for _, meta := range configMetadata {
662
setupConfigFlags(rootCmd, meta)
663
}
664
665
return createConfigFromViper(), nil
666
}
667
668
func readConfigWithComments(configPath string) (*yaml.Node, error) {
669
data, err := os.ReadFile(configPath)
670
if err != nil {
671
return nil, err
672
}
673
674
var rootNode yaml.Node
675
if err := yaml.Unmarshal(data, &rootNode); err != nil {
676
return nil, fmt.Errorf("failed to unmarshal YAML: %w", err)
677
}
678
return &rootNode, nil
679
}
680
681
func readInput(rl *readline.Instance, multiline *bool) (string, error) {
682
var lines []string
683
684
sugar := zap.S()
685
if *multiline {
686
sugar.Infoln("Multiline mode enabled. Type 'EOF' on a new line to submit your query.")
687
}
688
689
// Custom keybinding to handle backspace in multiline mode
690
rl.Config.SetListener(func(line []rune, pos int, key rune) ([]rune, int, bool) {
691
// Check if backspace is pressed and if multiline mode is enabled
692
if *multiline && key == readline.CharBackspace && pos == 0 && len(lines) > 0 {
693
fmt.Print("\033[A") // Move cursor up one line
694
695
// Print the last line without clearing
696
lastLine := lines[len(lines)-1]
697
fmt.Print(lastLine)
698
699
// Remove the last line from the slice
700
lines = lines[:len(lines)-1]
701
702
// Set the cursor at the end of the previous line
703
return []rune(lastLine), len(lastLine), true
704
}
705
return line, pos, false // Default behavior for other keys
706
})
707
708
for {
709
line, err := rl.Readline()
710
if errors.Is(err, readline.ErrInterrupt) || err == io.EOF {
711
return "", io.EOF
712
}
713
714
switch line {
715
case "clear":
716
fmt.Print("\033[H\033[2J") // ANSI escape code to clear the screen
717
continue
718
case "multiline":
719
if *multiline {
720
sugar.Infoln("Multiline mode disabled.")
721
} else {
722
sugar.Infoln("Multiline mode enabled. Type 'EOF' on a new line to submit your query.")
723
}
724
*multiline = !*multiline
725
continue
726
case "exit", "/q":
727
return "", io.EOF
728
}
729
730
if *multiline {
731
if line == "EOF" {
732
break
733
}
734
lines = append(lines, line)
735
} else {
736
return line, nil
737
}
738
}
739
740
// Join and return all accumulated lines as a single string
741
return strings.Join(lines, "\n"), nil
742
}
743
744
func isTerminal(f *os.File) bool {
745
stat, err := f.Stat()
746
if err != nil {
747
return false
748
}
749
return (stat.Mode() & os.ModeCharDevice) != 0
750
}
751
752
func setShellTitle(title string) error {
753
f := os.Stdout
754
755
if !isTerminal(f) {
756
// Not a TTY: silently skip
757
return nil
758
}
759
760
// ANSI: ESC ] 0 ; <title> BEL
761
_, err := fmt.Fprintf(f, "\033]0;%s\007", title)
762
if err != nil {
763
return fmt.Errorf("failed to write shell title: %w", err)
764
}
765
766
return nil
767
}
768
769
func appendAgentRunToHistory(store history.Store, systemRole, goal, answer string) error {
770
thread := store.GetThread()
771
772
entries, err := store.ReadThread(thread)
773
if err != nil {
774
// treat missing history file as empty thread
775
if errors.Is(err, os.ErrNotExist) {
776
entries = nil
777
} else {
778
return err
779
}
780
}
781
782
now := time.Now()
783
784
// Ensure a system message exists at the beginning (optional but matches your UX)
785
if len(entries) == 0 || strings.ToLower(strings.TrimSpace(entries[0].Role)) != "system" {
786
entries = append(entries, history.History{
787
Message: api.Message{
788
Role: "system",
789
Content: systemRole,
790
},
791
Timestamp: now,
792
})
793
}
794
795
entries = append(entries,
796
history.History{
797
Message: api.Message{Role: "user", Content: goal},
798
Timestamp: now,
799
},
800
history.History{
801
Message: api.Message{Role: "assistant", Content: answer},
802
Timestamp: now,
803
},
804
)
805
806
// store already has thread set, but being explicit is fine
807
store.SetThread(thread)
808
return store.Write(entries)
809
}
810
811
func resolveAgentMode(flagMode, cfgMode string) (string, error) {
812
mode := strings.ToLower(strings.TrimSpace(flagMode))
813
if mode == "" {
814
mode = strings.ToLower(strings.TrimSpace(cfgMode))
815
}
816
if mode == "" {
817
mode = "react"
818
}
819
820
// allow some aliases, normalize to "react" or "plan"
821
switch mode {
822
case "react":
823
return "react", nil
824
case "plan":
825
return "plan", nil
826
case "plan_execute", "plan-execute":
827
return "plan", nil
828
default:
829
return "", fmt.Errorf("unknown agent mode %q (expected react|plan)", mode)
830
}
831
}
832
833
func buildAgentGoal(chatContext string, args []string) (string, error) {
834
var parts []string
835
if s := strings.TrimSpace(chatContext); s != "" {
836
parts = append(parts, s)
837
}
838
if len(args) > 0 {
839
parts = append(parts, strings.Join(args, " "))
840
}
841
842
goal := strings.TrimSpace(strings.Join(parts, "\n"))
843
if goal == "" {
844
return "", errors.New("missing agent goal (provide args or pipe)")
845
}
846
return goal, nil
847
}
848
849
func runAgent(ctx context.Context, c *client.Client, cfg config.Config, mode string, goal string) (string, error) {
850
clk := core.NewRealClock()
851
llm := tools.NewClientLLM(c)
852
853
tools, err := buildAgentTools(llm)
854
if err != nil {
855
return "", err
856
}
857
858
policy, err := buildAgentPolicy(cfg)
859
if err != nil {
860
return "", err
861
}
862
863
budget := core.NewDefaultBudget(utils.BudgetLimitsFromConfig(cfg))
864
runner := core.NewDefaultRunner(tools, clk, budget, policy)
865
866
logs, err := core.NewLogs()
867
if err != nil {
868
return "", err
869
}
870
defer logs.Close()
871
872
// Tee human output: terminal + transcript file.
873
// zap.L() uses the global logger core (terminal), logs.HumanZap.Core() writes to transcript file.
874
humanTeeZap := zap.New(zapcore.NewTee(
875
zap.L().Core(),
876
logs.HumanZap.Core(),
877
))
878
humanTeeSug := humanTeeZap.Sugar()
879
880
baseOpts := []core.BaseOption{
881
core.WithWorkDir(cfg.Agent.WorkDir),
882
core.WithDryRun(cfg.Agent.DryRun),
883
884
// Human (transcript): terminal + file
885
core.WithHumanLogger(humanTeeSug, func() {
886
// best-effort sync
887
_ = humanTeeZap.Sync()
888
}),
889
890
// Debug: JSONL file only (already JSON encoder in logs.go)
891
core.WithDebugLogger(logs.DebugLogger, func() {
892
_ = logs.DebugZap.Sync()
893
}),
894
}
895
896
switch mode {
897
case "react":
898
a, err := factory.New(factory.ModeReAct, factory.Deps{
899
Clock: clk,
900
LLM: llm,
901
Runner: runner,
902
Budget: budget,
903
}, baseOpts...)
904
if err != nil {
905
return "", err
906
}
907
return a.RunAgentGoal(ctx, goal)
908
909
case "plan":
910
var planner planexec.Planner = planexec.NewDefaultPlanner(
911
llm,
912
budget,
913
clk,
914
planexec.WithPlannerRawSink(func(raw string) {
915
if !cfg.Agent.WritePlanJSON {
916
return
917
}
918
planPath := strings.TrimSpace(cfg.Agent.PlanJSONPath)
919
if planPath == "" {
920
planPath = filepath.Join(logs.Dir, "plan.json")
921
}
922
_ = os.WriteFile(planPath, []byte(strings.TrimSpace(raw)), 0o644)
923
}),
924
)
925
926
planner = planexec.NewLoggingPlanner(planner, logs)
927
928
a, err := factory.New(factory.ModePlanExecute, factory.Deps{
929
Clock: clk,
930
Planner: planner,
931
Runner: runner,
932
LLM: llm,
933
Budget: budget,
934
}, baseOpts...)
935
if err != nil {
936
return "", err
937
}
938
return a.RunAgentGoal(ctx, goal)
939
940
default:
941
return "", fmt.Errorf("internal error: unsupported mode %q", mode)
942
}
943
}
944
945
func buildAgentTools(llm tools.LLM) (core.Tools, error) {
946
sh := tools.NewExecShellRunner()
947
r := fsio.NewRealReader(fsio.DefaultBufferSize)
948
w := &fsio.RealWriter{}
949
files := tools.NewFSIOFileOps(r, w)
950
951
return core.Tools{
952
Shell: sh,
953
LLM: llm,
954
Files: files,
955
}, nil
956
}
957
958
func buildAgentPolicy(cfg config.Config) (core.Policy, error) {
959
allowedTools, err := parseToolKinds(cfg.Agent.AllowedTools)
960
if err != nil {
961
return nil, err
962
}
963
964
return core.NewDefaultPolicy(core.PolicyLimits{
965
AllowedTools: allowedTools,
966
DeniedShellCommands: cfg.Agent.DeniedShellCommands,
967
AllowedFileOps: cfg.Agent.AllowedFileOps,
968
RestrictFilesToWorkDir: cfg.Agent.RestrictFilesToWorkDir,
969
}), nil
970
}
971
972
func toAliasFlagName(viperKey string) string {
973
s := strings.ReplaceAll(viperKey, ".", "-")
974
s = strings.ReplaceAll(s, "_", "-")
975
return s
976
}
977
978
func updateConfig(node *yaml.Node, changes map[string]interface{}) error {
979
// If the node is not a document or has no content, create an empty mapping node.
980
if node.Kind != yaml.DocumentNode || len(node.Content) == 0 {
981
node.Kind = yaml.DocumentNode
982
node.Content = []*yaml.Node{
983
{
984
Kind: yaml.MappingNode,
985
Content: []*yaml.Node{},
986
},
987
}
988
}
989
990
// Assume the root is now a mapping node.
991
mapNode := node.Content[0]
992
if mapNode.Kind != yaml.MappingNode {
993
return errors.New("expected a mapping node at the root of the YAML document")
994
}
995
996
// Update the values in the mapNode.
997
for i := 0; i < len(mapNode.Content); i += 2 {
998
keyNode := mapNode.Content[i]
999
valueNode := mapNode.Content[i+1]
1000
1001
key := keyNode.Value
1002
if newValue, ok := changes[key]; ok {
1003
newValueStr := fmt.Sprintf("%v", newValue)
1004
valueNode.Value = newValueStr
1005
}
1006
}
1007
1008
// Add any new keys that don't exist in the current mapNode.
1009
for key, value := range changes {
1010
if !keyExistsInNode(mapNode, key) {
1011
mapNode.Content = append(mapNode.Content, &yaml.Node{
1012
Kind: yaml.ScalarNode,
1013
Value: key,
1014
}, &yaml.Node{
1015
Kind: yaml.ScalarNode,
1016
Value: fmt.Sprintf("%v", value),
1017
})
1018
}
1019
}
1020
1021
return nil
1022
}
1023
1024
func keyExistsInNode(mapNode *yaml.Node, key string) bool {
1025
for i := 0; i < len(mapNode.Content); i += 2 {
1026
if mapNode.Content[i].Value == key {
1027
return true
1028
}
1029
}
1030
return false
1031
}
1032
1033
func saveConfigWithComments(configPath string, node *yaml.Node) error {
1034
out, err := yaml.Marshal(node)
1035
if err != nil {
1036
return fmt.Errorf("failed to marshal YAML: %w", err)
1037
}
1038
return os.WriteFile(configPath, out, 0o600)
1039
}
1040
1041
func saveConfig(changedValues map[string]interface{}) error {
1042
configFile := viper.ConfigFileUsed()
1043
configHome, err := internal.GetConfigHome()
1044
if err != nil {
1045
return fmt.Errorf("failed to get config home: %w", err)
1046
}
1047
1048
// If the config file is not specified, assume it's supposed to be in the default location.
1049
if configFile == "" {
1050
configFile = fmt.Sprintf("%s/config.yaml", configHome)
1051
}
1052
1053
// Check if the config directory exists.
1054
if _, err := os.Stat(configHome); os.IsNotExist(err) {
1055
return fmt.Errorf("config directory does not exist: %s", configHome)
1056
}
1057
1058
// Check if the config file itself exists, and create it if it doesn't.
1059
if _, err := os.Stat(configFile); os.IsNotExist(err) {
1060
file, err := os.Create(configFile)
1061
if err != nil {
1062
return fmt.Errorf("failed to create config file: %w", err)
1063
}
1064
defer file.Close()
1065
}
1066
1067
// Read the existing config with comments.
1068
rootNode, err := readConfigWithComments(configFile)
1069
if err != nil {
1070
return fmt.Errorf("failed to read config with comments: %w", err)
1071
}
1072
1073
// Update the config with the new values.
1074
if err := updateConfig(rootNode, changedValues); err != nil {
1075
return fmt.Errorf("failed to update config: %w", err)
1076
}
1077
1078
// Write back the updated config with preserved comments.
1079
return saveConfigWithComments(configFile, rootNode)
1080
}
1081
1082
func setCustomHelp(rootCmd *cobra.Command) {
1083
sugar := zap.S()
1084
rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
1085
sugar.Infoln("ChatGPT CLI - A powerful client for interacting with GPT models.")
1086
1087
sugar.Infoln("\nUsage:")
1088
sugar.Infof(" chatgpt [flags]\n")
1089
1090
sugar.Infoln("General Flags:")
1091
printFlagWithPadding("-q, --query", "Use query mode instead of stream mode")
1092
printFlagWithPadding("-i, --interactive", "Use interactive mode")
1093
printFlagWithPadding("-p, --prompt", "Provide a prompt file for context")
1094
printFlagWithPadding("-n, --new-thread", "Create a new thread with a random name and target it")
1095
printFlagWithPadding("-c, --config", "Display the configuration")
1096
printFlagWithPadding("-v, --version", "Display the version information")
1097
printFlagWithPadding("-l, --list-models", "List available models")
1098
printFlagWithPadding("--list-threads", "List available threads")
1099
printFlagWithPadding("--delete-thread", "Delete the specified thread (supports wildcards)")
1100
printFlagWithPadding("--clear-history", "Clear the history of the current thread")
1101
printFlagWithPadding("--show-history [thread]", "Show the human-readable conversation history")
1102
printFlagWithPadding("--image", "Upload an image from the specified local path or URL")
1103
printFlagWithPadding("--audio", "Upload an audio file (mp3 or wav)")
1104
printFlagWithPadding("--transcribe", "Transcribe an audio file")
1105
printFlagWithPadding("--speak", "Use text-to-speech")
1106
printFlagWithPadding("--draw", "Draw an image")
1107
printFlagWithPadding("--output", "The output audio file for text-to-speech")
1108
printFlagWithPadding("--role-file", "Set the system role from the specified file")
1109
printFlagWithPadding("--debug", "Print debug messages")
1110
printFlagWithPadding("--agent", "Enable agent mode")
1111
printFlagWithPadding("--target", "Load configuration from config.<target>.yaml")
1112
printFlagWithPadding("--mcp", "MCP endpoint URL (e.g. http://localhost:3333)")
1113
printFlagWithPadding("--mcp-tool", "Tool name to call on the MCP server")
1114
printFlagWithPadding("--mcp-header", "HTTP header for MCP call (repeatable, 'Key: Value')")
1115
printFlagWithPadding("--mcp-param", "Key-value pair as key=value. Can be specified multiple times")
1116
printFlagWithPadding("--mcp-params", "Provide parameters as a raw JSON string")
1117
printFlagWithPadding("--set-completions", "Generate autocompletion script for your current shell")
1118
sugar.Infoln()
1119
1120
sugar.Infoln("Persistent Configuration Setters:")
1121
cmd.Flags().VisitAll(func(f *pflag.Flag) {
1122
if strings.HasPrefix(f.Name, "set-") && !isNonConfigSetter(f.Name) {
1123
printFlagWithPadding("--"+f.Name, f.Usage)
1124
}
1125
})
1126
1127
sugar.Infoln("\nRuntime Value Overrides:")
1128
cmd.Flags().VisitAll(func(f *pflag.Flag) {
1129
if isConfigAlias(f.Name) {
1130
printFlagWithPadding("--"+f.Name, "Override value for "+strings.ReplaceAll(f.Name, "_", "-"))
1131
}
1132
})
1133
1134
sugar.Infoln("\nEnvironment Variables:")
1135
sugar.Infoln(" You can also use environment variables to set config values. For example:")
1136
sugar.Infof(" %s_API_KEY=your_api_key chatgpt --query 'Hello'", strings.ToUpper(viper.GetEnvPrefix()))
1137
1138
configHome, _ := internal.GetConfigHome()
1139
1140
sugar.Infoln("\nConfiguration File:")
1141
sugar.Infoln(" All configuration changes made with the setters will be saved in the config.yaml file.")
1142
sugar.Infof(" The config.yaml file is located in the following path: %s/config.yaml", configHome)
1143
sugar.Infoln(" You can edit this file manually to change configuration settings as well.")
1144
})
1145
}
1146
1147
func setupFlags(rootCmd *cobra.Command) {
1148
rootCmd.PersistentFlags().BoolVarP(&interactiveMode, "interactive", "i", false, "Use interactive mode")
1149
rootCmd.PersistentFlags().BoolVarP(&queryMode, "query", "q", false, "Use query mode instead of stream mode")
1150
rootCmd.PersistentFlags().BoolVar(&clearHistory, "clear-history", false, "Clear all prior conversation context for the current thread")
1151
rootCmd.PersistentFlags().BoolVarP(&showConfig, "config", "c", false, "Display the configuration")
1152
rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "Display the version information")
1153
rootCmd.PersistentFlags().BoolVarP(&showDebug, "debug", "", false, "Enable debug mode")
1154
rootCmd.PersistentFlags().BoolVarP(&newThread, "new-thread", "n", false, "Create a new thread with a random name and target it")
1155
rootCmd.PersistentFlags().BoolVarP(&listModels, "list-models", "l", false, "List available models")
1156
rootCmd.PersistentFlags().BoolVarP(&useSpeak, "speak", "", false, "Use text-to-speak")
1157
rootCmd.PersistentFlags().BoolVarP(&useDraw, "draw", "", false, "Draw an image")
1158
rootCmd.PersistentFlags().StringVarP(&promptFile, "prompt", "p", "", "Provide a prompt file")
1159
rootCmd.PersistentFlags().StringVarP(&roleFile, "role-file", "", "", "Provide a role file")
1160
rootCmd.PersistentFlags().StringVarP(&imageFile, "image", "", "", "Provide an image from a local path or URL")
1161
rootCmd.PersistentFlags().StringVarP(&outputFile, "output", "", "", "Provide an output file for text-to-speech")
1162
rootCmd.PersistentFlags().StringVarP(&audioFile, "audio", "", "", "Provide an audio file from a local path")
1163
rootCmd.PersistentFlags().StringVarP(&audioFile, "transcribe", "", "", "Provide an audio file from a local path")
1164
rootCmd.PersistentFlags().BoolVarP(&listThreads, "list-threads", "", false, "List available threads")
1165
rootCmd.PersistentFlags().StringVar(&threadName, "delete-thread", "", "Delete the specified thread")
1166
rootCmd.PersistentFlags().BoolVar(&showHistory, "show-history", false, "Show the human-readable conversation history")
1167
rootCmd.PersistentFlags().StringVar(&shell, "set-completions", "", "Generate autocompletion script for your current shell")
1168
rootCmd.PersistentFlags().StringVar(&modelTarget, "target", "", "Specify the model to target")
1169
rootCmd.PersistentFlags().StringVar(&mcpEndpoint, "mcp", "", "MCP endpoint URL (e.g. http://localhost:3333)")
1170
rootCmd.PersistentFlags().StringVar(&mcpTool, "mcp-tool", "", "MCP tool name to call")
1171
rootCmd.PersistentFlags().StringArrayVar(&mcpHeaders, "mcp-header", []string{}, "MCP header in the form 'Key: Value' (repeatable)")
1172
rootCmd.PersistentFlags().StringArrayVar(&paramsList, "mcp-param", []string{}, "Key-value pair as key=value. Can be specified multiple times")
1173
rootCmd.PersistentFlags().StringVar(&paramsJSON, "mcp-params", "", "Provide parameters as a raw JSON string")
1174
rootCmd.PersistentFlags().BoolVar(&agentEnabled, "agent", false, "Run agent (experimental)")
1175
}
1176
1177
func setupConfigFlags(rootCmd *cobra.Command, meta ConfigMetadata) {
1178
aliasFlagName := toAliasFlagName(meta.Key)
1179
1180
switch meta.DefaultValue.(type) {
1181
case string:
1182
rootCmd.PersistentFlags().String(meta.FlagName, viper.GetString(meta.Key), meta.Description)
1183
rootCmd.PersistentFlags().String(aliasFlagName, viper.GetString(meta.Key), fmt.Sprintf("Alias for setting %s", meta.Key))
1184
case int:
1185
rootCmd.PersistentFlags().Int(meta.FlagName, viper.GetInt(meta.Key), meta.Description)
1186
rootCmd.PersistentFlags().Int(aliasFlagName, viper.GetInt(meta.Key), fmt.Sprintf("Alias for setting %s", meta.Key))
1187
case bool:
1188
rootCmd.PersistentFlags().Bool(meta.FlagName, viper.GetBool(meta.Key), meta.Description)
1189
rootCmd.PersistentFlags().Bool(aliasFlagName, viper.GetBool(meta.Key), fmt.Sprintf("Alias for setting %s", meta.Key))
1190
case float64:
1191
rootCmd.PersistentFlags().Float64(meta.FlagName, viper.GetFloat64(meta.Key), meta.Description)
1192
rootCmd.PersistentFlags().Float64(aliasFlagName, viper.GetFloat64(meta.Key), fmt.Sprintf("Alias for setting %s", meta.Key))
1193
case []string:
1194
rootCmd.PersistentFlags().StringSlice(meta.FlagName, viper.GetStringSlice(meta.Key), meta.Description)
1195
rootCmd.PersistentFlags().StringSlice(aliasFlagName, viper.GetStringSlice(meta.Key), fmt.Sprintf("Alias for setting %s", meta.Key))
1196
}
1197
1198
// Bind the flags directly to Viper keys
1199
_ = viper.BindPFlag(meta.Key, rootCmd.PersistentFlags().Lookup(meta.FlagName))
1200
_ = viper.BindPFlag(meta.Key, rootCmd.PersistentFlags().Lookup(aliasFlagName))
1201
viper.SetDefault(meta.Key, meta.DefaultValue)
1202
}
1203
1204
func isNonConfigSetter(name string) bool {
1205
return name == "set-completions"
1206
}
1207
1208
func isGeneralFlag(name string) bool {
1209
var generalFlags = map[string]bool{
1210
"query": true,
1211
"interactive": true,
1212
"config": true,
1213
"version": true,
1214
"new-thread": true,
1215
"list-models": true,
1216
"list-threads": true,
1217
"clear-history": true,
1218
"delete-thread": true,
1219
"show-history": true,
1220
"prompt": true,
1221
"agent": true,
1222
"set-completions": true,
1223
"help": true,
1224
"role-file": true,
1225
"image": true,
1226
"audio": true,
1227
"speak": true,
1228
"draw": true,
1229
"output": true,
1230
"transcribe": true,
1231
"mcp": true,
1232
"mcp-header": true,
1233
"mcp-param": true,
1234
"mcp-params": true,
1235
"mcp-tool": true,
1236
"target": true,
1237
}
1238
1239
return generalFlags[name]
1240
}
1241
1242
func isConfigAlias(name string) bool {
1243
return !strings.HasPrefix(name, "set-") && !isGeneralFlag(name)
1244
}
1245
1246
func printFlagWithPadding(name, description string) {
1247
sugar := zap.S()
1248
padding := 30
1249
sugar.Infof(" %-*s %s", padding, name, description)
1250
}
1251
1252
func syncFlagsWithViper(cmd *cobra.Command) error {
1253
for _, meta := range configMetadata {
1254
aliasFlagName := toAliasFlagName(meta.Key)
1255
if err := syncFlag(cmd, meta, aliasFlagName); err != nil {
1256
return err
1257
}
1258
}
1259
return nil
1260
}
1261
1262
func syncFlag(cmd *cobra.Command, meta ConfigMetadata, alias string) error {
1263
mainFlag := cmd.Flag(meta.FlagName)
1264
aliasFlag := cmd.Flag(alias)
1265
1266
// If either doesn't exist, just treat it as "not changed"
1267
mainChanged := mainFlag != nil && mainFlag.Changed
1268
aliasChanged := aliasFlag != nil && aliasFlag.Changed
1269
1270
if !mainChanged && !aliasChanged {
1271
return nil
1272
}
1273
1274
var (
1275
value interface{}
1276
err error
1277
)
1278
1279
switch meta.DefaultValue.(type) {
1280
case string:
1281
if aliasChanged {
1282
value = aliasFlag.Value.String()
1283
} else {
1284
value = mainFlag.Value.String()
1285
}
1286
1287
case int:
1288
if aliasChanged {
1289
value, err = cmd.Flags().GetInt(alias)
1290
} else {
1291
value, err = cmd.Flags().GetInt(meta.FlagName)
1292
}
1293
1294
case bool:
1295
if aliasChanged {
1296
value, err = cmd.Flags().GetBool(alias)
1297
} else {
1298
value, err = cmd.Flags().GetBool(meta.FlagName)
1299
}
1300
1301
case float64:
1302
if aliasChanged {
1303
value, err = cmd.Flags().GetFloat64(alias)
1304
} else {
1305
value, err = cmd.Flags().GetFloat64(meta.FlagName)
1306
}
1307
1308
case []string:
1309
if aliasChanged {
1310
value, err = cmd.Flags().GetStringSlice(alias)
1311
} else {
1312
value, err = cmd.Flags().GetStringSlice(meta.FlagName)
1313
}
1314
1315
default:
1316
return fmt.Errorf("unsupported type for %s", meta.FlagName)
1317
}
1318
1319
if err != nil {
1320
return fmt.Errorf("failed to parse value for %s: %w", meta.FlagName, err)
1321
}
1322
1323
viper.Set(meta.Key, value)
1324
return nil
1325
}
1326
1327
func createConfigFromViper() config.Config {
1328
return config.Config{
1329
Name: viper.GetString("name"),
1330
APIKey: viper.GetString("api_key"),
1331
APIKeyFile: viper.GetString("api_key_file"),
1332
Model: viper.GetString("model"),
1333
MaxTokens: viper.GetInt("max_tokens"),
1334
ContextWindow: viper.GetInt("context_window"),
1335
Role: viper.GetString("role"),
1336
Temperature: viper.GetFloat64("temperature"),
1337
TopP: viper.GetFloat64("top_p"),
1338
FrequencyPenalty: viper.GetFloat64("frequency_penalty"),
1339
PresencePenalty: viper.GetFloat64("presence_penalty"),
1340
Thread: viper.GetString("thread"),
1341
OmitHistory: viper.GetBool("omit_history"),
1342
URL: viper.GetString("url"),
1343
CompletionsPath: viper.GetString("completions_path"),
1344
ResponsesPath: viper.GetString("responses_path"),
1345
TranscriptionsPath: viper.GetString("transcriptions_path"),
1346
SpeechPath: viper.GetString("speech_path"),
1347
ImageGenerationsPath: viper.GetString("image_generations_path"),
1348
ImageEditsPath: viper.GetString("image_edits_path"),
1349
ModelsPath: viper.GetString("models_path"),
1350
AuthHeader: viper.GetString("auth_header"),
1351
AuthTokenPrefix: viper.GetString("auth_token_prefix"),
1352
CommandPrompt: viper.GetString("command_prompt"),
1353
CommandPromptColor: viper.GetString("command_prompt_color"),
1354
OutputPrompt: viper.GetString("output_prompt"),
1355
OutputPromptColor: viper.GetString("output_prompt_color"),
1356
AutoCreateNewThread: viper.GetBool("auto_create_new_thread"),
1357
AutoShellTitle: viper.GetBool("auto_shell_title"),
1358
TrackTokenUsage: viper.GetBool("track_token_usage"),
1359
SkipTLSVerify: viper.GetBool("skip_tls_verify"),
1360
Multiline: viper.GetBool("multiline"),
1361
Seed: viper.GetInt("seed"),
1362
Effort: viper.GetString("effort"),
1363
Web: viper.GetBool("web"),
1364
WebContextSize: viper.GetString("web_context_size"),
1365
Voice: viper.GetString("voice"),
1366
UserAgent: viper.GetString("user_agent"),
1367
CustomHeaders: viper.GetStringMapString("custom_headers"),
1368
Agent: config.AgentConfig{
1369
Mode: viper.GetString("agent.mode"),
1370
WorkDir: viper.GetString("agent.work_dir"),
1371
DryRun: viper.GetBool("agent.dry_run"),
1372
MaxSteps: viper.GetInt("agent.max_steps"),
1373
MaxIterations: viper.GetInt("agent.max_iterations"),
1374
MaxWallTime: viper.GetInt("agent.max_wall_time"),
1375
MaxShellCalls: viper.GetInt("agent.max_shell_calls"),
1376
MaxLLMCalls: viper.GetInt("agent.max_llm_calls"),
1377
MaxFileOps: viper.GetInt("agent.max_file_ops"),
1378
MaxLLMTokens: viper.GetInt("agent.max_llm_tokens"),
1379
1380
AllowedTools: viper.GetStringSlice("agent.allowed_tools"),
1381
DeniedShellCommands: viper.GetStringSlice("agent.denied_shell_commands"),
1382
AllowedFileOps: viper.GetStringSlice("agent.allowed_file_ops"),
1383
RestrictFilesToWorkDir: viper.GetBool("agent.restrict_files_to_work_dir"),
1384
1385
WritePlanJSON: viper.GetBool("agent.write_plan_json"),
1386
PlanJSONPath: viper.GetString("agent.plan_json_path"),
1387
},
1388
}
1389
}
1390
1391
func parseToolKinds(in []string) ([]types.ToolKind, error) {
1392
out := make([]types.ToolKind, 0, len(in))
1393
seen := map[types.ToolKind]bool{}
1394
1395
for _, raw := range in {
1396
s := strings.ToLower(strings.TrimSpace(raw))
1397
if s == "" {
1398
continue
1399
}
1400
1401
var k types.ToolKind
1402
switch s {
1403
case "shell":
1404
k = types.ToolShell
1405
case "llm":
1406
k = types.ToolLLM
1407
case "files", "file":
1408
k = types.ToolFiles
1409
default:
1410
return nil, fmt.Errorf("unknown agent.allowed_tools entry %q (expected shell|llm|files)", raw)
1411
}
1412
1413
if !seen[k] {
1414
seen[k] = true
1415
out = append(out, k)
1416
}
1417
}
1418
1419
// If config is empty, decide your behavior. I’d rather error than silently allow all.
1420
if len(out) == 0 {
1421
return nil, errors.New("agent.allowed_tools is empty (expected at least one of shell|llm|files)")
1422
}
1423
1424
return out, nil
1425
}
1426
1427
func fileExists(filename string) bool {
1428
_, err := os.Stat(filename)
1429
if os.IsNotExist(err) {
1430
return false
1431
}
1432
return err == nil
1433
}
1434
1435
func mergeMaps(m1, m2 map[string]interface{}) map[string]interface{} {
1436
for k, v := range m2 {
1437
m1[k] = v
1438
}
1439
return m1
1440
}
1441
1442