Skip to content

Commit facd093

Browse files
wesmclaude
andcommitted
feat: add Copilot CLI as an insights agent
- Add generateCopilot() using -p (prompt as arg), --silent, --no-custom-instructions, --no-ask-user, --available-tools (no tools) for sandboxed non-interactive execution - Plain text output parsed from stdout (no JSON needed) - Update ValidAgents, server validation, AgentName type, and agent dropdown to include copilot - Add TestGenerateCopilot_CLIFlags and TestGenerateCopilot_EmptyResult Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 23d99f5 commit facd093

5 files changed

Lines changed: 155 additions & 6 deletions

File tree

frontend/src/lib/api/types/insights.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export interface InsightsResponse {
1919
insights: Insight[];
2020
}
2121

22-
export type AgentName = "claude" | "codex" | "gemini";
22+
export type AgentName = "claude" | "codex" | "copilot" | "gemini";
2323

2424
export interface GenerateInsightRequest {
2525
type: InsightType;

frontend/src/lib/components/insights/InsightsPage.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@
220220
>
221221
<option value="claude">Claude</option>
222222
<option value="codex">Codex</option>
223+
<option value="copilot">Copilot</option>
223224
<option value="gemini">Gemini</option>
224225
</select>
225226
</div>

internal/insight/generate.go

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ type Result struct {
2929

3030
// ValidAgents lists the supported agent names.
3131
var ValidAgents = map[string]bool{
32-
"claude": true,
33-
"codex": true,
34-
"gemini": true,
32+
"claude": true,
33+
"codex": true,
34+
"copilot": true,
35+
"gemini": true,
3536
}
3637

3738
// GenerateFunc is the signature for insight generation,
@@ -87,6 +88,8 @@ func GenerateStream(
8788
switch agent {
8889
case "codex":
8990
return generateCodex(ctx, path, prompt, onLog)
91+
case "copilot":
92+
return generateCopilot(ctx, path, prompt, onLog)
9093
case "gemini":
9194
return generateGemini(ctx, path, prompt, onLog)
9295
default:
@@ -441,6 +444,78 @@ func parseCodexStream(
441444
return strings.Join(messages, "\n"), nil
442445
}
443446

447+
// generateCopilot invokes `copilot -p <prompt> --silent`.
448+
// The prompt is passed as the -p argument (copilot does not
449+
// read prompts from stdin). Output is plain text on stdout.
450+
func generateCopilot(
451+
ctx context.Context, path, prompt string, onLog LogFunc,
452+
) (Result, error) {
453+
cmd := exec.CommandContext(
454+
ctx, path,
455+
"-p", prompt,
456+
"--silent",
457+
"--no-custom-instructions",
458+
"--no-ask-user",
459+
"--available-tools",
460+
)
461+
cmd.Dir = os.TempDir()
462+
cmd.Env = cleanEnv()
463+
464+
stdoutPipe, err := cmd.StdoutPipe()
465+
if err != nil {
466+
return Result{}, fmt.Errorf(
467+
"create stdout pipe: %w", err,
468+
)
469+
}
470+
stderrPipe, err := cmd.StderrPipe()
471+
if err != nil {
472+
return Result{}, fmt.Errorf(
473+
"create stderr pipe: %w", err,
474+
)
475+
}
476+
477+
if err := cmd.Start(); err != nil {
478+
return Result{}, fmt.Errorf(
479+
"start copilot: %w", err,
480+
)
481+
}
482+
483+
stderrDone := collectStreamLines(
484+
stderrPipe, "stderr", onLog,
485+
)
486+
stdoutDone := collectStreamLines(
487+
stdoutPipe, "stdout", onLog,
488+
)
489+
490+
stdoutText := <-stdoutDone
491+
stderrText := <-stderrDone
492+
runErr := cmd.Wait()
493+
494+
if runErr != nil && ctx.Err() != nil {
495+
return Result{}, fmt.Errorf(
496+
"copilot CLI cancelled: %w", ctx.Err(),
497+
)
498+
}
499+
if runErr != nil {
500+
return Result{}, fmt.Errorf(
501+
"copilot CLI failed: %w\nstderr: %s",
502+
runErr, stderrText,
503+
)
504+
}
505+
506+
content := strings.TrimSpace(stdoutText)
507+
if content == "" {
508+
return Result{}, fmt.Errorf(
509+
"copilot returned empty result",
510+
)
511+
}
512+
513+
return Result{
514+
Content: content,
515+
Agent: "copilot",
516+
}, nil
517+
}
518+
444519
// generateGemini invokes `gemini --output-format stream-json`
445520
// and parses the JSONL stream for result/assistant messages.
446521
func generateGemini(

internal/insight/generate_test.go

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ func TestEnvKeyAllowed(t *testing.T) {
263263

264264
func TestValidAgents(t *testing.T) {
265265
for _, agent := range []string{
266-
"claude", "codex", "gemini",
266+
"claude", "codex", "copilot", "gemini",
267267
} {
268268
if !ValidAgents[agent] {
269269
t.Errorf("%s should be valid", agent)
@@ -423,6 +423,79 @@ func TestGenerateCodex_CLIFlags(t *testing.T) {
423423
}
424424
}
425425

426+
func TestGenerateCopilot_CLIFlags(t *testing.T) {
427+
if runtime.GOOS == "windows" {
428+
t.Skip("shell script test not supported on windows")
429+
}
430+
431+
bin, argsFile := createMockBinary(
432+
t, "Hello from copilot", 0, true, "copilot",
433+
)
434+
435+
result, err := generateCopilot(
436+
context.Background(), bin, "test prompt", nil,
437+
)
438+
if err != nil {
439+
t.Fatalf("generateCopilot: %v", err)
440+
}
441+
if result.Content != "Hello from copilot" {
442+
t.Errorf(
443+
"Content = %q, want %q",
444+
result.Content, "Hello from copilot",
445+
)
446+
}
447+
if result.Agent != "copilot" {
448+
t.Errorf("Agent = %q, want copilot", result.Agent)
449+
}
450+
451+
argsData, err := os.ReadFile(argsFile)
452+
if err != nil {
453+
t.Fatalf("reading args: %v", err)
454+
}
455+
args := strings.Split(
456+
strings.TrimSpace(string(argsData)), "\n",
457+
)
458+
459+
wantArgs := []string{
460+
"-p", "test prompt",
461+
"--silent",
462+
"--no-custom-instructions",
463+
"--no-ask-user",
464+
"--available-tools",
465+
}
466+
if len(args) != len(wantArgs) {
467+
t.Fatalf("args = %v, want %v", args, wantArgs)
468+
}
469+
for i, want := range wantArgs {
470+
if args[i] != want {
471+
t.Errorf(
472+
"arg[%d] = %q, want %q",
473+
i, args[i], want,
474+
)
475+
}
476+
}
477+
}
478+
479+
func TestGenerateCopilot_EmptyResult(t *testing.T) {
480+
if runtime.GOOS == "windows" {
481+
t.Skip("shell script test not supported on windows")
482+
}
483+
484+
bin, _ := createMockBinary(
485+
t, "", 0, false, "copilot",
486+
)
487+
488+
_, err := generateCopilot(
489+
context.Background(), bin, "test", nil,
490+
)
491+
if err == nil {
492+
t.Fatal("expected error for empty result")
493+
}
494+
if !strings.Contains(err.Error(), "empty result") {
495+
t.Errorf("error = %q, want empty result", err)
496+
}
497+
}
498+
426499
func TestGenerateClaude_SalvageOnNonZeroExit(t *testing.T) {
427500
tests := []struct {
428501
name string

internal/server/insights.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ func (s *Server) handleGenerateInsight(
170170
}
171171
if !insight.ValidAgents[req.Agent] {
172172
writeError(w, http.StatusBadRequest,
173-
"invalid agent: must be claude, codex, or gemini")
173+
"invalid agent: must be claude, codex, copilot, or gemini")
174174
return
175175
}
176176

0 commit comments

Comments
 (0)