Skip to content

Commit 9986f0d

Browse files
authored
feat: configuration via env variables (#44)
1 parent 7c6d16e commit 9986f0d

File tree

7 files changed

+352
-45
lines changed

7 files changed

+352
-45
lines changed

cmd/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,6 @@ func Execute() {
2525
}
2626

2727
func init() {
28-
rootCmd.AddCommand(server.ServerCmd)
28+
rootCmd.AddCommand(server.CreateServerCmd())
2929
rootCmd.AddCommand(attach.AttachCmd)
3030
}

cmd/server/server.go

Lines changed: 77 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strings"
1212

1313
"github.com/spf13/cobra"
14+
"github.com/spf13/viper"
1415
"golang.org/x/xerrors"
1516

1617
"github.com/coder/agentapi/lib/httpapi"
@@ -19,15 +20,6 @@ import (
1920
"github.com/coder/agentapi/lib/termexec"
2021
)
2122

22-
var (
23-
agentTypeVar string
24-
port int
25-
printOpenAPI bool
26-
chatBasePath string
27-
termWidth uint16
28-
termHeight uint16
29-
)
30-
3123
type AgentType = msgfmt.AgentType
3224

3325
const (
@@ -68,11 +60,15 @@ func parseAgentType(firstArg string, agentTypeVar string) (AgentType, error) {
6860

6961
func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) error {
7062
agent := argsToPass[0]
71-
agentType, err := parseAgentType(agent, agentTypeVar)
63+
agentTypeValue := viper.GetString(FlagType)
64+
agentType, err := parseAgentType(agent, agentTypeValue)
7265
if err != nil {
7366
return xerrors.Errorf("failed to parse agent type: %w", err)
7467
}
7568

69+
termWidth := viper.GetUint16(FlagTermWidth)
70+
termHeight := viper.GetUint16(FlagTermHeight)
71+
7672
if termWidth < 10 {
7773
return xerrors.Errorf("term width must be at least 10")
7874
}
@@ -83,6 +79,7 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
8379
termHeight = 930 // codex has a bug where the TUI distorts the screen if the height is too large, see: https://github.com/openai/codex/issues/1608
8480
}
8581

82+
printOpenAPI := viper.GetBool(FlagPrintOpenAPI)
8683
var process *termexec.Process
8784
if printOpenAPI {
8885
process = nil
@@ -97,7 +94,13 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
9794
return xerrors.Errorf("failed to setup process: %w", err)
9895
}
9996
}
100-
srv := httpapi.NewServer(ctx, agentType, process, port, chatBasePath)
97+
port := viper.GetInt(FlagPort)
98+
srv := httpapi.NewServer(ctx, httpapi.ServerConfig{
99+
AgentType: agentType,
100+
Process: process,
101+
Port: port,
102+
ChatBasePath: viper.GetString(FlagChatBasePath),
103+
})
101104
if printOpenAPI {
102105
fmt.Println(srv.GetOpenAPI())
103106
return nil
@@ -138,26 +141,69 @@ var agentNames = (func() []string {
138141
return names
139142
})()
140143

141-
var ServerCmd = &cobra.Command{
142-
Use: "server [agent]",
143-
Short: "Run the server",
144-
Long: fmt.Sprintf("Run the server with the specified agent (one of: %s)", strings.Join(agentNames, ", ")),
145-
Args: cobra.MinimumNArgs(1),
146-
Run: func(cmd *cobra.Command, args []string) {
147-
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
148-
ctx := logctx.WithLogger(context.Background(), logger)
149-
if err := runServer(ctx, logger, cmd.Flags().Args()); err != nil {
150-
fmt.Fprintf(os.Stderr, "%+v\n", err)
151-
os.Exit(1)
152-
}
153-
},
144+
type flagSpec struct {
145+
name string
146+
shorthand string
147+
defaultValue any
148+
usage string
149+
flagType string
154150
}
155151

156-
func init() {
157-
ServerCmd.Flags().StringVarP(&agentTypeVar, "type", "t", "", fmt.Sprintf("Override the agent type (one of: %s, custom)", strings.Join(agentNames, ", ")))
158-
ServerCmd.Flags().IntVarP(&port, "port", "p", 3284, "Port to run the server on")
159-
ServerCmd.Flags().BoolVarP(&printOpenAPI, "print-openapi", "P", false, "Print the OpenAPI schema to stdout and exit")
160-
ServerCmd.Flags().StringVarP(&chatBasePath, "chat-base-path", "c", "/chat", "Base path for assets and routes used in the static files of the chat interface")
161-
ServerCmd.Flags().Uint16VarP(&termWidth, "term-width", "W", 80, "Width of the emulated terminal")
162-
ServerCmd.Flags().Uint16VarP(&termHeight, "term-height", "H", 1000, "Height of the emulated terminal")
152+
const (
153+
FlagType = "type"
154+
FlagPort = "port"
155+
FlagPrintOpenAPI = "print-openapi"
156+
FlagChatBasePath = "chat-base-path"
157+
FlagTermWidth = "term-width"
158+
FlagTermHeight = "term-height"
159+
)
160+
161+
func CreateServerCmd() *cobra.Command {
162+
serverCmd := &cobra.Command{
163+
Use: "server [agent]",
164+
Short: "Run the server",
165+
Long: fmt.Sprintf("Run the server with the specified agent (one of: %s)", strings.Join(agentNames, ", ")),
166+
Args: cobra.MinimumNArgs(1),
167+
Run: func(cmd *cobra.Command, args []string) {
168+
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
169+
ctx := logctx.WithLogger(context.Background(), logger)
170+
if err := runServer(ctx, logger, cmd.Flags().Args()); err != nil {
171+
fmt.Fprintf(os.Stderr, "%+v\n", err)
172+
os.Exit(1)
173+
}
174+
},
175+
}
176+
177+
flagSpecs := []flagSpec{
178+
{FlagType, "t", "", fmt.Sprintf("Override the agent type (one of: %s, custom)", strings.Join(agentNames, ", ")), "string"},
179+
{FlagPort, "p", 3284, "Port to run the server on", "int"},
180+
{FlagPrintOpenAPI, "P", false, "Print the OpenAPI schema to stdout and exit", "bool"},
181+
{FlagChatBasePath, "c", "/chat", "Base path for assets and routes used in the static files of the chat interface", "string"},
182+
{FlagTermWidth, "W", uint16(80), "Width of the emulated terminal", "uint16"},
183+
{FlagTermHeight, "H", uint16(1000), "Height of the emulated terminal", "uint16"},
184+
}
185+
186+
for _, spec := range flagSpecs {
187+
switch spec.flagType {
188+
case "string":
189+
serverCmd.Flags().StringP(spec.name, spec.shorthand, spec.defaultValue.(string), spec.usage)
190+
case "int":
191+
serverCmd.Flags().IntP(spec.name, spec.shorthand, spec.defaultValue.(int), spec.usage)
192+
case "bool":
193+
serverCmd.Flags().BoolP(spec.name, spec.shorthand, spec.defaultValue.(bool), spec.usage)
194+
case "uint16":
195+
serverCmd.Flags().Uint16P(spec.name, spec.shorthand, spec.defaultValue.(uint16), spec.usage)
196+
default:
197+
panic(fmt.Sprintf("unknown flag type: %s", spec.flagType))
198+
}
199+
if err := viper.BindPFlag(spec.name, serverCmd.Flags().Lookup(spec.name)); err != nil {
200+
panic(fmt.Sprintf("failed to bind flag %s: %v", spec.name, err))
201+
}
202+
}
203+
204+
viper.SetEnvPrefix("AGENTAPI")
205+
viper.AutomaticEnv()
206+
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
207+
208+
return serverCmd
163209
}

cmd/server/server_test.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ package server
22

33
import (
44
"fmt"
5+
"os"
6+
"strings"
57
"testing"
68

9+
"github.com/spf13/cobra"
10+
"github.com/spf13/viper"
11+
"github.com/stretchr/testify/assert"
712
"github.com/stretchr/testify/require"
813
)
914

@@ -83,3 +88,206 @@ func TestParseAgentType(t *testing.T) {
8388
require.Error(t, err)
8489
})
8590
}
91+
92+
// Test helper to isolate viper config between tests
93+
func isolateViper(t *testing.T) {
94+
// Save current state
95+
oldConfig := viper.AllSettings()
96+
97+
// Reset viper
98+
viper.Reset()
99+
100+
// Clear AGENTAPI_ env vars
101+
var agentapiEnvs []string
102+
for _, env := range os.Environ() {
103+
if strings.HasPrefix(env, "AGENTAPI_") {
104+
parts := strings.SplitN(env, "=", 2)
105+
agentapiEnvs = append(agentapiEnvs, parts[0])
106+
if err := os.Unsetenv(parts[0]); err != nil {
107+
t.Fatalf("Failed to unset env var %s: %v", parts[0], err)
108+
}
109+
}
110+
}
111+
112+
t.Cleanup(func() {
113+
// Restore state
114+
viper.Reset()
115+
for key, value := range oldConfig {
116+
viper.Set(key, value)
117+
}
118+
119+
// Restore env vars
120+
for _, key := range agentapiEnvs {
121+
if val := os.Getenv(key); val != "" {
122+
if err := os.Setenv(key, val); err != nil {
123+
t.Fatalf("Failed to set env var %s: %v", key, err)
124+
}
125+
}
126+
}
127+
})
128+
}
129+
130+
// Test configuration values via ServerCmd execution
131+
func TestServerCmd_AllArgs_Defaults(t *testing.T) {
132+
tests := []struct {
133+
name string
134+
flag string
135+
expected any
136+
getter func() any
137+
}{
138+
{"type default", FlagType, "", func() any { return viper.GetString(FlagType) }},
139+
{"port default", FlagPort, 3284, func() any { return viper.GetInt(FlagPort) }},
140+
{"print-openapi default", FlagPrintOpenAPI, false, func() any { return viper.GetBool(FlagPrintOpenAPI) }},
141+
{"chat-base-path default", FlagChatBasePath, "/chat", func() any { return viper.GetString(FlagChatBasePath) }},
142+
{"term-width default", FlagTermWidth, uint16(80), func() any { return viper.GetUint16(FlagTermWidth) }},
143+
{"term-height default", FlagTermHeight, uint16(1000), func() any { return viper.GetUint16(FlagTermHeight) }},
144+
}
145+
146+
for _, tt := range tests {
147+
t.Run(tt.name, func(t *testing.T) {
148+
isolateViper(t)
149+
serverCmd := CreateServerCmd()
150+
cmd := &cobra.Command{}
151+
cmd.AddCommand(serverCmd)
152+
153+
// Execute with no args to get defaults
154+
serverCmd.SetArgs([]string{"--help"}) // Use help to avoid actual execution
155+
if err := serverCmd.Execute(); err != nil {
156+
t.Fatalf("Failed to execute server command: %v", err)
157+
}
158+
159+
assert.Equal(t, tt.expected, tt.getter())
160+
})
161+
}
162+
}
163+
164+
func TestServerCmd_AllEnvVars(t *testing.T) {
165+
tests := []struct {
166+
name string
167+
envVar string
168+
envValue string
169+
expected any
170+
getter func() any
171+
}{
172+
{"AGENTAPI_TYPE", "AGENTAPI_TYPE", "claude", "claude", func() any { return viper.GetString(FlagType) }},
173+
{"AGENTAPI_PORT", "AGENTAPI_PORT", "8080", 8080, func() any { return viper.GetInt(FlagPort) }},
174+
{"AGENTAPI_PRINT_OPENAPI", "AGENTAPI_PRINT_OPENAPI", "true", true, func() any { return viper.GetBool(FlagPrintOpenAPI) }},
175+
{"AGENTAPI_CHAT_BASE_PATH", "AGENTAPI_CHAT_BASE_PATH", "/api", "/api", func() any { return viper.GetString(FlagChatBasePath) }},
176+
{"AGENTAPI_TERM_WIDTH", "AGENTAPI_TERM_WIDTH", "120", uint16(120), func() any { return viper.GetUint16(FlagTermWidth) }},
177+
{"AGENTAPI_TERM_HEIGHT", "AGENTAPI_TERM_HEIGHT", "500", uint16(500), func() any { return viper.GetUint16(FlagTermHeight) }},
178+
}
179+
180+
for _, tt := range tests {
181+
t.Run(tt.name, func(t *testing.T) {
182+
isolateViper(t)
183+
t.Setenv(tt.envVar, tt.envValue)
184+
185+
serverCmd := CreateServerCmd()
186+
cmd := &cobra.Command{}
187+
cmd.AddCommand(serverCmd)
188+
189+
serverCmd.SetArgs([]string{"--help"})
190+
if err := serverCmd.Execute(); err != nil {
191+
t.Fatalf("Failed to execute server command: %v", err)
192+
}
193+
194+
assert.Equal(t, tt.expected, tt.getter())
195+
})
196+
}
197+
}
198+
199+
func TestServerCmd_ArgsPrecedenceOverEnv(t *testing.T) {
200+
tests := []struct {
201+
name string
202+
envVar string
203+
envValue string
204+
args []string
205+
expected any
206+
getter func() any
207+
}{
208+
{
209+
"type: CLI overrides env",
210+
"AGENTAPI_TYPE", "goose",
211+
[]string{"--type", "claude"},
212+
"claude",
213+
func() any { return viper.GetString(FlagType) },
214+
},
215+
{
216+
"port: CLI overrides env",
217+
"AGENTAPI_PORT", "8080",
218+
[]string{"--port", "9090"},
219+
9090,
220+
func() any { return viper.GetInt(FlagPort) },
221+
},
222+
{
223+
"print-openapi: CLI overrides env",
224+
"AGENTAPI_PRINT_OPENAPI", "false",
225+
[]string{"--print-openapi"},
226+
true,
227+
func() any { return viper.GetBool(FlagPrintOpenAPI) },
228+
},
229+
{
230+
"chat-base-path: CLI overrides env",
231+
"AGENTAPI_CHAT_BASE_PATH", "/env-path",
232+
[]string{"--chat-base-path", "/cli-path"},
233+
"/cli-path",
234+
func() any { return viper.GetString(FlagChatBasePath) },
235+
},
236+
{
237+
"term-width: CLI overrides env",
238+
"AGENTAPI_TERM_WIDTH", "100",
239+
[]string{"--term-width", "150"},
240+
uint16(150),
241+
func() any { return viper.GetUint16(FlagTermWidth) },
242+
},
243+
{
244+
"term-height: CLI overrides env",
245+
"AGENTAPI_TERM_HEIGHT", "500",
246+
[]string{"--term-height", "600"},
247+
uint16(600),
248+
func() any { return viper.GetUint16(FlagTermHeight) },
249+
},
250+
}
251+
252+
for _, tt := range tests {
253+
t.Run(tt.name, func(t *testing.T) {
254+
isolateViper(t)
255+
t.Setenv(tt.envVar, tt.envValue)
256+
257+
// Mock execution to test arg parsing without running server
258+
args := append(tt.args, "--help")
259+
serverCmd := CreateServerCmd()
260+
serverCmd.SetArgs(args)
261+
if err := serverCmd.Execute(); err != nil {
262+
t.Fatalf("Failed to execute server command: %v", err)
263+
}
264+
265+
assert.Equal(t, tt.expected, tt.getter())
266+
})
267+
}
268+
}
269+
270+
func TestMixed_ConfigurationScenarios(t *testing.T) {
271+
t.Run("some env, some cli, some defaults", func(t *testing.T) {
272+
isolateViper(t)
273+
274+
// Set some env vars
275+
t.Setenv("AGENTAPI_TYPE", "goose")
276+
t.Setenv("AGENTAPI_TERM_WIDTH", "120")
277+
278+
// Set some CLI args
279+
serverCmd := CreateServerCmd()
280+
serverCmd.SetArgs([]string{"--port", "9999", "--print-openapi", "--help"})
281+
if err := serverCmd.Execute(); err != nil {
282+
t.Fatalf("Failed to execute server command: %v", err)
283+
}
284+
285+
// Verify mixed configuration
286+
assert.Equal(t, "goose", viper.GetString(FlagType)) // from env
287+
assert.Equal(t, 9999, viper.GetInt(FlagPort)) // from CLI
288+
assert.Equal(t, true, viper.GetBool(FlagPrintOpenAPI)) // from CLI
289+
assert.Equal(t, "/chat", viper.GetString(FlagChatBasePath)) // default
290+
assert.Equal(t, uint16(120), viper.GetUint16(FlagTermWidth)) // from env
291+
assert.Equal(t, uint16(1000), viper.GetUint16(FlagTermHeight)) // default
292+
})
293+
}

0 commit comments

Comments
 (0)