Skip to content

Commit e94409f

Browse files
committed
feat(cmd/server): support reading initial prompt from stdin
1 parent d99fcde commit e94409f

File tree

2 files changed

+65
-2
lines changed

2 files changed

+65
-2
lines changed

cmd/server/server.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"io"
78
"log/slog"
89
"net/http"
910
"os"
@@ -70,6 +71,11 @@ func parseAgentType(firstArg string, agentTypeVar string) (AgentType, error) {
7071
return AgentTypeCustom, nil
7172
}
7273

74+
// isStdinPiped checks if stdin is piped (not a terminal)
75+
func isStdinPiped(stat os.FileInfo) bool {
76+
return (stat.Mode() & os.ModeCharDevice) == 0
77+
}
78+
7379
func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) error {
7480
agent := argsToPass[0]
7581
agentTypeValue := viper.GetString(FlagType)
@@ -88,6 +94,19 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
8894
return xerrors.Errorf("term height must be at least 10")
8995
}
9096

97+
// Read stdin if it's piped, to be used as initial prompt
98+
initialPrompt := viper.GetString(FlagInitialPrompt)
99+
if initialPrompt == "" {
100+
if stat, err := os.Stdin.Stat(); err == nil && isStdinPiped(stat) {
101+
if stdinData, err := io.ReadAll(os.Stdin); err != nil {
102+
return xerrors.Errorf("failed to read stdin: %w", err)
103+
} else if len(stdinData) > 0 {
104+
initialPrompt = string(stdinData)
105+
logger.Info("Read initial prompt from stdin", "bytes", len(stdinData))
106+
}
107+
}
108+
}
109+
91110
printOpenAPI := viper.GetBool(FlagPrintOpenAPI)
92111
var process *termexec.Process
93112
if printOpenAPI {
@@ -112,7 +131,7 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
112131
ChatBasePath: viper.GetString(FlagChatBasePath),
113132
AllowedHosts: viper.GetStringSlice(FlagAllowedHosts),
114133
AllowedOrigins: viper.GetStringSlice(FlagAllowedOrigins),
115-
InitialPrompt: viper.GetString(FlagInitialPrompt),
134+
InitialPrompt: initialPrompt,
116135
})
117136
if err != nil {
118137
return xerrors.Errorf("failed to create server: %w", err)
@@ -213,7 +232,7 @@ func CreateServerCmd() *cobra.Command {
213232
{FlagAllowedHosts, "a", []string{"localhost", "127.0.0.1", "[::1]"}, "HTTP allowed hosts (hostnames only, no ports). Use '*' for all, comma-separated list via flag, space-separated list via AGENTAPI_ALLOWED_HOSTS env var", "stringSlice"},
214233
// localhost:3284 is the default origin when you open the chat interface in your browser. localhost:3000 and 3001 are used during development.
215234
{FlagAllowedOrigins, "o", []string{"http://localhost:3284", "http://localhost:3000", "http://localhost:3001"}, "HTTP allowed origins. Use '*' for all, comma-separated list via flag, space-separated list via AGENTAPI_ALLOWED_ORIGINS env var", "stringSlice"},
216-
{FlagInitialPrompt, "I", "", "Initial prompt for the agent (recommended only if the agent doesn't support initial prompt in interaction mode)", "string"},
235+
{FlagInitialPrompt, "I", "", "Initial prompt for the agent. Recommended only if the agent doesn't support initial prompt in interaction mode. Will be read from stdin if piped (e.g., echo 'prompt' | agentapi server -- my-agent)", "string"},
217236
}
218237

219238
for _, spec := range flagSpecs {

cmd/server/server_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"strings"
77
"testing"
8+
"time"
89

910
"github.com/spf13/cobra"
1011
"github.com/spf13/viper"
@@ -571,3 +572,46 @@ func TestServerCmd_AllowedOrigins(t *testing.T) {
571572
})
572573
}
573574
}
575+
576+
func TestIsStdinPiped(t *testing.T) {
577+
tests := []struct {
578+
name string
579+
fileInfo os.FileInfo
580+
expected bool
581+
}{
582+
{
583+
name: "regular file (piped)",
584+
fileInfo: &mockFileInfo{mode: 0},
585+
expected: true,
586+
},
587+
{
588+
name: "character device (terminal)",
589+
fileInfo: &mockFileInfo{mode: os.ModeCharDevice},
590+
expected: false,
591+
},
592+
{
593+
name: "named pipe",
594+
fileInfo: &mockFileInfo{mode: os.ModeNamedPipe},
595+
expected: true,
596+
},
597+
}
598+
599+
for _, tt := range tests {
600+
t.Run(tt.name, func(t *testing.T) {
601+
result := isStdinPiped(tt.fileInfo)
602+
assert.Equal(t, tt.expected, result)
603+
})
604+
}
605+
}
606+
607+
// mockFileInfo implements os.FileInfo for testing
608+
type mockFileInfo struct {
609+
mode os.FileMode
610+
}
611+
612+
func (m *mockFileInfo) Name() string { return "stdin" }
613+
func (m *mockFileInfo) Size() int64 { return 0 }
614+
func (m *mockFileInfo) Mode() os.FileMode { return m.mode }
615+
func (m *mockFileInfo) ModTime() time.Time { return time.Time{} }
616+
func (m *mockFileInfo) IsDir() bool { return false }
617+
func (m *mockFileInfo) Sys() interface{} { return nil }

0 commit comments

Comments
 (0)