// Package handlers provides HTTP handlers for the rdev API. package handlers import ( "context" "encoding/base64" "errors" "fmt" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/adapter/kubernetes" "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/service" "github.com/orchard9/rdev/internal/validate" "github.com/orchard9/rdev/pkg/api" ) // 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 { projectRepo *kubernetes.ProjectRepository executor *kubernetes.Executor projectService *service.ProjectService } // NewClaudeConfigHandler creates a new claude config handler with injected dependencies. func NewClaudeConfigHandler(projectRepo *kubernetes.ProjectRepository, exec *kubernetes.Executor) *ClaudeConfigHandler { return &ClaudeConfigHandler{ projectRepo: projectRepo, executor: exec, } } // NewClaudeConfigHandlerWithService creates a new claude config handler with injected dependencies. // This maintains proper DI by receiving all dependencies from the caller. func NewClaudeConfigHandlerWithService( projectService *service.ProjectService, projectRepo *kubernetes.ProjectRepository, exec *kubernetes.Executor, ) *ClaudeConfigHandler { return &ClaudeConfigHandler{ projectService: projectService, projectRepo: projectRepo, 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 (read) r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/", h.Overview) // Commands - read r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/commands", h.ListCommands) r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/commands/{name}", h.GetCommand) // Commands - write r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/commands", h.CreateCommand) r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Put("/commands/{name}", h.UpdateCommand) r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Delete("/commands/{name}", h.DeleteCommand) // Skills - read r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/skills", h.ListSkills) r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/skills/{name}", h.GetSkill) // Skills - write r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/skills", h.CreateSkill) r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Put("/skills/{name}", h.UpdateSkill) r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Delete("/skills/{name}", h.DeleteSkill) // Agents - read r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/agents", h.ListAgents) r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/agents/{name}", h.GetAgent) // Agents - write r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/agents", h.CreateAgent) r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Put("/agents/{name}", h.UpdateAgent) r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).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, err := h.getProject(r.Context(), domain.ProjectID(id)) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } api.WriteInternalError(w, r, "failed to get project") return } overview := ConfigOverview{ Project: id, Path: "/workspace/.claude", Commands: h.listItems(r.Context(), project.PodName, "commands"), Skills: h.listItems(r.Context(), project.PodName, "skills"), Agents: h.listItems(r.Context(), 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(ctx context.Context, pod, itemType string) []string { cmd := fmt.Sprintf("ls -1 /workspace/.claude/%s 2>/dev/null | sed 's/\\.md$//'", itemType) output, err := h.executor.ExecSimple(ctx, 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, err := h.getProject(r.Context(), domain.ProjectID(id)) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } api.WriteInternalError(w, r, "failed to get project") return } items := h.listItems(r.Context(), 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, err := h.getProject(r.Context(), domain.ProjectID(id)) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } api.WriteInternalError(w, r, "failed to get project") return } // Limit request body size to prevent DoS r.Body = http.MaxBytesReader(w, r.Body, maxContentSize) var req ConfigItemRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } v := validate.New() v.Required(req.Name, "name") v.Required(req.Content, "content") if err := v.Error(); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Validate name (alphanumeric, dashes, underscores only, 1-64 chars) if err := validate.Name(req.Name, "name"); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Ensure directory exists dirCmd := fmt.Sprintf("mkdir -p /workspace/.claude/%s", itemType) if _, err := h.executor.ExecSimple(r.Context(), 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(r.Context(), 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, err := h.getProject(r.Context(), domain.ProjectID(id)) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } api.WriteInternalError(w, r, "failed to get project") return } if err := validate.Name(name, "name"); err != nil { api.WriteBadRequest(w, r, err.Error()) 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(r.Context(), 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, err := h.getProject(r.Context(), domain.ProjectID(id)) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } api.WriteInternalError(w, r, "failed to get project") return } if err := validate.Name(name, "name"); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Limit request body size to prevent DoS r.Body = http.MaxBytesReader(w, r.Body, maxContentSize) var req ConfigItemRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } if err := validate.Required(req.Content, "content"); err != nil { api.WriteBadRequest(w, r, err.Error()) 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(r.Context(), 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(r.Context(), 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, err := h.getProject(r.Context(), domain.ProjectID(id)) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } api.WriteInternalError(w, r, "failed to get project") return } if err := validate.Name(name, "name"); err != nil { api.WriteBadRequest(w, r, err.Error()) 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(r.Context(), 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(r.Context(), 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}) } // getProject retrieves a project by ID using available methods. // It prefers the project service if available, otherwise falls back to the project repository. func (h *ClaudeConfigHandler) getProject(ctx context.Context, id domain.ProjectID) (*domain.Project, error) { // Add timeout for project lookup ctx, cancel := context.WithTimeout(ctx, TimeoutFastLookup) defer cancel() // Use service if available if h.projectService != nil { return h.projectService.Get(ctx, id) } // Fall back to direct repository access if h.projectRepo != nil { return h.projectRepo.Get(ctx, id) } return nil, domain.ErrProjectNotFound }