feat: platform-specific shell and agent launch helpers

DefaultShell, ExecAgent, ExecShell with prompt prefix. Banner, container notice. Tests for all helpers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 18:32:55 -04:00
parent 6740c3835e
commit afd594ed6c
2 changed files with 171 additions and 0 deletions
+106
View File
@@ -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()
}
+65
View File
@@ -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)
}
}