// Package handlers provides HTTP handlers for the rdev API. package handlers import ( "encoding/base64" "encoding/json" "fmt" "net/http" "regexp" "strings" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/executor" "github.com/orchard9/rdev/internal/projects" "github.com/orchard9/rdev/pkg/api" ) // Package-level compiled regex for name validation (performance optimization). var validNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) // maxContentSize limits the size of content that can be written (1MB). const maxContentSize = 1 << 20 // ClaudeConfigHandler handles Claude config management endpoints. // Commands, skills, and agents live in /workspace/.claude/ (per-project, in git). // Credentials live in /root/.claude/ (shared PVC). type ClaudeConfigHandler struct { registry *projects.Registry executor *executor.Executor } // NewClaudeConfigHandler creates a new claude config handler. func NewClaudeConfigHandler(registry *projects.Registry, exec *executor.Executor) *ClaudeConfigHandler { return &ClaudeConfigHandler{ registry: registry, executor: exec, } } // Mount registers the claude-config routes. func (h *ClaudeConfigHandler) Mount(r api.Router) { r.Route("/projects/{id}/claude-config", func(r chi.Router) { // Overview r.Get("/", h.Overview) // Commands r.Get("/commands", h.ListCommands) r.Post("/commands", h.CreateCommand) r.Get("/commands/{name}", h.GetCommand) r.Put("/commands/{name}", h.UpdateCommand) r.Delete("/commands/{name}", h.DeleteCommand) // Skills r.Get("/skills", h.ListSkills) r.Post("/skills", h.CreateSkill) r.Get("/skills/{name}", h.GetSkill) r.Put("/skills/{name}", h.UpdateSkill) r.Delete("/skills/{name}", h.DeleteSkill) // Agents r.Get("/agents", h.ListAgents) r.Post("/agents", h.CreateAgent) r.Get("/agents/{name}", h.GetAgent) r.Put("/agents/{name}", h.UpdateAgent) r.Delete("/agents/{name}", h.DeleteAgent) }) } // ConfigOverview is the response for GET /projects/{id}/claude-config type ConfigOverview struct { Project string `json:"project"` Path string `json:"path"` Commands []string `json:"commands"` Skills []string `json:"skills"` Agents []string `json:"agents"` } // Overview returns an overview of the project's Claude config. // GET /projects/{id}/claude-config func (h *ClaudeConfigHandler) Overview(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") project, ok := h.registry.Get(id) if !ok { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } overview := ConfigOverview{ Project: id, Path: "/workspace/.claude", Commands: h.listItems(project.PodName, "commands"), Skills: h.listItems(project.PodName, "skills"), Agents: h.listItems(project.PodName, "agents"), } api.WriteSuccess(w, r, overview) } // ConfigItem represents a command, skill, or agent. type ConfigItem struct { Name string `json:"name"` Type string `json:"type"` Content string `json:"content"` } // ConfigItemRequest is the request body for creating/updating items. type ConfigItemRequest struct { Name string `json:"name,omitempty"` // Optional for POST (can be in URL) Content string `json:"content"` } // --- Commands --- // ListCommands returns all commands for a project. // GET /projects/{id}/claude-config/commands func (h *ClaudeConfigHandler) ListCommands(w http.ResponseWriter, r *http.Request) { h.listType(w, r, "commands") } // CreateCommand creates a new command. // POST /projects/{id}/claude-config/commands func (h *ClaudeConfigHandler) CreateCommand(w http.ResponseWriter, r *http.Request) { h.createItem(w, r, "commands") } // GetCommand returns a specific command. // GET /projects/{id}/claude-config/commands/{name} func (h *ClaudeConfigHandler) GetCommand(w http.ResponseWriter, r *http.Request) { h.getItem(w, r, "commands") } // UpdateCommand updates a command. // PUT /projects/{id}/claude-config/commands/{name} func (h *ClaudeConfigHandler) UpdateCommand(w http.ResponseWriter, r *http.Request) { h.updateItem(w, r, "commands") } // DeleteCommand deletes a command. // DELETE /projects/{id}/claude-config/commands/{name} func (h *ClaudeConfigHandler) DeleteCommand(w http.ResponseWriter, r *http.Request) { h.deleteItem(w, r, "commands") } // --- Skills --- // ListSkills returns all skills for a project. // GET /projects/{id}/claude-config/skills func (h *ClaudeConfigHandler) ListSkills(w http.ResponseWriter, r *http.Request) { h.listType(w, r, "skills") } // CreateSkill creates a new skill. // POST /projects/{id}/claude-config/skills func (h *ClaudeConfigHandler) CreateSkill(w http.ResponseWriter, r *http.Request) { h.createItem(w, r, "skills") } // GetSkill returns a specific skill. // GET /projects/{id}/claude-config/skills/{name} func (h *ClaudeConfigHandler) GetSkill(w http.ResponseWriter, r *http.Request) { h.getItem(w, r, "skills") } // UpdateSkill updates a skill. // PUT /projects/{id}/claude-config/skills/{name} func (h *ClaudeConfigHandler) UpdateSkill(w http.ResponseWriter, r *http.Request) { h.updateItem(w, r, "skills") } // DeleteSkill deletes a skill. // DELETE /projects/{id}/claude-config/skills/{name} func (h *ClaudeConfigHandler) DeleteSkill(w http.ResponseWriter, r *http.Request) { h.deleteItem(w, r, "skills") } // --- Agents --- // ListAgents returns all agents for a project. // GET /projects/{id}/claude-config/agents func (h *ClaudeConfigHandler) ListAgents(w http.ResponseWriter, r *http.Request) { h.listType(w, r, "agents") } // CreateAgent creates a new agent. // POST /projects/{id}/claude-config/agents func (h *ClaudeConfigHandler) CreateAgent(w http.ResponseWriter, r *http.Request) { h.createItem(w, r, "agents") } // GetAgent returns a specific agent. // GET /projects/{id}/claude-config/agents/{name} func (h *ClaudeConfigHandler) GetAgent(w http.ResponseWriter, r *http.Request) { h.getItem(w, r, "agents") } // UpdateAgent updates an agent. // PUT /projects/{id}/claude-config/agents/{name} func (h *ClaudeConfigHandler) UpdateAgent(w http.ResponseWriter, r *http.Request) { h.updateItem(w, r, "agents") } // DeleteAgent deletes an agent. // DELETE /projects/{id}/claude-config/agents/{name} func (h *ClaudeConfigHandler) DeleteAgent(w http.ResponseWriter, r *http.Request) { h.deleteItem(w, r, "agents") } // --- Helper methods --- // listItems returns the names of items in a directory. func (h *ClaudeConfigHandler) listItems(pod, itemType string) []string { cmd := fmt.Sprintf("ls -1 /workspace/.claude/%s 2>/dev/null | sed 's/\\.md$//'", itemType) output, err := h.executor.ExecSimple(pod, cmd) if err != nil { return []string{} } items := []string{} for _, line := range strings.Split(strings.TrimSpace(output), "\n") { if line != "" { items = append(items, line) } } return items } // listType handles GET /projects/{id}/claude-config/{type} func (h *ClaudeConfigHandler) listType(w http.ResponseWriter, r *http.Request, itemType string) { id := chi.URLParam(r, "id") project, ok := h.registry.Get(id) if !ok { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } items := h.listItems(project.PodName, itemType) api.WriteSuccess(w, r, items) } // createItem handles POST /projects/{id}/claude-config/{type} func (h *ClaudeConfigHandler) createItem(w http.ResponseWriter, r *http.Request, itemType string) { id := chi.URLParam(r, "id") project, ok := h.registry.Get(id) if !ok { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } // Limit request body size to prevent DoS r.Body = http.MaxBytesReader(w, r.Body, maxContentSize) var req ConfigItemRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { api.WriteBadRequest(w, r, "invalid request body or content too large") return } if req.Name == "" { api.WriteBadRequest(w, r, "name is required") return } if req.Content == "" { api.WriteBadRequest(w, r, "content is required") return } // Validate name (alphanumeric, dashes, underscores only) if !isValidName(req.Name) { api.WriteBadRequest(w, r, "name must be alphanumeric with dashes or underscores") return } // Ensure directory exists dirCmd := fmt.Sprintf("mkdir -p /workspace/.claude/%s", itemType) if _, err := h.executor.ExecSimple(project.PodName, dirCmd); err != nil { api.WriteInternalError(w, r, fmt.Sprintf("failed to create directory: %v", err)) return } // Write file using base64 encoding to prevent shell injection // This avoids heredoc terminator injection attacks filePath := fmt.Sprintf("/workspace/.claude/%s/%s.md", itemType, req.Name) encoded := base64.StdEncoding.EncodeToString([]byte(req.Content)) writeCmd := fmt.Sprintf("echo '%s' | base64 -d > %s", encoded, filePath) if _, err := h.executor.ExecSimple(project.PodName, writeCmd); err != nil { api.WriteInternalError(w, r, fmt.Sprintf("failed to write file: %v", err)) return } item := ConfigItem{ Name: req.Name, Type: itemType, Content: req.Content, } api.WriteJSON(w, r, http.StatusCreated, item) } // getItem handles GET /projects/{id}/claude-config/{type}/{name} func (h *ClaudeConfigHandler) getItem(w http.ResponseWriter, r *http.Request, itemType string) { id := chi.URLParam(r, "id") name := chi.URLParam(r, "name") project, ok := h.registry.Get(id) if !ok { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } if !isValidName(name) { api.WriteBadRequest(w, r, "invalid name") return } filePath := fmt.Sprintf("/workspace/.claude/%s/%s.md", itemType, name) cmd := fmt.Sprintf("cat %s 2>/dev/null", filePath) output, err := h.executor.ExecSimple(project.PodName, cmd) if err != nil || output == "" { api.WriteNotFound(w, r, fmt.Sprintf("%s not found: %s", itemType, name)) return } item := ConfigItem{ Name: name, Type: itemType, Content: strings.TrimSpace(output), } api.WriteSuccess(w, r, item) } // updateItem handles PUT /projects/{id}/claude-config/{type}/{name} func (h *ClaudeConfigHandler) updateItem(w http.ResponseWriter, r *http.Request, itemType string) { id := chi.URLParam(r, "id") name := chi.URLParam(r, "name") project, ok := h.registry.Get(id) if !ok { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } if !isValidName(name) { api.WriteBadRequest(w, r, "invalid name") return } // Limit request body size to prevent DoS r.Body = http.MaxBytesReader(w, r.Body, maxContentSize) var req ConfigItemRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { api.WriteBadRequest(w, r, "invalid request body or content too large") return } if req.Content == "" { api.WriteBadRequest(w, r, "content is required") return } // Check file exists filePath := fmt.Sprintf("/workspace/.claude/%s/%s.md", itemType, name) checkCmd := fmt.Sprintf("test -f %s && echo exists", filePath) output, _ := h.executor.ExecSimple(project.PodName, checkCmd) if strings.TrimSpace(output) != "exists" { api.WriteNotFound(w, r, fmt.Sprintf("%s not found: %s", itemType, name)) return } // Write file using base64 encoding to prevent shell injection encoded := base64.StdEncoding.EncodeToString([]byte(req.Content)) writeCmd := fmt.Sprintf("echo '%s' | base64 -d > %s", encoded, filePath) if _, err := h.executor.ExecSimple(project.PodName, writeCmd); err != nil { api.WriteInternalError(w, r, fmt.Sprintf("failed to write file: %v", err)) return } item := ConfigItem{ Name: name, Type: itemType, Content: req.Content, } api.WriteSuccess(w, r, item) } // deleteItem handles DELETE /projects/{id}/claude-config/{type}/{name} func (h *ClaudeConfigHandler) deleteItem(w http.ResponseWriter, r *http.Request, itemType string) { id := chi.URLParam(r, "id") name := chi.URLParam(r, "name") project, ok := h.registry.Get(id) if !ok { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } if !isValidName(name) { api.WriteBadRequest(w, r, "invalid name") return } filePath := fmt.Sprintf("/workspace/.claude/%s/%s.md", itemType, name) // Check file exists checkCmd := fmt.Sprintf("test -f %s && echo exists", filePath) output, _ := h.executor.ExecSimple(project.PodName, checkCmd) if strings.TrimSpace(output) != "exists" { api.WriteNotFound(w, r, fmt.Sprintf("%s not found: %s", itemType, name)) return } // Delete file deleteCmd := fmt.Sprintf("rm %s", filePath) if _, err := h.executor.ExecSimple(project.PodName, deleteCmd); err != nil { api.WriteInternalError(w, r, fmt.Sprintf("failed to delete file: %v", err)) return } api.WriteSuccess(w, r, map[string]string{"deleted": name}) } // isValidName checks if a name is safe for use in file paths. func isValidName(name string) bool { if name == "" || len(name) > 64 { return false } // Only allow alphanumeric, dashes, and underscores // Uses package-level compiled regex for performance return validNameRegex.MatchString(name) }