diff --git a/README.md b/README.md index b5cc7bb..a2dc74b 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ iris config show [core] version = 1 shell = "" # "zsh", "bash", "fish", or empty for auto-detection +shell-login = false # run the selected shell as a login shell; can also be enabled with iris --shell-login mode = "last" # "last", "spec", or "history" debug = false expand-alias = true diff --git a/internal/config/config.go b/internal/config/config.go index ae21f0e..e602aa4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,7 @@ func (d Duration) MarshalText() ([]byte, error) { type CoreConfig struct { Version int `toml:"version"` Shell string `toml:"shell"` + ShellLogin bool `toml:"shell-login"` Mode string `toml:"mode"` Debug bool `toml:"debug"` ExpandAlias bool `toml:"expand-alias"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6987f8b..b45cb26 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -12,6 +12,9 @@ func TestDefaultConfigAndState(t *testing.T) { if cfg.Core.Version != 1 { t.Errorf("expected version 1, got %d", cfg.Core.Version) } + if cfg.Core.ShellLogin { + t.Errorf("expected login shell to be disabled by default") + } if cfg.UI.MaxSuggestions != 100 { t.Errorf("expected suggestions 100, got %d", cfg.UI.MaxSuggestions) } @@ -90,14 +93,23 @@ func TestValidationAndEnvironmentOverrides(t *testing.T) { } defer os.RemoveAll(tmpDir) + // UserConfigDir follows HOME on macOS and XDG_CONFIG_HOME on Unix. + // Override both so the test cannot read or write the user's real config. + t.Setenv("HOME", tmpDir) t.Setenv("XDG_CONFIG_HOME", tmpDir) - configDir := filepath.Join(tmpDir, "iris") + configPath, err := ConfigPath() + if err != nil { + t.Fatalf("failed to get config path: %v", err) + } + configDir := filepath.Dir(configPath) if mkErr := os.MkdirAll(configDir, 0755); mkErr != nil { t.Fatalf("failed to create config dir: %v", mkErr) } - configPath := filepath.Join(configDir, "config.toml") tomlContent := ` +[core] +shell-login = true + [ai] enabled = true provider = "groq" @@ -132,6 +144,9 @@ model = "qwen-2.5-coder-32b" if !cfg.Core.Debug { t.Errorf("expected debug to be true") } + if !cfg.Core.ShellLogin { + t.Errorf("expected login shell to be enabled from TOML") + } if cfg.Core.Shell != "fish" { t.Errorf("expected shell fish, got %q", cfg.Core.Shell) } @@ -178,6 +193,9 @@ func TestLoadSave(t *testing.T) { } defer os.RemoveAll(tmpDir) + // UserConfigDir follows HOME on macOS and XDG_CONFIG_HOME on Unix. + // Override both so the test cannot read or write the user's real config. + t.Setenv("HOME", tmpDir) t.Setenv("XDG_CONFIG_HOME", tmpDir) cfg, err := Load() @@ -186,6 +204,7 @@ func TestLoadSave(t *testing.T) { } cfg.Core.Shell = "zsh" + cfg.Core.ShellLogin = true cfg.UI.MaxHeight = 20 err = Save(cfg) @@ -201,6 +220,9 @@ func TestLoadSave(t *testing.T) { if loaded.Core.Shell != "zsh" { t.Errorf("expected loaded shell to be zsh, got %q", loaded.Core.Shell) } + if !loaded.Core.ShellLogin { + t.Errorf("expected loaded login shell setting to be true") + } if loaded.UI.MaxHeight != 20 { t.Errorf("expected loaded height to be 20, got %d", loaded.UI.MaxHeight) } @@ -250,5 +272,3 @@ func TestMigration(t *testing.T) { t.Errorf("expected backup file update_state.json.bak to exist") } } - - diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 27e651f..4b6c40d 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -7,6 +7,7 @@ func DefaultConfig() *Config { Core: CoreConfig{ Version: 1, Shell: "", + ShellLogin: false, Mode: "last", Debug: false, ExpandAlias: true, diff --git a/root/config_cmd.go b/root/config_cmd.go index 491c29f..e664471 100644 --- a/root/config_cmd.go +++ b/root/config_cmd.go @@ -43,6 +43,9 @@ version = 1 # override shell: "bash", "zsh", "fish", keep empty for auto detection shell = "" +# run the selected shell as a login shell +shell-login = false + # startup mode: "last", "spec", "history" # "last" = remember last mode used mode = "last" diff --git a/root/config_cmd_test.go b/root/config_cmd_test.go index 53b79cd..7755ee9 100644 --- a/root/config_cmd_test.go +++ b/root/config_cmd_test.go @@ -3,8 +3,10 @@ package root import ( "bytes" "os" - "path/filepath" + "strings" "testing" + + "github.com/versenilvis/iris/internal/config" ) func TestConfigCommands(t *testing.T) { @@ -14,14 +16,27 @@ func TestConfigCommands(t *testing.T) { } defer os.RemoveAll(tmpDir) + // UserConfigDir follows HOME on macOS and XDG_CONFIG_HOME on Unix. + // Override both so the command cannot read or write the user's real config. + t.Setenv("HOME", tmpDir) t.Setenv("XDG_CONFIG_HOME", tmpDir) ConfigInitCmd.Run(ConfigInitCmd, []string{}) - configPath := filepath.Join(tmpDir, "iris", "config.toml") - if _, err := os.Stat(configPath); err != nil { + configPath, err := config.ConfigPath() + if err != nil { + t.Fatalf("failed to get config path: %v", err) + } + if _, statErr := os.Stat(configPath); statErr != nil { t.Errorf("expected config file to be created at %s, but it was not", configPath) } + content, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read config file: %v", err) + } + if !strings.Contains(string(content), "shell-login = false") { + t.Error("expected initialized config to include shell-login = false") + } buf := new(bytes.Buffer) ConfigShowCmd.SetOut(buf) diff --git a/root/init.go b/root/init.go index 506c3ef..facc8b3 100644 --- a/root/init.go +++ b/root/init.go @@ -148,7 +148,7 @@ var setupCmd = &cobra.Command{ var shellName string if len(args) > 0 { - shellName = args[0] + shellName = filepath.Base(args[0]) } else { shellPath := os.Getenv("SHELL") shellName = filepath.Base(shellPath) @@ -208,6 +208,9 @@ version = 1 # override shell: "bash", "zsh", "fish", keep empty for auto detection shell = "" +# run the selected shell as a login shell +shell-login = false + # startup mode: "last", "spec", "history" # "last" = remember last mode used mode = "last" diff --git a/root/root.go b/root/root.go index 353f78d..8d9ad02 100644 --- a/root/root.go +++ b/root/root.go @@ -50,24 +50,29 @@ It works exactly like coding editor suggestion menu drop down.`, runWrapper() }, } - shellFlag string - debugMode bool + shellFlag string + shellLoginFlag bool + debugMode bool ) func init() { rootCmd.PersistentFlags().StringVarP(&shellFlag, "shell", "s", "", "shell to use (bash, zsh, fish)") + rootCmd.PersistentFlags().BoolVar(&shellLoginFlag, "shell-login", false, "run the selected shell as a login shell") rootCmd.PersistentFlags().BoolVarP(&debugMode, "debug", "d", false, "enable debug logging to iris.log") rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { if shellFlag != "" { config.Get().Core.Shell = shellFlag } + if cmd.Flags().Changed("shell-login") { + config.Get().Core.ShellLogin = shellLoginFlag + } logDir, err := config.CachePath() if err == nil { logger.Init(filepath.Join(logDir, "iris.log"), debugMode || config.Get().Core.Debug) logger.Infof("IRIS session started: os=%s, arch=%s, go=%s, pid=%d", runtime.GOOS, runtime.GOARCH, runtime.Version(), os.Getpid()) cfg := config.Get() - logger.Debugf("IRIS loaded config: shell=%q, mode=%q, ghost-text=%v, max-suggestions=%d", cfg.Core.Shell, cfg.Core.Mode, cfg.UI.GhostText, cfg.UI.MaxSuggestions) + logger.Debugf("IRIS loaded config: shell=%q, shell-login=%v, mode=%q, ghost-text=%v, max-suggestions=%d", cfg.Core.Shell, cfg.Core.ShellLogin, cfg.Core.Mode, cfg.UI.GhostText, cfg.UI.MaxSuggestions) } } } diff --git a/root/shell_args_test.go b/root/shell_args_test.go new file mode 100644 index 0000000..c80a07a --- /dev/null +++ b/root/shell_args_test.go @@ -0,0 +1,35 @@ +package root + +import ( + "reflect" + "testing" +) + +func TestShellArgs(t *testing.T) { + tests := []struct { + name string + login bool + want []string + }{ + {name: "login", login: true, want: []string{"--login"}}, + {name: "non-login", login: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shellArgs(tt.login); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("shellArgs(%v) = %v, want %v", tt.login, got, tt.want) + } + }) + } +} + +func TestShellLoginFlagIsRegistered(t *testing.T) { + flag := rootCmd.PersistentFlags().Lookup("shell-login") + if flag == nil { + t.Fatal("expected --shell-login flag to be registered") + } + if flag.DefValue != "false" { + t.Fatalf("expected --shell-login to default to false, got %q", flag.DefValue) + } +} diff --git a/root/wrapper.go b/root/wrapper.go index 6df169c..3fc251a 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -82,6 +82,13 @@ func saveMode(mode string) { _ = config.SaveState(state) } +func shellArgs(login bool) []string { + if login { + return []string{"--login"} + } + return nil +} + var ( oldState *term.State oldStateFd int @@ -141,7 +148,7 @@ func runWrapper() { adapter := shell.Current ctx := context.Background() - c := exec.CommandContext(ctx, adapter.GetShellPath()) + c := exec.CommandContext(ctx, adapter.GetShellPath(), shellArgs(config.Get().Core.ShellLogin)...) c.ExtraFiles = make([]*os.File, 11) // pass write end of pipe to shell as fd 13 (since index 10 maps to 13) c.ExtraFiles[10] = w