diff --git a/apps/node/README.md b/apps/node/README.md index 5c1ef09..80c9875 100644 --- a/apps/node/README.md +++ b/apps/node/README.md @@ -17,7 +17,7 @@ internal/ mock/ — 에코 테스트 어댑터 ollama/ — Ollama API 어댑터 (TODO) vllm/ — vLLM OpenAI-compatible 어댑터 (TODO) - cli/ — CLI 프로세스 어댑터 (claude/gemini/codex/opencode) + cli/ — CLI 프로세스 어댑터 (claude/gemini/codex/opencode/cline) store/ — SQLite 실행 이력 ``` @@ -81,9 +81,11 @@ edge-node transport 연결은 **호스트당 1개**를 유지한다. 그 연결 | `mock` | 입력 에코, 스트리밍 테스트용 | 구현 완료 | | `ollama` | 로컬 Ollama 서버 연동 | TODO | | `vllm` | vLLM OpenAI-compatible API | TODO | -| `cli` | claude/gemini/codex/opencode CLI 실행 | 구현 완료 | +| `cli` | claude/gemini/codex/opencode/cline CLI 실행 | 구현 완료 | -`claude`, `gemini`, `codex`, `opencode`처럼 기본이 interactive TUI인 CLI는 이 파이프라인에서 non-interactive 모드로 설정해야 한다. 예: `claude -p --dangerously-skip-permissions`, `gemini --approval-mode yolo -p `, `codex exec --dangerously-bypass-approvals-and-sandbox`, `opencode run --model ollama-dgx/qwen3.6:35b-a3b-bf16 --format default --dangerously-skip-permissions `. +`claude`, `gemini`, `codex`, `opencode`, `cline`처럼 기본이 interactive TUI인 CLI는 이 파이프라인에서 non-interactive 모드로 설정해야 한다. 예: `claude -p --dangerously-skip-permissions`, `gemini --approval-mode yolo -p `, `codex exec --dangerously-bypass-approvals-and-sandbox`, `opencode run --model ollama-dgx/qwen3.6:35b-a3b-bf16 --format json --dangerously-skip-permissions `, `cline -y --json --config /config/.cline/profiles/ollama-dgx `. + +Cline은 mutable `--config` 디렉터리를 프로필처럼 나눈다. `/config/.cline/profiles/ollama-dgx`와 `/config/.cline/profiles/ollama-m1`는 `opencode`의 `ollama-dgx`/`ollama-m1` provider 설정을 Cline의 `ollama` provider 설정으로 변환한 값이다. ## Transport diff --git a/apps/node/internal/adapters/cli/cli.go b/apps/node/internal/adapters/cli/cli.go index 06be4d7..f32c839 100644 --- a/apps/node/internal/adapters/cli/cli.go +++ b/apps/node/internal/adapters/cli/cli.go @@ -1,5 +1,5 @@ // Package cli provides an Adapter that runs external CLI tools as inference -// backends. Profiles (claude, gemini, codex, opencode) are configured in +// backends. Profiles (claude, gemini, codex, opencode, cline) are configured in // configs/node.yaml and select the command + args to execute. Interactive CLIs // should be configured with their non-interactive/headless flags for use in the // request/response node pipeline. @@ -22,7 +22,7 @@ import ( const Name = "cli" // known agents — must match CLI tool names -var knownAgents = []string{"claude", "gemini", "codex", "opencode"} +var knownAgents = []string{"claude", "gemini", "codex", "opencode", "cline"} type cliOutput struct { line string diff --git a/apps/node/internal/adapters/cli/oneshot.go b/apps/node/internal/adapters/cli/oneshot.go index 82dcb8b..73f0c1c 100644 --- a/apps/node/internal/adapters/cli/oneshot.go +++ b/apps/node/internal/adapters/cli/oneshot.go @@ -73,6 +73,8 @@ func (c *CLI) executeCommand(ctx context.Context, spec runtime.ExecutionSpec, pr outputTokens, readErr = emitClaudeJSONLines(ctx, stdout, sink, spec.RunID, &outBuf) case "opencode-json": outputTokens, readErr = emitOpencodeJSON(ctx, stdout, sink, spec.RunID, &outBuf) + case "cline-json": + outputTokens, readErr = emitClineJSON(ctx, stdout, sink, spec.RunID, &outBuf) default: outputTokens, readErr = emitStdoutChunks(ctx, stdout, sink, spec.RunID, &outBuf) } @@ -398,6 +400,74 @@ func emitOpencodeJSON(ctx context.Context, stdout io.Reader, sink runtime.EventS return outputTokens, nil } +// emitClineJSON parses `cline --json` headless output. Cline emits lifecycle +// events as JSONL; assistant text is carried by say=="text" events. +func emitClineJSON(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) { + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 64*1024), 8*1024*1024) + outputTokens := 0 + + for scanner.Scan() { + line := scanner.Bytes() + outBuf.Write(line) + outBuf.WriteByte('\n') + + trimmed := strings.TrimSpace(string(line)) + if trimmed == "" || trimmed[0] != '{' { + continue + } + + var ev struct { + Type string `json:"type"` + Say string `json:"say"` + Ask string `json:"ask"` + Text string `json:"text"` + Message string `json:"message"` + Status string `json:"status"` + Error string `json:"error"` + } + if err := json.Unmarshal(line, &ev); err != nil { + continue + } + + switch { + case ev.Type == "say" && ev.Say == "text" && ev.Text != "": + outputTokens += len(strings.Fields(ev.Text)) + _ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeDelta, Delta: ev.Text, Timestamp: time.Now()}) + case ev.Type == "say" && ev.Say == "error": + msg := ev.Text + if msg == "" { + msg = ev.Message + } + if msg != "" { + _ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeError, Error: msg, Timestamp: time.Now()}) + } + case ev.Type == "completion" && ev.Status == "error": + msg := ev.Error + if msg == "" { + msg = ev.Message + } + if msg == "" { + msg = "cline task failed" + } + _ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeError, Error: msg, Timestamp: time.Now()}) + case ev.Type == "ask" && ev.Ask == "api_req_failed": + msg := ev.Text + if msg == "" { + msg = ev.Message + } + if msg != "" { + _ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeError, Error: msg, Timestamp: time.Now()}) + } + } + } + + if err := scanner.Err(); err != nil { + return outputTokens, err + } + return outputTokens, nil +} + func extractPrompt(input map[string]any) string { if input == nil { return "" diff --git a/apps/node/internal/adapters/cli/oneshot/cli_test.go b/apps/node/internal/adapters/cli/oneshot/cli_test.go index efa49b3..9d934da 100644 --- a/apps/node/internal/adapters/cli/oneshot/cli_test.go +++ b/apps/node/internal/adapters/cli/oneshot/cli_test.go @@ -317,6 +317,81 @@ EOF` } } +func TestCLIExecuteOneShotClineJSONParsesTextEvents(t *testing.T) { + testutil.RequireUnixShell(t) + + script := `cat <<'EOF' +{"type":"task_started","taskId":"1777860858386"} +{"ts":1777860858391,"type":"say","say":"task","text":"Hi","modelInfo":{"providerId":"ollama","modelId":"qwen3.6:35b-a3b-bf16","mode":"act"}} +{"ts":1777860858965,"type":"say","say":"api_req_started","text":"{\"request\":\"Hi\"}"} +{"ts":1777860864009,"type":"say","say":"text","text":"Hello from Cline.","partial":false} +{"type":"completion","status":"success","timestamp":1777860865000} +EOF` + cfg := config.CLIConf{ + Enabled: true, + Profiles: map[string]config.CLIProfileConf{ + "cline-dgx": { + Command: "sh", + Args: []string{"-c", script, "sh"}, + OutputFormat: "cline-json", + }, + }, + } + c := clipkg.New(cfg, zap.NewNop()) + sink := &testutil.FakeSink{} + + if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ + RunID: "run-cline", + Model: "cline-dgx", + Input: map[string]any{"prompt": "hi"}, + }, sink); err != nil { + t.Fatalf("execute: %v", err) + } + combined := testutil.CollectDeltas(sink.Events()) + if combined != "Hello from Cline." { + t.Fatalf("expected cline-json deltas to be %q, got %q", "Hello from Cline.", combined) + } +} + +func TestCLIExecuteOneShotClineJSONParsesErrorEvents(t *testing.T) { + testutil.RequireUnixShell(t) + + script := `cat <<'EOF' +{"type":"task_started","taskId":"1777860858386"} +{"ts":1777860858965,"type":"ask","ask":"api_req_failed","text":"model unavailable"} +EOF` + cfg := config.CLIConf{ + Enabled: true, + Profiles: map[string]config.CLIProfileConf{ + "cline-dgx": { + Command: "sh", + Args: []string{"-c", script, "sh"}, + OutputFormat: "cline-json", + }, + }, + } + c := clipkg.New(cfg, zap.NewNop()) + sink := &testutil.FakeSink{} + + if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ + RunID: "run-cline-err", + Model: "cline-dgx", + Input: map[string]any{"prompt": "hi"}, + }, sink); err != nil { + t.Fatalf("execute: %v", err) + } + var got string + for _, e := range sink.Events() { + if e.Type == noderuntime.EventTypeError { + got = e.Error + break + } + } + if got != "model unavailable" { + t.Fatalf("expected cline error message, got %q", got) + } +} + func TestCLIExecuteOneShotDrainsStderrConcurrently(t *testing.T) { testutil.RequireUnixShell(t) diff --git a/apps/node/internal/adapters/factory_internal_test.go b/apps/node/internal/adapters/factory_internal_test.go index 007c656..8c2ac96 100644 --- a/apps/node/internal/adapters/factory_internal_test.go +++ b/apps/node/internal/adapters/factory_internal_test.go @@ -112,6 +112,40 @@ func TestCLIConfFromStruct_GeminiHeadlessYoloProfile(t *testing.T) { } } +func TestCLIConfFromStruct_ClineConfigProfile(t *testing.T) { + st, err := structpb.NewStruct(map[string]any{ + "profiles": map[string]any{ + "cline-dgx": map[string]any{ + "command": "/config/.npm-global/bin/cline", + "args": []any{"-y", "--json", "--config", "/config/.cline/profiles/ollama-dgx"}, + "env": []any{}, + "persistent": false, + "terminal": false, + "output_format": "cline-json", + }, + }, + }) + if err != nil { + t.Fatalf("structpb: %v", err) + } + + cfg := cliConfFromStruct(st) + + prof, ok := cfg.Profiles["cline-dgx"] + if !ok { + t.Fatal("expected cline-dgx profile") + } + if prof.Command != "/config/.npm-global/bin/cline" { + t.Fatalf("command: got %q", prof.Command) + } + if len(prof.Args) != 4 || prof.Args[0] != "-y" || prof.Args[1] != "--json" || prof.Args[2] != "--config" || prof.Args[3] != "/config/.cline/profiles/ollama-dgx" { + t.Fatalf("args: got %#v", prof.Args) + } + if prof.OutputFormat != "cline-json" { + t.Fatalf("output_format: got %q", prof.OutputFormat) + } +} + func TestCLIConfFromStruct_NilSettings(t *testing.T) { cfg := cliConfFromStruct(nil) if cfg.Enabled { diff --git a/configs/edge.yaml b/configs/edge.yaml index 79825be..44e13fe 100644 --- a/configs/edge.yaml +++ b/configs/edge.yaml @@ -86,6 +86,28 @@ nodes: persistent: false terminal: false output_format: "opencode-json" + cline-dgx: + command: "/config/.npm-global/bin/cline" + args: + - "-y" + - "--json" + - "--config" + - "/config/.cline/profiles/ollama-dgx" + env: [] + persistent: false + terminal: false + output_format: "cline-json" + cline-m1: + command: "/config/.npm-global/bin/cline" + args: + - "-y" + - "--json" + - "--config" + - "/config/.cline/profiles/ollama-m1" + env: [] + persistent: false + terminal: false + output_format: "cline-json" runtime: concurrency: 4 workspace_root: "/tmp/iop/workspace"