Skip to content

Commit 89e3a72

Browse files
authored
feat: add support for piped input to CLI (anomalyco#51)
1 parent b9ebcea commit 89e3a72

File tree

3 files changed

+187
-1
lines changed

3 files changed

+187
-1
lines changed

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,26 +245,39 @@ opencode -c /path/to/project
245245

246246
## Non-interactive Prompt Mode
247247

248-
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.
248+
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.
249249

250250
```bash
251251
# Run a single prompt and print the AI's response to the terminal
252252
opencode -p "Explain the use of context in Go"
253253

254+
# Pipe input to OpenCode (equivalent to using -p flag)
255+
echo "Explain the use of context in Go" | opencode
256+
254257
# Get response in JSON format
255258
opencode -p "Explain the use of context in Go" -f json
259+
# Or with piped input
260+
echo "Explain the use of context in Go" | opencode -f json
256261

257262
# Run without showing the spinner
258263
opencode -p "Explain the use of context in Go" -q
264+
# Or with piped input
265+
echo "Explain the use of context in Go" | opencode -q
259266

260267
# Enable verbose logging to stderr
261268
opencode -p "Explain the use of context in Go" --verbose
269+
# Or with piped input
270+
echo "Explain the use of context in Go" | opencode --verbose
262271

263272
# Restrict the agent to only use specific tools
264273
opencode -p "Explain the use of context in Go" --allowedTools=view,ls,glob
274+
# Or with piped input
275+
echo "Explain the use of context in Go" | opencode --allowedTools=view,ls,glob
265276

266277
# Prevent the agent from using specific tools
267278
opencode -p "Explain the use of context in Go" --excludedTools=bash,edit
279+
# Or with piped input
280+
echo "Explain the use of context in Go" | opencode --excludedTools=bash,edit
268281
```
269282

270283
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.

cmd/root.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"context"
55
"fmt"
6+
"io"
67
"os"
78
"sync"
89
"time"
@@ -91,6 +92,16 @@ to assist developers in writing, debugging, and understanding code directly from
9192

9293
// Check if we're in non-interactive mode
9394
prompt, _ := cmd.Flags().GetString("prompt")
95+
96+
// Check for piped input if no prompt was provided via flag
97+
if prompt == "" {
98+
pipedInput, hasPipedInput := checkStdinPipe()
99+
if hasPipedInput {
100+
prompt = pipedInput
101+
}
102+
}
103+
104+
// If we have a prompt (either from flag or piped input), run in non-interactive mode
94105
if prompt != "" {
95106
outputFormatStr, _ := cmd.Flags().GetString("output-format")
96107
outputFormat := format.OutputFormat(outputFormatStr)
@@ -311,6 +322,25 @@ func Execute() {
311322
}
312323
}
313324

325+
// checkStdinPipe checks if there's data being piped into stdin
326+
func checkStdinPipe() (string, bool) {
327+
// Check if stdin is not a terminal (i.e., it's being piped)
328+
stat, _ := os.Stdin.Stat()
329+
if (stat.Mode() & os.ModeCharDevice) == 0 {
330+
// Read all data from stdin
331+
data, err := io.ReadAll(os.Stdin)
332+
if err != nil {
333+
return "", false
334+
}
335+
336+
// If we got data, return it
337+
if len(data) > 0 {
338+
return string(data), true
339+
}
340+
}
341+
return "", false
342+
}
343+
314344
func init() {
315345
rootCmd.Flags().BoolP("help", "h", false, "Help")
316346
rootCmd.Flags().BoolP("version", "v", false, "Version")

cmd/root_test.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"os"
7+
"testing"
8+
)
9+
10+
func TestCheckStdinPipe(t *testing.T) {
11+
// Save original stdin
12+
origStdin := os.Stdin
13+
14+
// Restore original stdin when test completes
15+
defer func() {
16+
os.Stdin = origStdin
17+
}()
18+
19+
// Test case 1: Data is piped in
20+
t.Run("WithPipedData", func(t *testing.T) {
21+
// Create a pipe
22+
r, w, err := os.Pipe()
23+
if err != nil {
24+
t.Fatalf("Failed to create pipe: %v", err)
25+
}
26+
27+
// Replace stdin with our pipe
28+
os.Stdin = r
29+
30+
// Write test data to the pipe
31+
testData := "test piped input"
32+
go func() {
33+
defer w.Close()
34+
w.Write([]byte(testData))
35+
}()
36+
37+
// Call the function
38+
data, hasPiped := checkStdinPipe()
39+
40+
// Check results
41+
if !hasPiped {
42+
t.Error("Expected hasPiped to be true, got false")
43+
}
44+
if data != testData {
45+
t.Errorf("Expected data to be %q, got %q", testData, data)
46+
}
47+
})
48+
49+
// Test case 2: No data is piped in (simulated terminal)
50+
t.Run("WithoutPipedData", func(t *testing.T) {
51+
// Create a temporary file to simulate a terminal
52+
tmpFile, err := os.CreateTemp("", "terminal-sim")
53+
if err != nil {
54+
t.Fatalf("Failed to create temp file: %v", err)
55+
}
56+
defer os.Remove(tmpFile.Name())
57+
defer tmpFile.Close()
58+
59+
// Open the file for reading
60+
f, err := os.Open(tmpFile.Name())
61+
if err != nil {
62+
t.Fatalf("Failed to open temp file: %v", err)
63+
}
64+
defer f.Close()
65+
66+
// Replace stdin with our file
67+
os.Stdin = f
68+
69+
// Call the function
70+
data, hasPiped := checkStdinPipe()
71+
72+
// Check results
73+
if hasPiped {
74+
t.Error("Expected hasPiped to be false, got true")
75+
}
76+
if data != "" {
77+
t.Errorf("Expected data to be empty, got %q", data)
78+
}
79+
})
80+
}
81+
82+
// This is a mock implementation for testing since we can't easily mock os.Stdin.Stat()
83+
// in a way that would return the correct Mode() for our test cases
84+
func mockCheckStdinPipe(reader io.Reader, isPipe bool) (string, bool) {
85+
if !isPipe {
86+
return "", false
87+
}
88+
89+
data, err := io.ReadAll(reader)
90+
if err != nil {
91+
return "", false
92+
}
93+
94+
if len(data) > 0 {
95+
return string(data), true
96+
}
97+
return "", false
98+
}
99+
100+
func TestMockCheckStdinPipe(t *testing.T) {
101+
// Test with data
102+
t.Run("WithData", func(t *testing.T) {
103+
testData := "test data"
104+
reader := bytes.NewBufferString(testData)
105+
106+
data, hasPiped := mockCheckStdinPipe(reader, true)
107+
108+
if !hasPiped {
109+
t.Error("Expected hasPiped to be true, got false")
110+
}
111+
if data != testData {
112+
t.Errorf("Expected data to be %q, got %q", testData, data)
113+
}
114+
})
115+
116+
// Test without data
117+
t.Run("WithoutData", func(t *testing.T) {
118+
reader := bytes.NewBufferString("")
119+
120+
data, hasPiped := mockCheckStdinPipe(reader, true)
121+
122+
if hasPiped {
123+
t.Error("Expected hasPiped to be false, got true")
124+
}
125+
if data != "" {
126+
t.Errorf("Expected data to be empty, got %q", data)
127+
}
128+
})
129+
130+
// Test not a pipe
131+
t.Run("NotAPipe", func(t *testing.T) {
132+
reader := bytes.NewBufferString("data that should be ignored")
133+
134+
data, hasPiped := mockCheckStdinPipe(reader, false)
135+
136+
if hasPiped {
137+
t.Error("Expected hasPiped to be false, got true")
138+
}
139+
if data != "" {
140+
t.Errorf("Expected data to be empty, got %q", data)
141+
}
142+
})
143+
}

0 commit comments

Comments
 (0)