diff --git a/internal/config/agents.go b/internal/config/agents.go index 860481ce..08e8d654 100644 --- a/internal/config/agents.go +++ b/internal/config/agents.go @@ -327,10 +327,18 @@ func RuntimeConfigFromPreset(preset AgentPreset) *RuntimeConfig { return DefaultRuntimeConfig() } - return &RuntimeConfig{ + rc := &RuntimeConfig{ Command: info.Command, Args: append([]string(nil), info.Args...), // Copy to avoid mutation } + + // Resolve command path for claude preset (handles alias installations) + // Uses resolveClaudePath() from types.go which finds ~/.claude/local/claude + if preset == AgentClaude && rc.Command == "claude" { + rc.Command = resolveClaudePath() + } + + return rc } // BuildResumeCommand builds a command to resume an agent session. diff --git a/internal/config/agents_test.go b/internal/config/agents_test.go index b318ef14..ef5596f4 100644 --- a/internal/config/agents_test.go +++ b/internal/config/agents_test.go @@ -8,6 +8,12 @@ import ( "testing" ) +// isClaudeCmd checks if a command is claude (either "claude" or a path ending in "/claude"). +// Note: Named differently from loader_test.go's isClaudeCommand to avoid redeclaration. +func isClaudeCmd(cmd string) bool { + return cmd == "claude" || strings.HasSuffix(cmd, "/claude") +} + func TestBuiltinPresets(t *testing.T) { t.Parallel() // Ensure all built-in presets are accessible @@ -71,7 +77,7 @@ func TestRuntimeConfigFromPreset(t *testing.T) { preset AgentPreset wantCommand string }{ - {AgentClaude, "claude"}, + {AgentClaude, "claude"}, // Note: claude may resolve to full path {AgentGemini, "gemini"}, {AgentCodex, "codex"}, {AgentCursor, "cursor-agent"}, @@ -82,7 +88,13 @@ func TestRuntimeConfigFromPreset(t *testing.T) { for _, tt := range tests { t.Run(string(tt.preset), func(t *testing.T) { rc := RuntimeConfigFromPreset(tt.preset) - if rc.Command != tt.wantCommand { + // For claude, command may be full path due to resolveClaudePath + if tt.preset == AgentClaude { + if !isClaudeCmd(rc.Command) { + t.Errorf("RuntimeConfigFromPreset(%s).Command = %v, want claude or path ending in /claude", + tt.preset, rc.Command) + } + } else if rc.Command != tt.wantCommand { t.Errorf("RuntimeConfigFromPreset(%s).Command = %v, want %v", tt.preset, rc.Command, tt.wantCommand) } @@ -226,8 +238,8 @@ func TestMergeWithPreset(t *testing.T) { var nilConfig *RuntimeConfig merged = nilConfig.MergeWithPreset(AgentClaude) - if merged.Command != "claude" { - t.Errorf("nil config merge should get preset command, got %s", merged.Command) + if !isClaudeCmd(merged.Command) { + t.Errorf("nil config merge should get preset command (claude or path), got %s", merged.Command) } // Test empty config gets preset defaults @@ -456,7 +468,12 @@ func TestAgentCommandGeneration(t *testing.T) { t.Fatal("RuntimeConfigFromPreset returned nil") } - if rc.Command != tt.wantCommand { + // For claude, command may be full path due to resolveClaudePath + if tt.preset == AgentClaude { + if !isClaudeCmd(rc.Command) { + t.Errorf("Command = %q, want claude or path ending in /claude", rc.Command) + } + } else if rc.Command != tt.wantCommand { t.Errorf("Command = %q, want %q", rc.Command, tt.wantCommand) } diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index b8b845ba..09a3be08 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -24,6 +24,12 @@ func skipIfAgentBinaryMissing(t *testing.T, agents ...string) { } } +// isClaudeCommand checks if a command is claude (either "claude" or a path ending in "/claude"). +// This handles the case where resolveClaudePath returns the full path to the claude binary. +func isClaudeCommand(cmd string) bool { + return cmd == "claude" || strings.HasSuffix(cmd, "/claude") +} + func TestTownConfigRoundTrip(t *testing.T) { t.Parallel() dir := t.TempDir() @@ -821,8 +827,8 @@ func TestRuntimeConfigDefaults(t *testing.T) { if rc.Provider != "claude" { t.Errorf("Provider = %q, want %q", rc.Provider, "claude") } - if rc.Command != "claude" { - t.Errorf("Command = %q, want %q", rc.Command, "claude") + if !isClaudeCommand(rc.Command) { + t.Errorf("Command = %q, want claude or path ending in /claude", rc.Command) } if len(rc.Args) != 1 || rc.Args[0] != "--dangerously-skip-permissions" { t.Errorf("Args = %v, want [--dangerously-skip-permissions]", rc.Args) @@ -835,42 +841,58 @@ func TestRuntimeConfigDefaults(t *testing.T) { func TestRuntimeConfigBuildCommand(t *testing.T) { t.Parallel() tests := []struct { - name string - rc *RuntimeConfig - want string + name string + rc *RuntimeConfig + wantContains []string // Parts the command should contain + isClaudeCmd bool // Whether command should be claude (or path to claude) }{ { - name: "nil config uses defaults", - rc: nil, - want: "claude --dangerously-skip-permissions", + name: "nil config uses defaults", + rc: nil, + wantContains: []string{"--dangerously-skip-permissions"}, + isClaudeCmd: true, }, { - name: "default config", - rc: DefaultRuntimeConfig(), - want: "claude --dangerously-skip-permissions", + name: "default config", + rc: DefaultRuntimeConfig(), + wantContains: []string{"--dangerously-skip-permissions"}, + isClaudeCmd: true, }, { - name: "custom command", - rc: &RuntimeConfig{Command: "aider", Args: []string{"--no-git"}}, - want: "aider --no-git", + name: "custom command", + rc: &RuntimeConfig{Command: "aider", Args: []string{"--no-git"}}, + wantContains: []string{"aider", "--no-git"}, + isClaudeCmd: false, }, { - name: "multiple args", - rc: &RuntimeConfig{Command: "claude", Args: []string{"--model", "opus", "--no-confirm"}}, - want: "claude --model opus --no-confirm", + name: "multiple args", + rc: &RuntimeConfig{Command: "claude", Args: []string{"--model", "opus", "--no-confirm"}}, + wantContains: []string{"--model", "opus", "--no-confirm"}, + isClaudeCmd: true, }, { - name: "empty command uses default", - rc: &RuntimeConfig{Command: "", Args: nil}, - want: "claude --dangerously-skip-permissions", + name: "empty command uses default", + rc: &RuntimeConfig{Command: "", Args: nil}, + wantContains: []string{"--dangerously-skip-permissions"}, + isClaudeCmd: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.rc.BuildCommand() - if got != tt.want { - t.Errorf("BuildCommand() = %q, want %q", got, tt.want) + // Check command contains expected parts + for _, part := range tt.wantContains { + if !strings.Contains(got, part) { + t.Errorf("BuildCommand() = %q, should contain %q", got, part) + } + } + // Check if command starts with claude (or path to claude) + if tt.isClaudeCmd { + parts := strings.Fields(got) + if len(parts) > 0 && !isClaudeCommand(parts[0]) { + t.Errorf("BuildCommand() = %q, command should be claude or path to claude", got) + } } }) } @@ -879,48 +901,64 @@ func TestRuntimeConfigBuildCommand(t *testing.T) { func TestRuntimeConfigBuildCommandWithPrompt(t *testing.T) { t.Parallel() tests := []struct { - name string - rc *RuntimeConfig - prompt string - want string + name string + rc *RuntimeConfig + prompt string + wantContains []string // Parts the command should contain + isClaudeCmd bool // Whether command should be claude (or path to claude) }{ { - name: "no prompt", - rc: DefaultRuntimeConfig(), - prompt: "", - want: "claude --dangerously-skip-permissions", + name: "no prompt", + rc: DefaultRuntimeConfig(), + prompt: "", + wantContains: []string{"--dangerously-skip-permissions"}, + isClaudeCmd: true, }, { - name: "with prompt", - rc: DefaultRuntimeConfig(), - prompt: "gt prime", - want: `claude --dangerously-skip-permissions "gt prime"`, + name: "with prompt", + rc: DefaultRuntimeConfig(), + prompt: "gt prime", + wantContains: []string{"--dangerously-skip-permissions", `"gt prime"`}, + isClaudeCmd: true, }, { - name: "prompt with quotes", - rc: DefaultRuntimeConfig(), - prompt: `Hello "world"`, - want: `claude --dangerously-skip-permissions "Hello \"world\""`, + name: "prompt with quotes", + rc: DefaultRuntimeConfig(), + prompt: `Hello "world"`, + wantContains: []string{"--dangerously-skip-permissions", `"Hello \"world\""`}, + isClaudeCmd: true, }, { - name: "config initial prompt used if no override", - rc: &RuntimeConfig{Command: "aider", Args: []string{}, InitialPrompt: "/help"}, - prompt: "", - want: `aider "/help"`, + name: "config initial prompt used if no override", + rc: &RuntimeConfig{Command: "aider", Args: []string{}, InitialPrompt: "/help"}, + prompt: "", + wantContains: []string{"aider", `"/help"`}, + isClaudeCmd: false, }, { - name: "override takes precedence over config", - rc: &RuntimeConfig{Command: "aider", Args: []string{}, InitialPrompt: "/help"}, - prompt: "custom prompt", - want: `aider "custom prompt"`, + name: "override takes precedence over config", + rc: &RuntimeConfig{Command: "aider", Args: []string{}, InitialPrompt: "/help"}, + prompt: "custom prompt", + wantContains: []string{"aider", `"custom prompt"`}, + isClaudeCmd: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.rc.BuildCommandWithPrompt(tt.prompt) - if got != tt.want { - t.Errorf("BuildCommandWithPrompt(%q) = %q, want %q", tt.prompt, got, tt.want) + // Check command contains expected parts + for _, part := range tt.wantContains { + if !strings.Contains(got, part) { + t.Errorf("BuildCommandWithPrompt(%q) = %q, should contain %q", tt.prompt, got, part) + } + } + // Check if command starts with claude (or path to claude) + if tt.isClaudeCmd { + parts := strings.Fields(got) + if len(parts) > 0 && !isClaudeCommand(parts[0]) { + t.Errorf("BuildCommandWithPrompt(%q) = %q, command should be claude or path to claude", tt.prompt, got) + } } }) } @@ -1051,11 +1089,13 @@ func TestResolveAgentConfigWithOverride(t *testing.T) { if name != "claude-haiku" { t.Fatalf("name = %q, want %q", name, "claude-haiku") } - if rc.Command != "claude" { - t.Fatalf("rc.Command = %q, want %q", rc.Command, "claude") + if !isClaudeCommand(rc.Command) { + t.Fatalf("rc.Command = %q, want claude or path ending in /claude", rc.Command) } - if got := rc.BuildCommand(); got != "claude --model haiku --dangerously-skip-permissions" { - t.Fatalf("BuildCommand() = %q, want %q", got, "claude --model haiku --dangerously-skip-permissions") + got := rc.BuildCommand() + // Check command includes expected flags (path to claude may vary) + if !strings.Contains(got, "--model haiku") || !strings.Contains(got, "--dangerously-skip-permissions") { + t.Fatalf("BuildCommand() = %q, want command with --model haiku and --dangerously-skip-permissions", got) } }) @@ -1407,8 +1447,9 @@ func TestResolveRoleAgentConfig_FallsBackOnInvalidAgent(t *testing.T) { // Should fall back to default (claude) when agent is invalid rc := ResolveRoleAgentConfig(constants.RoleRefinery, townRoot, rigPath) - if rc.Command != "claude" { - t.Errorf("expected fallback to claude, got: %s", rc.Command) + // Command can be "claude" or full path to claude + if rc.Command != "claude" && !strings.HasSuffix(rc.Command, "/claude") { + t.Errorf("expected fallback to claude or path ending in /claude, got: %s", rc.Command) } } @@ -1490,8 +1531,8 @@ func TestLoadRuntimeConfigFallsBackToDefaults(t *testing.T) { t.Parallel() // Non-existent path should use defaults rc := LoadRuntimeConfig("/nonexistent/path") - if rc.Command != "claude" { - t.Errorf("Command = %q, want %q (default)", rc.Command, "claude") + if !isClaudeCommand(rc.Command) { + t.Errorf("Command = %q, want claude or path ending in /claude (default)", rc.Command) } } @@ -1964,7 +2005,12 @@ func TestLookupAgentConfigWithRigSettings(t *testing.T) { t.Errorf("lookupAgentConfig(%s) returned nil", tt.name) } - if rc.Command != tt.expectedCommand { + // For claude commands, allow either "claude" or path ending in /claude + if tt.expectedCommand == "claude" { + if !isClaudeCommand(rc.Command) { + t.Errorf("lookupAgentConfig(%s).Command = %s, want claude or path ending in /claude", tt.name, rc.Command) + } + } else if rc.Command != tt.expectedCommand { t.Errorf("lookupAgentConfig(%s).Command = %s, want %s", tt.name, rc.Command, tt.expectedCommand) } }) @@ -2008,8 +2054,8 @@ func TestResolveRoleAgentConfig(t *testing.T) { t.Run("rig RoleAgents overrides town RoleAgents", func(t *testing.T) { rc := ResolveRoleAgentConfig("witness", townRoot, rigPath) // Should get claude-haiku from rig's RoleAgents - if rc.Command != "claude" { - t.Errorf("Command = %q, want %q", rc.Command, "claude") + if !isClaudeCommand(rc.Command) { + t.Errorf("Command = %q, want claude or path ending in /claude", rc.Command) } cmd := rc.BuildCommand() if !strings.Contains(cmd, "--model haiku") { @@ -2035,9 +2081,9 @@ func TestResolveRoleAgentConfig(t *testing.T) { t.Run("town-level role (no rigPath) uses town RoleAgents", func(t *testing.T) { rc := ResolveRoleAgentConfig("mayor", townRoot, "") - // mayor is in town's RoleAgents - if rc.Command != "claude" { - t.Errorf("Command = %q, want %q", rc.Command, "claude") + // mayor is in town's RoleAgents - command can be "claude" or full path to claude + if rc.Command != "claude" && !strings.HasSuffix(rc.Command, "/claude") { + t.Errorf("Command = %q, want claude or path ending in /claude", rc.Command) } }) } diff --git a/internal/config/types.go b/internal/config/types.go index 57a9de6b..9489e0de 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -2,8 +2,9 @@ package config import ( - "path/filepath" "os" + "os/exec" + "path/filepath" "strings" "time" ) @@ -469,10 +470,35 @@ func defaultRuntimeCommand(provider string) string { case "generic": return "" default: - return "claude" + return resolveClaudePath() } } +// resolveClaudePath finds the claude binary, checking PATH first then common installation locations. +// This handles the case where claude is installed as an alias (not in PATH) which doesn't work +// in non-interactive shells spawned by tmux. +func resolveClaudePath() string { + // First, try to find claude in PATH + if path, err := exec.LookPath("claude"); err == nil { + return path + } + + // Check common Claude Code installation locations + home, err := os.UserHomeDir() + if err != nil { + return "claude" // Fall back to bare command + } + + // Standard Claude Code installation path + claudePath := filepath.Join(home, ".claude", "local", "claude") + if _, err := os.Stat(claudePath); err == nil { + return claudePath + } + + // Fall back to bare command (might work if PATH is set differently in tmux) + return "claude" +} + func defaultRuntimeArgs(provider string) []string { switch provider { case "claude":