// Package claudebox provides an HTTP client for the claudebox sidecar. // This client is used by standalone workers to communicate with the local // claudebox sidecar instead of using kubectl exec. package claudebox import ( "bufio" "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) // Client is an HTTP client for the claudebox sidecar. type Client struct { baseURL string httpClient *http.Client } // ClientConfig holds configuration for the claudebox client. type ClientConfig struct { // BaseURL is the base URL of the claudebox sidecar (e.g., "http://localhost:8080"). BaseURL string // Timeout is the default request timeout. Timeout time.Duration } // NewClient creates a new claudebox client. func NewClient(cfg ClientConfig) *Client { if cfg.Timeout == 0 { cfg.Timeout = 10 * time.Minute } return &Client{ baseURL: strings.TrimSuffix(cfg.BaseURL, "/"), httpClient: &http.Client{ Timeout: cfg.Timeout, }, } } // HealthResponse is the health check response. type HealthResponse struct { Status string `json:"status"` Timestamp string `json:"timestamp"` WorkDir string `json:"work_dir"` } // Health checks if the claudebox sidecar is healthy. func (c *Client) Health(ctx context.Context) (*HealthResponse, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("health check: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("health check returned status %d", resp.StatusCode) } var result HealthResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("decode response: %w", err) } return &result, nil } // ExecuteRequest is the request to execute Claude Code. type ExecuteRequest struct { Prompt string `json:"prompt"` AllowedTools []string `json:"allowed_tools,omitempty"` WorkingDir string `json:"working_dir,omitempty"` Timeout int `json:"timeout_seconds,omitempty"` // seconds Metadata map[string]string `json:"metadata,omitempty"` } // ExecuteResponse is the response from executing Claude Code. type ExecuteResponse struct { Success bool `json:"success"` Output string `json:"output"` ExitCode int `json:"exit_code"` DurationMs int64 `json:"duration_ms"` Error string `json:"error,omitempty"` SessionID string `json:"session_id,omitempty"` FinalOutput string `json:"final_output,omitempty"` Artifacts map[string]string `json:"artifacts,omitempty"` } // Execute runs Claude Code and returns the complete result. func (c *Client) Execute(ctx context.Context, req *ExecuteRequest) (*ExecuteResponse, error) { body, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/execute", bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("execute: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { bodyBytes, readErr := io.ReadAll(resp.Body) if readErr != nil { return nil, fmt.Errorf("execute returned status %d (failed to read body: %w)", resp.StatusCode, readErr) } return nil, fmt.Errorf("execute returned status %d: %s", resp.StatusCode, string(bodyBytes)) } var result ExecuteResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("decode response: %w", err) } return &result, nil } // StreamEvent is an SSE event from streaming execution. type StreamEvent struct { Type string `json:"type"` Content string `json:"content,omitempty"` Stream string `json:"stream,omitempty"` ToolName string `json:"tool_name,omitempty"` Data map[string]any `json:"data,omitempty"` Timestamp string `json:"timestamp"` } // StreamEventHandler is called for each event during streaming execution. type StreamEventHandler func(StreamEvent) // ExecuteStream runs Claude Code and streams events to the handler. func (c *Client) ExecuteStream(ctx context.Context, req *ExecuteRequest, handler StreamEventHandler) error { body, err := json.Marshal(req) if err != nil { return fmt.Errorf("marshal request: %w", err) } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/execute/stream", bytes.NewReader(body)) if err != nil { return fmt.Errorf("create request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "text/event-stream") resp, err := c.httpClient.Do(httpReq) if err != nil { return fmt.Errorf("execute stream: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { bodyBytes, readErr := io.ReadAll(resp.Body) if readErr != nil { return fmt.Errorf("execute stream returned status %d (failed to read body: %w)", resp.StatusCode, readErr) } return fmt.Errorf("execute stream returned status %d: %s", resp.StatusCode, string(bodyBytes)) } // Parse SSE events scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { line := scanner.Text() if !strings.HasPrefix(line, "data: ") { continue } data := strings.TrimPrefix(line, "data: ") if data == "" { continue } var event StreamEvent if err := json.Unmarshal([]byte(data), &event); err != nil { continue // Skip malformed events } handler(event) } if err := scanner.Err(); err != nil { return fmt.Errorf("read stream: %w", err) } return nil } // GitCloneRequest is the request to clone a repository. type GitCloneRequest struct { CloneURL string `json:"clone_url"` WorkDir string `json:"work_dir,omitempty"` } // GitCloneResponse is the response from cloning. type GitCloneResponse struct { Success bool `json:"success"` Cloned bool `json:"cloned"` Error string `json:"error,omitempty"` } // GitClone clones or updates a git repository. func (c *Client) GitClone(ctx context.Context, cloneURL, workDir string) (*GitCloneResponse, error) { req := GitCloneRequest{ CloneURL: cloneURL, WorkDir: workDir, } body, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/git/clone", bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("git clone: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { bodyBytes, readErr := io.ReadAll(resp.Body) if readErr != nil { return nil, fmt.Errorf("git clone returned status %d (failed to read body: %w)", resp.StatusCode, readErr) } return nil, fmt.Errorf("git clone returned status %d: %s", resp.StatusCode, string(bodyBytes)) } var result GitCloneResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("decode response: %w", err) } return &result, nil } // GitCommitAndPushRequest is the request to commit and push changes. type GitCommitAndPushRequest struct { Message string `json:"message"` Push bool `json:"push"` WorkDir string `json:"work_dir,omitempty"` } // GitCommitAndPushResponse is the response from commit and push. type GitCommitAndPushResponse struct { Success bool `json:"success"` HasChanges bool `json:"has_changes"` CommitSHA string `json:"commit_sha,omitempty"` FilesChanged []string `json:"files_changed,omitempty"` Pushed bool `json:"pushed"` Error string `json:"error,omitempty"` } // GitCommitAndPush commits and optionally pushes changes. func (c *Client) GitCommitAndPush(ctx context.Context, message string, push bool, workDir string) (*GitCommitAndPushResponse, error) { req := GitCommitAndPushRequest{ Message: message, Push: push, WorkDir: workDir, } body, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/git/commit-and-push", bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("git commit: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { bodyBytes, readErr := io.ReadAll(resp.Body) if readErr != nil { return nil, fmt.Errorf("git commit returned status %d (failed to read body: %w)", resp.StatusCode, readErr) } return nil, fmt.Errorf("git commit returned status %d: %s", resp.StatusCode, string(bodyBytes)) } var result GitCommitAndPushResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("decode response: %w", err) } return &result, nil } // GitStatusResponse is the response from git status. type GitStatusResponse struct { IsRepo bool `json:"is_repo"` HasChanges bool `json:"has_changes"` ChangedFiles []string `json:"changed_files,omitempty"` Branch string `json:"branch,omitempty"` Error string `json:"error,omitempty"` } // GitStatus returns the git status of the workspace. func (c *Client) GitStatus(ctx context.Context, workDir string) (*GitStatusResponse, error) { reqURL := c.baseURL + "/git/status" if workDir != "" { reqURL += "?work_dir=" + url.QueryEscape(workDir) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("git status: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { bodyBytes, readErr := io.ReadAll(resp.Body) if readErr != nil { return nil, fmt.Errorf("git status returned status %d (failed to read body: %w)", resp.StatusCode, readErr) } return nil, fmt.Errorf("git status returned status %d: %s", resp.StatusCode, string(bodyBytes)) } var result GitStatusResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("decode response: %w", err) } return &result, nil } // SDLCRequest is the request to run an SDLC command. type SDLCRequest struct { Command string `json:"command"` Args []string `json:"args,omitempty"` WorkDir string `json:"work_dir,omitempty"` } // SDLCResponse is the response from running an SDLC command. type SDLCResponse struct { Success bool `json:"success"` Output string `json:"output"` Data json.RawMessage `json:"data,omitempty"` Error string `json:"error,omitempty"` } // RunSDLC executes an SDLC CLI command. func (c *Client) RunSDLC(ctx context.Context, command string, args []string, workDir string) (*SDLCResponse, error) { req := SDLCRequest{ Command: command, Args: args, WorkDir: workDir, } body, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sdlc", bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("sdlc: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { bodyBytes, readErr := io.ReadAll(resp.Body) if readErr != nil { return nil, fmt.Errorf("sdlc returned status %d (failed to read body: %w)", resp.StatusCode, readErr) } return nil, fmt.Errorf("sdlc returned status %d: %s", resp.StatusCode, string(bodyBytes)) } var result SDLCResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("decode response: %w", err) } return &result, nil }