From d9066a97e8b9455354a9112aa8bde72a7dd3bb27 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 5 Aug 2025 17:14:34 +0200 Subject: [PATCH 1/3] feat: configuration via env variables --- cmd/server/server.go | 78 +++++++++++++++++++++++++++++--------- go.mod | 15 +++++++- go.sum | 29 +++++++++++++- lib/httpapi/server.go | 21 ++++++---- lib/httpapi/server_test.go | 14 ++++++- 5 files changed, 127 insertions(+), 30 deletions(-) diff --git a/cmd/server/server.go b/cmd/server/server.go index 4fc3943..744564e 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/spf13/viper" "golang.org/x/xerrors" "github.com/coder/agentapi/lib/httpapi" @@ -19,15 +20,6 @@ import ( "github.com/coder/agentapi/lib/termexec" ) -var ( - agentTypeVar string - port int - printOpenAPI bool - chatBasePath string - termWidth uint16 - termHeight uint16 -) - type AgentType = msgfmt.AgentType const ( @@ -68,11 +60,15 @@ func parseAgentType(firstArg string, agentTypeVar string) (AgentType, error) { func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) error { agent := argsToPass[0] - agentType, err := parseAgentType(agent, agentTypeVar) + agentTypeValue := viper.GetString(FlagType) + agentType, err := parseAgentType(agent, agentTypeValue) if err != nil { return xerrors.Errorf("failed to parse agent type: %w", err) } + termWidth := viper.GetUint16(FlagTermWidth) + termHeight := viper.GetUint16(FlagTermHeight) + if termWidth < 10 { return xerrors.Errorf("term width must be at least 10") } @@ -83,6 +79,7 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er 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 } + printOpenAPI := viper.GetBool(FlagPrintOpenAPI) var process *termexec.Process if printOpenAPI { process = nil @@ -97,7 +94,13 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er return xerrors.Errorf("failed to setup process: %w", err) } } - srv := httpapi.NewServer(ctx, agentType, process, port, chatBasePath) + port := viper.GetInt(FlagPort) + srv := httpapi.NewServer(ctx, httpapi.ServerConfig{ + AgentType: agentType, + Process: process, + Port: port, + ChatBasePath: viper.GetString(FlagChatBasePath), + }) if printOpenAPI { fmt.Println(srv.GetOpenAPI()) return nil @@ -153,11 +156,52 @@ var ServerCmd = &cobra.Command{ }, } +type flagSpec struct { + name string + shorthand string + defaultValue any + usage string + flagType string +} + +const ( + FlagType = "type" + FlagPort = "port" + FlagPrintOpenAPI = "print-openapi" + FlagChatBasePath = "chat-base-path" + FlagTermWidth = "term-width" + FlagTermHeight = "term-height" +) + func init() { - ServerCmd.Flags().StringVarP(&agentTypeVar, "type", "t", "", fmt.Sprintf("Override the agent type (one of: %s, custom)", strings.Join(agentNames, ", "))) - ServerCmd.Flags().IntVarP(&port, "port", "p", 3284, "Port to run the server on") - ServerCmd.Flags().BoolVarP(&printOpenAPI, "print-openapi", "P", false, "Print the OpenAPI schema to stdout and exit") - ServerCmd.Flags().StringVarP(&chatBasePath, "chat-base-path", "c", "/chat", "Base path for assets and routes used in the static files of the chat interface") - ServerCmd.Flags().Uint16VarP(&termWidth, "term-width", "W", 80, "Width of the emulated terminal") - ServerCmd.Flags().Uint16VarP(&termHeight, "term-height", "H", 1000, "Height of the emulated terminal") + flagSpecs := []flagSpec{ + {FlagType, "t", "", fmt.Sprintf("Override the agent type (one of: %s, custom)", strings.Join(agentNames, ", ")), "string"}, + {FlagPort, "p", 3284, "Port to run the server on", "int"}, + {FlagPrintOpenAPI, "P", false, "Print the OpenAPI schema to stdout and exit", "bool"}, + {FlagChatBasePath, "c", "/chat", "Base path for assets and routes used in the static files of the chat interface", "string"}, + {FlagTermWidth, "W", uint16(80), "Width of the emulated terminal", "uint16"}, + {FlagTermHeight, "H", uint16(1000), "Height of the emulated terminal", "uint16"}, + } + + for _, spec := range flagSpecs { + switch spec.flagType { + case "string": + ServerCmd.Flags().StringP(spec.name, spec.shorthand, spec.defaultValue.(string), spec.usage) + case "int": + ServerCmd.Flags().IntP(spec.name, spec.shorthand, spec.defaultValue.(int), spec.usage) + case "bool": + ServerCmd.Flags().BoolP(spec.name, spec.shorthand, spec.defaultValue.(bool), spec.usage) + case "uint16": + ServerCmd.Flags().Uint16P(spec.name, spec.shorthand, spec.defaultValue.(uint16), spec.usage) + default: + panic(fmt.Sprintf("unknown flag type: %s", spec.flagType)) + } + if err := viper.BindPFlag(spec.name, ServerCmd.Flags().Lookup(spec.name)); err != nil { + panic(fmt.Sprintf("failed to bind flag %s: %v", spec.name, err)) + } + } + + viper.SetEnvPrefix("AGENTAPI") + viper.AutomaticEnv() + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) } diff --git a/go.mod b/go.mod index d7a1e5c..f7d41e6 100644 --- a/go.mod +++ b/go.mod @@ -9,12 +9,25 @@ require ( github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/cors v1.2.1 github.com/spf13/cobra v1.9.1 + github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 github.com/tmaxmax/go-sse v0.10.0 golang.org/x/term v0.30.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da ) +require ( + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect +) + require ( github.com/ActiveState/termtest/conpty v0.5.0 // indirect github.com/ActiveState/vt10x v1.3.1 // indirect @@ -29,7 +42,6 @@ require ( github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pty v1.1.8 // indirect - github.com/kr/text v0.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -45,6 +57,5 @@ require ( golang.org/x/sync v0.12.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 211c699..c64063b 100644 --- a/go.sum +++ b/go.sum @@ -33,12 +33,20 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gdamore/encoding v0.0.0-20151215212835-b23993cbb635/go.mod h1:yrQYJKKDTrHmbYxI7CYi+/hbdiDT2m4Hj+t0ikCjsrQ= github.com/gdamore/tcell v1.0.1-0.20180608172421-b3cebc399d6f/go.mod h1:tqyG50u7+Ctv1w5VX67kLzKcj9YXR/JSBZQq/+mLl1A= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= @@ -51,8 +59,8 @@ github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3x github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v0.0.0-20180526135729-345fbb3dbcdb/go.mod h1:NXg0ArsFk0Y01623LgUqoqcouGDB+PwCCQlrwrG6xJ4= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= @@ -69,6 +77,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -77,21 +87,36 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tmaxmax/go-sse v0.10.0 h1:j9F93WB4Hxt8wUf6oGffMm4dutALvUPoDDxfuDQOSqA= github.com/tmaxmax/go-sse v0.10.0/go.mod h1:u/2kZQR1tyngo1lKaNCj1mJmhXGZWS1Zs5yiSOD+Eg8= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200427165652-729f1e841bcc/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= diff --git a/lib/httpapi/server.go b/lib/httpapi/server.go index 5717044..7032f68 100644 --- a/lib/httpapi/server.go +++ b/lib/httpapi/server.go @@ -59,8 +59,15 @@ func (s *Server) GetOpenAPI() string { // because the action of taking a snapshot takes time too. const snapshotInterval = 25 * time.Millisecond +type ServerConfig struct { + AgentType mf.AgentType + Process *termexec.Process + Port int + ChatBasePath string +} + // NewServer creates a new server instance -func NewServer(ctx context.Context, agentType mf.AgentType, process *termexec.Process, port int, chatBasePath string) *Server { +func NewServer(ctx context.Context, config ServerConfig) *Server { router := chi.NewMux() corsMiddleware := cors.New(cors.Options{ @@ -77,10 +84,10 @@ func NewServer(ctx context.Context, agentType mf.AgentType, process *termexec.Pr humaConfig.Info.Description = "HTTP API for Claude Code, Goose, and Aider.\n\nhttps://github.com/coder/agentapi" api := humachi.New(router, humaConfig) formatMessage := func(message string, userInput string) string { - return mf.FormatAgentMessage(agentType, message, userInput) + return mf.FormatAgentMessage(config.AgentType, message, userInput) } conversation := st.NewConversation(ctx, st.ConversationConfig{ - AgentIO: process, + AgentIO: config.Process, GetTime: func() time.Time { return time.Now() }, @@ -92,13 +99,13 @@ func NewServer(ctx context.Context, agentType mf.AgentType, process *termexec.Pr s := &Server{ router: router, api: api, - port: port, + port: config.Port, conversation: conversation, logger: logctx.From(ctx), - agentio: process, - agentType: agentType, + agentio: config.Process, + agentType: config.AgentType, emitter: emitter, - chatBasePath: strings.TrimSuffix(chatBasePath, "/"), + chatBasePath: strings.TrimSuffix(config.ChatBasePath, "/"), } // Register API routes diff --git a/lib/httpapi/server_test.go b/lib/httpapi/server_test.go index 5abc5f5..badc974 100644 --- a/lib/httpapi/server_test.go +++ b/lib/httpapi/server_test.go @@ -46,7 +46,12 @@ func TestOpenAPISchema(t *testing.T) { t.Parallel() ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) - srv := httpapi.NewServer(ctx, msgfmt.AgentTypeClaude, nil, 0, "/chat") + srv := httpapi.NewServer(ctx, httpapi.ServerConfig{ + AgentType: msgfmt.AgentTypeClaude, + Process: nil, + Port: 0, + ChatBasePath: "/chat", + }) currentSchemaStr := srv.GetOpenAPI() var currentSchema any if err := json.Unmarshal([]byte(currentSchemaStr), ¤tSchema); err != nil { @@ -90,7 +95,12 @@ func TestServer_redirectToChat(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() tCtx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) - s := httpapi.NewServer(tCtx, msgfmt.AgentTypeClaude, nil, 0, tc.chatBasePath) + s := httpapi.NewServer(tCtx, httpapi.ServerConfig{ + AgentType: msgfmt.AgentTypeClaude, + Process: nil, + Port: 0, + ChatBasePath: tc.chatBasePath, + }) tsServer := httptest.NewServer(s.Handler()) t.Cleanup(tsServer.Close) From 079369bde0ac85cda705cfcbd2facc1c14609d33 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 5 Aug 2025 17:55:59 +0200 Subject: [PATCH 2/3] chore: refactor server cmd initialization for tests, add tests --- cmd/root.go | 2 +- cmd/server/server.go | 44 +++++---- cmd/server/server_test.go | 200 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 22 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 7f12a80..6761fdb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,6 +25,6 @@ func Execute() { } func init() { - rootCmd.AddCommand(server.ServerCmd) + rootCmd.AddCommand(server.CreateServerCmd()) rootCmd.AddCommand(attach.AttachCmd) } diff --git a/cmd/server/server.go b/cmd/server/server.go index 744564e..3313b51 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -141,21 +141,6 @@ var agentNames = (func() []string { return names })() -var ServerCmd = &cobra.Command{ - Use: "server [agent]", - Short: "Run the server", - Long: fmt.Sprintf("Run the server with the specified agent (one of: %s)", strings.Join(agentNames, ", ")), - Args: cobra.MinimumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) - ctx := logctx.WithLogger(context.Background(), logger) - if err := runServer(ctx, logger, cmd.Flags().Args()); err != nil { - fmt.Fprintf(os.Stderr, "%+v\n", err) - os.Exit(1) - } - }, -} - type flagSpec struct { name string shorthand string @@ -173,7 +158,22 @@ const ( FlagTermHeight = "term-height" ) -func init() { +func CreateServerCmd() *cobra.Command { + serverCmd := &cobra.Command{ + Use: "server [agent]", + Short: "Run the server", + Long: fmt.Sprintf("Run the server with the specified agent (one of: %s)", strings.Join(agentNames, ", ")), + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + ctx := logctx.WithLogger(context.Background(), logger) + if err := runServer(ctx, logger, cmd.Flags().Args()); err != nil { + fmt.Fprintf(os.Stderr, "%+v\n", err) + os.Exit(1) + } + }, + } + flagSpecs := []flagSpec{ {FlagType, "t", "", fmt.Sprintf("Override the agent type (one of: %s, custom)", strings.Join(agentNames, ", ")), "string"}, {FlagPort, "p", 3284, "Port to run the server on", "int"}, @@ -186,17 +186,17 @@ func init() { for _, spec := range flagSpecs { switch spec.flagType { case "string": - ServerCmd.Flags().StringP(spec.name, spec.shorthand, spec.defaultValue.(string), spec.usage) + serverCmd.Flags().StringP(spec.name, spec.shorthand, spec.defaultValue.(string), spec.usage) case "int": - ServerCmd.Flags().IntP(spec.name, spec.shorthand, spec.defaultValue.(int), spec.usage) + serverCmd.Flags().IntP(spec.name, spec.shorthand, spec.defaultValue.(int), spec.usage) case "bool": - ServerCmd.Flags().BoolP(spec.name, spec.shorthand, spec.defaultValue.(bool), spec.usage) + serverCmd.Flags().BoolP(spec.name, spec.shorthand, spec.defaultValue.(bool), spec.usage) case "uint16": - ServerCmd.Flags().Uint16P(spec.name, spec.shorthand, spec.defaultValue.(uint16), spec.usage) + serverCmd.Flags().Uint16P(spec.name, spec.shorthand, spec.defaultValue.(uint16), spec.usage) default: panic(fmt.Sprintf("unknown flag type: %s", spec.flagType)) } - if err := viper.BindPFlag(spec.name, ServerCmd.Flags().Lookup(spec.name)); err != nil { + if err := viper.BindPFlag(spec.name, serverCmd.Flags().Lookup(spec.name)); err != nil { panic(fmt.Sprintf("failed to bind flag %s: %v", spec.name, err)) } } @@ -204,4 +204,6 @@ func init() { viper.SetEnvPrefix("AGENTAPI") viper.AutomaticEnv() viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + + return serverCmd } diff --git a/cmd/server/server_test.go b/cmd/server/server_test.go index ff0611e..a70ce67 100644 --- a/cmd/server/server_test.go +++ b/cmd/server/server_test.go @@ -2,8 +2,13 @@ package server import ( "fmt" + "os" + "strings" "testing" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -83,3 +88,198 @@ func TestParseAgentType(t *testing.T) { require.Error(t, err) }) } + +// Test helper to isolate viper config between tests +func isolateViper(t *testing.T) { + // Save current state + oldConfig := viper.AllSettings() + + // Reset viper + viper.Reset() + + // Clear AGENTAPI_ env vars + var agentapiEnvs []string + for _, env := range os.Environ() { + if strings.HasPrefix(env, "AGENTAPI_") { + parts := strings.SplitN(env, "=", 2) + agentapiEnvs = append(agentapiEnvs, parts[0]) + os.Unsetenv(parts[0]) + } + } + + t.Cleanup(func() { + // Restore state + viper.Reset() + for key, value := range oldConfig { + viper.Set(key, value) + } + + // Restore env vars + for _, key := range agentapiEnvs { + if val := os.Getenv(key); val != "" { + os.Setenv(key, val) + } + } + }) +} + +// Test configuration values via ServerCmd execution +func TestServerCmd_AllArgs_Defaults(t *testing.T) { + tests := []struct { + name string + flag string + expected any + getter func() any + }{ + {"type default", FlagType, "", func() any { return viper.GetString(FlagType) }}, + {"port default", FlagPort, 3284, func() any { return viper.GetInt(FlagPort) }}, + {"print-openapi default", FlagPrintOpenAPI, false, func() any { return viper.GetBool(FlagPrintOpenAPI) }}, + {"chat-base-path default", FlagChatBasePath, "/chat", func() any { return viper.GetString(FlagChatBasePath) }}, + {"term-width default", FlagTermWidth, uint16(80), func() any { return viper.GetUint16(FlagTermWidth) }}, + {"term-height default", FlagTermHeight, uint16(1000), func() any { return viper.GetUint16(FlagTermHeight) }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isolateViper(t) + serverCmd := CreateServerCmd() + cmd := &cobra.Command{} + cmd.AddCommand(serverCmd) + + // Execute with no args to get defaults + serverCmd.SetArgs([]string{"--help"}) // Use help to avoid actual execution + serverCmd.Execute() + + assert.Equal(t, tt.expected, tt.getter()) + }) + } +} + +func TestServerCmd_AllEnvVars(t *testing.T) { + tests := []struct { + name string + envVar string + envValue string + expected any + getter func() any + }{ + {"AGENTAPI_TYPE", "AGENTAPI_TYPE", "claude", "claude", func() any { return viper.GetString(FlagType) }}, + {"AGENTAPI_PORT", "AGENTAPI_PORT", "8080", 8080, func() any { return viper.GetInt(FlagPort) }}, + {"AGENTAPI_PRINT_OPENAPI", "AGENTAPI_PRINT_OPENAPI", "true", true, func() any { return viper.GetBool(FlagPrintOpenAPI) }}, + {"AGENTAPI_CHAT_BASE_PATH", "AGENTAPI_CHAT_BASE_PATH", "/api", "/api", func() any { return viper.GetString(FlagChatBasePath) }}, + {"AGENTAPI_TERM_WIDTH", "AGENTAPI_TERM_WIDTH", "120", uint16(120), func() any { return viper.GetUint16(FlagTermWidth) }}, + {"AGENTAPI_TERM_HEIGHT", "AGENTAPI_TERM_HEIGHT", "500", uint16(500), func() any { return viper.GetUint16(FlagTermHeight) }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isolateViper(t) + os.Setenv(tt.envVar, tt.envValue) + defer os.Unsetenv(tt.envVar) + + serverCmd := CreateServerCmd() + cmd := &cobra.Command{} + cmd.AddCommand(serverCmd) + + serverCmd.SetArgs([]string{"--help"}) + serverCmd.Execute() + + assert.Equal(t, tt.expected, tt.getter()) + }) + } +} + +func TestServerCmd_ArgsPrecedenceOverEnv(t *testing.T) { + tests := []struct { + name string + envVar string + envValue string + args []string + expected any + getter func() any + }{ + { + "type: CLI overrides env", + "AGENTAPI_TYPE", "goose", + []string{"--type", "claude"}, + "claude", + func() any { return viper.GetString(FlagType) }, + }, + { + "port: CLI overrides env", + "AGENTAPI_PORT", "8080", + []string{"--port", "9090"}, + 9090, + func() any { return viper.GetInt(FlagPort) }, + }, + { + "print-openapi: CLI overrides env", + "AGENTAPI_PRINT_OPENAPI", "false", + []string{"--print-openapi"}, + true, + func() any { return viper.GetBool(FlagPrintOpenAPI) }, + }, + { + "chat-base-path: CLI overrides env", + "AGENTAPI_CHAT_BASE_PATH", "/env-path", + []string{"--chat-base-path", "/cli-path"}, + "/cli-path", + func() any { return viper.GetString(FlagChatBasePath) }, + }, + { + "term-width: CLI overrides env", + "AGENTAPI_TERM_WIDTH", "100", + []string{"--term-width", "150"}, + uint16(150), + func() any { return viper.GetUint16(FlagTermWidth) }, + }, + { + "term-height: CLI overrides env", + "AGENTAPI_TERM_HEIGHT", "500", + []string{"--term-height", "600"}, + uint16(600), + func() any { return viper.GetUint16(FlagTermHeight) }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isolateViper(t) + os.Setenv(tt.envVar, tt.envValue) + defer os.Unsetenv(tt.envVar) + + // Mock execution to test arg parsing without running server + args := append(tt.args, "--help") + serverCmd := CreateServerCmd() + serverCmd.SetArgs(args) + serverCmd.Execute() + + assert.Equal(t, tt.expected, tt.getter()) + }) + } +} + +func TestMixed_ConfigurationScenarios(t *testing.T) { + t.Run("some env, some cli, some defaults", func(t *testing.T) { + isolateViper(t) + + // Set some env vars + os.Setenv("AGENTAPI_TYPE", "goose") + os.Setenv("AGENTAPI_TERM_WIDTH", "120") + defer os.Unsetenv("AGENTAPI_TYPE") + defer os.Unsetenv("AGENTAPI_TERM_WIDTH") + + // Set some CLI args + serverCmd := CreateServerCmd() + serverCmd.SetArgs([]string{"--port", "9999", "--print-openapi", "--help"}) + serverCmd.Execute() + + // Verify mixed configuration + assert.Equal(t, "goose", viper.GetString(FlagType)) // from env + assert.Equal(t, 9999, viper.GetInt(FlagPort)) // from CLI + assert.Equal(t, true, viper.GetBool(FlagPrintOpenAPI)) // from CLI + assert.Equal(t, "/chat", viper.GetString(FlagChatBasePath)) // default + assert.Equal(t, uint16(120), viper.GetUint16(FlagTermWidth)) // from env + assert.Equal(t, uint16(1000), viper.GetUint16(FlagTermHeight)) // default + }) +} From 4d2eeb7ba97e27b13347db3f062fb5c58396342d Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 5 Aug 2025 19:56:15 +0200 Subject: [PATCH 3/3] chore: os.Setenv -> t.Setenv + lint --- cmd/server/server_test.go | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/cmd/server/server_test.go b/cmd/server/server_test.go index a70ce67..59b1ccc 100644 --- a/cmd/server/server_test.go +++ b/cmd/server/server_test.go @@ -103,7 +103,9 @@ func isolateViper(t *testing.T) { if strings.HasPrefix(env, "AGENTAPI_") { parts := strings.SplitN(env, "=", 2) agentapiEnvs = append(agentapiEnvs, parts[0]) - os.Unsetenv(parts[0]) + if err := os.Unsetenv(parts[0]); err != nil { + t.Fatalf("Failed to unset env var %s: %v", parts[0], err) + } } } @@ -117,7 +119,9 @@ func isolateViper(t *testing.T) { // Restore env vars for _, key := range agentapiEnvs { if val := os.Getenv(key); val != "" { - os.Setenv(key, val) + if err := os.Setenv(key, val); err != nil { + t.Fatalf("Failed to set env var %s: %v", key, err) + } } } }) @@ -148,7 +152,9 @@ func TestServerCmd_AllArgs_Defaults(t *testing.T) { // Execute with no args to get defaults serverCmd.SetArgs([]string{"--help"}) // Use help to avoid actual execution - serverCmd.Execute() + if err := serverCmd.Execute(); err != nil { + t.Fatalf("Failed to execute server command: %v", err) + } assert.Equal(t, tt.expected, tt.getter()) }) @@ -174,15 +180,16 @@ func TestServerCmd_AllEnvVars(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { isolateViper(t) - os.Setenv(tt.envVar, tt.envValue) - defer os.Unsetenv(tt.envVar) + t.Setenv(tt.envVar, tt.envValue) serverCmd := CreateServerCmd() cmd := &cobra.Command{} cmd.AddCommand(serverCmd) serverCmd.SetArgs([]string{"--help"}) - serverCmd.Execute() + if err := serverCmd.Execute(); err != nil { + t.Fatalf("Failed to execute server command: %v", err) + } assert.Equal(t, tt.expected, tt.getter()) }) @@ -245,14 +252,15 @@ func TestServerCmd_ArgsPrecedenceOverEnv(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { isolateViper(t) - os.Setenv(tt.envVar, tt.envValue) - defer os.Unsetenv(tt.envVar) + t.Setenv(tt.envVar, tt.envValue) // Mock execution to test arg parsing without running server args := append(tt.args, "--help") serverCmd := CreateServerCmd() serverCmd.SetArgs(args) - serverCmd.Execute() + if err := serverCmd.Execute(); err != nil { + t.Fatalf("Failed to execute server command: %v", err) + } assert.Equal(t, tt.expected, tt.getter()) }) @@ -264,15 +272,15 @@ func TestMixed_ConfigurationScenarios(t *testing.T) { isolateViper(t) // Set some env vars - os.Setenv("AGENTAPI_TYPE", "goose") - os.Setenv("AGENTAPI_TERM_WIDTH", "120") - defer os.Unsetenv("AGENTAPI_TYPE") - defer os.Unsetenv("AGENTAPI_TERM_WIDTH") + t.Setenv("AGENTAPI_TYPE", "goose") + t.Setenv("AGENTAPI_TERM_WIDTH", "120") // Set some CLI args serverCmd := CreateServerCmd() serverCmd.SetArgs([]string{"--port", "9999", "--print-openapi", "--help"}) - serverCmd.Execute() + if err := serverCmd.Execute(); err != nil { + t.Fatalf("Failed to execute server command: %v", err) + } // Verify mixed configuration assert.Equal(t, "goose", viper.GetString(FlagType)) // from env