Refactor real-time sync from per-event precise mutations to WS-as-invalidation-signal + debounced refetch. Backend: - Add SubscribeAll to Event Bus — auto-broadcasts ALL events, eliminates manual 25-item allEvents list - Add skill event constants to protocol, fix skill handler string literals - Add title_changed activity tracking Frontend: - WSClient: add onAny() method for wildcard event subscription - useRealtimeSync: rewrite to refreshMap + prefix routing + 100ms debounce - Precise handlers only for side effects: workspace:deleted, member:removed, member:added (self-check) - Reconnect now refetches all stores (fixes missing members/skills/workspace refresh) - Stale-while-revalidate: fetch() only shows loading spinner on initial load, not on refetch - Remove redundant useWSEvent in agents/page.tsx and skills-page.tsx - WSClient.disconnect() now clears all handler registrations Inbox bugfixes: - Unify sidebar badge count with page count via dedupedItems + unreadCount in store - Sort by time DESC (removed severity-first ordering) - Ellipsis on truncated detail labels UI: - Status/Priority pickers: replace RadioGroup with MenuItem for auto-close on selection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1016 lines
28 KiB
Go
1016 lines
28 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
// --- 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(protocol.EventSkillCreated, 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(protocol.EventSkillUpdated, 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(protocol.EventSkillDeleted, uuidToString(skill.WorkspaceID), "member", requestUserID(r), map[string]any{"skill_id": id})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// --- Skill import ---
|
|
|
|
type ImportSkillRequest struct {
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// importedSkill holds the data extracted from an external source.
|
|
type importedSkill struct {
|
|
name string
|
|
description string
|
|
content string // SKILL.md body
|
|
files []importedFile
|
|
}
|
|
|
|
type importedFile struct {
|
|
path string
|
|
content string
|
|
}
|
|
|
|
// --- ClawHub types ---
|
|
|
|
type clawhubGetSkillResponse struct {
|
|
Skill clawhubSkill `json:"skill"`
|
|
LatestVersion *clawhubLatestVersion `json:"latestVersion"`
|
|
}
|
|
|
|
type clawhubSkill struct {
|
|
Slug string `json:"slug"`
|
|
DisplayName string `json:"displayName"`
|
|
Summary string `json:"summary"`
|
|
Tags map[string]string `json:"tags"`
|
|
}
|
|
|
|
type clawhubLatestVersion struct {
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
type clawhubVersionDetailResponse struct {
|
|
Version clawhubVersionDetail `json:"version"`
|
|
}
|
|
|
|
type clawhubVersionDetail struct {
|
|
Version string `json:"version"`
|
|
Files []clawhubFileEntry `json:"files"`
|
|
}
|
|
|
|
type clawhubFileEntry struct {
|
|
Path string `json:"path"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
// --- GitHub types (for skills.sh) ---
|
|
|
|
type githubContentEntry struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Type string `json:"type"` // "file" or "dir"
|
|
DownloadURL string `json:"download_url"`
|
|
}
|
|
|
|
// --- URL detection ---
|
|
|
|
// importSource identifies where a URL points.
|
|
type importSource int
|
|
|
|
const (
|
|
sourceClawHub importSource = iota
|
|
sourceSkillsSh
|
|
)
|
|
|
|
// detectImportSource determines the source from a URL.
|
|
// Returns the source and a normalized URL (with scheme).
|
|
func detectImportSource(raw string) (importSource, string, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return 0, "", fmt.Errorf("empty URL")
|
|
}
|
|
|
|
normalized := raw
|
|
if !strings.HasPrefix(normalized, "http://") && !strings.HasPrefix(normalized, "https://") {
|
|
normalized = "https://" + normalized
|
|
}
|
|
|
|
parsed, err := url.Parse(normalized)
|
|
if err != nil {
|
|
return 0, "", fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
|
|
host := strings.ToLower(parsed.Hostname())
|
|
switch {
|
|
case host == "skills.sh" || host == "www.skills.sh":
|
|
return sourceSkillsSh, normalized, nil
|
|
case host == "clawhub.ai" || host == "www.clawhub.ai":
|
|
return sourceClawHub, normalized, nil
|
|
default:
|
|
// If no host (bare slug), default to clawhub
|
|
if !strings.Contains(raw, "/") || !strings.Contains(raw, ".") {
|
|
return sourceClawHub, raw, nil
|
|
}
|
|
return 0, "", fmt.Errorf("unsupported source: %s (supported: clawhub.ai, skills.sh)", host)
|
|
}
|
|
}
|
|
|
|
// --- ClawHub import ---
|
|
|
|
// parseClawHubSlug extracts the skill slug from a clawhub.ai URL.
|
|
func parseClawHubSlug(raw string) (string, error) {
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
|
// /{owner}/{slug} — take the last segment as the slug
|
|
if len(parts) == 2 {
|
|
return parts[1], nil
|
|
}
|
|
if len(parts) == 1 && parts[0] != "" {
|
|
return parts[0], nil
|
|
}
|
|
// Bare slug (no path)
|
|
if raw == parsed.Host || parsed.Path == "" || parsed.Path == "/" {
|
|
return "", fmt.Errorf("missing skill slug in URL")
|
|
}
|
|
return "", fmt.Errorf("could not extract skill slug from URL: %s", raw)
|
|
}
|
|
|
|
func fetchFromClawHub(httpClient *http.Client, rawURL string) (*importedSkill, error) {
|
|
slug, err := parseClawHubSlug(rawURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
apiBase := "https://clawhub.ai/api/v1"
|
|
|
|
// 1. Fetch skill metadata
|
|
skillResp, err := httpClient.Get(apiBase + "/skills/" + url.PathEscape(slug))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to reach ClawHub: %w", err)
|
|
}
|
|
defer skillResp.Body.Close()
|
|
|
|
if skillResp.StatusCode == http.StatusNotFound {
|
|
return nil, fmt.Errorf("skill not found on ClawHub: %s", slug)
|
|
}
|
|
if skillResp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("ClawHub returned status %d", skillResp.StatusCode)
|
|
}
|
|
|
|
var chResp clawhubGetSkillResponse
|
|
if err := json.NewDecoder(skillResp.Body).Decode(&chResp); err != nil {
|
|
return nil, fmt.Errorf("failed to parse ClawHub response")
|
|
}
|
|
chSkill := chResp.Skill
|
|
|
|
// 2. Determine latest version and fetch file list
|
|
latestVersion := ""
|
|
if v, ok := chSkill.Tags["latest"]; ok {
|
|
latestVersion = v
|
|
} else if chResp.LatestVersion != nil {
|
|
latestVersion = chResp.LatestVersion.Version
|
|
}
|
|
|
|
var filePaths []string
|
|
if latestVersion != "" {
|
|
vURL := fmt.Sprintf("%s/skills/%s/versions/%s", apiBase, url.PathEscape(slug), url.PathEscape(latestVersion))
|
|
vResp, err := httpClient.Get(vURL)
|
|
if err == nil {
|
|
defer vResp.Body.Close()
|
|
if vResp.StatusCode == http.StatusOK {
|
|
var vDetail clawhubVersionDetailResponse
|
|
if err := json.NewDecoder(vResp.Body).Decode(&vDetail); err == nil {
|
|
for _, f := range vDetail.Version.Files {
|
|
filePaths = append(filePaths, f.Path)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Download each file
|
|
result := &importedSkill{
|
|
name: chSkill.DisplayName,
|
|
description: chSkill.Summary,
|
|
}
|
|
if result.name == "" {
|
|
result.name = slug
|
|
}
|
|
|
|
for _, fp := range filePaths {
|
|
fileURL := fmt.Sprintf("%s/skills/%s/file?path=%s", apiBase, url.PathEscape(slug), url.QueryEscape(fp))
|
|
if latestVersion != "" {
|
|
fileURL += "&version=" + url.QueryEscape(latestVersion)
|
|
}
|
|
body, err := fetchRawFile(httpClient, fileURL)
|
|
if err != nil {
|
|
slog.Warn("clawhub import: file download failed", "path", fp, "error", err)
|
|
continue
|
|
}
|
|
if fp == "SKILL.md" {
|
|
result.content = string(body)
|
|
} else {
|
|
result.files = append(result.files, importedFile{path: fp, content: string(body)})
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// --- skills.sh import ---
|
|
|
|
// parseSkillsShParts extracts owner, repo, skill-name from a skills.sh URL.
|
|
// URL format: https://skills.sh/{owner}/{repo}/{skill-name}
|
|
func parseSkillsShParts(raw string) (owner, repo, skillName string, err error) {
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return "", "", "", fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
|
if len(parts) != 3 {
|
|
return "", "", "", fmt.Errorf("expected URL format: skills.sh/{owner}/{repo}/{skill-name}, got: %s", parsed.Path)
|
|
}
|
|
return parts[0], parts[1], parts[2], nil
|
|
}
|
|
|
|
func fetchFromSkillsSh(httpClient *http.Client, rawURL string) (*importedSkill, error) {
|
|
owner, repo, skillName, err := parseSkillsShParts(rawURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Skills can be at different paths depending on the repo structure:
|
|
// skills/{name}/SKILL.md (most common)
|
|
// plugin/skills/{name}/SKILL.md (e.g. microsoft repos)
|
|
// {name}/SKILL.md (skill at repo root level)
|
|
rawPrefix := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/main",
|
|
url.PathEscape(owner), url.PathEscape(repo))
|
|
|
|
candidatePaths := []string{
|
|
"skills/" + skillName,
|
|
"plugin/skills/" + skillName,
|
|
skillName,
|
|
}
|
|
|
|
var skillMdBody []byte
|
|
var skillDir string
|
|
for _, dir := range candidatePaths {
|
|
body, err := fetchRawFile(httpClient, rawPrefix+"/"+dir+"/SKILL.md")
|
|
if err == nil {
|
|
skillMdBody = body
|
|
skillDir = dir
|
|
break
|
|
}
|
|
}
|
|
if skillMdBody == nil {
|
|
return nil, fmt.Errorf("SKILL.md not found in repository %s/%s for skill %s", owner, repo, skillName)
|
|
}
|
|
|
|
// Parse name and description from YAML frontmatter
|
|
name, description := parseSkillFrontmatter(string(skillMdBody))
|
|
if name == "" {
|
|
name = skillName
|
|
}
|
|
|
|
result := &importedSkill{
|
|
name: name,
|
|
description: description,
|
|
content: string(skillMdBody),
|
|
}
|
|
|
|
// 2. List supporting files via GitHub API
|
|
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s",
|
|
url.PathEscape(owner), url.PathEscape(repo), skillDir)
|
|
dirResp, err := httpClient.Get(apiURL)
|
|
if err != nil || dirResp.StatusCode != http.StatusOK {
|
|
// Can't list files — return what we have (SKILL.md only)
|
|
if dirResp != nil {
|
|
dirResp.Body.Close()
|
|
}
|
|
return result, nil
|
|
}
|
|
defer dirResp.Body.Close()
|
|
|
|
var entries []githubContentEntry
|
|
if err := json.NewDecoder(dirResp.Body).Decode(&entries); err != nil {
|
|
return result, nil
|
|
}
|
|
|
|
// 3. Recursively collect files (excluding SKILL.md and LICENSE)
|
|
var allFiles []githubContentEntry
|
|
collectGitHubFiles(httpClient, entries, &allFiles, apiURL)
|
|
|
|
// 4. Download each file
|
|
basePath := skillDir + "/"
|
|
for _, entry := range allFiles {
|
|
if entry.DownloadURL == "" {
|
|
continue
|
|
}
|
|
body, err := fetchRawFile(httpClient, entry.DownloadURL)
|
|
if err != nil {
|
|
slog.Warn("skills.sh import: file download failed", "path", entry.Path, "error", err)
|
|
continue
|
|
}
|
|
// Convert absolute GitHub path to relative path within skill
|
|
relPath := strings.TrimPrefix(entry.Path, basePath)
|
|
result.files = append(result.files, importedFile{path: relPath, content: string(body)})
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// collectGitHubFiles recursively collects file entries from a GitHub directory listing.
|
|
func collectGitHubFiles(httpClient *http.Client, entries []githubContentEntry, out *[]githubContentEntry, parentURL string) {
|
|
for _, entry := range entries {
|
|
lower := strings.ToLower(entry.Name)
|
|
if lower == "skill.md" || lower == "license" || lower == "license.txt" || lower == "license.md" {
|
|
continue
|
|
}
|
|
if entry.Type == "file" {
|
|
*out = append(*out, entry)
|
|
} else if entry.Type == "dir" {
|
|
// Fetch subdirectory contents
|
|
subURL := parentURL + "/" + url.PathEscape(entry.Name)
|
|
subResp, err := httpClient.Get(subURL)
|
|
if err != nil || subResp.StatusCode != http.StatusOK {
|
|
if subResp != nil {
|
|
subResp.Body.Close()
|
|
}
|
|
continue
|
|
}
|
|
var subEntries []githubContentEntry
|
|
json.NewDecoder(subResp.Body).Decode(&subEntries)
|
|
subResp.Body.Close()
|
|
collectGitHubFiles(httpClient, subEntries, out, subURL)
|
|
}
|
|
}
|
|
}
|
|
|
|
// parseSkillFrontmatter extracts name and description from YAML frontmatter in SKILL.md.
|
|
func parseSkillFrontmatter(content string) (name, description string) {
|
|
if !strings.HasPrefix(content, "---") {
|
|
return "", ""
|
|
}
|
|
end := strings.Index(content[3:], "---")
|
|
if end < 0 {
|
|
return "", ""
|
|
}
|
|
frontmatter := content[3 : 3+end]
|
|
for _, line := range strings.Split(frontmatter, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "name:") {
|
|
name = strings.TrimSpace(strings.TrimPrefix(line, "name:"))
|
|
name = strings.Trim(name, "\"'")
|
|
} else if strings.HasPrefix(line, "description:") {
|
|
description = strings.TrimSpace(strings.TrimPrefix(line, "description:"))
|
|
description = strings.Trim(description, "\"'")
|
|
}
|
|
}
|
|
return name, description
|
|
}
|
|
|
|
// --- Shared helpers ---
|
|
|
|
// fetchRawFile downloads a URL and returns the body bytes. Limit 1MB.
|
|
func fetchRawFile(httpClient *http.Client, fileURL string) ([]byte, error) {
|
|
resp, err := httpClient.Get(fileURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
}
|
|
return io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
}
|
|
|
|
// --- Import handler ---
|
|
|
|
func (h *Handler) ImportSkill(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 ImportSkillRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
source, normalized, err := detectImportSource(req.URL)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
|
|
|
var imported *importedSkill
|
|
switch source {
|
|
case sourceClawHub:
|
|
imported, err = fetchFromClawHub(httpClient, normalized)
|
|
case sourceSkillsSh:
|
|
imported, err = fetchFromSkillsSh(httpClient, normalized)
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, err.Error())
|
|
return
|
|
}
|
|
|
|
// Create skill in database
|
|
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: imported.name,
|
|
Description: imported.description,
|
|
Content: imported.content,
|
|
Config: []byte("{}"),
|
|
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(imported.files))
|
|
for _, f := range imported.files {
|
|
if !validateFilePath(f.path) {
|
|
continue
|
|
}
|
|
sf, err := qtx.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{
|
|
SkillID: skill.ID,
|
|
Path: f.path,
|
|
Content: f.content,
|
|
})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
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(protocol.EventSkillCreated, workspaceID, "member", creatorID, map[string]any{"skill": resp})
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
// --- 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(protocol.EventAgentStatus, uuidToString(agent.WorkspaceID), "member", requestUserID(r), map[string]any{"agent_id": uuidToString(agent.ID), "skills": resp})
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|