// Package claudebox provides HTTP server and handlers for the claudebox sidecar. // This package enables HTTP-based execution of Claude Code, git, and SDLC operations // instead of kubectl exec. package claudebox import ( "encoding/json" "log/slog" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/logging" "github.com/orchard9/rdev/pkg/api" ) // writeRawJSON writes a raw JSON response without the rdev-api wrapper. // The claudebox sidecar uses direct JSON responses, not the {data, meta} format. func writeRawJSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(data) } // Server handles HTTP requests for claudebox operations. type Server struct { executor *Executor gitOps *GitOperations sdlcRunner *SDLCRunner logger *slog.Logger } // ServerConfig holds configuration for the claudebox server. type ServerConfig struct { Executor *Executor GitOps *GitOperations SDLCRunner *SDLCRunner Logger *slog.Logger } // NewServer creates a new claudebox HTTP server. func NewServer(cfg ServerConfig) *Server { return &Server{ executor: cfg.Executor, gitOps: cfg.GitOps, sdlcRunner: cfg.SDLCRunner, logger: cfg.Logger, } } // Mount registers server routes on the router. func (s *Server) Mount(r chi.Router) { r.Get("/health", s.handleHealth) r.Post("/execute", s.handleExecute) r.Post("/execute/stream", s.handleExecuteStream) r.Post("/git/clone", s.handleGitClone) r.Post("/git/commit-and-push", s.handleGitCommitAndPush) r.Post("/git/reset-to-main", s.handleGitResetToMain) r.Get("/git/status", s.handleGitStatus) r.Post("/sdlc", s.handleSDLC) } // HealthResponse is the health check response. type HealthResponse struct { Status string `json:"status"` Timestamp string `json:"timestamp"` WorkDir string `json:"work_dir"` } // handleHealth returns server health status. func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { resp := HealthResponse{ Status: "healthy", Timestamp: time.Now().UTC().Format(time.RFC3339), WorkDir: s.executor.workDir, } writeRawJSON(w, http.StatusOK, resp) } // 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"` ResumeSessionID string `json:"resume_session_id,omitempty"` // passed as --resume to claude } // 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"` } // handleExecute runs Claude Code and returns the complete result. func (s *Server) handleExecute(w http.ResponseWriter, r *http.Request) { ctx := r.Context() log := logging.FromContext(ctx) var req ExecuteRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } if req.Prompt == "" { api.WriteBadRequest(w, r, "prompt is required") return } log.Info("executing Claude Code", "prompt_len", len(req.Prompt)) result := s.executor.Execute(ctx, &req) resp := ExecuteResponse{ Success: result.Success, Output: result.Output, ExitCode: result.ExitCode, DurationMs: result.DurationMs, SessionID: result.SessionID, FinalOutput: result.FinalOutput, } if result.Error != nil { resp.Error = result.Error.Error() } writeRawJSON(w, http.StatusOK, resp) } // StreamEvent is an SSE event for 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"` } // handleExecuteStream runs Claude Code and streams events via SSE. func (s *Server) handleExecuteStream(w http.ResponseWriter, r *http.Request) { ctx := r.Context() log := logging.FromContext(ctx) var req ExecuteRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } if req.Prompt == "" { api.WriteBadRequest(w, r, "prompt is required") return } // Set up SSE headers w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") flusher, ok := w.(http.Flusher) if !ok { api.WriteInternalError(w, r, "streaming not supported") return } log.Info("starting streaming execution", "prompt_len", len(req.Prompt)) // Stream events via callback eventCh := make(chan StreamEvent, 100) go func() { defer close(eventCh) s.executor.ExecuteStream(ctx, &req, func(evt StreamEvent) { select { case eventCh <- evt: case <-ctx.Done(): } }) }() // Write events to client for evt := range eventCh { data, err := json.Marshal(evt) if err != nil { log.Warn("failed to marshal event", logging.FieldError, err) continue } _, writeErr := w.Write([]byte("data: " + string(data) + "\n\n")) if writeErr != nil { log.Debug("client disconnected during stream") return } flusher.Flush() } } // GitCloneRequest is the request to clone a repository. type GitCloneRequest struct { CloneURL string `json:"clone_url"` WorkDir string `json:"work_dir,omitempty"` // defaults to /workspace } // GitCloneResponse is the response from cloning. type GitCloneResponse struct { Success bool `json:"success"` Cloned bool `json:"cloned"` // true if cloned, false if already existed Error string `json:"error,omitempty"` } // handleGitClone clones or updates a git repository. func (s *Server) handleGitClone(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var req GitCloneRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } if req.CloneURL == "" { api.WriteBadRequest(w, r, "clone_url is required") return } workDir := req.WorkDir if workDir == "" { workDir = s.gitOps.workDir } result := s.gitOps.CloneRepo(ctx, workDir, req.CloneURL) resp := GitCloneResponse{ Success: result.Error == nil, Cloned: result.Cloned, } if result.Error != nil { resp.Error = result.Error.Error() } writeRawJSON(w, http.StatusOK, resp) } // 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"` // defaults to /workspace } // 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"` } // handleGitCommitAndPush commits and optionally pushes changes. func (s *Server) handleGitCommitAndPush(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var req GitCommitAndPushRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } if req.Message == "" { api.WriteBadRequest(w, r, "message is required") return } workDir := req.WorkDir if workDir == "" { workDir = s.gitOps.workDir } result := s.gitOps.CommitAndPush(ctx, workDir, req.Message, req.Push) resp := GitCommitAndPushResponse{ Success: result.Error == nil, HasChanges: result.HasChanges, CommitSHA: result.CommitSHA, FilesChanged: result.FilesChanged, Pushed: result.Pushed, } if result.Error != nil { resp.Error = result.Error.Error() } writeRawJSON(w, http.StatusOK, resp) } // GitResetToMainRequest is the request to reset the workspace to main. type GitResetToMainRequest struct { WorkDir string `json:"work_dir,omitempty"` // defaults to /workspace } // GitResetToMainResponse is the response from resetting to main. type GitResetToMainResponse struct { Success bool `json:"success"` Error string `json:"error,omitempty"` } // handleGitResetToMain resets the workspace to the main branch. func (s *Server) handleGitResetToMain(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var req GitResetToMainRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } workDir := req.WorkDir if workDir == "" { workDir = s.gitOps.workDir } err := s.gitOps.ResetToMain(ctx, workDir) resp := GitResetToMainResponse{ Success: err == nil, } if err != nil { resp.Error = err.Error() } writeRawJSON(w, http.StatusOK, resp) } // 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"` } // handleGitStatus returns the git status of the workspace. func (s *Server) handleGitStatus(w http.ResponseWriter, r *http.Request) { ctx := r.Context() workDir := r.URL.Query().Get("work_dir") if workDir == "" { workDir = s.gitOps.workDir } status, err := s.gitOps.Status(ctx, workDir) if err != nil { writeRawJSON(w, http.StatusOK, GitStatusResponse{ IsRepo: false, Error: err.Error(), }) return } writeRawJSON(w, http.StatusOK, status) } // 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"` // defaults to /workspace } // 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"` // Parsed JSON from sdlc --json output Error string `json:"error,omitempty"` } // handleSDLC runs an SDLC CLI command. func (s *Server) handleSDLC(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var req SDLCRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } if req.Command == "" { api.WriteBadRequest(w, r, "command is required") return } workDir := req.WorkDir if workDir == "" { workDir = s.sdlcRunner.workDir } result := s.sdlcRunner.Run(ctx, workDir, req.Command, req.Args) resp := SDLCResponse{ Success: result.Success, Output: result.Output, Data: result.Data, } if result.Error != nil { resp.Error = result.Error.Error() } writeRawJSON(w, http.StatusOK, resp) }