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:
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user