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
19 changes: 14 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,26 @@ LDFLAGS := -s -w -X main.version=$(VERSION)

IMAGE := $(REGISTRY):sandbox-$(VERSION)

.PHONY: all cli \
.PHONY: all cli orchestrator \
vet lint test test-local test-kind test-remote test-all \
dev-sandbox dev-push tag clean help

## ── CLI ──────────────────────────────────────────────────────────────

## Build CLI + sandbox image for local dev
all: cli dev-sandbox
## Build CLI + orchestrator + sandbox image for local dev
all: cli orchestrator dev-sandbox

## Build the harness CLI binary
cli:
CGO_ENABLED=0 go build -ldflags '$(LDFLAGS)' -o harness .
@echo "Built: ./harness ($(VERSION))"

## Build the in-sandbox orchestrator binary (linux/amd64)
orchestrator:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags '$(LDFLAGS)' \
-o bin/harness-orchestrator ./cmd/orchestrator
@echo "Built: bin/harness-orchestrator (linux/amd64, $(VERSION))"
Comment on lines +37 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

orchestrator target hardcodes GOARCH=amd64, conflicting with native-arch dev builds.

dev-sandbox is documented as "native arch only" (line 98) and the Dockerfile downloads arch-specific binaries via uname -m (e.g. lines 52-54), implying the sandbox image is built for the host's native architecture. But this target unconditionally cross-compiles harness-orchestrator for linux/amd64. On an arm64 dev host (e.g. Apple Silicon), make dev-sandbox will bake an amd64 binary into an arm64 image, causing an exec-format failure at container runtime — and since executor.go only checks command -v harness-orchestrator (existence, not architecture), there is no fallback to run.sh in that case.

🔧 Proposed fix
+ORCH_GOARCH ?= $(shell go env GOARCH)
+
 ## Build the in-sandbox orchestrator binary (linux/amd64)
 orchestrator:
-	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags '$(LDFLAGS)' \
+	CGO_ENABLED=0 GOOS=linux GOARCH=$(ORCH_GOARCH) go build -ldflags '$(LDFLAGS)' \
 		-o bin/harness-orchestrator ./cmd/orchestrator
