Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion go/core/cli/cmd/kagent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -429,12 +429,29 @@ Examples:

rootCmd.AddCommand(installCmd, uninstallCmd, invokeCmd, bugReportCmd, versionCmd, dashboardCmd, getCmd, initCmd, buildCmd, deployCmd, addMcpCmd, runCmd, mcp.NewMCPCmd(), envdoc.NewEnvCmd())

// Initialize config
// Initialize config file and viper defaults
if err := config.Init(); err != nil {
fmt.Fprintf(os.Stderr, "Error initializing config: %v\n", err)
os.Exit(1)
}

// Bind cobra flags to viper so config file values are used when flags
// are not explicitly set on the command line.
config.BindFlags(rootCmd.PersistentFlags())

// Populate cfg from the merged viper state (config file + env + flags)
// before any subcommand runs, ensuring all commands see config file values.
originalPreRunE := rootCmd.PersistentPreRunE
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
if err := config.Apply(cfg); err != nil {
return fmt.Errorf("error applying config: %w", err)
}
if originalPreRunE != nil {
return originalPreRunE(cmd, args)
}
return nil
Comment on lines +442 to +452
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling config.Apply(cfg) from rootCmd.PersistentPreRunE overwrites the already-parsed flag-backed cfg fields with viper state. This breaks command-local overrides of shared config fields that are not bound to viper. For example, deployCmd defines its own --namespace flag (separate from the persistent one), so kagent deploy --namespace X will be parsed into cfg.Namespace and then potentially clobbered by Apply() since viper is only bound to the persistent flag. Consider binding using the executing command's flag set (e.g., in this PreRunE: BindFlags(cmd.Flags()) before Apply), or remove the command-local --namespace and rely on the persistent flag so CLI overrides are preserved.

Copilot uses AI. Check for mistakes.
}

if err := rootCmd.ExecuteContext(ctx); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)

Expand Down
24 changes: 24 additions & 0 deletions go/core/cli/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,30 @@ func Init() error {
return nil
}

