From c8a652ec15dd9fdab1291d2e452749ce1afa7629 Mon Sep 17 00:00:00 2001 From: cexll Date: Thu, 27 Nov 2025 14:33:13 +0800 Subject: [PATCH] Add codex-wrapper Go implementation --- .github/workflows/release.yml | 104 +++++ README.md | 6 + codex-wrapper/go.mod | 3 + codex-wrapper/main.go | 492 ++++++++++++++++++++++ codex-wrapper/main_test.go | 748 ++++++++++++++++++++++++++++++++++ install.sh | 46 +++ skills/codex/SKILL.md | 45 +- 7 files changed, 1421 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 codex-wrapper/go.mod create mode 100644 codex-wrapper/main.go create mode 100644 codex-wrapper/main_test.go create mode 100644 install.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..262f01b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,104 @@ +name: Release codex-wrapper + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run tests + working-directory: codex-wrapper + run: go test -v -coverprofile=cover.out ./... + + - name: Check coverage + working-directory: codex-wrapper + run: | + go tool cover -func=cover.out | grep total + COVERAGE=$(go tool cover -func=cover.out | grep total | awk '{print $3}' | sed 's/%//') + echo "Coverage: ${COVERAGE}%" + + build: + name: Build + needs: test + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Build binary + working-directory: codex-wrapper + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + VERSION=${GITHUB_REF#refs/tags/} + OUTPUT_NAME=codex-wrapper-${{ matrix.goos }}-${{ matrix.goarch }} + go build -ldflags="-s -w -X main.version=${VERSION}" -o ${OUTPUT_NAME} . + chmod +x ${OUTPUT_NAME} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: codex-wrapper-${{ matrix.goos }}-${{ matrix.goarch }} + path: codex-wrapper/codex-wrapper-${{ matrix.goos }}-${{ matrix.goarch }} + + release: + name: Create Release + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Prepare release files + run: | + mkdir -p release + find artifacts -type f -name "codex-wrapper-*" -exec mv {} release/ \; + cp install.sh release/ + ls -la release/ + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: release/* + generate_release_notes: true + draft: false + prerelease: false diff --git a/README.md b/README.md index b53c896..a347f78 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ make install | **[bmad-agile-workflow](docs/BMAD-WORKFLOW.md)** | Complete BMAD methodology with 6 specialized agents | `/bmad-pilot` | | **[requirements-driven-workflow](docs/REQUIREMENTS-WORKFLOW.md)** | Streamlined requirements-to-code workflow | `/requirements-pilot` | | **[dev-workflow](dev-workflow/README.md)** | Extreme lightweight end-to-end development workflow | `/dev` | +| **[codex-wrapper](codex-wrapper/)** | Go binary wrapper for Codex CLI integration | `codex-wrapper` | | **[development-essentials](docs/DEVELOPMENT-COMMANDS.md)** | Core development slash commands | `/code` `/debug` `/test` `/optimize` | | **[advanced-ai-agents](docs/ADVANCED-AGENTS.md)** | GPT-5 deep reasoning integration | Agent: `gpt5` | | **[requirements-clarity](docs/REQUIREMENTS-CLARITY.md)** | Automated requirements clarification with 100-point scoring | Auto-activated skill | @@ -89,6 +90,11 @@ make install ## 🛠️ Installation Methods +**Codex Wrapper** (Go binary for Codex CLI) +```bash +curl -fsSL https://raw.githubusercontent.com/chenwenjie/myclaude/master/install.sh | bash +``` + **Method 1: Plugin Install** (One command) ```bash /plugin install bmad-agile-workflow diff --git a/codex-wrapper/go.mod b/codex-wrapper/go.mod new file mode 100644 index 0000000..f0a6ef9 --- /dev/null +++ b/codex-wrapper/go.mod @@ -0,0 +1,3 @@ +module codex-wrapper + +go 1.25.3 diff --git a/codex-wrapper/main.go b/codex-wrapper/main.go new file mode 100644 index 0000000..56d13b7 --- /dev/null +++ b/codex-wrapper/main.go @@ -0,0 +1,492 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "strconv" + "strings" + "syscall" + "time" +) + +const ( + version = "1.0.0" + defaultWorkdir = "." + defaultTimeout = 7200 // seconds + forceKillDelay = 5 // seconds +) + +// Test hooks for dependency injection +var ( + stdinReader io.Reader = os.Stdin + isTerminalFn = defaultIsTerminal + codexCommand = "codex" +) + +// Config holds CLI configuration +type Config struct { + Mode string // "new" or "resume" + Task string + SessionID string + WorkDir string + ExplicitStdin bool + Timeout int +} + +// JSONEvent represents a Codex JSON output event +type JSONEvent struct { + Type string `json:"type"` + ThreadID string `json:"thread_id,omitempty"` + Item *EventItem `json:"item,omitempty"` +} + +// EventItem represents the item field in a JSON event +type EventItem struct { + Type string `json:"type"` + Text interface{} `json:"text"` +} + +func main() { + exitCode := run() + os.Exit(exitCode) +} + +// run is the main logic, returns exit code for testability +func run() int { + // Handle --version and --help first + if len(os.Args) > 1 { + switch os.Args[1] { + case "--version", "-v": + fmt.Printf("codex-wrapper version %s\n", version) + return 0 + case "--help", "-h": + printHelp() + return 0 + } + } + + logInfo("Script started") + + cfg, err := parseArgs() + if err != nil { + logError(err.Error()) + return 1 + } + logInfo(fmt.Sprintf("Parsed args: mode=%s, task_len=%d", cfg.Mode, len(cfg.Task))) + + timeoutSec := resolveTimeout() + logInfo(fmt.Sprintf("Timeout: %ds", timeoutSec)) + cfg.Timeout = timeoutSec + + // Determine task text and stdin mode + var taskText string + var piped bool + + if cfg.ExplicitStdin { + logInfo("Explicit stdin mode: reading task from stdin") + data, err := io.ReadAll(stdinReader) + if err != nil { + logError("Failed to read stdin: " + err.Error()) + return 1 + } + taskText = string(data) + if taskText == "" { + logError("Explicit stdin mode requires task input from stdin") + return 1 + } + piped = !isTerminal() + } else { + pipedTask := readPipedTask() + piped = pipedTask != "" + if piped { + taskText = pipedTask + } else { + taskText = cfg.Task + } + } + + useStdin := cfg.ExplicitStdin || shouldUseStdin(taskText, piped) + + if useStdin { + var reasons []string + if piped { + reasons = append(reasons, "piped input") + } + if cfg.ExplicitStdin { + reasons = append(reasons, "explicit \"-\"") + } + if strings.Contains(taskText, "\n") { + reasons = append(reasons, "newline") + } + if strings.Contains(taskText, "\\") { + reasons = append(reasons, "backslash") + } + if len(taskText) > 800 { + reasons = append(reasons, "length>800") + } + if len(reasons) > 0 { + logWarn(fmt.Sprintf("Using stdin mode for task due to: %s", strings.Join(reasons, ", "))) + } + } + + targetArg := taskText + if useStdin { + targetArg = "-" + } + + codexArgs := buildCodexArgs(cfg, targetArg) + logInfo("codex running...") + + message, threadID, exitCode := runCodexProcess(codexArgs, taskText, useStdin, cfg.Timeout) + + if exitCode != 0 { + return exitCode + } + + // Output agent_message + fmt.Println(message) + + // Output session_id if present + if threadID != "" { + fmt.Printf("\n---\nSESSION_ID: %s\n", threadID) + } + + return 0 +} + +func parseArgs() (*Config, error) { + args := os.Args[1:] + if len(args) == 0 { + return nil, fmt.Errorf("task required") + } + + cfg := &Config{ + WorkDir: defaultWorkdir, + } + + // Check for resume mode + if args[0] == "resume" { + if len(args) < 3 { + return nil, fmt.Errorf("resume mode requires: resume ") + } + cfg.Mode = "resume" + cfg.SessionID = args[1] + cfg.Task = args[2] + cfg.ExplicitStdin = (args[2] == "-") + if len(args) > 3 { + cfg.WorkDir = args[3] + } + } else { + cfg.Mode = "new" + cfg.Task = args[0] + cfg.ExplicitStdin = (args[0] == "-") + if len(args) > 1 { + cfg.WorkDir = args[1] + } + } + + return cfg, nil +} + +func readPipedTask() string { + if isTerminal() { + logInfo("Stdin is tty, skipping pipe read") + return "" + } + logInfo("Reading from stdin pipe...") + data, err := io.ReadAll(stdinReader) + if err != nil || len(data) == 0 { + logInfo("Stdin pipe returned empty data") + return "" + } + logInfo(fmt.Sprintf("Read %d bytes from stdin pipe", len(data))) + return string(data) +} + +func shouldUseStdin(taskText string, piped bool) bool { + if piped { + return true + } + if strings.Contains(taskText, "\n") { + return true + } + if strings.Contains(taskText, "\\") { + return true + } + if len(taskText) > 800 { + return true + } + return false +} + +func buildCodexArgs(cfg *Config, targetArg string) []string { + if cfg.Mode == "resume" { + return []string{ + "e", + "--skip-git-repo-check", + "--json", + "resume", + cfg.SessionID, + targetArg, + } + } + return []string{ + "e", + "--skip-git-repo-check", + "-C", cfg.WorkDir, + "--json", + targetArg, + } +} + +func runCodexProcess(codexArgs []string, taskText string, useStdin bool, timeoutSec int) (message, threadID string, exitCode int) { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, codexCommand, codexArgs...) + cmd.Stderr = os.Stderr + + // Setup stdin if needed + var stdinPipe io.WriteCloser + var err error + if useStdin { + stdinPipe, err = cmd.StdinPipe() + if err != nil { + logError("Failed to create stdin pipe: " + err.Error()) + return "", "", 1 + } + } + + // Setup stdout + stdout, err := cmd.StdoutPipe() + if err != nil { + logError("Failed to create stdout pipe: " + err.Error()) + return "", "", 1 + } + + logInfo(fmt.Sprintf("Starting codex with args: codex %s...", strings.Join(codexArgs[:min(5, len(codexArgs))], " "))) + + // Start process + if err := cmd.Start(); err != nil { + if strings.Contains(err.Error(), "executable file not found") { + logError("codex command not found in PATH") + return "", "", 127 + } + logError("Failed to start codex: " + err.Error()) + return "", "", 1 + } + logInfo(fmt.Sprintf("Process started with PID: %d", cmd.Process.Pid)) + + // Write to stdin if needed + if useStdin && stdinPipe != nil { + logInfo(fmt.Sprintf("Writing %d chars to stdin...", len(taskText))) + go func() { + defer stdinPipe.Close() + io.WriteString(stdinPipe, taskText) + }() + logInfo("Stdin closed") + } + + // Setup signal handling + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigCh + logError(fmt.Sprintf("Received signal: %v", sig)) + if cmd.Process != nil { + cmd.Process.Signal(syscall.SIGTERM) + time.AfterFunc(time.Duration(forceKillDelay)*time.Second, func() { + if cmd.Process != nil { + cmd.Process.Kill() + } + }) + } + }() + + logInfo("Reading stdout...") + + // Parse JSON stream + message, threadID = parseJSONStream(stdout) + + // Wait for process to complete + err = cmd.Wait() + + // Check for timeout + if ctx.Err() == context.DeadlineExceeded { + logError("Codex execution timeout") + if cmd.Process != nil { + cmd.Process.Kill() + } + return "", "", 124 + } + + // Check exit code + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + code := exitErr.ExitCode() + logError(fmt.Sprintf("Codex exited with status %d", code)) + return "", "", code + } + logError("Codex error: " + err.Error()) + return "", "", 1 + } + + if message == "" { + logError("Codex completed without agent_message output") + return "", "", 1 + } + + return message, threadID, 0 +} + +func parseJSONStream(r io.Reader) (message, threadID string) { + scanner := bufio.NewScanner(r) + // Set larger buffer for long lines + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var event JSONEvent + if err := json.Unmarshal([]byte(line), &event); err != nil { + logWarn(fmt.Sprintf("Failed to parse line: %s", truncate(line, 100))) + continue + } + + // Capture thread_id + if event.Type == "thread.started" { + threadID = event.ThreadID + } + + // Capture agent_message + if event.Type == "item.completed" && event.Item != nil { + if event.Item.Type == "agent_message" { + text := normalizeText(event.Item.Text) + if text != "" { + message = text + } + } + } + } + + if err := scanner.Err(); err != nil { + logWarn("Scanner error: " + err.Error()) + } + + return message, threadID +} + +func normalizeText(text interface{}) string { + switch v := text.(type) { + case string: + return v + case []interface{}: + var sb strings.Builder + for _, item := range v { + if s, ok := item.(string); ok { + sb.WriteString(s) + } + } + return sb.String() + default: + return "" + } +} + +func resolveTimeout() int { + raw := os.Getenv("CODEX_TIMEOUT") + if raw == "" { + return defaultTimeout + } + + parsed, err := strconv.Atoi(raw) + if err != nil || parsed <= 0 { + logWarn(fmt.Sprintf("Invalid CODEX_TIMEOUT '%s', falling back to %ds", raw, defaultTimeout)) + return defaultTimeout + } + + // Environment variable is in milliseconds if > 10000, convert to seconds + if parsed > 10000 { + return parsed / 1000 + } + return parsed +} + +func defaultIsTerminal() bool { + fi, err := os.Stdin.Stat() + if err != nil { + return true + } + return (fi.Mode() & os.ModeCharDevice) != 0 +} + +func isTerminal() bool { + return isTerminalFn() +} + +func getEnv(key, defaultValue string) string { + if val := os.Getenv(key); val != "" { + return val + } + return defaultValue +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func logInfo(msg string) { + fmt.Fprintf(os.Stderr, "INFO: %s\n", msg) +} + +func logWarn(msg string) { + fmt.Fprintf(os.Stderr, "WARN: %s\n", msg) +} + +func logError(msg string) { + fmt.Fprintf(os.Stderr, "ERROR: %s\n", msg) +} + +func printHelp() { + help := `codex-wrapper - Go wrapper for Codex CLI + +Usage: + codex-wrapper "task" [workdir] + codex-wrapper - [workdir] Read task from stdin + codex-wrapper resume "task" [workdir] + codex-wrapper resume - [workdir] + codex-wrapper --version + codex-wrapper --help + +Environment Variables: + CODEX_TIMEOUT Timeout in milliseconds (default: 7200000) + +Exit Codes: + 0 Success + 1 General error (missing args, no output) + 124 Timeout + 127 codex command not found + 130 Interrupted (Ctrl+C) + * Passthrough from codex process` + fmt.Println(help) +} diff --git a/codex-wrapper/main_test.go b/codex-wrapper/main_test.go new file mode 100644 index 0000000..112330b --- /dev/null +++ b/codex-wrapper/main_test.go @@ -0,0 +1,748 @@ +package main + +import ( + "bytes" + "io" + "os" + "strings" + "testing" +) + +// Helper to reset test hooks +func resetTestHooks() { + stdinReader = os.Stdin + isTerminalFn = defaultIsTerminal + codexCommand = "codex" +} + +func TestParseArgs_NewMode(t *testing.T) { + tests := []struct { + name string + args []string + want *Config + wantErr bool + }{ + { + name: "simple task", + args: []string{"codex-wrapper", "analyze code"}, + want: &Config{ + Mode: "new", + Task: "analyze code", + WorkDir: ".", + ExplicitStdin: false, + }, + }, + { + name: "task with workdir", + args: []string{"codex-wrapper", "analyze code", "/path/to/dir"}, + want: &Config{ + Mode: "new", + Task: "analyze code", + WorkDir: "/path/to/dir", + ExplicitStdin: false, + }, + }, + { + name: "explicit stdin mode", + args: []string{"codex-wrapper", "-"}, + want: &Config{ + Mode: "new", + Task: "-", + WorkDir: ".", + ExplicitStdin: true, + }, + }, + { + name: "stdin with workdir", + args: []string{"codex-wrapper", "-", "/some/dir"}, + want: &Config{ + Mode: "new", + Task: "-", + WorkDir: "/some/dir", + ExplicitStdin: true, + }, + }, + { + name: "no args", + args: []string{"codex-wrapper"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Args = tt.args + + cfg, err := parseArgs() + + if tt.wantErr { + if err == nil { + t.Errorf("parseArgs() expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("parseArgs() unexpected error: %v", err) + return + } + + if cfg.Mode != tt.want.Mode { + t.Errorf("Mode = %v, want %v", cfg.Mode, tt.want.Mode) + } + if cfg.Task != tt.want.Task { + t.Errorf("Task = %v, want %v", cfg.Task, tt.want.Task) + } + if cfg.WorkDir != tt.want.WorkDir { + t.Errorf("WorkDir = %v, want %v", cfg.WorkDir, tt.want.WorkDir) + } + if cfg.ExplicitStdin != tt.want.ExplicitStdin { + t.Errorf("ExplicitStdin = %v, want %v", cfg.ExplicitStdin, tt.want.ExplicitStdin) + } + }) + } +} + +func TestParseArgs_ResumeMode(t *testing.T) { + tests := []struct { + name string + args []string + want *Config + wantErr bool + }{ + { + name: "resume with task", + args: []string{"codex-wrapper", "resume", "session-123", "continue task"}, + want: &Config{ + Mode: "resume", + SessionID: "session-123", + Task: "continue task", + WorkDir: ".", + ExplicitStdin: false, + }, + }, + { + name: "resume with workdir", + args: []string{"codex-wrapper", "resume", "session-456", "task", "/work"}, + want: &Config{ + Mode: "resume", + SessionID: "session-456", + Task: "task", + WorkDir: "/work", + ExplicitStdin: false, + }, + }, + { + name: "resume with stdin", + args: []string{"codex-wrapper", "resume", "session-789", "-"}, + want: &Config{ + Mode: "resume", + SessionID: "session-789", + Task: "-", + WorkDir: ".", + ExplicitStdin: true, + }, + }, + { + name: "resume missing session_id", + args: []string{"codex-wrapper", "resume"}, + wantErr: true, + }, + { + name: "resume missing task", + args: []string{"codex-wrapper", "resume", "session-123"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Args = tt.args + + cfg, err := parseArgs() + + if tt.wantErr { + if err == nil { + t.Errorf("parseArgs() expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("parseArgs() unexpected error: %v", err) + return + } + + if cfg.Mode != tt.want.Mode { + t.Errorf("Mode = %v, want %v", cfg.Mode, tt.want.Mode) + } + if cfg.SessionID != tt.want.SessionID { + t.Errorf("SessionID = %v, want %v", cfg.SessionID, tt.want.SessionID) + } + if cfg.Task != tt.want.Task { + t.Errorf("Task = %v, want %v", cfg.Task, tt.want.Task) + } + if cfg.WorkDir != tt.want.WorkDir { + t.Errorf("WorkDir = %v, want %v", cfg.WorkDir, tt.want.WorkDir) + } + if cfg.ExplicitStdin != tt.want.ExplicitStdin { + t.Errorf("ExplicitStdin = %v, want %v", cfg.ExplicitStdin, tt.want.ExplicitStdin) + } + }) + } +} + +func TestShouldUseStdin(t *testing.T) { + tests := []struct { + name string + task string + piped bool + want bool + }{ + {"simple task", "analyze code", false, false}, + {"piped input", "analyze code", true, true}, + {"contains newline", "line1\nline2", false, true}, + {"contains backslash", "path\\to\\file", false, true}, + {"long task", strings.Repeat("a", 801), false, true}, + {"exactly 800 chars", strings.Repeat("a", 800), false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldUseStdin(tt.task, tt.piped) + if got != tt.want { + t.Errorf("shouldUseStdin(%q, %v) = %v, want %v", truncate(tt.task, 20), tt.piped, got, tt.want) + } + }) + } +} + +func TestBuildCodexArgs_NewMode(t *testing.T) { + cfg := &Config{ + Mode: "new", + WorkDir: "/test/dir", + } + + args := buildCodexArgs(cfg, "my task") + + expected := []string{ + "e", + "--skip-git-repo-check", + "-C", "/test/dir", + "--json", + "my task", + } + + if len(args) != len(expected) { + t.Errorf("buildCodexArgs() returned %d args, want %d", len(args), len(expected)) + return + } + + for i, arg := range args { + if arg != expected[i] { + t.Errorf("buildCodexArgs()[%d] = %v, want %v", i, arg, expected[i]) + } + } +} + +func TestBuildCodexArgs_ResumeMode(t *testing.T) { + cfg := &Config{ + Mode: "resume", + SessionID: "session-abc", + } + + args := buildCodexArgs(cfg, "-") + + expected := []string{ + "e", + "--skip-git-repo-check", + "--json", + "resume", + "session-abc", + "-", + } + + if len(args) != len(expected) { + t.Errorf("buildCodexArgs() returned %d args, want %d", len(args), len(expected)) + return + } + + for i, arg := range args { + if arg != expected[i] { + t.Errorf("buildCodexArgs()[%d] = %v, want %v", i, arg, expected[i]) + } + } +} + +func TestResolveTimeout(t *testing.T) { + tests := []struct { + name string + envVal string + want int + }{ + {"empty env", "", 7200}, + {"milliseconds", "7200000", 7200}, + {"seconds", "3600", 3600}, + {"invalid", "invalid", 7200}, + {"negative", "-100", 7200}, + {"zero", "0", 7200}, + {"small milliseconds", "5000", 5000}, + {"boundary", "10000", 10000}, + {"above boundary", "10001", 10}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Setenv("CODEX_TIMEOUT", tt.envVal) + defer os.Unsetenv("CODEX_TIMEOUT") + + got := resolveTimeout() + if got != tt.want { + t.Errorf("resolveTimeout() with env=%q = %v, want %v", tt.envVal, got, tt.want) + } + }) + } +} + +func TestNormalizeText(t *testing.T) { + tests := []struct { + name string + input interface{} + want string + }{ + {"string", "hello world", "hello world"}, + {"string array", []interface{}{"hello", " ", "world"}, "hello world"}, + {"empty array", []interface{}{}, ""}, + {"mixed array", []interface{}{"text", 123, "more"}, "textmore"}, + {"nil", nil, ""}, + {"number", 123, ""}, + {"empty string", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeText(tt.input) + if got != tt.want { + t.Errorf("normalizeText(%v) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestParseJSONStream(t *testing.T) { + tests := []struct { + name string + input string + wantMessage string + wantThreadID string + }{ + { + name: "thread started and agent message", + input: `{"type":"thread.started","thread_id":"abc-123"} +{"type":"item.completed","item":{"type":"agent_message","text":"Hello world"}}`, + wantMessage: "Hello world", + wantThreadID: "abc-123", + }, + { + name: "multiple agent messages (last wins)", + input: `{"type":"item.completed","item":{"type":"agent_message","text":"First"}} +{"type":"item.completed","item":{"type":"agent_message","text":"Second"}}`, + wantMessage: "Second", + wantThreadID: "", + }, + { + name: "text as array", + input: `{"type":"item.completed","item":{"type":"agent_message","text":["Hello"," ","World"]}}`, + wantMessage: "Hello World", + wantThreadID: "", + }, + { + name: "ignore other event types", + input: `{"type":"other.event","data":"ignored"} +{"type":"item.completed","item":{"type":"other_type","text":"ignored"}} +{"type":"item.completed","item":{"type":"agent_message","text":"Valid"}}`, + wantMessage: "Valid", + wantThreadID: "", + }, + { + name: "empty input", + input: "", + wantMessage: "", + wantThreadID: "", + }, + { + name: "invalid JSON (skipped)", + input: "not valid json\n{\"type\":\"thread.started\",\"thread_id\":\"xyz\"}", + wantMessage: "", + wantThreadID: "xyz", + }, + { + name: "blank lines ignored", + input: "\n\n{\"type\":\"thread.started\",\"thread_id\":\"test\"}\n\n", + wantMessage: "", + wantThreadID: "test", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := strings.NewReader(tt.input) + gotMessage, gotThreadID := parseJSONStream(r) + + if gotMessage != tt.wantMessage { + t.Errorf("parseJSONStream() message = %q, want %q", gotMessage, tt.wantMessage) + } + if gotThreadID != tt.wantThreadID { + t.Errorf("parseJSONStream() threadID = %q, want %q", gotThreadID, tt.wantThreadID) + } + }) + } +} + +func TestGetEnv(t *testing.T) { + tests := []struct { + name string + key string + defaultVal string + envVal string + setEnv bool + want string + }{ + {"env set", "TEST_KEY", "default", "custom", true, "custom"}, + {"env not set", "TEST_KEY_MISSING", "default", "", false, "default"}, + {"env empty", "TEST_KEY_EMPTY", "default", "", true, "default"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Unsetenv(tt.key) + if tt.setEnv { + os.Setenv(tt.key, tt.envVal) + defer os.Unsetenv(tt.key) + } + + got := getEnv(tt.key, tt.defaultVal) + if got != tt.want { + t.Errorf("getEnv(%q, %q) = %q, want %q", tt.key, tt.defaultVal, got, tt.want) + } + }) + } +} + +func TestTruncate(t *testing.T) { + tests := []struct { + name string + input string + maxLen int + want string + }{ + {"short string", "hello", 10, "hello"}, + {"exact length", "hello", 5, "hello"}, + {"truncate", "hello world", 5, "hello..."}, + {"empty", "", 5, ""}, + {"zero maxLen", "hello", 0, "..."}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncate(tt.input, tt.maxLen) + if got != tt.want { + t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want) + } + }) + } +} + +func TestMin(t *testing.T) { + tests := []struct { + a, b, want int + }{ + {1, 2, 1}, + {2, 1, 1}, + {5, 5, 5}, + {-1, 0, -1}, + {0, -1, -1}, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + got := min(tt.a, tt.b) + if got != tt.want { + t.Errorf("min(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestLogFunctions(t *testing.T) { + // Capture stderr + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + logInfo("info message") + logWarn("warn message") + logError("error message") + + w.Close() + os.Stderr = oldStderr + + var buf bytes.Buffer + io.Copy(&buf, r) + output := buf.String() + + if !strings.Contains(output, "INFO: info message") { + t.Errorf("logInfo output missing, got: %s", output) + } + if !strings.Contains(output, "WARN: warn message") { + t.Errorf("logWarn output missing, got: %s", output) + } + if !strings.Contains(output, "ERROR: error message") { + t.Errorf("logError output missing, got: %s", output) + } +} + +func TestPrintHelp(t *testing.T) { + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + printHelp() + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + output := buf.String() + + expectedPhrases := []string{ + "codex-wrapper", + "Usage:", + "resume", + "CODEX_TIMEOUT", + "Exit Codes:", + } + + for _, phrase := range expectedPhrases { + if !strings.Contains(output, phrase) { + t.Errorf("printHelp() missing phrase %q", phrase) + } + } +} + +// Tests for isTerminal with mock +func TestIsTerminal(t *testing.T) { + defer resetTestHooks() + + tests := []struct { + name string + mockFn func() bool + want bool + }{ + {"is terminal", func() bool { return true }, true}, + {"is not terminal", func() bool { return false }, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isTerminalFn = tt.mockFn + got := isTerminal() + if got != tt.want { + t.Errorf("isTerminal() = %v, want %v", got, tt.want) + } + }) + } +} + +// Tests for readPipedTask with mock +func TestReadPipedTask(t *testing.T) { + defer resetTestHooks() + + tests := []struct { + name string + isTerminal bool + stdinContent string + want string + }{ + {"terminal mode", true, "ignored", ""}, + {"piped with data", false, "task from pipe", "task from pipe"}, + {"piped empty", false, "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isTerminalFn = func() bool { return tt.isTerminal } + stdinReader = strings.NewReader(tt.stdinContent) + + got := readPipedTask() + if got != tt.want { + t.Errorf("readPipedTask() = %q, want %q", got, tt.want) + } + }) + } +} + +// Tests for runCodexProcess with mock command +func TestRunCodexProcess_CommandNotFound(t *testing.T) { + defer resetTestHooks() + + codexCommand = "nonexistent-command-xyz" + + _, _, exitCode := runCodexProcess([]string{"arg1"}, "task", false, 10) + + if exitCode != 127 { + t.Errorf("runCodexProcess() exitCode = %d, want 127 for command not found", exitCode) + } +} + +func TestRunCodexProcess_WithEcho(t *testing.T) { + defer resetTestHooks() + + // Use echo to simulate codex output + codexCommand = "echo" + + jsonOutput := `{"type":"thread.started","thread_id":"test-session"} +{"type":"item.completed","item":{"type":"agent_message","text":"Test output"}}` + + message, threadID, exitCode := runCodexProcess([]string{jsonOutput}, "", false, 10) + + if exitCode != 0 { + t.Errorf("runCodexProcess() exitCode = %d, want 0", exitCode) + } + if message != "Test output" { + t.Errorf("runCodexProcess() message = %q, want %q", message, "Test output") + } + if threadID != "test-session" { + t.Errorf("runCodexProcess() threadID = %q, want %q", threadID, "test-session") + } +} + +func TestRunCodexProcess_NoMessage(t *testing.T) { + defer resetTestHooks() + + codexCommand = "echo" + + // Output without agent_message + jsonOutput := `{"type":"thread.started","thread_id":"test-session"}` + + _, _, exitCode := runCodexProcess([]string{jsonOutput}, "", false, 10) + + if exitCode != 1 { + t.Errorf("runCodexProcess() exitCode = %d, want 1 for no message", exitCode) + } +} + +func TestRunCodexProcess_WithStdin(t *testing.T) { + defer resetTestHooks() + + // Use cat to echo stdin back + codexCommand = "cat" + + message, _, exitCode := runCodexProcess([]string{}, `{"type":"item.completed","item":{"type":"agent_message","text":"from stdin"}}`, true, 10) + + if exitCode != 0 { + t.Errorf("runCodexProcess() exitCode = %d, want 0", exitCode) + } + if message != "from stdin" { + t.Errorf("runCodexProcess() message = %q, want %q", message, "from stdin") + } +} + +func TestRunCodexProcess_ExitError(t *testing.T) { + defer resetTestHooks() + + // Use false command which exits with code 1 + codexCommand = "false" + + _, _, exitCode := runCodexProcess([]string{}, "", false, 10) + + if exitCode == 0 { + t.Errorf("runCodexProcess() exitCode = 0, want non-zero for failed command") + } +} + +func TestDefaultIsTerminal(t *testing.T) { + // This test just ensures defaultIsTerminal doesn't panic + // The actual result depends on the test environment + _ = defaultIsTerminal() +} + +// Tests for run() function +func TestRun_Version(t *testing.T) { + defer resetTestHooks() + + os.Args = []string{"codex-wrapper", "--version"} + exitCode := run() + if exitCode != 0 { + t.Errorf("run() with --version returned %d, want 0", exitCode) + } +} + +func TestRun_VersionShort(t *testing.T) { + defer resetTestHooks() + + os.Args = []string{"codex-wrapper", "-v"} + exitCode := run() + if exitCode != 0 { + t.Errorf("run() with -v returned %d, want 0", exitCode) + } +} + +func TestRun_Help(t *testing.T) { + defer resetTestHooks() + + os.Args = []string{"codex-wrapper", "--help"} + exitCode := run() + if exitCode != 0 { + t.Errorf("run() with --help returned %d, want 0", exitCode) + } +} + +func TestRun_HelpShort(t *testing.T) { + defer resetTestHooks() + + os.Args = []string{"codex-wrapper", "-h"} + exitCode := run() + if exitCode != 0 { + t.Errorf("run() with -h returned %d, want 0", exitCode) + } +} + +func TestRun_NoArgs(t *testing.T) { + defer resetTestHooks() + + os.Args = []string{"codex-wrapper"} + exitCode := run() + if exitCode != 1 { + t.Errorf("run() with no args returned %d, want 1", exitCode) + } +} + +func TestRun_ExplicitStdinEmpty(t *testing.T) { + defer resetTestHooks() + + os.Args = []string{"codex-wrapper", "-"} + stdinReader = strings.NewReader("") + isTerminalFn = func() bool { return false } + + exitCode := run() + if exitCode != 1 { + t.Errorf("run() with empty stdin returned %d, want 1", exitCode) + } +} + +func TestRun_CommandFails(t *testing.T) { + defer resetTestHooks() + + os.Args = []string{"codex-wrapper", "task"} + stdinReader = strings.NewReader("") + isTerminalFn = func() bool { return true } + codexCommand = "false" + + exitCode := run() + if exitCode == 0 { + t.Errorf("run() with failing command returned 0, want non-zero") + } +} diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..5008467 --- /dev/null +++ b/install.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set -e + +# Detect platform +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) + +# Normalize architecture names +case "$ARCH" in + x86_64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;; +esac + +# Build download URL +REPO="chenwenjie/myclaude" +VERSION="latest" +BINARY_NAME="codex-wrapper-${OS}-${ARCH}" +URL="https://github.com/${REPO}/releases/${VERSION}/download/${BINARY_NAME}" + +echo "Downloading codex-wrapper from ${URL}..." +if ! curl -fsSL "$URL" -o /tmp/codex-wrapper; then + echo "ERROR: failed to download binary" >&2 + exit 1 +fi + +mkdir -p "$HOME/bin" + +mv /tmp/codex-wrapper "$HOME/bin/codex-wrapper" +chmod +x "$HOME/bin/codex-wrapper" + +if "$HOME/bin/codex-wrapper" --version >/dev/null 2>&1; then + echo "codex-wrapper installed successfully to ~/bin/codex-wrapper" +else + echo "ERROR: installation verification failed" >&2 + exit 1 +fi + +if [[ ":$PATH:" != *":$HOME/bin:"* ]]; then + echo "" + echo "WARNING: ~/bin is not in your PATH" + echo "Add this line to your ~/.bashrc or ~/.zshrc:" + echo "" + echo " export PATH=\"\$HOME/bin:\$PATH\"" + echo "" +fi diff --git a/skills/codex/SKILL.md b/skills/codex/SKILL.md index f093098..28de131 100644 --- a/skills/codex/SKILL.md +++ b/skills/codex/SKILL.md @@ -20,7 +20,7 @@ Execute Codex CLI commands and parse structured JSON responses. Supports file re **Mandatory**: Run every automated invocation through the Bash tool in the foreground with **HEREDOC syntax** to avoid shell quoting issues, keeping the `timeout` parameter fixed at `7200000` milliseconds (do not change it or use any other entry point). ```bash -uv run ~/.claude/skills/codex/scripts/codex.py - [working_dir] <<'EOF' +codex-wrapper - [working_dir] <<'EOF' EOF ``` @@ -32,12 +32,12 @@ EOF **Simple tasks** (backward compatibility): For simple single-line tasks without special characters, you can still use direct quoting: ```bash -uv run ~/.claude/skills/codex/scripts/codex.py "simple task here" [working_dir] +codex-wrapper "simple task here" [working_dir] ``` **Resume a session with HEREDOC:** ```bash -uv run ~/.claude/skills/codex/scripts/codex.py resume - [working_dir] <<'EOF' +codex-wrapper resume - [working_dir] <<'EOF' EOF ``` @@ -46,18 +46,19 @@ EOF - **Bash/Zsh**: Use `<<'EOF'` (single quotes prevent variable expansion) - **PowerShell 5.1+**: Use `@'` and `'@` (here-string syntax) ```powershell - uv run ~/.claude/skills/codex/scripts/codex.py - @' + codex-wrapper - @' task content '@ ``` ## Environment Variables + - **CODEX_TIMEOUT**: Override timeout in milliseconds (default: 7200000 = 2 hours) - Example: `export CODEX_TIMEOUT=3600000` for 1 hour ## Timeout Control -- **Built-in**: Script enforces 2-hour timeout by default +- **Built-in**: Binary enforces 2-hour timeout by default - **Override**: Set `CODEX_TIMEOUT` environment variable (in milliseconds, e.g., `CODEX_TIMEOUT=3600000` for 1 hour) - **Behavior**: On timeout, sends SIGTERM, then SIGKILL after 5s if process doesn't exit - **Exit code**: Returns 124 on timeout (consistent with GNU timeout) @@ -91,7 +92,7 @@ All automated executions must use HEREDOC syntax through the Bash tool in the fo ``` Bash tool parameters: -- command: uv run ~/.claude/skills/codex/scripts/codex.py - [working_dir] <<'EOF' +- command: codex-wrapper - [working_dir] <<'EOF' EOF - timeout: 7200000 @@ -106,19 +107,19 @@ Run every call in the foreground—never append `&` to background it—so logs a **Basic code analysis:** ```bash -# Recommended: via uv run with HEREDOC (handles any special characters) -uv run ~/.claude/skills/codex/scripts/codex.py - <<'EOF' +# Recommended: with HEREDOC (handles any special characters) +codex-wrapper - <<'EOF' explain @src/main.ts EOF # timeout: 7200000 # Alternative: simple direct quoting (if task is simple) -uv run ~/.claude/skills/codex/scripts/codex.py "explain @src/main.ts" +codex-wrapper "explain @src/main.ts" ``` **Refactoring with multiline instructions:** ```bash -uv run ~/.claude/skills/codex/scripts/codex.py - <<'EOF' +codex-wrapper - <<'EOF' refactor @src/utils for performance: - Extract duplicate code into helpers - Use memoization for expensive calculations @@ -129,7 +130,7 @@ EOF **Multi-file analysis:** ```bash -uv run ~/.claude/skills/codex/scripts/codex.py - "/path/to/project" <<'EOF' +codex-wrapper - "/path/to/project" <<'EOF' analyze @. and find security issues: 1. Check for SQL injection vulnerabilities 2. Identify XSS risks in templates @@ -142,13 +143,13 @@ EOF **Resume previous session:** ```bash # First session -uv run ~/.claude/skills/codex/scripts/codex.py - <<'EOF' +codex-wrapper - <<'EOF' add comments to @utils.js explaining the caching logic EOF # Output includes: SESSION_ID: 019a7247-ac9d-71f3-89e2-a823dbd8fd14 # Continue the conversation with more context -uv run ~/.claude/skills/codex/scripts/codex.py resume 019a7247-ac9d-71f3-89e2-a823dbd8fd14 - <<'EOF' +codex-wrapper resume 019a7247-ac9d-71f3-89e2-a823dbd8fd14 - <<'EOF' now add TypeScript type hints and handle edge cases where cache is null EOF # timeout: 7200000 @@ -156,7 +157,7 @@ EOF **Task with code snippets and special characters:** ```bash -uv run ~/.claude/skills/codex/scripts/codex.py - <<'EOF' +codex-wrapper - <<'EOF' Fix the bug in @app.js where the regex /\d+/ doesn't match "123" The current code is: const re = /\d+/; @@ -173,18 +174,16 @@ EOF | ID | Description | Scope | Dependencies | Tests | Command | | --- | --- | --- | --- | --- | --- | -| T1 | Review @spec.md to extract requirements | docs/, @spec.md | None | None | `uv run ~/.claude/skills/codex/scripts/codex.py - <<'EOF'`
`analyze requirements @spec.md`
`EOF` | -| T2 | Implement the module and add test cases | src/module | T1 | npm test -- --runInBand | `uv run ~/.claude/skills/codex/scripts/codex.py - <<'EOF'`
`implement and test @src/module`
`EOF` | +| T1 | Review @spec.md to extract requirements | docs/, @spec.md | None | None | `codex-wrapper - <<'EOF'`
`analyze requirements @spec.md`
`EOF` | +| T2 | Implement the module and add test cases | src/module | T1 | npm test -- --runInBand | `codex-wrapper - <<'EOF'`
`implement and test @src/module`
`EOF` | ## Notes -- **Recommended**: Use `uv run` for automatic Python environment management (requires uv installed) -- **Alternative**: Direct execution `./codex.py` (uses system Python via shebang) -- Python implementation using standard library (zero dependencies) -- All automated runs must use the Bash tool with the fixed timeout to provide dual timeout protection and unified logging/exit semantics; any alternative approach is limited to manual foreground execution. -- Cross-platform compatible (Windows/macOS/Linux) -- PEP 723 compliant (inline script metadata) -- Runs with `--dangerously-bypass-approvals-and-sandbox` for automation (new sessions only) +- **Binary distribution**: Single Go binary, zero dependencies +- **Installation**: Download from GitHub Releases or use install.sh +- **Cross-platform compatible**: Linux (amd64/arm64), macOS (amd64/arm64) +- All automated runs must use the Bash tool with the fixed timeout to provide dual timeout protection and unified logging/exit semantics +for automation (new sessions only) - Uses `--skip-git-repo-check` to work in any directory - Streams progress, returns only final agent message - Every execution returns a session ID for resuming conversations