package handler import ( "encoding/json" "net/http" "path/filepath" "strings" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" db "github.com/multica-ai/multica/server/pkg/db/generated" ) // --- Response structs --- type SkillResponse struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` Name string `json:"name"` Description string `json:"description"` Content string `json:"content"` Config any `json:"config"` CreatedBy *string `json:"created_by"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } type SkillFileResponse struct { ID string `json:"id"` SkillID string `json:"skill_id"` Path string `json:"path"` Content string `json:"content"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } type SkillWithFilesResponse struct { SkillResponse Files []SkillFileResponse `json:"files"` } func skillToResponse(s db.Skill) SkillResponse { var config any if s.Config != nil { json.Unmarshal(s.Config, &config) } if config == nil { config = map[string]any{} } return SkillResponse{ ID: uuidToString(s.ID), WorkspaceID: uuidToString(s.WorkspaceID), Name: s.Name, Description: s.Description, Content: s.Content, Config: config, CreatedBy: uuidToPtr(s.CreatedBy), CreatedAt: timestampToString(s.CreatedAt), UpdatedAt: timestampToString(s.UpdatedAt), } } func skillFileToResponse(f db.SkillFile) SkillFileResponse { return SkillFileResponse{ ID: uuidToString(f.ID), SkillID: uuidToString(f.SkillID), Path: f.Path, Content: f.Content, CreatedAt: timestampToString(f.CreatedAt), UpdatedAt: timestampToString(f.UpdatedAt), } } // --- Request structs --- type CreateSkillRequest struct { Name string `json:"name"` Description string `json:"description"` Content string `json:"content"` Config any `json:"config"` Files []CreateSkillFileRequest `json:"files,omitempty"` } type CreateSkillFileRequest struct { Path string `json:"path"` Content string `json:"content"` } type UpdateSkillRequest struct { Name *string `json:"name"` Description *string `json:"description"` Content *string `json:"content"` Config any `json:"config"` Files []CreateSkillFileRequest `json:"files,omitempty"` } type SetAgentSkillsRequest struct { SkillIDs []string `json:"skill_ids"` } // --- Helpers --- // validateFilePath checks that a file path is safe (no traversal, no absolute paths). func validateFilePath(p string) bool { if p == "" { return false } if filepath.IsAbs(p) { return false } cleaned := filepath.Clean(p) if strings.HasPrefix(cleaned, "..") { return false } return true } func (h *Handler) loadSkillForUser(w http.ResponseWriter, r *http.Request, id string) (db.Skill, bool) { skill, err := h.Queries.GetSkill(r.Context(), parseUUID(id)) if err != nil { if isNotFound(err) { writeError(w, http.StatusNotFound, "skill not found") } else { writeError(w, http.StatusInternalServerError, "failed to load skill") } return skill, false } if _, ok := h.requireWorkspaceMember(w, r, uuidToString(skill.WorkspaceID), "skill not found"); !ok { return skill, false } return skill, true } // --- Skill CRUD --- func (h *Handler) ListSkills(w http.ResponseWriter, r *http.Request) { workspaceID := resolveWorkspaceID(r) if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok { return } skills, err := h.Queries.ListSkillsByWorkspace(r.Context(), parseUUID(workspaceID)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list skills") return } resp := make([]SkillResponse, len(skills)) for i, s := range skills { resp[i] = skillToResponse(s) } writeJSON(w, http.StatusOK, resp) } func (h *Handler) GetSkill(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") skill, ok := h.loadSkillForUser(w, r, id) if !ok { return } files, err := h.Queries.ListSkillFiles(r.Context(), skill.ID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list skill files") return } fileResps := make([]SkillFileResponse, len(files)) for i, f := range files { fileResps[i] = skillFileToResponse(f) } writeJSON(w, http.StatusOK, SkillWithFilesResponse{ SkillResponse: skillToResponse(skill), Files: fileResps, }) } func (h *Handler) CreateSkill(w http.ResponseWriter, r *http.Request) { workspaceID := resolveWorkspaceID(r) if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok { return } creatorID, ok := requireUserID(w, r) if !ok { return } var req CreateSkillRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Name == "" { writeError(w, http.StatusBadRequest, "name is required") return } for _, f := range req.Files { if !validateFilePath(f.Path) { writeError(w, http.StatusBadRequest, "invalid file path: "+f.Path) return } } config, _ := json.Marshal(req.Config) if req.Config == nil { config = []byte("{}") } tx, err := h.TxStarter.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "failed to start transaction") return } defer tx.Rollback(r.Context()) qtx := h.Queries.WithTx(tx) skill, err := qtx.CreateSkill(r.Context(), db.CreateSkillParams{ WorkspaceID: parseUUID(workspaceID), Name: req.Name, Description: req.Description, Content: req.Content, Config: config, CreatedBy: parseUUID(creatorID), }) if err != nil { if isUniqueViolation(err) { writeError(w, http.StatusConflict, "a skill with this name already exists") return } writeError(w, http.StatusInternalServerError, "failed to create skill: "+err.Error()) return } fileResps := make([]SkillFileResponse, 0, len(req.Files)) for _, f := range req.Files { sf, err := qtx.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{ SkillID: skill.ID, Path: f.Path, Content: f.Content, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create skill file: "+err.Error()) return } fileResps = append(fileResps, skillFileToResponse(sf)) } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "failed to commit") return } resp := SkillWithFilesResponse{ SkillResponse: skillToResponse(skill), Files: fileResps, } h.publish("skill:created", workspaceID, "member", creatorID, map[string]any{"skill": resp}) writeJSON(w, http.StatusCreated, resp) } func (h *Handler) UpdateSkill(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") skill, ok := h.loadSkillForUser(w, r, id) if !ok { return } if _, ok := h.requireWorkspaceRole(w, r, uuidToString(skill.WorkspaceID), "skill not found", "owner", "admin"); !ok { return } var req UpdateSkillRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } for _, f := range req.Files { if !validateFilePath(f.Path) { writeError(w, http.StatusBadRequest, "invalid file path: "+f.Path) return } } tx, err := h.TxStarter.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "failed to start transaction") return } defer tx.Rollback(r.Context()) qtx := h.Queries.WithTx(tx) params := db.UpdateSkillParams{ ID: parseUUID(id), } if req.Name != nil { params.Name = pgtype.Text{String: *req.Name, Valid: true} } if req.Description != nil { params.Description = pgtype.Text{String: *req.Description, Valid: true} } if req.Content != nil { params.Content = pgtype.Text{String: *req.Content, Valid: true} } if req.Config != nil { config, _ := json.Marshal(req.Config) params.Config = config } skill, err = qtx.UpdateSkill(r.Context(), params) if err != nil { if isUniqueViolation(err) { writeError(w, http.StatusConflict, "a skill with this name already exists") return } writeError(w, http.StatusInternalServerError, "failed to update skill: "+err.Error()) return } // If files are provided, replace all files. var fileResps []SkillFileResponse if req.Files != nil { if err := qtx.DeleteSkillFilesBySkill(r.Context(), skill.ID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete old skill files") return } fileResps = make([]SkillFileResponse, 0, len(req.Files)) for _, f := range req.Files { sf, err := qtx.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{ SkillID: skill.ID, Path: f.Path, Content: f.Content, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to upsert skill file: "+err.Error()) return } fileResps = append(fileResps, skillFileToResponse(sf)) } } else { files, _ := qtx.ListSkillFiles(r.Context(), skill.ID) fileResps = make([]SkillFileResponse, len(files)) for i, f := range files { fileResps[i] = skillFileToResponse(f) } } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "failed to commit") return } resp := SkillWithFilesResponse{ SkillResponse: skillToResponse(skill), Files: fileResps, } h.publish("skill:updated", resolveWorkspaceID(r), "member", requestUserID(r), map[string]any{"skill": resp}) writeJSON(w, http.StatusOK, resp) } func (h *Handler) DeleteSkill(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") skill, ok := h.loadSkillForUser(w, r, id) if !ok { return } if _, ok := h.requireWorkspaceRole(w, r, uuidToString(skill.WorkspaceID), "skill not found", "owner", "admin"); !ok { return } if err := h.Queries.DeleteSkill(r.Context(), parseUUID(id)); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete skill") return } h.publish("skill:deleted", uuidToString(skill.WorkspaceID), "member", requestUserID(r), map[string]any{"skill_id": id}) w.WriteHeader(http.StatusNoContent) } // --- Skill File endpoints --- func (h *Handler) ListSkillFiles(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") skill, ok := h.loadSkillForUser(w, r, id) if !ok { return } files, err := h.Queries.ListSkillFiles(r.Context(), skill.ID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list skill files") return } resp := make([]SkillFileResponse, len(files)) for i, f := range files { resp[i] = skillFileToResponse(f) } writeJSON(w, http.StatusOK, resp) } func (h *Handler) UpsertSkillFile(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") skill, ok := h.loadSkillForUser(w, r, id) if !ok { return } if _, ok := h.requireWorkspaceRole(w, r, uuidToString(skill.WorkspaceID), "skill not found", "owner", "admin"); !ok { return } var req CreateSkillFileRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if !validateFilePath(req.Path) { writeError(w, http.StatusBadRequest, "invalid file path") return } sf, err := h.Queries.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{ SkillID: skill.ID, Path: req.Path, Content: req.Content, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to upsert skill file: "+err.Error()) return } writeJSON(w, http.StatusOK, skillFileToResponse(sf)) } func (h *Handler) DeleteSkillFile(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") skill, ok := h.loadSkillForUser(w, r, id) if !ok { return } if _, ok := h.requireWorkspaceRole(w, r, uuidToString(skill.WorkspaceID), "skill not found", "owner", "admin"); !ok { return } fileID := chi.URLParam(r, "fileId") if err := h.Queries.DeleteSkillFile(r.Context(), parseUUID(fileID)); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete skill file") return } w.WriteHeader(http.StatusNoContent) } // --- Agent-Skill junction --- func (h *Handler) ListAgentSkills(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") agent, ok := h.loadAgentForUser(w, r, id) if !ok { return } skills, err := h.Queries.ListAgentSkills(r.Context(), agent.ID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list agent skills") return } resp := make([]SkillResponse, len(skills)) for i, s := range skills { resp[i] = skillToResponse(s) } writeJSON(w, http.StatusOK, resp) } func (h *Handler) SetAgentSkills(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") agent, ok := h.loadAgentForUser(w, r, id) if !ok { return } if _, ok := h.requireWorkspaceRole(w, r, uuidToString(agent.WorkspaceID), "agent not found", "owner", "admin"); !ok { return } var req SetAgentSkillsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } tx, err := h.TxStarter.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "failed to start transaction") return } defer tx.Rollback(r.Context()) qtx := h.Queries.WithTx(tx) if err := qtx.RemoveAllAgentSkills(r.Context(), agent.ID); err != nil { writeError(w, http.StatusInternalServerError, "failed to clear agent skills") return } for _, skillID := range req.SkillIDs { if err := qtx.AddAgentSkill(r.Context(), db.AddAgentSkillParams{ AgentID: agent.ID, SkillID: parseUUID(skillID), }); err != nil { writeError(w, http.StatusInternalServerError, "failed to add agent skill: "+err.Error()) return } } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "failed to commit") return } // Return the updated skills list. skills, err := h.Queries.ListAgentSkills(r.Context(), agent.ID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list agent skills") return } resp := make([]SkillResponse, len(skills)) for i, s := range skills { resp[i] = skillToResponse(s) } h.publish("agent:status", uuidToString(agent.WorkspaceID), "member", requestUserID(r), map[string]any{"agent_id": uuidToString(agent.ID), "skills": resp}) writeJSON(w, http.StatusOK, resp) }