Skip to content

Commit 272dac6

Browse files
intel352claude
andcommitted
feat: add PTY CLI providers for Claude Code, Copilot, Codex, Gemini, Cursor
Implements CLIAdapter interface and ptyProvider driving AI CLI tools via pseudo-terminal. Chat() runs non-interactively via -p flag; Stream() keeps a PTY session alive for multi-turn conversation. Factory functions registered in both ProviderRegistry implementations under types: claude_code, copilot_cli, codex_cli, gemini_cli, cursor_cli. cfg.BaseURL repurposed as workDir. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fd59276 commit 272dac6

9 files changed

Lines changed: 996 additions & 0 deletions

File tree

genkit/providers.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"os/exec"
78
"sync"
9+
"time"
810

911
"github.com/GoCodeAlone/workflow-plugin-agent/provider"
1012
gk "github.com/firebase/genkit/go/genkit"
@@ -317,6 +319,52 @@ func NewVertexAIProvider(ctx context.Context, projectID, region, model, credenti
317319
}, nil
318320
}
319321

322+
// newPTYProvider creates a ptyProvider for the given CLIAdapter.
323+
// workDir optionally sets the working directory for the CLI process.
324+
func newPTYProvider(adapter CLIAdapter, workDir string) (provider.Provider, error) {
325+
binPath, err := exec.LookPath(adapter.Binary())
326+
if err != nil {
327+
return nil, fmt.Errorf("pty provider %s: binary %q not found in PATH: %w", adapter.Name(), adapter.Binary(), err)
328+
}
329+
return &ptyProvider{
330+
adapter: adapter,
331+
binPath: binPath,
332+
workDir: workDir,
333+
timeout: 5 * time.Minute,
334+
authInfo: provider.AuthModeInfo{
335+
Mode: "none",
336+
DisplayName: adapter.Name(),
337+
Description: "CLI-driven provider via PTY",
338+
ServerSafe: false,
339+
},
340+
}, nil
341+
}
342+
343+
// NewClaudeCodeProvider creates a provider backed by the `claude` CLI.
344+
func NewClaudeCodeProvider(workDir string) (provider.Provider, error) {
345+
return newPTYProvider(ClaudeCodeAdapter{}, workDir)
346+
}
347+
348+
// NewCopilotCLIProvider creates a provider backed by the `copilot` CLI.
349+
func NewCopilotCLIProvider(workDir string) (provider.Provider, error) {
350+
return newPTYProvider(CopilotCLIAdapter{}, workDir)
351+
}
352+
353+
// NewCodexCLIProvider creates a provider backed by the `codex` CLI.
354+
func NewCodexCLIProvider(workDir string) (provider.Provider, error) {
355+
return newPTYProvider(CodexCLIAdapter{}, workDir)
356+
}
357+
358+
// NewGeminiCLIProvider creates a provider backed by the `gemini` CLI.
359+
func NewGeminiCLIProvider(workDir string) (provider.Provider, error) {
360+
return newPTYProvider(GeminiCLIAdapter{}, workDir)
361+
}
362+
363+
// NewCursorCLIProvider creates a provider backed by the `agent` CLI (Cursor).
364+
func NewCursorCLIProvider(workDir string) (provider.Provider, error) {
365+
return newPTYProvider(CursorCLIAdapter{}, workDir)
366+
}
367+
320368
// NewBedrockProvider creates a provider for AWS Bedrock using an OpenAI-compatible endpoint.
321369
// Wraps existing Bedrock implementation as a provider.Provider until a native Genkit plugin is available.
322370
func NewBedrockProvider(ctx context.Context, region, model, accessKeyID, secretAccessKey, sessionToken, baseURL string, maxTokens int) (provider.Provider, error) {

genkit/pty_adapters.go

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
package genkit
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
)
7+
8+
// ansiEscape matches ANSI escape sequences for stripping from PTY output.
9+
var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b[()][A-Z0-9]|\x1b[^[]`)
10+
11+
// stripANSI removes ANSI escape codes from s.
12+
func stripANSI(s string) string {
13+
return ansiEscape.ReplaceAllString(s, "")
14+
}
15+
16+
// promptRegex matches a line starting with ❯ or > (prompt indicators).
17+
var promptRegex = regexp.MustCompile(`(?m)^[❯>]\s`)
18+
19+
// detectPromptDefault returns true when a standard prompt character appears.
20+
func detectPromptDefault(output string) bool {
21+
clean := stripANSI(output)
22+
return promptRegex.MatchString(clean)
23+
}
24+
25+
// detectResponseEndDefault returns true when the prompt reappears after content.
26+
// We require at least some non-whitespace content before the prompt.
27+
func detectResponseEndDefault(output string) bool {
28+
clean := stripANSI(output)
29+
// Find prompt positions
30+
locs := promptRegex.FindAllStringIndex(clean, -1)
31+
if len(locs) < 2 {
32+
return false
33+
}
34+
// Check there's non-whitespace content between first and second prompt.
35+
between := clean[locs[0][1]:locs[1][0]]
36+
return strings.TrimSpace(between) != ""
37+
}
38+
39+
// parseResponseDefault strips ANSI, trims whitespace, and removes spinner lines.
40+
func parseResponseDefault(raw string) string {
41+
clean := stripANSI(raw)
42+
var lines []string
43+
for _, line := range strings.Split(clean, "\n") {
44+
trimmed := strings.TrimSpace(line)
45+
// Skip empty lines, prompt lines, and spinner/status indicators.
46+
if trimmed == "" || promptRegex.MatchString(line) {
47+
continue
48+
}
49+
lines = append(lines, trimmed)
50+
}
51+
return strings.Join(lines, "\n")
52+
}
53+
54+
// ── Claude Code ──────────────────────────────────────────────────────────────
55+
56+
// ClaudeCodeAdapter drives the `claude` CLI.
57+
type ClaudeCodeAdapter struct{}
58+
59+
func (ClaudeCodeAdapter) Name() string { return "claude_code" }
60+
func (ClaudeCodeAdapter) Binary() string { return "claude" }
61+
62+
func (ClaudeCodeAdapter) NonInteractiveArgs(msg string) []string {
63+
return []string{"-p", msg, "--output-format", "text"}
64+
}
65+
66+
func (ClaudeCodeAdapter) HealthCheckArgs() []string {
67+
return []string{"-p", "say ok", "--output-format", "text"}
68+
}
69+
70+
func (ClaudeCodeAdapter) DetectPrompt(output string) bool {
71+
return detectPromptDefault(output)
72+
}
73+
74+
func (ClaudeCodeAdapter) DetectResponseEnd(output string) bool {
75+
return detectResponseEndDefault(output)
76+
}
77+
78+
func (ClaudeCodeAdapter) ParseResponse(raw string) string {
79+
return parseResponseDefault(raw)
80+
}
81+
82+
// ── Copilot CLI ───────────────────────────────────────────────────────────────
83+
84+
// CopilotCLIAdapter drives the `copilot` CLI.
85+
type CopilotCLIAdapter struct{}
86+
87+
func (CopilotCLIAdapter) Name() string { return "copilot_cli" }
88+
func (CopilotCLIAdapter) Binary() string { return "copilot" }
89+
90+
func (CopilotCLIAdapter) NonInteractiveArgs(msg string) []string {
91+
return []string{"-p", msg}
92+
}
93+
94+
func (CopilotCLIAdapter) HealthCheckArgs() []string {
95+
return []string{"-p", "say ok"}
96+
}
97+
98+
var copilotPromptRegex = regexp.MustCompile(`(?m)^>\s`)
99+
100+
func (CopilotCLIAdapter) DetectPrompt(output string) bool {
101+
clean := stripANSI(output)
102+
return copilotPromptRegex.MatchString(clean)
103+
}
104+
105+
func (CopilotCLIAdapter) DetectResponseEnd(output string) bool {
106+
clean := stripANSI(output)
107+
locs := copilotPromptRegex.FindAllStringIndex(clean, -1)
108+
if len(locs) < 2 {
109+
return false
110+
}
111+
between := clean[locs[0][1]:locs[1][0]]
112+
return strings.TrimSpace(between) != ""
113+
}
114+
115+
func (CopilotCLIAdapter) ParseResponse(raw string) string {
116+
clean := stripANSI(raw)
117+
var lines []string
118+
for _, line := range strings.Split(clean, "\n") {
119+
trimmed := strings.TrimSpace(line)
120+
if trimmed == "" || copilotPromptRegex.MatchString(line) {
121+
continue
122+
}
123+
lines = append(lines, trimmed)
124+
}
125+
return strings.Join(lines, "\n")
126+
}
127+
128+
// ── Codex CLI ─────────────────────────────────────────────────────────────────
129+
130+
// CodexCLIAdapter drives the `codex` CLI.
131+
type CodexCLIAdapter struct{}
132+
133+
func (CodexCLIAdapter) Name() string { return "codex_cli" }
134+
func (CodexCLIAdapter) Binary() string { return "codex" }
135+
136+
func (CodexCLIAdapter) NonInteractiveArgs(msg string) []string {
137+
return []string{"exec", msg}
138+
}
139+
140+
func (CodexCLIAdapter) HealthCheckArgs() []string {
141+
return []string{"exec", "say ok"}
142+
}
143+
144+
// codexPromptRegex matches Codex's composer input area indicator.
145+
var codexPromptRegex = regexp.MustCompile(`(?m)^[>❯]\s|composer|Type your`)
146+
147+
func (CodexCLIAdapter) DetectPrompt(output string) bool {
148+
clean := stripANSI(output)
149+
return codexPromptRegex.MatchString(clean)
150+
}
151+
152+
func (CodexCLIAdapter) DetectResponseEnd(output string) bool {
153+
return detectResponseEndDefault(output)
154+
}
155+
156+
func (CodexCLIAdapter) ParseResponse(raw string) string {
157+
return parseResponseDefault(raw)
158+
}
159+
160+
// ── Gemini CLI ────────────────────────────────────────────────────────────────
161+
162+
// GeminiCLIAdapter drives the `gemini` CLI.
163+
type GeminiCLIAdapter struct{}
164+
165+
func (GeminiCLIAdapter) Name() string { return "gemini_cli" }
166+
func (GeminiCLIAdapter) Binary() string { return "gemini" }
167+
168+
func (GeminiCLIAdapter) NonInteractiveArgs(msg string) []string {
169+
return []string{"-p", msg}
170+
}
171+
172+
func (GeminiCLIAdapter) HealthCheckArgs() []string {
173+
return []string{"-p", "say ok"}
174+
}
175+
176+
func (GeminiCLIAdapter) DetectPrompt(output string) bool {
177+
return detectPromptDefault(output)
178+
}
179+
180+
func (GeminiCLIAdapter) DetectResponseEnd(output string) bool {
181+
return detectResponseEndDefault(output)
182+
}
183+
184+
func (GeminiCLIAdapter) ParseResponse(raw string) string {
185+
return parseResponseDefault(raw)
186+
}
187+
188+
// ── Cursor CLI ────────────────────────────────────────────────────────────────
189+
190+
// CursorCLIAdapter drives the `agent` binary (Cursor's agent CLI).
191+
type CursorCLIAdapter struct{}
192+
193+
func (CursorCLIAdapter) Name() string { return "cursor_cli" }
194+
func (CursorCLIAdapter) Binary() string { return "agent" }
195+
196+
func (CursorCLIAdapter) NonInteractiveArgs(msg string) []string {
197+
return []string{"-p", msg}
198+
}
199+
200+
func (CursorCLIAdapter) HealthCheckArgs() []string {
201+
return []string{"-p", "say ok"}
202+
}
203+
204+
var cursorPromptRegex = regexp.MustCompile(`(?m)^>\s`)
205+
206+
func (CursorCLIAdapter) DetectPrompt(output string) bool {
207+
clean := stripANSI(output)
208+
return cursorPromptRegex.MatchString(clean)
209+
}
210+
211+
func (CursorCLIAdapter) DetectResponseEnd(output string) bool {
212+
clean := stripANSI(output)
213+
locs := cursorPromptRegex.FindAllStringIndex(clean, -1)
214+
if len(locs) < 2 {
215+
return false
216+
}
217+
between := clean[locs[0][1]:locs[1][0]]
218+
return strings.TrimSpace(between) != ""
219+
}
220+
221+
func (CursorCLIAdapter) ParseResponse(raw string) string {
222+
clean := stripANSI(raw)
223+
var lines []string
224+
for _, line := range strings.Split(clean, "\n") {
225+
trimmed := strings.TrimSpace(line)
226+
if trimmed == "" || cursorPromptRegex.MatchString(line) {
227+
continue
228+
}
229+
lines = append(lines, trimmed)
230+
}
231+
return strings.Join(lines, "\n")
232+
}

0 commit comments

Comments
 (0)