mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 19:20:07 +02:00
* fix(daemon): disable Claude AskUserQuestion in non-interactive mode (MUL-2244) GitHub #2588: when Claude Code calls its built-in AskUserQuestion tool inside the daemon's stream-json runtime, the question never reaches the user — there's no UI to render it — so the SDK returns an empty answer and the agent silently "infers" and continues. From the issue's perspective, execution looks stuck while the agent is actually charging ahead on its own guess. Two-part fix: - `buildClaudeArgs` now passes `--disallowedTools AskUserQuestion` so the tool is not exposed to the model at all. - The Claude-specific runtime brief tells the agent to use a `blocked` issue comment for genuine clarification, or to state an explicit assumption and proceed. Adds a regression test that pins both: AskUserQuestion is forbidden in CLAUDE.md and is NOT mentioned in the AGENTS.md emitted for non-Claude providers (the tool is Claude-specific). Co-authored-by: multica-agent <github@multica.ai> * refactor(daemon): drop CLAUDE.md AskUserQuestion guidance, rely on --disallowedTools The --disallowedTools flag already prevents Claude from invoking AskUserQuestion, so duplicating the rule in the runtime brief just bloats the prompt without changing behavior. Removes the section and its regression test; the argv-level test in pkg/agent already pins the flag. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
635 lines
19 KiB
Go
635 lines
19 KiB
Go
package agent
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// claudeBackend implements Backend by spawning the Claude Code CLI
|
|
// with --output-format stream-json.
|
|
type claudeBackend struct {
|
|
cfg Config
|
|
}
|
|
|
|
func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
|
|
execPath := b.cfg.ExecutablePath
|
|
if execPath == "" {
|
|
execPath = "claude"
|
|
}
|
|
if _, err := exec.LookPath(execPath); err != nil {
|
|
return nil, fmt.Errorf("claude executable not found at %q: %w", execPath, err)
|
|
}
|
|
|
|
timeout := opts.Timeout
|
|
if timeout == 0 {
|
|
timeout = 20 * time.Minute
|
|
}
|
|
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
|
|
args := buildClaudeArgs(opts, b.cfg.Logger)
|
|
|
|
// If the caller provided an MCP config, write it to a temp file and pass
|
|
// --mcp-config <path> so the agent uses a controlled set of MCP servers
|
|
// instead of inheriting from the outer Claude Code session.
|
|
var mcpConfigPath string
|
|
var mcpFileCleanup func() // non-nil while this function owns the temp file
|
|
if len(opts.McpConfig) > 0 {
|
|
path, err := writeMcpConfigToTemp(opts.McpConfig)
|
|
if err != nil {
|
|
cancel()
|
|
return nil, err
|
|
}
|
|
mcpConfigPath = path
|
|
mcpFileCleanup = func() { os.Remove(mcpConfigPath) }
|
|
args = append(args, "--mcp-config", mcpConfigPath)
|
|
}
|
|
// Clean up the temp file if we return before the goroutine takes ownership.
|
|
defer func() {
|
|
if mcpFileCleanup != nil {
|
|
mcpFileCleanup()
|
|
}
|
|
}()
|
|
|
|
cmd := exec.CommandContext(runCtx, execPath, args...)
|
|
hideAgentWindow(cmd)
|
|
b.cfg.Logger.Info("agent command", "exec", execPath, "args", args)
|
|
cmd.WaitDelay = 10 * time.Second
|
|
if opts.Cwd != "" {
|
|
cmd.Dir = opts.Cwd
|
|
}
|
|
cmd.Env = buildEnv(b.cfg.Env)
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("claude stdout pipe: %w", err)
|
|
}
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("claude stdin pipe: %w", err)
|
|
}
|
|
closeStdin := func() {
|
|
if stdin != nil {
|
|
_ = stdin.Close()
|
|
stdin = nil
|
|
}
|
|
}
|
|
// Capture stderr into both the daemon log (as before) and a bounded tail
|
|
// buffer so we can include the last few KB in Result.Error when claude
|
|
// exits unexpectedly. Without the tail, an exit-code-only failure looks
|
|
// like "claude exited with error: exit status 3" — which is useless for
|
|
// root-causing V8 aborts, Bun panics, or any other CLI-side crash.
|
|
stderrBuf := newStderrTail(newLogWriter(b.cfg.Logger, "[claude:stderr] "), agentStderrTailBytes)
|
|
cmd.Stderr = stderrBuf
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
closeStdin()
|
|
cancel()
|
|
return nil, fmt.Errorf("start claude: %w", err)
|
|
}
|
|
if err := writeClaudeInput(stdin, prompt); err != nil {
|
|
// claude almost certainly died during startup (broken pipe). The
|
|
// real reason is sitting in stderrBuf — surface it the same way the
|
|
// post-handshake error path does, otherwise the daemon log is the
|
|
// only place that knows whether it was a V8 abort, a missing native
|
|
// module, or anything else. cmd.Wait() flushes os/exec's stderr
|
|
// copy goroutine, so stderrBuf.Tail() is safe to read.
|
|
closeStdin()
|
|
cancel()
|
|
_ = cmd.Wait()
|
|
return nil, errors.New(withAgentStderr(fmt.Sprintf("write claude input: %v", err), "claude", stderrBuf.Tail()))
|
|
}
|
|
closeStdin()
|
|
|
|
b.cfg.Logger.Info("claude started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
|
|
|
|
// cmd.Start() succeeded — transfer temp file ownership to the goroutine.
|
|
mcpFileCleanup = nil
|
|
|
|
msgCh := make(chan Message, 256)
|
|
resCh := make(chan Result, 1)
|
|
|
|
go func() {
|
|
defer cancel()
|
|
defer close(msgCh)
|
|
defer close(resCh)
|
|
if mcpConfigPath != "" {
|
|
defer os.Remove(mcpConfigPath)
|
|
}
|
|
|
|
startTime := time.Now()
|
|
var output strings.Builder
|
|
var sessionID string
|
|
finalStatus := "completed"
|
|
var finalError string
|
|
usage := make(map[string]TokenUsage)
|
|
|
|
// Close stdout when the context is cancelled so scanner.Scan() unblocks.
|
|
go func() {
|
|
<-runCtx.Done()
|
|
_ = stdout.Close()
|
|
}()
|
|
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var msg claudeSDKMessage
|
|
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
|
continue
|
|
}
|
|
|
|
switch msg.Type {
|
|
case "assistant":
|
|
b.handleAssistant(msg, msgCh, &output, usage)
|
|
case "user":
|
|
b.handleUser(msg, msgCh)
|
|
case "system":
|
|
if msg.SessionID != "" {
|
|
sessionID = msg.SessionID
|
|
}
|
|
trySend(msgCh, Message{Type: MessageStatus, Status: "running", SessionID: sessionID})
|
|
case "result":
|
|
closeStdin()
|
|
sessionID = msg.SessionID
|
|
if msg.ResultText != "" {
|
|
output.Reset()
|
|
output.WriteString(msg.ResultText)
|
|
}
|
|
if msg.IsError {
|
|
finalStatus = "failed"
|
|
finalError = msg.ResultText
|
|
}
|
|
case "log":
|
|
if msg.Log != nil {
|
|
trySend(msgCh, Message{
|
|
Type: MessageLog,
|
|
Level: msg.Log.Level,
|
|
Content: msg.Log.Message,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wait for process exit
|
|
exitErr := cmd.Wait()
|
|
duration := time.Since(startTime)
|
|
|
|
if runCtx.Err() == context.DeadlineExceeded {
|
|
finalStatus = "timeout"
|
|
finalError = fmt.Sprintf("claude timed out after %s", timeout)
|
|
} else if runCtx.Err() == context.Canceled {
|
|
finalStatus = "aborted"
|
|
finalError = "execution cancelled"
|
|
} else if exitErr != nil && finalStatus == "completed" {
|
|
finalStatus = "failed"
|
|
finalError = fmt.Sprintf("claude exited with error: %v", exitErr)
|
|
}
|
|
|
|
// cmd.Wait() has returned — os/exec's stderr copy goroutine has
|
|
// observed every byte claude wrote to stderr before exiting, so
|
|
// stderrBuf.Tail() is safe to sample now. Attach the tail to any
|
|
// non-empty failure message; callers upstream surface this as the
|
|
// task's error field, which is the only place users see it.
|
|
if finalError != "" {
|
|
finalError = withAgentStderr(finalError, "claude", stderrBuf.Tail())
|
|
}
|
|
|
|
b.cfg.Logger.Info("claude finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
|
|
|
|
reportedSessionID := resolveSessionID(opts.ResumeSessionID, sessionID, finalStatus == "failed")
|
|
if reportedSessionID != sessionID {
|
|
b.cfg.Logger.Info("claude resume did not land; clearing fresh session id for daemon fallback",
|
|
"requested_resume", opts.ResumeSessionID,
|
|
"emitted_session", sessionID,
|
|
)
|
|
}
|
|
|
|
resCh <- Result{
|
|
Status: finalStatus,
|
|
Output: output.String(),
|
|
Error: finalError,
|
|
DurationMs: duration.Milliseconds(),
|
|
SessionID: reportedSessionID,
|
|
Usage: usage,
|
|
}
|
|
}()
|
|
|
|
return &Session{Messages: msgCh, Result: resCh}, nil
|
|
}
|
|
|
|
func (b *claudeBackend) handleAssistant(msg claudeSDKMessage, ch chan<- Message, output *strings.Builder, usage map[string]TokenUsage) {
|
|
var content claudeMessageContent
|
|
if err := json.Unmarshal(msg.Message, &content); err != nil {
|
|
return
|
|
}
|
|
|
|
// Accumulate token usage per model.
|
|
if content.Usage != nil && content.Model != "" {
|
|
u := usage[content.Model]
|
|
u.InputTokens += content.Usage.InputTokens
|
|
u.OutputTokens += content.Usage.OutputTokens
|
|
u.CacheReadTokens += content.Usage.CacheReadInputTokens
|
|
u.CacheWriteTokens += content.Usage.CacheCreationInputTokens
|
|
usage[content.Model] = u
|
|
}
|
|
|
|
for _, block := range content.Content {
|
|
switch block.Type {
|
|
case "text":
|
|
if block.Text != "" {
|
|
output.WriteString(block.Text)
|
|
trySend(ch, Message{Type: MessageText, Content: block.Text})
|
|
}
|
|
case "thinking":
|
|
if block.Text != "" {
|
|
trySend(ch, Message{Type: MessageThinking, Content: block.Text})
|
|
}
|
|
case "tool_use":
|
|
var input map[string]any
|
|
if block.Input != nil {
|
|
_ = json.Unmarshal(block.Input, &input)
|
|
}
|
|
trySend(ch, Message{
|
|
Type: MessageToolUse,
|
|
Tool: block.Name,
|
|
CallID: block.ID,
|
|
Input: input,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *claudeBackend) handleUser(msg claudeSDKMessage, ch chan<- Message) {
|
|
var content claudeMessageContent
|
|
if err := json.Unmarshal(msg.Message, &content); err != nil {
|
|
return
|
|
}
|
|
|
|
for _, block := range content.Content {
|
|
if block.Type == "tool_result" {
|
|
resultStr := ""
|
|
if block.Content != nil {
|
|
resultStr = string(block.Content)
|
|
}
|
|
trySend(ch, Message{
|
|
Type: MessageToolResult,
|
|
CallID: block.ToolUseID,
|
|
Output: resultStr,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *claudeBackend) handleControlRequest(msg claudeSDKMessage, stdin interface{ Write([]byte) (int, error) }) {
|
|
// Auto-approve all tool uses in autonomous/daemon mode.
|
|
var req claudeControlRequestPayload
|
|
if err := json.Unmarshal(msg.Request, &req); err != nil {
|
|
return
|
|
}
|
|
|
|
var inputMap map[string]any
|
|
if req.Input != nil {
|
|
_ = json.Unmarshal(req.Input, &inputMap)
|
|
}
|
|
if inputMap == nil {
|
|
inputMap = map[string]any{}
|
|
}
|
|
|
|
response := map[string]any{
|
|
"type": "control_response",
|
|
"response": map[string]any{
|
|
"subtype": "success",
|
|
"request_id": msg.RequestID,
|
|
"response": map[string]any{
|
|
"behavior": "allow",
|
|
"updatedInput": inputMap,
|
|
},
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(response)
|
|
if err != nil {
|
|
b.cfg.Logger.Warn("claude: failed to marshal control response", "error", err)
|
|
return
|
|
}
|
|
data = append(data, '\n')
|
|
if _, err := stdin.Write(data); err != nil {
|
|
b.cfg.Logger.Warn("claude: failed to write control response", "error", err)
|
|
}
|
|
}
|
|
|
|
// ── Claude SDK JSON types ──
|
|
|
|
type claudeSDKMessage struct {
|
|
Type string `json:"type"`
|
|
Message json.RawMessage `json:"message,omitempty"`
|
|
Subtype string `json:"subtype,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
|
|
// result fields
|
|
ResultText string `json:"result,omitempty"`
|
|
IsError bool `json:"is_error,omitempty"`
|
|
DurationMs float64 `json:"duration_ms,omitempty"`
|
|
NumTurns int `json:"num_turns,omitempty"`
|
|
|
|
// log fields
|
|
Log *claudeLogEntry `json:"log,omitempty"`
|
|
|
|
// control request fields
|
|
RequestID string `json:"request_id,omitempty"`
|
|
Request json.RawMessage `json:"request,omitempty"`
|
|
}
|
|
|
|
type claudeLogEntry struct {
|
|
Level string `json:"level"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type claudeMessageContent struct {
|
|
Role string `json:"role"`
|
|
Model string `json:"model"`
|
|
Content []claudeContentBlock `json:"content"`
|
|
Usage *claudeUsage `json:"usage,omitempty"`
|
|
}
|
|
|
|
type claudeUsage struct {
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
|
|
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
|
|
}
|
|
|
|
type claudeContentBlock struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Input json.RawMessage `json:"input,omitempty"`
|
|
ToolUseID string `json:"tool_use_id,omitempty"`
|
|
Content json.RawMessage `json:"content,omitempty"`
|
|
}
|
|
|
|
type claudeControlRequestPayload struct {
|
|
Subtype string `json:"subtype"`
|
|
ToolName string `json:"tool_name,omitempty"`
|
|
Input json.RawMessage `json:"input,omitempty"`
|
|
}
|
|
|
|
// ── Shared helpers ──
|
|
|
|
func trySend(ch chan<- Message, msg Message) {
|
|
select {
|
|
case ch <- msg:
|
|
default:
|
|
// Channel full — drop message. Final output is accumulated separately
|
|
// in Result.Output, so only streaming consumers are affected.
|
|
}
|
|
}
|
|
|
|
// claudeBlockedArgs are flags hardcoded by the daemon that must not be
|
|
// overridden by user-configured custom_args. Overriding these would break
|
|
// the daemon↔Claude communication protocol.
|
|
var claudeBlockedArgs = map[string]blockedArgMode{
|
|
"-p": blockedStandalone, // non-interactive mode
|
|
"--output-format": blockedWithValue, // stream-json protocol
|
|
"--input-format": blockedWithValue, // stream-json protocol
|
|
"--permission-mode": blockedWithValue, // bypassPermissions for autonomous operation
|
|
"--mcp-config": blockedWithValue, // set by daemon from agent.mcp_config
|
|
}
|
|
|
|
func buildClaudeArgs(opts ExecOptions, logger *slog.Logger) []string {
|
|
args := []string{
|
|
"-p",
|
|
"--output-format", "stream-json",
|
|
"--input-format", "stream-json",
|
|
"--verbose",
|
|
"--strict-mcp-config",
|
|
"--permission-mode", "bypassPermissions",
|
|
// AskUserQuestion is Claude Code's built-in interactive question tool.
|
|
// The daemon runs Claude in non-interactive stream-json mode and has
|
|
// no UI for the prompt to render in, so a call returns an empty
|
|
// answer and the agent ends up "inferring" silently — the user
|
|
// never sees the question (see GitHub #2588). User-facing
|
|
// clarification belongs in an issue comment instead.
|
|
"--disallowedTools", "AskUserQuestion",
|
|
}
|
|
if opts.Model != "" {
|
|
args = append(args, "--model", opts.Model)
|
|
}
|
|
if opts.MaxTurns > 0 {
|
|
args = append(args, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns))
|
|
}
|
|
if opts.SystemPrompt != "" {
|
|
args = append(args, "--append-system-prompt", opts.SystemPrompt)
|
|
}
|
|
if opts.ResumeSessionID != "" {
|
|
args = append(args, "--resume", opts.ResumeSessionID)
|
|
}
|
|
args = append(args, filterCustomArgs(opts.ExtraArgs, claudeBlockedArgs, logger)...)
|
|
args = append(args, filterCustomArgs(opts.CustomArgs, claudeBlockedArgs, logger)...)
|
|
return args
|
|
}
|
|
|
|
func writeClaudeInput(w io.Writer, prompt string) error {
|
|
data, err := buildClaudeInput(prompt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := w.Write(data); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildClaudeInput(prompt string) ([]byte, error) {
|
|
payload := map[string]any{
|
|
"type": "user",
|
|
"message": map[string]any{
|
|
"role": "user",
|
|
"content": []map[string]string{
|
|
{
|
|
"type": "text",
|
|
"text": prompt,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal claude input: %w", err)
|
|
}
|
|
return append(data, '\n'), nil
|
|
}
|
|
|
|
// resolveSessionID decides which session id to report on the Result. When the
|
|
// caller requested --resume but claude emitted a fresh, different session id
|
|
// AND the run failed, the resume did not land (claude prints
|
|
// "No conversation found with session ID: ..." to stderr, generates a fresh
|
|
// session, and exits). Returning "" in that case keeps the daemon's
|
|
// retry-with-fresh-session fallback able to trigger, instead of silently
|
|
// persisting a brand-new id as if resume had succeeded.
|
|
func resolveSessionID(requestedResume, emitted string, failed bool) string {
|
|
if failed && requestedResume != "" && emitted != "" && emitted != requestedResume {
|
|
return ""
|
|
}
|
|
return emitted
|
|
}
|
|
|
|
func buildEnv(extra map[string]string) []string {
|
|
return mergeEnv(os.Environ(), extra)
|
|
}
|
|
|
|
func mergeEnv(base []string, extra map[string]string) []string {
|
|
env := make([]string, 0, len(base)+len(extra))
|
|
for _, entry := range base {
|
|
key, _, _ := strings.Cut(entry, "=")
|
|
if isFilteredChildEnvKey(key) {
|
|
continue
|
|
}
|
|
env = append(env, entry)
|
|
}
|
|
for k, v := range extra {
|
|
env = append(env, k+"="+v)
|
|
}
|
|
return env
|
|
}
|
|
|
|
func isFilteredChildEnvKey(key string) bool {
|
|
return key == "CLAUDECODE" ||
|
|
strings.HasPrefix(key, "CLAUDECODE_") ||
|
|
strings.HasPrefix(key, "CLAUDE_CODE_")
|
|
}
|
|
|
|
// blockedArgMode specifies whether a blocked arg takes a value or is standalone.
|
|
type blockedArgMode int
|
|
|
|
const (
|
|
blockedWithValue blockedArgMode = iota // flag takes a value (next arg or =value)
|
|
blockedStandalone // flag is boolean, no value
|
|
)
|
|
|
|
// filterCustomArgs removes protocol-critical flags from user-configured custom
|
|
// args to prevent breaking daemon↔agent communication. Each backend defines its
|
|
// own blocked set (the flags it hardcodes). This is intentionally narrow — we
|
|
// only block args that would break the communication protocol, not every
|
|
// possible dangerous flag. Workspace members are trusted to configure agents
|
|
// sensibly, same as with custom_env.
|
|
func filterCustomArgs(args []string, blocked map[string]blockedArgMode, logger *slog.Logger) []string {
|
|
if len(args) == 0 {
|
|
return args
|
|
}
|
|
filtered := make([]string, 0, len(args))
|
|
skip := false
|
|
for _, arg := range args {
|
|
if skip {
|
|
skip = false
|
|
continue
|
|
}
|
|
// Check if this arg is a blocked flag or starts with "blockedFlag=".
|
|
flag := arg
|
|
hasInlineValue := false
|
|
if idx := strings.Index(arg, "="); idx > 0 {
|
|
flag = arg[:idx]
|
|
hasInlineValue = true
|
|
}
|
|
mode, isBlocked := blocked[flag]
|
|
if isBlocked {
|
|
logger.Warn("custom_args: blocked protocol-critical flag, skipping", "flag", flag)
|
|
if mode == blockedWithValue && !hasInlineValue {
|
|
// The next arg is the value for this flag — skip it too.
|
|
skip = true
|
|
}
|
|
continue
|
|
}
|
|
filtered = append(filtered, arg)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// writeMcpConfigToTemp writes raw MCP config JSON to a temporary file and returns
|
|
// its path. The caller is responsible for removing the file when done.
|
|
func writeMcpConfigToTemp(raw json.RawMessage) (string, error) {
|
|
f, err := os.CreateTemp("", "multica-mcp-*.json")
|
|
if err != nil {
|
|
return "", fmt.Errorf("create mcp config temp file: %w", err)
|
|
}
|
|
if _, err := f.Write(raw); err != nil {
|
|
f.Close()
|
|
os.Remove(f.Name())
|
|
return "", fmt.Errorf("write mcp config temp file: %w", err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
os.Remove(f.Name())
|
|
return "", fmt.Errorf("close mcp config temp file: %w", err)
|
|
}
|
|
return f.Name(), nil
|
|
}
|
|
|
|
func detectCLIVersion(ctx context.Context, execPath string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, execPath, "--version")
|
|
hideAgentWindow(cmd)
|
|
data, err := cmd.Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("detect version for %s: %w", execPath, err)
|
|
}
|
|
return extractVersionLine(string(data)), nil
|
|
}
|
|
|
|
// extractVersionLine pulls the version line out of a `<cli> --version` capture,
|
|
// discarding leading shell noise. On Windows, npm-installed CLI shims (notably
|
|
// gemini's) emit `chcp` output like `Active code page: 65001` before the real
|
|
// version reaches stdout, and the raw concatenation was being persisted as the
|
|
// runtime version (see #2516).
|
|
//
|
|
// The heuristic: return the first non-empty line that contains a semver-shaped
|
|
// token (matches versionRe). Full version strings like "2.1.5 (Claude Code)"
|
|
// or "codex-cli 0.118.0" survive unchanged because the whole matching line is
|
|
// returned. If no line carries a semver token, fall back to the trimmed raw
|
|
// output so unusual version formats aren't silently dropped to empty.
|
|
func extractVersionLine(raw string) string {
|
|
for _, line := range strings.Split(raw, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if versionRe.MatchString(line) {
|
|
return line
|
|
}
|
|
}
|
|
return strings.TrimSpace(raw)
|
|
}
|
|
|
|
// logWriter adapts a *slog.Logger to an io.Writer for capturing stderr.
|
|
type logWriter struct {
|
|
logger *slog.Logger
|
|
prefix string
|
|
}
|
|
|
|
func newLogWriter(logger *slog.Logger, prefix string) *logWriter {
|
|
return &logWriter{logger: logger, prefix: prefix}
|
|
}
|
|
|
|
func (w *logWriter) Write(p []byte) (int, error) {
|
|
text := strings.TrimSpace(string(p))
|
|
if text != "" {
|
|
w.logger.Debug(w.prefix + text)
|
|
}
|
|
return len(p), nil
|
|
}
|