Skip to content
Closed
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
10 changes: 10 additions & 0 deletions internal/session/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ import (
// geminiConfigDirOverride allows tests to override config directory
var geminiConfigDirOverride string

// GetGeminiCommand returns the configured Gemini command/binary.
// Priority: 1) UserConfig setting, 2) Default "gemini"
func GetGeminiCommand() string {
userConfig, _ := LoadUserConfig()
if userConfig != nil && userConfig.Gemini.Command != "" {
return userConfig.Gemini.Command
}
return "gemini"
}

// GetGeminiConfigDir returns ~/.gemini
// Unlike Claude, Gemini has no GEMINI_CONFIG_DIR env var override
func GetGeminiConfigDir() string {
Expand Down
60 changes: 44 additions & 16 deletions internal/session/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -697,14 +697,18 @@ func (i *Instance) buildGeminiCommand(baseCommand string) string {
}
}

// If baseCommand is just "gemini", handle specially
if baseCommand == "gemini" {
geminiCmd := GetGeminiCommand()

// If baseCommand is a standard start (canonical or configured binary), handle specially
isStandardStart := baseCommand == "gemini" || baseCommand == geminiCmd
if isStandardStart {
// If we already have a session ID, use simple resume
if i.GeminiSessionID != "" {
// GEMINI_YOLO_MODE and GEMINI_SESSION_ID are propagated via host-side
// SetEnvironment after tmux start. No inline tmux set-environment.
return envPrefix + fmt.Sprintf(
"gemini --resume %s%s%s",
"%s --resume %s%s%s",
geminiCmd,
i.GeminiSessionID,
yoloFlag,
modelFlag,
Expand All @@ -716,7 +720,8 @@ func (i *Instance) buildGeminiCommand(baseCommand string) string {
// because Gemini processes the "." prompt which takes too long
// GEMINI_YOLO_MODE is propagated via host-side SetEnvironment after tmux start.
return envPrefix + fmt.Sprintf(
`gemini%s%s`,
`%s%s%s`,
geminiCmd,
yoloFlag,
modelFlag,
)
Expand All @@ -726,6 +731,16 @@ func (i *Instance) buildGeminiCommand(baseCommand string) string {
return envPrefix + baseCommand
}

// GetOpenCodeCommand returns the configured OpenCode command/binary.
// Priority: 1) UserConfig setting, 2) Default "opencode"
func GetOpenCodeCommand() string {
userConfig, _ := LoadUserConfig()
if userConfig != nil && userConfig.OpenCode.Command != "" {
return userConfig.OpenCode.Command
}
return "opencode"
}

// buildOpenCodeCommand builds the command for OpenCode CLI
// OpenCode stores sessions in ~/.local/share/opencode/storage/session/
// Session IDs are in format: ses_XXXXX
Expand All @@ -740,20 +755,21 @@ func (i *Instance) buildOpenCodeCommand(baseCommand string) string {
}

envPrefix := i.buildEnvSourceCommand()
opencodeCmd := GetOpenCodeCommand()
extraFlags := i.buildOpenCodeExtraFlags()

// If baseCommand is just "opencode", handle specially
if baseCommand == "opencode" {
extraFlags := i.buildOpenCodeExtraFlags()

// If baseCommand is a standard start (canonical or configured binary), handle specially
isStandardStart := baseCommand == "opencode" || baseCommand == opencodeCmd
if isStandardStart {
// If we already have a session ID, use resume with -s flag.
// OPENCODE_SESSION_ID is propagated via host-side SetEnvironment after tmux start.
if i.OpenCodeSessionID != "" {
return envPrefix + fmt.Sprintf("opencode -s %s%s",
i.OpenCodeSessionID, extraFlags)
return envPrefix + fmt.Sprintf("%s -s %s%s",
opencodeCmd, i.OpenCodeSessionID, extraFlags)
}

// Start OpenCode fresh - session ID will be captured async after startup
return envPrefix + "opencode" + extraFlags
return envPrefix + opencodeCmd + extraFlags
}

// For custom commands (e.g., fork commands), return as-is
Expand Down Expand Up @@ -790,6 +806,16 @@ func (i *Instance) DetectOpenCodeSession() {
i.detectOpenCodeSessionAsync()
}

// GetCodexCommand returns the configured Codex command/binary.
// Priority: 1) UserConfig setting, 2) Default "codex"
func GetCodexCommand() string {
userConfig, _ := LoadUserConfig()
if userConfig != nil && userConfig.Codex.Command != "" {
return userConfig.Codex.Command
}
return "codex"
}

// buildCodexCommand builds the command for OpenAI Codex CLI
// resolveCodexYoloFlag returns " --yolo" if yolo mode is enabled (per-session override > global config), or "".
func (i *Instance) resolveCodexYoloFlag() string {
Expand Down Expand Up @@ -822,19 +848,21 @@ func (i *Instance) buildCodexCommand(baseCommand string) string {
i.ID, i.Title, i.Tool)
envPrefix += agentdeckEnvPrefix

codexCmd := GetCodexCommand()
yoloFlag := i.resolveCodexYoloFlag()

// If baseCommand is just "codex", handle specially
if baseCommand == "codex" {
// If baseCommand is a standard start (canonical or configured binary), handle specially
isStandardStart := baseCommand == "codex" || baseCommand == codexCmd
if isStandardStart {
// If we already have a session ID, use resume.
// CODEX_SESSION_ID is propagated via host-side SetEnvironment after tmux start.
if i.CodexSessionID != "" {
return envPrefix + fmt.Sprintf("codex%s resume %s",
yoloFlag, i.CodexSessionID)
return envPrefix + fmt.Sprintf("%s%s resume %s",
codexCmd, yoloFlag, i.CodexSessionID)
}

// Start Codex fresh - session ID will be captured async after startup
return envPrefix + "codex" + yoloFlag
return envPrefix + codexCmd + yoloFlag
}

// For custom commands (e.g., resume commands), preserve env propagation.
Expand Down
35 changes: 29 additions & 6 deletions internal/session/userconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ type UserConfig struct {

// SystemStats defines system stats display settings (CPU, RAM, etc.)
SystemStats SystemStatsSettings `toml:"system_stats"`

// These flags track whether update keys were explicitly defined in TOML.
// They are runtime metadata only (never persisted).
updatesCheckEnabledDefined bool `toml:"-"`
updatesCheckIntervalDefined bool `toml:"-"`
updatesNotifyInCLIDefined bool `toml:"-"`
}

// OpenClawSettings configures the OpenClaw gateway connection.
Expand Down Expand Up @@ -596,6 +602,10 @@ func (c *ClaudeSettings) GetHooksEnabled() bool {

// GeminiSettings defines Gemini CLI configuration
type GeminiSettings struct {
// Command is the Gemini CLI command or binary to use (e.g., "gemini", "gemini-proxy")
// Default: "gemini"
Command string `toml:"command"`

// YoloMode enables --yolo flag for Gemini sessions (auto-approve all actions)
// Default: false
YoloMode bool `toml:"yolo_mode"`
Expand All @@ -612,6 +622,10 @@ type GeminiSettings struct {

// OpenCodeSettings defines OpenCode CLI configuration
type OpenCodeSettings struct {
// Command is the OpenCode CLI command or binary to use (e.g., "opencode", "opencode-proxy")
// Default: "opencode"
Command string `toml:"command"`

// DefaultModel is the model to use for new OpenCode sessions
// Format: "provider/model" (e.g., "anthropic/claude-sonnet-4-5-20250929")
// If empty, OpenCode uses its own default
Expand All @@ -629,6 +643,10 @@ type OpenCodeSettings struct {

// CodexSettings defines Codex CLI configuration
type CodexSettings struct {
// Command is the Codex CLI command or binary to use (e.g., "codex", "codex-proxy")
// Default: "codex"
Command string `toml:"command"`

// YoloMode enables --yolo flag for Codex sessions (bypass approvals and sandbox)
// Default: false
YoloMode bool `toml:"yolo_mode"`
Expand Down Expand Up @@ -1067,13 +1085,18 @@ func LoadUserConfig() (*UserConfig, error) {
}

var config UserConfig
if _, err := toml.DecodeFile(configPath, &config); err != nil {
meta, err := toml.DecodeFile(configPath, &config)
if err != nil {
// Return error so caller can display it to user
// Still cache default to prevent repeated parse attempts
userConfigCache = &defaultUserConfig
return userConfigCache, fmt.Errorf("config.toml parse error: %w", err)
}

config.updatesCheckEnabledDefined = meta.IsDefined("updates", "check_enabled")
config.updatesCheckIntervalDefined = meta.IsDefined("updates", "check_interval_hours")
config.updatesNotifyInCLIDefined = meta.IsDefined("updates", "notify_in_cli")

// Initialize maps if nil
if config.Tools == nil {
config.Tools = make(map[string]ToolDef)
Expand Down Expand Up @@ -1498,14 +1521,14 @@ func GetUpdateSettings() UpdateSettings {

settings := config.Updates

// Apply defaults for unset values
// CheckEnabled defaults to true (need to detect if section exists)
if config.Updates.CheckIntervalHours == 0 {
// Apply defaults only when keys are not explicitly defined.
if !config.updatesCheckEnabledDefined {
settings.CheckEnabled = true
settings.CheckIntervalHours = 24
}
if !config.updatesNotifyInCLIDefined {
settings.NotifyInCLI = true
}
if settings.CheckIntervalHours <= 0 {
if !config.updatesCheckIntervalDefined || settings.CheckIntervalHours <= 0 {
settings.CheckIntervalHours = 24
}

Expand Down
66 changes: 66 additions & 0 deletions internal/session/userconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,72 @@ func TestSaveUserConfig(t *testing.T) {
}
}

func TestGetUpdateSettings_RespectsExplicitCheckEnabledFalseWithoutInterval(t *testing.T) {
tempDir := t.TempDir()
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
ClearUserConfigCache()

agentDeckDir := filepath.Join(tempDir, ".agent-deck")
if err := os.MkdirAll(agentDeckDir, 0o700); err != nil {
t.Fatalf("mkdir agent-deck dir: %v", err)
}

configContent := `
[updates]
check_enabled = false
auto_update = false
`
configPath := filepath.Join(agentDeckDir, UserConfigFileName)
if err := os.WriteFile(configPath, []byte(configContent), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
ClearUserConfigCache()

settings := GetUpdateSettings()
if settings.CheckEnabled {
t.Fatalf("CheckEnabled: got %v, want false", settings.CheckEnabled)
}
if settings.CheckIntervalHours != 24 {
t.Fatalf("CheckIntervalHours: got %d, want 24", settings.CheckIntervalHours)
}
if !settings.NotifyInCLI {
t.Fatalf("NotifyInCLI: got %v, want true", settings.NotifyInCLI)
}
}

func TestGetUpdateSettings_DefaultsWhenUpdatesSectionMissing(t *testing.T) {
tempDir := t.TempDir()
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
ClearUserConfigCache()

agentDeckDir := filepath.Join(tempDir, ".agent-deck")
if err := os.MkdirAll(agentDeckDir, 0o700); err != nil {
t.Fatalf("mkdir agent-deck dir: %v", err)
}

configContent := `default_tool = "claude"`
configPath := filepath.Join(agentDeckDir, UserConfigFileName)
if err := os.WriteFile(configPath, []byte(configContent), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
ClearUserConfigCache()

settings := GetUpdateSettings()
if !settings.CheckEnabled {
t.Fatalf("CheckEnabled: got %v, want true", settings.CheckEnabled)
}
if settings.CheckIntervalHours != 24 {
t.Fatalf("CheckIntervalHours: got %d, want 24", settings.CheckIntervalHours)
}
if !settings.NotifyInCLI {
t.Fatalf("NotifyInCLI: got %v, want true", settings.NotifyInCLI)
}
}

func TestGetTheme_Default(t *testing.T) {
// Setup: use temp directory with no config
tempDir := t.TempDir()
Expand Down
Loading