-	`@echo` "Built: bin/harness-orchestrator (linux/amd64, $(VERSION))"
+	`@echo` "Built: bin/harness-orchestrator (linux/$(ORCH_GOARCH), $(VERSION))"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## Build the in-sandbox orchestrator binary (linux/amd64)
orchestrator:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags '$(LDFLAGS)' \
-o bin/harness-orchestrator ./cmd/orchestrator
@echo "Built: bin/harness-orchestrator (linux/amd64, $(VERSION))"
ORCH_GOARCH ?= $(shell go env GOARCH)
## Build the in-sandbox orchestrator binary (linux/amd64)
orchestrator:
CGO_ENABLED=0 GOOS=linux GOARCH=$(ORCH_GOARCH) go build -ldflags '$(LDFLAGS)' \
-o bin/harness-orchestrator ./cmd/orchestrator
`@echo` "Built: bin/harness-orchestrator (linux/$(ORCH_GOARCH), $(VERSION))"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Makefile` around lines 37 - 41, The orchestrator build target is hardcoding a
linux/amd64 binary, which breaks native-arch sandbox builds on arm64 hosts.
Update the Makefile’s orchestrator target so it builds for the host architecture
instead of forcing GOARCH=amd64, and keep the output path/name used by
dev-sandbox and executor.go unchanged so the existing command lookup still
works. Use the orchestrator target as the fix point and align it with the
native-arch behavior implied by the Dockerfile and dev-sandbox flow.


## ── Lint targets ─────────────────────────────────────────────────────

## Run go vet
Expand Down Expand Up @@ -90,8 +96,11 @@ test-all: test test-local test-kind test-remote
## ── Dev image builds ─────────────────────────────────────────────────

## Build dev sandbox image locally (native arch only)
dev-sandbox:
## Copies the orchestrator binary into the Docker context if available.
dev-sandbox: orchestrator
cp bin/harness-orchestrator profiles/images/sandbox-default/harness-orchestrator 2>/dev/null || true
$(CONTAINER_CLI) build -t $(IMAGE) profiles/images/sandbox-default/
rm -f profiles/images/sandbox-default/harness-orchestrator
@echo "Built: $(IMAGE)"

## Build and push dev sandbox image (multi-arch)
Expand All @@ -111,7 +120,7 @@ tag:

## Clean built binaries
clean:
rm -f harness
rm -f harness bin/harness-orchestrator
@echo "Cleaned binaries"

## Show available targets
Expand Down
10 changes: 10 additions & 0 deletions cmd/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ func NewApplyCmd(harnessDir, cli string) *cobra.Command {
sandboxName string
task string
entrypoint string
mode string
watch bool
attach bool
providerRefresh bool
dryRun bool
Expand Down Expand Up @@ -50,6 +52,12 @@ then deploy a sandbox. Use --dry-run to validate without deploying, or
agentPath := resolveAgentPath(harnessDir, agentName, file)

// CLI overrides
if watch {
mode = "watch"
}
if mode != "" {
agentCfg.Mode = mode
}
Comment on lines +55 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

--mode/--watch skip the env-var tier and silently override conflicting input.

Two issues here:

  1. Other flags in this file (e.g. gatewayName at line 149) resolve via envOr("OPENSHELL_GATEWAY", ""), giving the order Explicit flag > OPENSHELL_* env var > config file > default. The new mode/watch flags default to ""/false with no env-var fallback, breaking that contract.
  2. If a user passes --mode once --watch together, watch silently overrides the explicit --mode value with no error, and an invalid --mode value (anything other than once/watch) is not validated here — it will only fail much later inside the sandbox when harness-orchestrator calls Validate().

As per coding guidelines, "Use flag resolution order: Explicit flag > OPENSHELL_* env var > config file > default."

🔧 Proposed fix
-	cmd.Flags().StringVar(&mode, "mode", "", "Orchestrator mode: once or watch")
+	cmd.Flags().StringVar(&mode, "mode", envOr("OPENSHELL_MODE", ""), "Orchestrator mode: once or watch")
 	cmd.Flags().BoolVar(&watch, "watch", false, "Shorthand for --mode=watch")
 			if watch {
-				mode = "watch"
+				if mode != "" && mode != "watch" {
+					return fmt.Errorf("--mode=%s conflicts with --watch", mode)
+				}
+				mode = "watch"
 			}
 			if mode != "" {
+				if mode != "once" && mode != "watch" {
+					return fmt.Errorf("--mode must be \"once\" or \"watch\", got %q", mode)
+				}
 				agentCfg.Mode = mode
 			}

Also applies to: 154-155

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/apply.go` around lines 55 - 60, The mode selection in the apply command
is bypassing the standard flag resolution and allowing conflicting inputs to
silently override each other. Update the apply flow around mode/watch handling
to use the same Explicit flag > OPENSHELL_* env var > config file > default
pattern used by gatewayName, and validate the resolved mode in the apply command
itself before assigning it to agentCfg.Mode. Also, when both the mode flag and
watch flag are provided, detect the conflict explicitly and return an error
instead of letting watch override the user’s chosen mode.

Source: Coding guidelines

