diff --git a/internal/shell/launch.go b/internal/shell/launch.go new file mode 100644 index 0000000..31796a0 --- /dev/null +++ b/internal/shell/launch.go @@ -0,0 +1,106 @@ +package shell + +import ( + "fmt" + "os" + "os/exec" + "runtime" +) + +// DefaultShell returns the platform-appropriate interactive shell. +func DefaultShell() string { + if runtime.GOOS == "windows" { + if _, err := exec.LookPath("powershell.exe"); err == nil { + return "powershell.exe" + } + return "cmd.exe" + } + shell := os.Getenv("SHELL") + if shell != "" { + return shell + } + return "bash" +} + +// BuildEnvList merges ctask env vars into the current process environment. +func BuildEnvList(vars map[string]string) []string { + env := os.Environ() + for k, v := range vars { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + return env +} + +// PromptPrefix returns the shell prompt prefix for --shell mode. +func PromptPrefix(slug, mode string) string { + return fmt.Sprintf("(ctask:%s|%s) ", slug, mode) +} + +// BannerLines returns the launch banner lines for agent mode. +func BannerLines(mode, slug, wsPath string) []string { + return []string{ + fmt.Sprintf("[ctask] %s :: %s", mode, slug), + fmt.Sprintf("[ctask] %s", wsPath), + } +} + +// ContainerNotice returns the v0.1 deferred container mode message. +func ContainerNotice() string { + return "[ctask] container mode is not available in v0.1. Use local mode or see docs for manual container setup." +} + +// ExecAgent launches the agent command in the workspace directory. +func ExecAgent(agent string, wsDir string, envVars map[string]string) error { + path, err := exec.LookPath(agent) + if err != nil { + return fmt.Errorf("agent command not found: %s", agent) + } + + cmd := exec.Command(path) + cmd.Dir = wsDir + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = BuildEnvList(envVars) + + return cmd.Run() +} + +// ExecShell launches an interactive shell in the workspace directory. +func ExecShell(wsDir string, envVars map[string]string, slug, mode string) error { + shellCmd := DefaultShell() + prefix := PromptPrefix(slug, mode) + + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + if shellCmd == "powershell.exe" { + cmd = exec.Command(shellCmd, "-NoExit", "-Command", + fmt.Sprintf("function prompt { '%s' + (Get-Location).Path + '> ' }", prefix)) + } else { + // cmd.exe + cmd = exec.Command(shellCmd) + } + } else { + cmd = exec.Command(shellCmd) + } + + cmd.Dir = wsDir + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + env := BuildEnvList(envVars) + + if runtime.GOOS == "windows" && shellCmd == "cmd.exe" { + env = append(env, fmt.Sprintf("PROMPT=%s$P$G", prefix)) + } else if runtime.GOOS != "windows" { + existingPS1 := os.Getenv("PS1") + if existingPS1 == "" { + existingPS1 = "\\u@\\h:\\w\\$ " + } + env = append(env, fmt.Sprintf("PS1=%s%s", prefix, existingPS1)) + } + + cmd.Env = env + return cmd.Run() +} diff --git a/internal/shell/launch_test.go b/internal/shell/launch_test.go new file mode 100644 index 0000000..1ff2702 --- /dev/null +++ b/internal/shell/launch_test.go @@ -0,0 +1,65 @@ +package shell + +import ( + "runtime" + "testing" +) + +func TestDefaultShell(t *testing.T) { + cmd := DefaultShell() + if runtime.GOOS == "windows" { + if cmd != "cmd.exe" && cmd != "powershell.exe" { + t.Errorf("unexpected Windows shell: %q", cmd) + } + } else { + if cmd == "" { + t.Error("shell should not be empty on Unix") + } + } +} + +func TestBuildEnvList(t *testing.T) { + vars := map[string]string{ + "CTASK_TASK": "my-task", + "CTASK_MODE": "local", + } + env := BuildEnvList(vars) + found := 0 + for _, e := range env { + if e == "CTASK_TASK=my-task" || e == "CTASK_MODE=local" { + found++ + } + } + if found != 2 { + t.Errorf("expected 2 ctask vars in env, found %d", found) + } +} + +func TestPromptPrefix(t *testing.T) { + prefix := PromptPrefix("my-task", "local") + expected := "(ctask:my-task|local) " + if prefix != expected { + t.Errorf("got %q, want %q", prefix, expected) + } +} + +func TestBannerLines(t *testing.T) { + lines := BannerLines("local", "my-task", "/home/user/ws/general/2026-04-05_my-task") + if len(lines) != 2 { + t.Fatalf("expected 2 banner lines, got %d", len(lines)) + } + if lines[0] != "[ctask] local :: my-task" { + t.Errorf("banner line 1: %q", lines[0]) + } + if lines[1] != "[ctask] /home/user/ws/general/2026-04-05_my-task" { + t.Errorf("banner line 2: %q", lines[1]) + } +} + +func TestContainerNotice(t *testing.T) { + msg := ContainerNotice() + expected := "[ctask] container mode is not available in v0.1. Use local mode or see docs for manual container setup." + if msg != expected { + t.Errorf("got %q, want %q", msg, expected) + } +}