Просмотр исходного кода

feat: add support for piped input to CLI (#51)

Ed Zynda 9 месяцев назад
Родитель
Сommit
89e3a72ae1
3 измененных файлов с 187 добавлено и 1 удалено
  1. 14 1
      README.md
  2. 30 0
      cmd/root.go
  3. 143 0
      cmd/root_test.go

+ 14 - 1
README.md

@@ -245,26 +245,39 @@ opencode -c /path/to/project
 
 ## Non-interactive Prompt Mode
 
-You can run OpenCode in non-interactive mode by passing a prompt directly as a command-line argument. This is useful for scripting, automation, or when you want a quick answer without launching the full TUI.
+You can run OpenCode in non-interactive mode by passing a prompt directly as a command-line argument or by piping text into the command. This is useful for scripting, automation, or when you want a quick answer without launching the full TUI.
 
 ```bash
 # Run a single prompt and print the AI's response to the terminal
 opencode -p "Explain the use of context in Go"
 
+# Pipe input to OpenCode (equivalent to using -p flag)
+echo "Explain the use of context in Go" | opencode
+
 # Get response in JSON format
 opencode -p "Explain the use of context in Go" -f json
+# Or with piped input
+echo "Explain the use of context in Go" | opencode -f json
 
 # Run without showing the spinner
 opencode -p "Explain the use of context in Go" -q
+# Or with piped input
+echo "Explain the use of context in Go" | opencode -q
 
 # Enable verbose logging to stderr
 opencode -p "Explain the use of context in Go" --verbose
+# Or with piped input
+echo "Explain the use of context in Go" | opencode --verbose
 
 # Restrict the agent to only use specific tools
 opencode -p "Explain the use of context in Go" --allowedTools=view,ls,glob
+# Or with piped input
+echo "Explain the use of context in Go" | opencode --allowedTools=view,ls,glob
 
 # Prevent the agent from using specific tools
 opencode -p "Explain the use of context in Go" --excludedTools=bash,edit
+# Or with piped input
+echo "Explain the use of context in Go" | opencode --excludedTools=bash,edit
 ```
 
 In this mode, OpenCode will process your prompt, print the result to standard output, and then exit. All permissions are auto-approved for the session.

+ 30 - 0
cmd/root.go

@@ -3,6 +3,7 @@ package cmd
 import (
 	"context"
 	"fmt"
+	"io"
 	"os"
 	"sync"
 	"time"
@@ -91,6 +92,16 @@ to assist developers in writing, debugging, and understanding code directly from
 
 		// Check if we're in non-interactive mode
 		prompt, _ := cmd.Flags().GetString("prompt")
+		
+		// Check for piped input if no prompt was provided via flag
+		if prompt == "" {
+			pipedInput, hasPipedInput := checkStdinPipe()
+			if hasPipedInput {
+				prompt = pipedInput
+			}
+		}
+		
+		// If we have a prompt (either from flag or piped input), run in non-interactive mode
 		if prompt != "" {
 			outputFormatStr, _ := cmd.Flags().GetString("output-format")
 			outputFormat := format.OutputFormat(outputFormatStr)
@@ -311,6 +322,25 @@ func Execute() {
 	}
 }
 
+// checkStdinPipe checks if there's data being piped into stdin
+func checkStdinPipe() (string, bool) {
+	// Check if stdin is not a terminal (i.e., it's being piped)
+	stat, _ := os.Stdin.Stat()
+	if (stat.Mode() & os.ModeCharDevice) == 0 {
+		// Read all data from stdin
+		data, err := io.ReadAll(os.Stdin)
+		if err != nil {
+			return "", false
+		}
+		
+		// If we got data, return it
+		if len(data) > 0 {
+			return string(data), true
+		}
+	}
+	return "", false
+}
+
 func init() {
 	rootCmd.Flags().BoolP("help", "h", false, "Help")
 	rootCmd.Flags().BoolP("version", "v", false, "Version")

+ 143 - 0
cmd/root_test.go

@@ -0,0 +1,143 @@
+package cmd
+
+import (
+	"bytes"
+	"io"
+	"os"
+	"testing"
+)
+
+func TestCheckStdinPipe(t *testing.T) {
+	// Save original stdin
+	origStdin := os.Stdin
+
+	// Restore original stdin when test completes
+	defer func() {
+		os.Stdin = origStdin
+	}()
+
+	// Test case 1: Data is piped in
+	t.Run("WithPipedData", func(t *testing.T) {
+		// Create a pipe
+		r, w, err := os.Pipe()
+		if err != nil {
+			t.Fatalf("Failed to create pipe: %v", err)
+		}
+
+		// Replace stdin with our pipe
+		os.Stdin = r
+
+		// Write test data to the pipe
+		testData := "test piped input"
+		go func() {
+			defer w.Close()
+			w.Write([]byte(testData))
+		}()
+
+		// Call the function
+		data, hasPiped := checkStdinPipe()
+
+		// Check results
+		if !hasPiped {
+			t.Error("Expected hasPiped to be true, got false")
+		}
+		if data != testData {
+			t.Errorf("Expected data to be %q, got %q", testData, data)
+		}
+	})
+
+	// Test case 2: No data is piped in (simulated terminal)
+	t.Run("WithoutPipedData", func(t *testing.T) {
+		// Create a temporary file to simulate a terminal
+		tmpFile, err := os.CreateTemp("", "terminal-sim")
+		if err != nil {
+			t.Fatalf("Failed to create temp file: %v", err)
+		}
+		defer os.Remove(tmpFile.Name())
+		defer tmpFile.Close()
+
+		// Open the file for reading
+		f, err := os.Open(tmpFile.Name())
+		if err != nil {
+			t.Fatalf("Failed to open temp file: %v", err)
+		}
+		defer f.Close()
+
+		// Replace stdin with our file
+		os.Stdin = f
+
+		// Call the function
+		data, hasPiped := checkStdinPipe()
+
+		// Check results
+		if hasPiped {
+			t.Error("Expected hasPiped to be false, got true")
+		}
+		if data != "" {
+			t.Errorf("Expected data to be empty, got %q", data)
+		}
+	})
+}
+
+// This is a mock implementation for testing since we can't easily mock os.Stdin.Stat()
+// in a way that would return the correct Mode() for our test cases
+func mockCheckStdinPipe(reader io.Reader, isPipe bool) (string, bool) {
+	if !isPipe {
+		return "", false
+	}
+
+	data, err := io.ReadAll(reader)
+	if err != nil {
+		return "", false
+	}
+
+	if len(data) > 0 {
+		return string(data), true
+	}
+	return "", false
+}
+
+func TestMockCheckStdinPipe(t *testing.T) {
+	// Test with data
+	t.Run("WithData", func(t *testing.T) {
+		testData := "test data"
+		reader := bytes.NewBufferString(testData)
+		
+		data, hasPiped := mockCheckStdinPipe(reader, true)
+		
+		if !hasPiped {
+			t.Error("Expected hasPiped to be true, got false")
+		}
+		if data != testData {
+			t.Errorf("Expected data to be %q, got %q", testData, data)
+		}
+	})
+	
+	// Test without data
+	t.Run("WithoutData", func(t *testing.T) {
+		reader := bytes.NewBufferString("")
+		
+		data, hasPiped := mockCheckStdinPipe(reader, true)
+		
+		if hasPiped {
+			t.Error("Expected hasPiped to be false, got true")
+		}
+		if data != "" {
+			t.Errorf("Expected data to be empty, got %q", data)
+		}
+	})
+	
+	// Test not a pipe
+	t.Run("NotAPipe", func(t *testing.T) {
+		reader := bytes.NewBufferString("data that should be ignored")
+		
+		data, hasPiped := mockCheckStdinPipe(reader, false)
+		
+		if hasPiped {
+			t.Error("Expected hasPiped to be false, got true")
+		}
+		if data != "" {
+			t.Errorf("Expected data to be empty, got %q", data)
+		}
+	})
+}