if entrypoint != "" {
agentCfg.Entrypoint = entrypoint
}
Expand Down Expand Up @@ -143,6 +151,8 @@ then deploy a sandbox. Use --dry-run to validate without deploying, or
cmd.Flags().StringVar(&sandboxName, "name", "", "Sandbox name (overrides agent config)")
cmd.Flags().StringVar(&task, "task", "", "Task to pass to the agent (inline text or @filepath)")
cmd.Flags().StringVar(&entrypoint, "entrypoint", "", "Override agent entrypoint (claude, opencode, bash)")
cmd.Flags().StringVar(&mode, "mode", "", "Orchestrator mode: once or watch")
cmd.Flags().BoolVar(&watch, "watch", false, "Shorthand for --mode=watch")
cmd.Flags().BoolVar(&attach, "attach", false, "Attach TTY after creation (interactive mode)")
cmd.Flags().BoolVar(&providerRefresh, "provider-refresh", false, "Delete and recreate all providers")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Validate configuration without deploying")
Expand Down
5 changes: 4 additions & 1 deletion cmd/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,10 @@ func upLocal(opts upLocalOpts) error {
if noTTY && agentCfg.Task == "" {
sandboxCmd = []string{"true"}
} else {
sandboxCmd = []string{"bash", "/sandbox/.config/openshell/run.sh"}
sandboxCmd = []string{
"sh", "-c",
`if command -v harness-orchestrator >/dev/null 2>&1; then exec harness-orchestrator --config /sandbox/.config/openshell/orchestrator.yaml; else exec bash /sandbox/.config/openshell/run.sh; fi`,
}
}

err = createSandbox(sandboxOpts{
Expand Down
51 changes: 51 additions & 0 deletions cmd/orchestrator/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package main

import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"

"github.com/stackrox/harness-openshell/internal/orchestrator"
)

var version = "dev"

func main() {
configPath := flag.String("config", "/sandbox/.config/openshell/orchestrator.yaml", "path to orchestrator config")
showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse()

if *showVersion {
fmt.Println("harness-orchestrator", version)
os.Exit(0)
}

cfg, err := orchestrator.LoadConfig(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "harness-orchestrator: %v\n", err)
os.Exit(1)
}

configDir := filepath.Dir(*configPath)
if cfg.Task != "" && !filepath.IsAbs(cfg.Task) {
cfg.Task = filepath.Join(configDir, cfg.Task)
}

ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()

orch, err := orchestrator.New(cfg, configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "harness-orchestrator: %v\n", err)
os.Exit(1)
}

if err := orch.Run(ctx); err != nil {
fmt.Fprintf(os.Stderr, "harness-orchestrator: %v\n", err)
os.Exit(1)
}
Comment on lines +38 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep run --pattern 'func (o *Orchestrator) runWatch($_) $_' --lang go internal/orchestrator/orchestrator.go
rg -n -A20 'func \(o \*Orchestrator\) runWatch' internal/orchestrator/orchestrator.go

Repository: stackrox/harness-openshell

Length of output: 4110


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the orchestrator entrypoint and Run implementation.
grep -n -A40 -B10 'func (o \*Orchestrator) Run' internal/orchestrator/orchestrator.go
printf '\n---\n'
sed -n '1,180p' internal/orchestrator/orchestrator.go
printf '\n---\n'
sed -n '1,120p' cmd/orchestrator/main.go

Repository: stackrox/harness-openshell

Length of output: 6619


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether any code already treats context cancellation specially at the CLI boundary.
rg -n 'context\.Canceled|context\.DeadlineExceeded|errors\.Is\(.*ctx|signal\.NotifyContext|os\.Exit\(1\)' cmd internal

Repository: stackrox/harness-openshell

Length of output: 407


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Run a small read-only probe to show the exact error propagated by runWatch on cancellation.
python3 - <<'PY'
from pathlib import Path
p = Path('internal/orchestrator/orchestrator.go')
text = p.read_text()
for needle in [
    'func (o *Orchestrator) Run',
    'func (o *Orchestrator) runWatch',
    'if ctx.Err() != nil {',
    'return ctx.Err()',
]:
    print(f'== {needle} ==')
    for i, line in enumerate(text.splitlines(), 1):
        if needle in line:
            start = max(1, i-3)
            end = min(len(text.splitlines()), i+6)
            for j in range(start, end+1):
                print(f'{j}:{text.splitlines()[j-1]}')
            break
PY

Repository: stackrox/harness-openshell

Length of output: 1456


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant CLI exit path in main.go with line numbers.
nl -ba cmd/orchestrator/main.go | sed -n '1,120p'

Repository: stackrox/harness-openshell

Length of output: 204


Treat SIGINT/SIGTERM as a clean shutdown.

runWatch returns ctx.Err() on cancellation, and main exits 1 on any non-nil error. Map context.Canceled to nil at the CLI boundary so a requested stop exits 0 instead of looking like a failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/orchestrator/main.go` around lines 38 - 50, Treat SIGINT/SIGTERM as a
clean shutdown at the CLI boundary by updating cmd/orchestrator/main.go so the
error returned from orch.Run(ctx) is mapped to nil when it is context.Canceled;
otherwise keep the existing non-zero exit path. Use the ctx from
signal.NotifyContext and the orch.Run entrypoint to locate the change, and
ensure only real failures still print an error and exit 1 while user-initiated
cancellation exits successfully.

}
75 changes: 75 additions & 0 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ type AgentConfig struct {
Image string `yaml:"image,omitempty"`
Include []string `yaml:"include,omitempty"`
Payloads []PayloadEntry `yaml:"payloads,omitempty"`

// Orchestrator fields — projected into orchestrator.yaml for in-sandbox use.
Mode string `yaml:"mode,omitempty"`
Sentinel *bool `yaml:"sentinel,omitempty"`
PollInterval int `yaml:"poll_interval,omitempty"`
MaxFailures int `yaml:"max_failures,omitempty"`
Heartbeat int `yaml:"heartbeat,omitempty"`
OnComplete []string `yaml:"on_complete,omitempty"`
OnPropose []string `yaml:"on_propose,omitempty"`
}

// MergeOver applies overlay fields on top of a base config. Non-empty
Expand Down Expand Up @@ -106,6 +115,29 @@ func (base *AgentConfig) MergeOver(overlay *AgentConfig) *AgentConfig {
merged.Include = append(merged.Include, overlay.Include...)
}

// Orchestrator fields
if overlay.Mode != "" {
merged.Mode = overlay.Mode
}
if overlay.Sentinel != nil {
merged.Sentinel = overlay.Sentinel
}
if overlay.PollInterval > 0 {
merged.PollInterval = overlay.PollInterval
}
if overlay.MaxFailures > 0 {
merged.MaxFailures = overlay.MaxFailures
}
if overlay.Heartbeat > 0 {
merged.Heartbeat = overlay.Heartbeat
}
if len(overlay.OnComplete) > 0 {
merged.OnComplete = overlay.OnComplete
}
if len(overlay.OnPropose) > 0 {
merged.OnPropose = overlay.OnPropose
}

merged.BaseAgent = ""
return &merged
}
Expand Down Expand Up @@ -389,6 +421,40 @@ func (c *AgentConfig) BuildRunSh() string {
return b.String()
}

// BuildOrchestratorConfig projects the host-side AgentConfig into the
// in-sandbox orchestrator config, emitting only non-default fields.
func (c *AgentConfig) BuildOrchestratorConfig() map[string]any {
cfg := map[string]any{
"entrypoint": c.EffectiveEntrypoint(),
"tty": !c.NoTTY(),
}
if c.Mode != "" {
cfg["mode"] = c.Mode
}
if c.Task != "" {
cfg["task"] = "task.md"
}
if c.Sentinel != nil {
cfg["sentinel"] = *c.Sentinel
}
if c.PollInterval > 0 {
cfg["poll_interval"] = c.PollInterval
}
if c.MaxFailures > 0 {
cfg["max_failures"] = c.MaxFailures
}
if c.Heartbeat > 0 {
cfg["heartbeat"] = c.Heartbeat
}
if len(c.OnComplete) > 0 {
cfg["on_complete"] = c.OnComplete
}
if len(c.OnPropose) > 0 {
cfg["on_propose"] = c.OnPropose
}
return cfg
}

func RenderPayload(cfg *AgentConfig, baseDir, destDir string) error {
if err := os.MkdirAll(destDir, 0o755); err != nil {
return fmt.Errorf("creating payload dir: %w", err)
Expand All @@ -403,6 +469,15 @@ func RenderPayload(cfg *AgentConfig, baseDir, destDir string) error {
return fmt.Errorf("writing run.sh: %w", err)
}

orchCfg := cfg.BuildOrchestratorConfig()
orchData, err := yaml.Marshal(orchCfg)
if err != nil {
return fmt.Errorf("marshaling orchestrator config: %w", err)
}
if err := os.WriteFile(filepath.Join(destDir, "orchestrator.yaml"), orchData, 0o644); err != nil {
return fmt.Errorf("writing orchestrator.yaml: %w", err)
}

if cfg.Task != "" {
taskSrc := cfg.Task
if !filepath.IsAbs(taskSrc) {
Expand Down
63 changes: 63 additions & 0 deletions internal/orchestrator/adapter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package orchestrator

import (
"fmt"
"os/exec"
)

type HarnessAdapter interface {
Name() string
BuildCommand(task string, headless bool) *exec.Cmd
}

func NewAdapter(name string) (HarnessAdapter, error) {
switch name {
case "claude":
return ClaudeAdapter{}, nil
case "codex":
return CodexAdapter{}, nil
case "opencode":
return OpenCodeAdapter{}, nil
default:
return nil, fmt.Errorf("unknown adapter %q", name)
}
}

type ClaudeAdapter struct{}

func (ClaudeAdapter) Name() string { return "claude" }

func (ClaudeAdapter) BuildCommand(task string, headless bool) *exec.Cmd {
if task == "" {
return exec.Command("claude")
}
if headless {
return exec.Command("claude", "--print", task)
}
return exec.Command("claude", "-p", task)
}

type CodexAdapter struct{}

func (CodexAdapter) Name() string { return "codex" }

func (CodexAdapter) BuildCommand(task string, headless bool) *exec.Cmd {
if task == "" {
return exec.Command("codex")
}
if headless {
return exec.Command("codex", "--print", task)
}
return exec.Command("codex", "-p", task)
}
Comment on lines +40 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## Files mentioning codex / adapter\n'
git ls-files | rg 'internal/orchestrator/adapter\.go|codex|orchestrator' || true

printf '\n## Search for codex references\n'
rg -n --hidden --glob '!**/.git/**' '\bcodex\b|--print|\-p\b|exec subcommand|codex exec|codex e' .

printf '\n## Read target file with line numbers\n'
sed -n '1,140p' internal/orchestrator/adapter.go

Repository: stackrox/harness-openshell

Length of output: 5751


🌐 Web query:

OpenAI Codex CLI documentation exec subcommand --print -p non-interactive prompt flags

💡 Result:

The OpenAI Codex CLI (a Rust-based coding agent) uses the exec subcommand to run tasks non-interactively, which is designed for automation, CI/CD pipelines, and scripting [1][2][3]. Key flags and usage patterns for codex exec: Basic Syntax codex exec [OPTIONS] [PROMPT] [3] Core Flags and Options - --json: Enables structured output by streaming newline-delimited JSON events instead of standard formatted text, which is ideal for programmatic processing (e.g., using jq) [1][3]. - --last: Used with the resume subcommand to continue the most recent session from the current working directory [1][4]. - --all: Used with the resume subcommand to search for sessions across all directories [1][4]. - --model (or -m): Specifies the AI model to use for the task [3][5]. - --sandbox: Configures permission levels for the environment, such as workspace-write (allow edits) or danger-full-access (broad access) [2]. Non-interactive Prompt Handling The exec command handles prompts in several ways: - Inline Prompt: Pass the prompt directly as an argument, e.g., codex exec "explain this codebase" [3]. - Stdin: If no prompt is provided, Codex reads from stdin [2][3]. You can force this behavior by explicitly using codex exec - [2][3]. - Input Pipes: You can pipe content into the command, for example, echo "add error handling" | codex exec [3]. Resume Subcommand If a non-interactive task needs to be continued, you can use the resume subcommand [1][3]: codex exec resume [SESSION_ID] [1][4] codex exec resume --last "follow-up prompt" [2][6] For comprehensive documentation, you can refer to the official OpenAI Codex CLI reference [1][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' internal/orchestrator/adapter_test.go
printf '\n---\n'
sed -n '1,220p' internal/orchestrator/orchestrator.go

Repository: stackrox/harness-openshell

Length of output: 7065


internal/orchestrator/adapter.go:46-51 — Use codex exec for task runs CodexAdapter still passes unsupported --print/-p flags to codex. The documented prompt entrypoint is codex exec [PROMPT], so any tasked run will fail until this adapter switches to that subcommand.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/orchestrator/adapter.go` around lines 40 - 52,
CodexAdapter.BuildCommand still uses unsupported flags for task execution, so
switch the task-run path to the documented codex exec subcommand instead of
passing --print or -p. Update BuildCommand in CodexAdapter so non-empty tasks
are invoked via codex exec with the prompt argument, while preserving the
empty-task behavior if needed.


type OpenCodeAdapter struct{}

func (OpenCodeAdapter) Name() string { return "opencode" }

func (OpenCodeAdapter) BuildCommand(task string, headless bool) *exec.Cmd {
if task == "" {
return exec.Command("opencode")
}
return exec.Command("opencode", "run", task)
}
85 changes: 85 additions & 0 deletions internal/orchestrator/adapter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package orchestrator

import (
"strings"
"testing"
)

func TestNewAdapterValid(t *testing.T) {
for _, name := range []string{"claude", "codex", "opencode"} {
a, err := NewAdapter(name)
if err != nil {
t.Errorf("NewAdapter(%q): %v", name, err)
}
if a.Name() != name {
t.Errorf("Name() = %q, want %q", a.Name(), name)
}
}
}

func TestNewAdapterInvalid(t *testing.T) {
_, err := NewAdapter("unknown")
if err == nil {
t.Error("expected error for unknown adapter")
}
}

func TestClaudeAdapterCommands(t *testing.T) {
a := ClaudeAdapter{}

cmd := a.BuildCommand("do stuff", true)
args := strings.Join(cmd.Args, " ")
if args != "claude --print do stuff" {
t.Errorf("headless+task = %q", args)
}

cmd = a.BuildCommand("do stuff", false)
args = strings.Join(cmd.Args, " ")
if args != "claude -p do stuff" {
t.Errorf("tty+task = %q", args)
}

cmd = a.BuildCommand("", false)
args = strings.Join(cmd.Args, " ")
if args != "claude" {
t.Errorf("no task = %q", args)
}
}

func TestCodexAdapterCommands(t *testing.T) {
a := CodexAdapter{}

cmd := a.BuildCommand("review code", true)
args := strings.Join(cmd.Args, " ")
if args != "codex --print review code" {
t.Errorf("headless+task = %q", args)
}

cmd = a.BuildCommand("review code", false)
args = strings.Join(cmd.Args, " ")
if args != "codex -p review code" {
t.Errorf("tty+task = %q", args)
}
}

func TestOpenCodeAdapterCommands(t *testing.T) {
a := OpenCodeAdapter{}

cmd := a.BuildCommand("fix bugs", true)
args := strings.Join(cmd.Args, " ")
if args != "opencode run fix bugs" {
t.Errorf("headless+task = %q", args)
}

cmd = a.BuildCommand("fix bugs", false)
args = strings.Join(cmd.Args, " ")
if args != "opencode run fix bugs" {
t.Errorf("tty+task (opencode always uses run) = %q", args)
}

cmd = a.BuildCommand("", false)
args = strings.Join(cmd.Args, " ")
if args != "opencode" {
t.Errorf("no task = %q", args)
}
}
Loading
Loading