// BindFlags binds cobra persistent flags to viper keys so that config file
// values are used as defaults when the corresponding CLI flag is not explicitly
// set. Flag names use dashes (e.g. "kagent-url") while config file keys use
// underscores (e.g. "kagent_url"), so each binding is explicit.
func BindFlags(flags *pflag.FlagSet) {
viper.BindPFlag("kagent_url", flags.Lookup("kagent-url")) //nolint:errcheck
viper.BindPFlag("namespace", flags.Lookup("namespace")) //nolint:errcheck
viper.BindPFlag("output_format", flags.Lookup("output-format")) //nolint:errcheck
viper.BindPFlag("verbose", flags.Lookup("verbose")) //nolint:errcheck
viper.BindPFlag("timeout", flags.Lookup("timeout")) //nolint:errcheck
Comment on lines +69 to +74
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BindFlags ignores the return value from viper.BindPFlag and also passes flags.Lookup(...) without checking for nil. If a flag is renamed/removed (or a different FlagSet is passed), the binding will silently fail and config precedence will regress. Please handle and surface these errors (e.g., return an error from BindFlags and check it in main), and explicitly error when Lookup returns nil for an expected flag.

Suggested change
func BindFlags(flags *pflag.FlagSet) {
viper.BindPFlag("kagent_url", flags.Lookup("kagent-url")) //nolint:errcheck
viper.BindPFlag("namespace", flags.Lookup("namespace")) //nolint:errcheck
viper.BindPFlag("output_format", flags.Lookup("output-format")) //nolint:errcheck
viper.BindPFlag("verbose", flags.Lookup("verbose")) //nolint:errcheck
viper.BindPFlag("timeout", flags.Lookup("timeout")) //nolint:errcheck
func BindFlags(flags *pflag.FlagSet) error {
if flags == nil {
return fmt.Errorf("BindFlags: flags must not be nil")
}
bind := func(key, flagName string) error {
f := flags.Lookup(flagName)
if f == nil {
return fmt.Errorf("BindFlags: expected flag %q not found in FlagSet", flagName)
}
if err := viper.BindPFlag(key, f); err != nil {
return fmt.Errorf("BindFlags: failed to bind viper key %q to flag %q: %w", key, flagName, err)
}
return nil
}
if err := bind("kagent_url", "kagent-url"); err != nil {
return err
}
if err := bind("namespace", "namespace"); err != nil {
return err
}
if err := bind("output_format", "output-format"); err != nil {
return err
}
if err := bind("verbose", "verbose"); err != nil {
return err
}
if err := bind("timeout", "timeout"); err != nil {
return err
}
return nil

Copilot uses AI. Check for mistakes.
}

// Apply populates the given Config from the merged viper state (config file +
// env + CLI flags). Call this after flag parsing to ensure cfg reflects all
// sources with the correct precedence: CLI flag > env > config file > default.
func Apply(cfg *Config) error {
Comment on lines +77 to +80
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Apply() docstring and precedence statement mention merging env vars, but this package currently only binds USER_ID and does not enable AutomaticEnv / BindEnv for the config keys (kagent_url, namespace, output_format, verbose, timeout). Either implement env var support for these keys (and document the expected env names), or update this comment so it matches actual behavior.

Copilot uses AI. Check for mistakes.
merged, err := Get()
if err != nil {
return err
}
*cfg = *merged
return nil
}

func Get() (*Config, error) {
var config Config
if err := viper.Unmarshal(&config); err != nil {
Expand Down
133 changes: 133 additions & 0 deletions go/core/cli/internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package config

import (
"os"
"path/filepath"
"testing"
"time"

"github.com/spf13/pflag"
"github.com/spf13/viper"
)

func TestConfigFileValuesAreUsed(t *testing.T) {
// Reset viper for a clean test
viper.Reset()

// Create a temp config directory
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.yaml")

// Write a config file with custom values
configContent := `kagent_url: http://custom-server:9090
namespace: my-namespace
output_format: json
verbose: true
timeout: 60s
`
if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil {
t.Fatalf("Failed to write config file: %v", err)
}

// Set up viper to read the config
viper.SetConfigFile(configFile)
viper.SetConfigType("yaml")
viper.SetDefault("kagent_url", "http://localhost:8083")
viper.SetDefault("output_format", "table")
viper.SetDefault("namespace", "kagent")
viper.SetDefault("timeout", 300*time.Second)

if err := viper.ReadInConfig(); err != nil {
t.Fatalf("Failed to read config: %v", err)
}

// Create flags (simulating what main.go does)
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
flags.String("kagent-url", "http://localhost:8083", "KAgent URL")
flags.StringP("namespace", "n", "kagent", "Namespace")
flags.StringP("output-format", "o", "table", "Output format")
flags.BoolP("verbose", "v", false, "Verbose output")
flags.Duration("timeout", 300*time.Second, "Timeout")

// Bind flags to viper
BindFlags(flags)

// Get merged config (should use config file values since no flags were explicitly set)
cfg := &Config{}
if err := Apply(cfg); err != nil {
t.Fatalf("Apply failed: %v", err)
}

if cfg.KAgentURL != "http://custom-server:9090" {
t.Errorf("Expected KAgentURL=http://custom-server:9090, got %s", cfg.KAgentURL)
}
if cfg.Namespace != "my-namespace" {
t.Errorf("Expected Namespace=my-namespace, got %s", cfg.Namespace)
}
if cfg.OutputFormat != "json" {
t.Errorf("Expected OutputFormat=json, got %s", cfg.OutputFormat)
}
if !cfg.Verbose {
t.Error("Expected Verbose=true, got false")
}
if cfg.Timeout != 60*time.Second {
t.Errorf("Expected Timeout=60s, got %v", cfg.Timeout)
}
}

func TestCLIFlagsOverrideConfigFile(t *testing.T) {
// Reset viper for a clean test
viper.Reset()

// Create a temp config directory
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.yaml")

// Write a config file with custom values
configContent := `kagent_url: http://config-server:9090
namespace: config-ns
`
if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil {
t.Fatalf("Failed to write config file: %v", err)
}

viper.SetConfigFile(configFile)
viper.SetConfigType("yaml")
viper.SetDefault("kagent_url", "http://localhost:8083")
viper.SetDefault("namespace", "kagent")
viper.SetDefault("output_format", "table")
viper.SetDefault("timeout", 300*time.Second)

if err := viper.ReadInConfig(); err != nil {
t.Fatalf("Failed to read config: %v", err)
}

// Create and parse flags with explicit values
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
flags.String("kagent-url", "http://localhost:8083", "KAgent URL")
flags.StringP("namespace", "n", "kagent", "Namespace")
flags.StringP("output-format", "o", "table", "Output format")
flags.BoolP("verbose", "v", false, "Verbose output")
flags.Duration("timeout", 300*time.Second, "Timeout")

// Simulate passing --kagent-url on the CLI
if err := flags.Parse([]string{"--kagent-url", "http://cli-override:7777"}); err != nil {
t.Fatalf("Failed to parse flags: %v", err)
}

BindFlags(flags)

cfg := &Config{}
if err := Apply(cfg); err != nil {
t.Fatalf("Apply failed: %v", err)
}

// CLI flag should win over config file
if cfg.KAgentURL != "http://cli-override:7777" {
t.Errorf("Expected KAgentURL=http://cli-override:7777, got %s", cfg.KAgentURL)
}
// Config file value should still be used for namespace (not overridden by CLI)
if cfg.Namespace != "config-ns" {
t.Errorf("Expected Namespace=config-ns, got %s", cfg.Namespace)
}
}
Loading