Skip to content

Commit f7c7c93

Browse files
Add CLIArgs option and fix CLI process error reporting in Go SDK
- Add CLIArgs option to ClientOptions for passing extra CLI arguments - Capture stderr from CLI process for better error messages - Add processDone channel to signal when CLI exits unexpectedly - Propagate process exit errors to pending JSON-RPC requests - Add E2E test verifying error reporting when CLI fails to start Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0cdf409 commit f7c7c93

4 files changed

Lines changed: 74 additions & 8 deletions

File tree

go/client.go

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@ package copilot
2929

3030
import (
3131
"bufio"
32+
"bytes"
3233
"context"
3334
"encoding/json"
3435
"errors"
3536
"fmt"
37+
"io"
3638
"net"
3739
"os"
3840
"os/exec"
@@ -85,6 +87,8 @@ type Client struct {
8587
lifecycleHandlers []SessionLifecycleHandler
8688
typedLifecycleHandlers map[SessionLifecycleEventType][]SessionLifecycleHandler
8789
lifecycleHandlersMux sync.Mutex
90+
stderrBuf bytes.Buffer // captures CLI stderr for error messages
91+
processDone chan error // signals when CLI process exits
8892

8993
// RPC provides typed server-scoped RPC methods.
9094
// This field is nil until the client is connected via Start().
@@ -149,6 +153,9 @@ func NewClient(options *ClientOptions) *Client {
149153
if options.CLIPath != "" {
150154
opts.CLIPath = options.CLIPath
151155
}
156+
if len(options.CLIArgs) > 0 {
157+
opts.CLIArgs = append([]string{}, options.CLIArgs...)
158+
}
152159
if options.Cwd != "" {
153160
opts.Cwd = options.Cwd
154161
}
@@ -1022,7 +1029,10 @@ func (c *Client) startCLIServer(ctx context.Context) error {
10221029
// Default to "copilot" in PATH if no embedded CLI is available and no custom path is set
10231030
cliPath = "copilot"
10241031
}
1025-
args := []string{"--headless", "--no-auto-update", "--log-level", c.options.LogLevel}
1032+
1033+
// Start with user-provided CLIArgs, then add SDK-managed args
1034+
args := append([]string{}, c.options.CLIArgs...)
1035+
args = append(args, "--headless", "--no-auto-update", "--log-level", c.options.LogLevel)
10261036

10271037
// Choose transport mode
10281038
if c.useStdio {
@@ -1087,21 +1097,32 @@ func (c *Client) startCLIServer(ctx context.Context) error {
10871097
return fmt.Errorf("failed to create stderr pipe: %w", err)
10881098
}
10891099

1090-
// Read stderr in background
1100+
// Read stderr in background, capturing for error messages
10911101
go func() {
1092-
scanner := bufio.NewScanner(stderr)
1093-
for scanner.Scan() {
1094-
// Optionally log stderr
1095-
// fmt.Fprintf(os.Stderr, "CLI stderr: %s\n", scanner.Text())
1096-
}
1102+
io.Copy(&c.stderrBuf, stderr)
10971103
}()
10981104

10991105
if err := c.process.Start(); err != nil {
11001106
return fmt.Errorf("failed to start CLI server: %w", err)
11011107
}
11021108

1109+
// Monitor process exit to signal pending requests
1110+
c.processDone = make(chan error, 1)
1111+
go func() {
1112+
err := c.process.Wait()
1113+
stderrOutput := strings.TrimSpace(c.stderrBuf.String())
1114+
if stderrOutput != "" {
1115+
c.processDone <- fmt.Errorf("CLI process exited: %v\nstderr: %s", err, stderrOutput)
1116+
} else if err != nil {
1117+
c.processDone <- fmt.Errorf("CLI process exited: %v", err)
1118+
} else {
1119+
c.processDone <- fmt.Errorf("CLI process exited unexpectedly")
1120+
}
1121+
}()
1122+
11031123
// Create JSON-RPC client immediately
11041124
c.client = jsonrpc2.NewClient(stdin, stdout)
1125+
c.client.SetProcessDone(c.processDone)
11051126
c.RPC = rpc.NewServerRpc(c.client)
11061127
c.setupNotificationHandler()
11071128
c.client.Start()

go/internal/e2e/client_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package e2e
22

33
import (
4+
"strings"
45
"testing"
56
"time"
67

@@ -225,4 +226,24 @@ func TestClient(t *testing.T) {
225226

226227
client.Stop()
227228
})
229+
230+
t.Run("should report error with stderr when CLI fails to start", func(t *testing.T) {
231+
client := copilot.NewClient(&copilot.ClientOptions{
232+
CLIPath: cliPath,
233+
CLIArgs: []string{"--nonexistent-flag-for-testing"},
234+
UseStdio: copilot.Bool(true),
235+
})
236+
t.Cleanup(func() { client.ForceStop() })
237+
238+
err := client.Start(t.Context())
239+
if err == nil {
240+
t.Fatal("Expected Start to fail with invalid CLI args")
241+
}
242+
243+
errStr := err.Error()
244+
// Verify we get the stderr output in the error message
245+
if !strings.Contains(errStr, "stderr") || !strings.Contains(errStr, "nonexistent") {
246+
t.Errorf("Expected error to contain stderr output about invalid flag, got: %v", err)
247+
}
248+
})
228249
}

go/internal/jsonrpc2/jsonrpc2.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ type Client struct {
5757
running bool
5858
stopChan chan struct{}
5959
wg sync.WaitGroup
60+
processDone <-chan error // signals when the underlying process exits
6061
}
6162

6263
// NewClient creates a new JSON-RPC client
@@ -70,6 +71,11 @@ func NewClient(stdin io.WriteCloser, stdout io.ReadCloser) *Client {
7071
}
7172
}
7273

74+
// SetProcessDone sets a channel that signals when the underlying process exits
75+
func (c *Client) SetProcessDone(ch <-chan error) {
76+
c.processDone = ch
77+
}
78+
7379
// Start begins listening for messages in a background goroutine
7480
func (c *Client) Start() {
7581
c.running = true
@@ -189,7 +195,23 @@ func (c *Client) Request(method string, params any) (json.RawMessage, error) {
189195
return nil, fmt.Errorf("failed to send request: %w", err)
190196
}
191197

192-
// Wait for response
198+
// Wait for response, also checking for process exit
199+
if c.processDone != nil {
200+
select {
201+
case response := <-responseChan:
202+
if response.Error != nil {
203+
return nil, response.Error
204+
}
205+
return response.Result, nil
206+
case err := <-c.processDone:
207+
if err != nil {
208+
return nil, err
209+
}
210+
return nil, fmt.Errorf("process exited unexpectedly")
211+
case <-c.stopChan:
212+
return nil, fmt.Errorf("client stopped")
213+
}
214+
}
193215
select {
194216
case response := <-responseChan:
195217
if response.Error != nil {

go/types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ const (
1616
type ClientOptions struct {
1717
// CLIPath is the path to the Copilot CLI executable (default: "copilot")
1818
CLIPath string
19+
// CLIArgs are extra arguments to pass to the CLI executable (inserted before SDK-managed args)
20+
CLIArgs []string
1921
// Cwd is the working directory for the CLI process (default: "" = inherit from current process)
2022
Cwd string
2123
// Port for TCP transport (default: 0 = random port)

0 commit comments

Comments
 (0)