Skip to content

Commit f8b59ba

Browse files
committed
✨ feat(init): add interactive init command for configuring API key and model
Signed-off-by: samzong <samzong.lu@gmail.com>
1 parent ccdacd2 commit f8b59ba

7 files changed

Lines changed: 412 additions & 0 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@
2727

2828
`gmc` reads configuration from `~/.gmc.yaml` by default (override with `--config`). On macOS/Linux, the config file is forced to permission `0600`.
2929

30+
If `api_key` is missing, `gmc` will prompt you to run the guided setup. You can also run it anytime:
31+
32+
```bash
33+
gmc init
34+
```
35+
3036
First use gmc to set the OpenAI API serivce:
3137

3238
```bash

cmd/cmd_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,11 @@ func TestExecute(t *testing.T) {
377377
// We can't test the full execution without mocking a lot of dependencies
378378
assert.NotNil(t, Execute)
379379

380+
viper.Reset()
381+
viper.Set("api_key", "test-api-key")
382+
viper.Set("model", "gpt-3.5-turbo")
383+
viper.Set("role", "Developer")
384+
380385
// Test that it doesn't panic when called (though it will likely error)
381386
assert.NotPanics(t, func() {
382387
_ = Execute()
@@ -513,6 +518,11 @@ func TestGenerateAndCommit(t *testing.T) {
513518
verbose = originalVerbose
514519
}()
515520

521+
viper.Reset()
522+
viper.Set("api_key", "test-api-key")
523+
viper.Set("model", "gpt-3.5-turbo")
524+
viper.Set("role", "Developer")
525+
516526
// Test with minimal setup
517527
branchDesc = ""
518528
addAll = false
@@ -670,6 +680,11 @@ func TestRootCommandSuccess(t *testing.T) {
670680
originalConfigErr := configErr
671681
defer func() { configErr = originalConfigErr }()
672682

683+
viper.Reset()
684+
viper.Set("api_key", "test-api-key")
685+
viper.Set("model", "gpt-3.5-turbo")
686+
viper.Set("role", "Developer")
687+
673688
// Clear config error
674689
configErr = nil
675690

cmd/init.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package cmd
2+
3+
import (
4+
"bufio"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"os"
9+
"strings"
10+
11+
"github.com/samzong/gmc/internal/config"
12+
"github.com/samzong/gmc/internal/llm"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
var (
17+
initCmd = &cobra.Command{
18+
Use: "init",
19+
Short: "Initialize gmc configuration",
20+
RunE: func(_ *cobra.Command, _ []string) error {
21+
cfg := config.GetConfig()
22+
if err := runInitWizard(os.Stdin, os.Stdout, cfg); err != nil {
23+
return err
24+
}
25+
fmt.Fprintln(os.Stdout, "Initialization complete.")
26+
return nil
27+
},
28+
}
29+
30+
saveConfigValues = func(apiKey, model, apiBase string) error {
31+
config.SetConfigValue("api_key", apiKey)
32+
config.SetConfigValue("model", model)
33+
config.SetConfigValue("api_base", apiBase)
34+
return config.SaveConfig()
35+
}
36+
37+
testLLMConnection = func(model string) error {
38+
return llm.TestConnection(model)
39+
}
40+
)
41+
42+
func runInitWizard(in io.Reader, out io.Writer, current *config.Config) error {
43+
cfg := current
44+
if cfg == nil {
45+
cfg = config.GetConfig()
46+
}
47+
48+
reader := bufio.NewReader(in)
49+
readLine := func() (string, error) {
50+
line, err := reader.ReadString('\n')
51+
if err != nil && !errors.Is(err, io.EOF) {
52+
return "", err
53+
}
54+
if errors.Is(err, io.EOF) && line == "" {
55+
return "", io.EOF
56+
}
57+
return strings.TrimSpace(line), nil
58+
}
59+
60+
fmt.Fprintln(out, "gmc init - configure your LLM settings")
61+
62+
apiKey := ""
63+
for {
64+
if cfg.APIKey != "" {
65+
fmt.Fprint(out, "OpenAI API Key (leave blank to keep current): ")
66+
} else {
67+
fmt.Fprint(out, "OpenAI API Key (required): ")
68+
}
69+
70+
line, err := readLine()
71+
if err != nil {
72+
return err
73+
}
74+
if line == "" {
75+
if cfg.APIKey != "" {
76+
apiKey = cfg.APIKey
77+
break
78+
}
79+
fmt.Fprintln(out, "API key is required.")
80+
continue
81+
}
82+
apiKey = line
83+
break
84+
}
85+
86+
modelDefault := cfg.Model
87+
if modelDefault == "" {
88+
modelDefault = config.DefaultModel
89+
}
90+
fmt.Fprintf(out, "Model (default: %s): ", modelDefault)
91+
line, err := readLine()
92+
if err != nil {
93+
return err
94+
}
95+
model := line
96+
if model == "" {
97+
model = modelDefault
98+
}
99+
100+
apiBaseDefault := cfg.APIBase
101+
apiBaseLabel := apiBaseDefault
102+
if apiBaseLabel == "" {
103+
apiBaseLabel = "<empty>"
104+
}
105+
fmt.Fprintf(out, "API Base URL (default: %s): ", apiBaseLabel)
106+
line, err = readLine()
107+
if err != nil {
108+
return err
109+
}
110+
apiBase := line
111+
if apiBase == "" {
112+
apiBase = apiBaseDefault
113+
}
114+
115+
if err := saveConfigValues(apiKey, model, apiBase); err != nil {
116+
return fmt.Errorf("failed to save configuration: %w", err)
117+
}
118+
119+
for {
120+
fmt.Fprint(out, "Test API connection now? [Y/n]: ")
121+
answer, err := readLine()
122+
if err != nil {
123+
return err
124+
}
125+
switch strings.ToLower(answer) {
126+
case "", "y", "yes":
127+
fmt.Fprintln(out, "Testing API connection...")
128+
if err := testLLMConnection(model); err != nil {
129+
fmt.Fprintf(out, "Connection test failed: %v\n", err)
130+
fmt.Fprintln(out, "You can re-run `gmc init` or update config with `gmc config set`.")
131+
} else {
132+
fmt.Fprintln(out, "Connection test succeeded.")
133+
}
134+
return nil
135+
case "n", "no":
136+
return nil
137+
default:
138+
fmt.Fprintln(out, "Please enter y or n.")
139+
}
140+
}
141+
}
142+
143+
func ensureLLMConfigured(cfg *config.Config, in io.Reader, out io.Writer, initRunner func(io.Reader, io.Writer, *config.Config) error) (bool, error) {
144+
current := cfg
145+
if current == nil {
146+
current = config.GetConfig()
147+
}
148+
if strings.TrimSpace(current.APIKey) != "" {
149+
return true, nil
150+
}
151+
152+
fmt.Fprintln(out, "API key is not configured.")
153+
fmt.Fprintln(out, "An API key is required to generate commit messages.")
154+
155+
reader := bufio.NewReader(in)
156+
for {
157+
fmt.Fprint(out, "Run `gmc init` now? [Y/n]: ")
158+
line, err := reader.ReadString('\n')
159+
if err != nil && !errors.Is(err, io.EOF) {
160+
return false, err
161+
}
162+
if errors.Is(err, io.EOF) && strings.TrimSpace(line) == "" {
163+
fmt.Fprintln(out, "Initialization skipped. Run `gmc init` anytime to configure.")
164+
return false, nil
165+
}
166+
167+
answer := strings.ToLower(strings.TrimSpace(line))
168+
switch answer {
169+
case "", "y", "yes":
170+
if err := initRunner(reader, out, current); err != nil {
171+
return false, err
172+
}
173+
return true, nil
174+
case "n", "no":
175+
fmt.Fprintln(out, "Initialization skipped. Run `gmc init` anytime to configure.")
176+
return false, nil
177+
default:
178+
fmt.Fprintln(out, "Please enter y or n.")
179+
}
180+
}
181+
}

cmd/init_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"io"
7+
"strings"
8+
"testing"
9+
10+
"github.com/samzong/gmc/internal/config"
11+
"github.com/stretchr/testify/assert"
12+
)
13+
14+
func TestRunInitWizard_RequiresAPIKeyAndUsesDefaults(t *testing.T) {
15+
input := strings.NewReader("\nkey123\n\nhttps://proxy.example/v1\nn\n")
16+
var output bytes.Buffer
17+
18+
cfg := &config.Config{
19+
Model: "gpt-4.1-mini",
20+
APIBase: "",
21+
APIKey: "",
22+
}
23+
24+
var savedAPIKey, savedModel, savedBase string
25+
origSave := saveConfigValues
26+
origTest := testLLMConnection
27+
defer func() {
28+
saveConfigValues = origSave
29+
testLLMConnection = origTest
30+
}()
31+
32+
saveConfigValues = func(apiKey, model, apiBase string) error {
33+
savedAPIKey = apiKey
34+
savedModel = model
35+
savedBase = apiBase
36+
return nil
37+
}
38+
39+
var testCalled bool
40+
testLLMConnection = func(_ string) error {
41+
testCalled = true
42+
return nil
43+
}
44+
45+
err := runInitWizard(input, &output, cfg)
46+
assert.NoError(t, err)
47+
assert.Equal(t, "key123", savedAPIKey)
48+
assert.Equal(t, "gpt-4.1-mini", savedModel)
49+
assert.Equal(t, "https://proxy.example/v1", savedBase)
50+
assert.False(t, testCalled)
51+
assert.Contains(t, output.String(), "API key is required")
52+
}
53+
54+
func TestRunInitWizard_KeepExistingKeyAndTestConnection(t *testing.T) {
55+
input := strings.NewReader("\ngpt-4.2\n\ny\n")
56+
var output bytes.Buffer
57+
58+
cfg := &config.Config{
59+
Model: "gpt-4.1-mini",
60+
APIBase: "https://proxy.example/v1",
61+
APIKey: "existing-key",
62+
}
63+
64+
var savedAPIKey, savedModel, savedBase string
65+
origSave := saveConfigValues
66+
origTest := testLLMConnection
67+
defer func() {
68+
saveConfigValues = origSave
69+
testLLMConnection = origTest
70+
}()
71+
72+
saveConfigValues = func(apiKey, model, apiBase string) error {
73+
savedAPIKey = apiKey
74+
savedModel = model
75+
savedBase = apiBase
76+
return nil
77+
}
78+
79+
var testedModel string
80+
testLLMConnection = func(model string) error {
81+
testedModel = model
82+
return nil
83+
}
84+
85+
err := runInitWizard(input, &output, cfg)
86+
assert.NoError(t, err)
87+
assert.Equal(t, "existing-key", savedAPIKey)
88+
assert.Equal(t, "gpt-4.2", savedModel)
89+
assert.Equal(t, "https://proxy.example/v1", savedBase)
90+
assert.Equal(t, "gpt-4.2", testedModel)
91+
}
92+
93+
func TestEnsureLLMConfigured_WithAPIKey(t *testing.T) {
94+
cfg := &config.Config{APIKey: "set"}
95+
input := strings.NewReader("n\n")
96+
var output bytes.Buffer
97+
98+
var initCalled bool
99+
proceed, err := ensureLLMConfigured(cfg, input, &output, func(in io.Reader, out io.Writer, cfg *config.Config) error {
100+
initCalled = true
101+
return nil
102+
})
103+
assert.NoError(t, err)
104+
assert.True(t, proceed)
105+
assert.False(t, initCalled)
106+
}
107+
108+
func TestEnsureLLMConfigured_MissingKeyDecline(t *testing.T) {
109+
cfg := &config.Config{APIKey: ""}
110+
input := strings.NewReader("n\n")
111+
var output bytes.Buffer
112+
113+
var initCalled bool
114+
proceed, err := ensureLLMConfigured(cfg, input, &output, func(_ io.Reader, _ io.Writer, _ *config.Config) error {
115+
initCalled = true
116+
return nil
117+
})
118+
assert.NoError(t, err)
119+
assert.False(t, proceed)
120+
assert.False(t, initCalled)
121+
assert.Contains(t, output.String(), "gmc init")
122+
}
123+
124+
func TestEnsureLLMConfigured_MissingKeyAccept(t *testing.T) {
125+
cfg := &config.Config{APIKey: ""}
126+
input := strings.NewReader("y\n")
127+
var output bytes.Buffer
128+
129+
var initCalled bool
130+
proceed, err := ensureLLMConfigured(cfg, input, &output, func(_ io.Reader, _ io.Writer, _ *config.Config) error {
131+
initCalled = true
132+
return nil
133+
})
134+
assert.NoError(t, err)
135+
assert.True(t, proceed)
136+
assert.True(t, initCalled)
137+
}
138+
139+
func TestEnsureLLMConfigured_InitError(t *testing.T) {
140+
cfg := &config.Config{APIKey: ""}
141+
input := strings.NewReader("y\n")
142+
var output bytes.Buffer
143+
144+
expectedErr := errors.New("init failed")
145+
proceed, err := ensureLLMConfigured(cfg, input, &output, func(_ io.Reader, _ io.Writer, _ *config.Config) error {
146+
return expectedErr
147+
})
148+
assert.ErrorIs(t, err, expectedErr)
149+
assert.False(t, proceed)
150+
}

0 commit comments

Comments
 (0)