feat: add shell login option (#75)

Closes #72
This commit is contained in:
Paolo Ricciuti
2026-07-30 22:45:23 +07:00
committed by GitHub
parent 37182f7212
commit 2573ad73b4
10 changed files with 103 additions and 12 deletions
+1
View File
@@ -156,6 +156,7 @@ iris config show
[core] [core]
version = 1 version = 1
shell = "" # "zsh", "bash", "fish", or empty for auto-detection 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" mode = "last" # "last", "spec", or "history"
debug = false debug = false
expand-alias = true expand-alias = true
+1
View File
@@ -35,6 +35,7 @@ func (d Duration) MarshalText() ([]byte, error) {
type CoreConfig struct { type CoreConfig struct {
Version int `toml:"version"` Version int `toml:"version"`
Shell string `toml:"shell"` Shell string `toml:"shell"`
ShellLogin bool `toml:"shell-login"`
Mode string `toml:"mode"` Mode string `toml:"mode"`
Debug bool `toml:"debug"` Debug bool `toml:"debug"`
ExpandAlias bool `toml:"expand-alias"` ExpandAlias bool `toml:"expand-alias"`
+24 -4
View File
@@ -12,6 +12,9 @@ func TestDefaultConfigAndState(t *testing.T) {
if cfg.Core.Version != 1 { if cfg.Core.Version != 1 {
t.Errorf("expected version 1, got %d", cfg.Core.Version) 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 { if cfg.UI.MaxSuggestions != 100 {
t.Errorf("expected suggestions 100, got %d", cfg.UI.MaxSuggestions) t.Errorf("expected suggestions 100, got %d", cfg.UI.MaxSuggestions)
} }
@@ -90,14 +93,23 @@ func TestValidationAndEnvironmentOverrides(t *testing.T) {
} }
defer os.RemoveAll(tmpDir) 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) 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 { if mkErr := os.MkdirAll(configDir, 0755); mkErr != nil {
t.Fatalf("failed to create config dir: %v", mkErr) t.Fatalf("failed to create config dir: %v", mkErr)
} }
configPath := filepath.Join(configDir, "config.toml")
tomlContent := ` tomlContent := `
[core]
shell-login = true
[ai] [ai]
enabled = true enabled = true
provider = "groq" provider = "groq"
@@ -132,6 +144,9 @@ model = "qwen-2.5-coder-32b"
if !cfg.Core.Debug { if !cfg.Core.Debug {
t.Errorf("expected debug to be true") 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" { if cfg.Core.Shell != "fish" {
t.Errorf("expected shell fish, got %q", cfg.Core.Shell) t.Errorf("expected shell fish, got %q", cfg.Core.Shell)
} }
@@ -178,6 +193,9 @@ func TestLoadSave(t *testing.T) {
} }
defer os.RemoveAll(tmpDir) 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) t.Setenv("XDG_CONFIG_HOME", tmpDir)
cfg, err := Load() cfg, err := Load()
@@ -186,6 +204,7 @@ func TestLoadSave(t *testing.T) {
} }
cfg.Core.Shell = "zsh" cfg.Core.Shell = "zsh"
cfg.Core.ShellLogin = true
cfg.UI.MaxHeight = 20 cfg.UI.MaxHeight = 20
err = Save(cfg) err = Save(cfg)
@@ -201,6 +220,9 @@ func TestLoadSave(t *testing.T) {
if loaded.Core.Shell != "zsh" { if loaded.Core.Shell != "zsh" {
t.Errorf("expected loaded shell to be zsh, got %q", loaded.Core.Shell) 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 { if loaded.UI.MaxHeight != 20 {
t.Errorf("expected loaded height to be 20, got %d", loaded.UI.MaxHeight) 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") t.Errorf("expected backup file update_state.json.bak to exist")
} }
} }
+1
View File
@@ -7,6 +7,7 @@ func DefaultConfig() *Config {
Core: CoreConfig{ Core: CoreConfig{
Version: 1, Version: 1,
Shell: "", Shell: "",
ShellLogin: false,
Mode: "last", Mode: "last",
Debug: false, Debug: false,
ExpandAlias: true, ExpandAlias: true,
+3
View File
@@ -43,6 +43,9 @@ version = 1
# override shell: "bash", "zsh", "fish", keep empty for auto detection # override shell: "bash", "zsh", "fish", keep empty for auto detection
shell = "" shell = ""
# run the selected shell as a login shell
shell-login = false
# startup mode: "last", "spec", "history" # startup mode: "last", "spec", "history"
# "last" = remember last mode used # "last" = remember last mode used
mode = "last" mode = "last"
+18 -3
View File
@@ -3,8 +3,10 @@ package root
import ( import (
"bytes" "bytes"
"os" "os"
"path/filepath" "strings"
"testing" "testing"
"github.com/versenilvis/iris/internal/config"
) )
func TestConfigCommands(t *testing.T) { func TestConfigCommands(t *testing.T) {
@@ -14,14 +16,27 @@ func TestConfigCommands(t *testing.T) {
} }
defer os.RemoveAll(tmpDir) 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) t.Setenv("XDG_CONFIG_HOME", tmpDir)
ConfigInitCmd.Run(ConfigInitCmd, []string{}) ConfigInitCmd.Run(ConfigInitCmd, []string{})
configPath := filepath.Join(tmpDir, "iris", "config.toml") configPath, err := config.ConfigPath()
if _, err := os.Stat(configPath); err != nil { 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) 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) buf := new(bytes.Buffer)
ConfigShowCmd.SetOut(buf) ConfigShowCmd.SetOut(buf)
+4 -1
View File
@@ -148,7 +148,7 @@ var setupCmd = &cobra.Command{
var shellName string var shellName string
if len(args) > 0 { if len(args) > 0 {
shellName = args[0] shellName = filepath.Base(args[0])
} else { } else {
shellPath := os.Getenv("SHELL") shellPath := os.Getenv("SHELL")
shellName = filepath.Base(shellPath) shellName = filepath.Base(shellPath)
@@ -208,6 +208,9 @@ version = 1
# override shell: "bash", "zsh", "fish", keep empty for auto detection # override shell: "bash", "zsh", "fish", keep empty for auto detection
shell = "" shell = ""
# run the selected shell as a login shell
shell-login = false
# startup mode: "last", "spec", "history" # startup mode: "last", "spec", "history"
# "last" = remember last mode used # "last" = remember last mode used
mode = "last" mode = "last"
+6 -1
View File
@@ -51,23 +51,28 @@ It works exactly like coding editor suggestion menu drop down.`,
}, },
} }
shellFlag string shellFlag string
shellLoginFlag bool
debugMode bool debugMode bool
) )
func init() { func init() {
rootCmd.PersistentFlags().StringVarP(&shellFlag, "shell", "s", "", "shell to use (bash, zsh, fish)") 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.PersistentFlags().BoolVarP(&debugMode, "debug", "d", false, "enable debug logging to iris.log")
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
if shellFlag != "" { if shellFlag != "" {
config.Get().Core.Shell = shellFlag config.Get().Core.Shell = shellFlag
} }
if cmd.Flags().Changed("shell-login") {
config.Get().Core.ShellLogin = shellLoginFlag
}
logDir, err := config.CachePath() logDir, err := config.CachePath()
if err == nil { if err == nil {
logger.Init(filepath.Join(logDir, "iris.log"), debugMode || config.Get().Core.Debug) 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()) logger.Infof("IRIS session started: os=%s, arch=%s, go=%s, pid=%d", runtime.GOOS, runtime.GOARCH, runtime.Version(), os.Getpid())
cfg := config.Get() 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)
} }
} }
} }
+35
View File
@@ -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)
}
}
+8 -1
View File
@@ -82,6 +82,13 @@ func saveMode(mode string) {
_ = config.SaveState(state) _ = config.SaveState(state)
} }
func shellArgs(login bool) []string {
if login {
return []string{"--login"}
}
return nil
}
var ( var (
oldState *term.State oldState *term.State
oldStateFd int oldStateFd int
@@ -141,7 +148,7 @@ func runWrapper() {
adapter := shell.Current adapter := shell.Current
ctx := context.Background() 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) c.ExtraFiles = make([]*os.File, 11)
// pass write end of pipe to shell as fd 13 (since index 10 maps to 13) // pass write end of pipe to shell as fd 13 (since index 10 maps to 13)
c.ExtraFiles[10] = w c.ExtraFiles[10] = w