Files
multica/server/internal/handler/skill.go
2026-06-10 16:12:11 +08:00

2247 lines
72 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
skillpkg "github.com/multica-ai/multica/server/internal/skill"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// sanitizeNullBytes makes a string safe for a PostgreSQL TEXT column.
//
// Two failure modes covered:
// - Embedded NUL (0x00) — PG rejects with SQLSTATE 22021. Removed.
// - Other invalid-UTF-8 byte sequences (e.g. 0x91 = Windows-1252 smart
// quote, which crashed agent-template import of skills containing
// Windows-encoded prose). `strings.ToValidUTF8` drops them.
//
// Name is kept for compatibility with the many call sites; the behaviour
// is a strict superset of the original.
func sanitizeNullBytes(s string) string {
return strings.ToValidUTF8(strings.ReplaceAll(s, "\x00", ""), "")
}
// --- 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"`
}
// SkillSummaryResponse is the list-endpoint shape: everything SkillResponse
// has except `content`. SKILL.md bodies routinely run 50200KB and shipping
// them in list payloads bloats responses past CLI timeouts on high-latency
// links (GH multica-ai/multica#2174). Detail endpoints still return the full
// SkillResponse with content.
type SkillSummaryResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
Config any `json:"config"`
CreatedBy *string `json:"created_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// AgentSkillSummary is the still-narrower shape used for skills embedded in
// an Agent payload (`GET /api/agents`, `GET /api/agents/{id}`). The agent
// list batch query only joins enough columns to render the assignee chip in
// the UI; the standalone `/api/agents/{id}/skills` endpoint returns the full
// SkillSummaryResponse for callers that need the source/origin info.
type AgentSkillSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
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 SkillSearchCandidateResponse struct {
Name string `json:"name"`
URL string `json:"url"`
Source string `json:"source"`
Repo *string `json:"repo"`
InstallCount *int64 `json:"install_count"`
GitHubStars *int64 `json:"github_stars"`
Description string `json:"description"`
}
type SkillWithFilesResponse struct {
SkillResponse
Files []SkillFileResponse `json:"files"`
}
type SkillImportResult struct {
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Skill *SkillWithFilesResponse `json:"skill,omitempty"`
ExistingSkill *ExistingSkillIdentity `json:"existing_skill,omitempty"`
}
type ExistingSkillIdentity struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedBy string `json:"created_by,omitempty"`
CanOverwrite bool `json:"can_overwrite,omitempty"`
}
func writeSkillImportDuplicateConflict(w http.ResponseWriter, existing ExistingSkillIdentity) {
writeJSON(w, http.StatusConflict, map[string]any{
"error": "a skill with this name already exists",
"existing_skill": existing,
})
}
func skillToResponse(s db.Skill) SkillResponse {
return SkillResponse{
ID: uuidToString(s.ID),
WorkspaceID: uuidToString(s.WorkspaceID),
Name: s.Name,
Description: s.Description,
Content: s.Content,
Config: decodeSkillConfig(s.Config),
CreatedBy: uuidToPtr(s.CreatedBy),
CreatedAt: timestampToString(s.CreatedAt),
UpdatedAt: timestampToString(s.UpdatedAt),
}
}
func (h *Handler) existingSkillIdentityByName(ctx context.Context, workspaceID pgtype.UUID, name string) (ExistingSkillIdentity, bool, error) {
skill, err := h.Queries.GetSkillByWorkspaceAndName(ctx, db.GetSkillByWorkspaceAndNameParams{
WorkspaceID: workspaceID,
Name: name,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ExistingSkillIdentity{}, false, nil
}
return ExistingSkillIdentity{}, false, err
}
return existingSkillIdentity(skill, ""), true, nil
}
func existingSkillIdentity(skill db.Skill, userID string) ExistingSkillIdentity {
identity := ExistingSkillIdentity{
ID: uuidToString(skill.ID),
Name: skill.Name,
CanOverwrite: canOverwriteSkillByLocalImport(userID, skill),
}
if skill.CreatedBy.Valid {
identity.CreatedBy = uuidToString(skill.CreatedBy)
}
return identity
}
// decodeSkillConfig decodes a JSONB skill.config blob, defaulting to {} when
// missing or unparseable so the API surface always returns a JSON object.
func decodeSkillConfig(raw []byte) any {
var config any
if raw != nil {
_ = json.Unmarshal(raw, &config)
}
if config == nil {
return map[string]any{}
}
return config
}
func skillSummaryToResponse(
id, workspaceID pgtype.UUID,
name, description string,
config []byte,
createdBy pgtype.UUID,
createdAt, updatedAt pgtype.Timestamptz,
) SkillSummaryResponse {
return SkillSummaryResponse{
ID: uuidToString(id),
WorkspaceID: uuidToString(workspaceID),
Name: name,
Description: description,
Config: decodeSkillConfig(config),
CreatedBy: uuidToPtr(createdBy),
CreatedAt: timestampToString(createdAt),
UpdatedAt: timestampToString(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"`
}
type AddAgentSkillsRequest 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) {
workspaceID := h.resolveWorkspaceID(r)
if workspaceID == "" {
writeError(w, http.StatusBadRequest, "workspace_id is required")
return db.Skill{}, false
}
skillUUID, ok := parseUUIDOrBadRequest(w, id, "skill id")
if !ok {
return db.Skill{}, false
}
skill, err := h.Queries.GetSkillInWorkspace(r.Context(), db.GetSkillInWorkspaceParams{
ID: skillUUID,
WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
writeError(w, http.StatusNotFound, "skill not found")
return skill, false
}
return skill, true
}
// --- Skill CRUD ---
func (h *Handler) ListSkills(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
skills, err := h.Queries.ListSkillSummariesByWorkspace(r.Context(), parseUUID(workspaceID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list skills")
return
}
resp := make([]SkillSummaryResponse, len(skills))
for i, s := range skills {
resp[i] = skillSummaryToResponse(
s.ID, s.WorkspaceID, s.Name, s.Description, s.Config,
s.CreatedBy, s.CreatedAt, s.UpdatedAt,
)
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) SearchSkills(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get("q"))
if query == "" {
writeError(w, http.StatusBadRequest, "query is required")
return
}
httpClient := &http.Client{Timeout: 30 * time.Second}
candidates, err := searchClawHubSkills(httpClient, query)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{
"code": "upstream_unavailable",
"error": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, candidates)
}
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 := h.resolveWorkspaceID(r)
creatorID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
if !ok {
return
}
creatorUUID := parseUUID(creatorID)
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
}
}
resp, err := h.createSkillWithFiles(r.Context(), skillCreateInput{
WorkspaceID: workspaceUUID,
CreatorID: creatorUUID,
Name: req.Name,
Description: req.Description,
Content: req.Content,
Config: req.Config,
Files: req.Files,
})
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
}
actorType, actorID := h.resolveActor(r, creatorID, workspaceID)
h.publish(protocol.EventSkillCreated, workspaceID, actorType, actorID, map[string]any{"skill": resp})
writeJSON(w, http.StatusCreated, resp)
}
// canManageSkill checks whether the current user can update or delete a skill.
// The skill creator or workspace owner/admin can manage any skill.
func (h *Handler) canManageSkill(w http.ResponseWriter, r *http.Request, skill db.Skill) bool {
wsID := uuidToString(skill.WorkspaceID)
member, ok := h.requireWorkspaceRole(w, r, wsID, "skill not found", "owner", "admin", "member")
if !ok {
return false
}
isAdmin := roleAllowed(member.Role, "owner", "admin")
isSkillCreator := skill.CreatedBy.Valid && uuidToString(skill.CreatedBy) == requestUserID(r)
if !isAdmin && !isSkillCreator {
writeError(w, http.StatusForbidden, "only the skill creator can manage this skill")
return false
}
return true
}
// canOverwriteSkillByLocalImport reports whether userID may overwrite skill via
// a runtime-local-skill re-import. This is intentionally NARROWER than
// canManageSkill: only the original creator may overwrite by re-importing.
// Workspace owners/admins who want to change a skill they did not create must
// edit it in-app instead. See MUL-2701 / MUL-2800.
func canOverwriteSkillByLocalImport(userID string, skill db.Skill) bool {
return skill.CreatedBy.Valid && uuidToString(skill.CreatedBy) == userID
}
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 !h.canManageSkill(w, r, skill) {
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: sanitizeNullBytes(*req.Name), Valid: true}
}
if req.Description != nil {
params.Description = pgtype.Text{String: sanitizeNullBytes(*req.Description), Valid: true}
}
if req.Content != nil {
params.Content = pgtype.Text{String: sanitizeNullBytes(*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 {
// SKILL.md is reserved for the primary skill content (skill.Content).
if skillpkg.IsReservedContentPath(f.Path) {
continue
}
sf, err := qtx.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{
SkillID: skill.ID,
Path: sanitizeNullBytes(f.Path),
Content: sanitizeNullBytes(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,
}
wsID := h.resolveWorkspaceID(r)
actorType, actorID := h.resolveActor(r, requestUserID(r), wsID)
h.publish(protocol.EventSkillUpdated, wsID, actorType, actorID, 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 !h.canManageSkill(w, r, skill) {
return
}
if err := h.Queries.DeleteSkill(r.Context(), db.DeleteSkillParams{
ID: skill.ID,
WorkspaceID: skill.WorkspaceID,
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete skill")
return
}
actorType, actorID := h.resolveActor(r, requestUserID(r), uuidToString(skill.WorkspaceID))
h.publish(protocol.EventSkillDeleted, uuidToString(skill.WorkspaceID), actorType, actorID, map[string]any{"skill_id": uuidToString(skill.ID)})
w.WriteHeader(http.StatusNoContent)
}
// --- Skill import ---
type ImportSkillRequest struct {
URL string `json:"url"`
OnConflict string `json:"on_conflict,omitempty"`
}
const (
importOnConflictFail = "fail"
importOnConflictOverwrite = "overwrite"
importOnConflictRename = "rename"
importOnConflictSkip = "skip"
)
const maxImportRenameAttempts = 50
func validImportOnConflict(strategy string) bool {
switch strategy {
case "", importOnConflictFail, importOnConflictOverwrite, importOnConflictRename, importOnConflictSkip:
return true
}
return false
}
// Per-import bundle limits. These mirror the local-runtime importer so that
// URL imports cannot smuggle in payloads that the rest of the stack would
// reject. fetchRawFile enforces the per-file cap; importedSkill.addFile
// enforces the bundle-wide caps.
const (
maxImportFileSize = 1 << 20 // 1 MiB per file
maxImportTotalSize = 8 << 20 // 8 MiB per import bundle (sum of supporting files)
maxImportFileCount = 128 // max number of supporting files
)
// importedSkill holds the data extracted from an external source.
type importedSkill struct {
name string
description string
content string // SKILL.md body
files []importedFile
bundleSize int // running sum of file content bytes for cap enforcement
origin map[string]any // written into skill.config.origin so the UI can show provenance
}
type importedFile struct {
path string
content string
}
// errImportCapExceeded marks an error caused by a per-file or per-bundle cap.
// Such errors must abort the import — silently dropping a file would otherwise
// produce an incomplete skill that looks valid to the user.
var errImportCapExceeded = errors.New("import cap exceeded")
// isCapError reports whether err is (or wraps) errImportCapExceeded.
func isCapError(err error) bool {
return errors.Is(err, errImportCapExceeded)
}
// addFile appends a supporting file while enforcing the per-bundle caps. It
// returns an error when either the file count or aggregate byte budget would
// be exceeded so the caller fails the import instead of silently truncating.
//
// Binary files (images, fonts, archives) are silently skipped: their bytes
// can't survive a PG TEXT column (SQLSTATE 22021), and they're reference
// assets the agent never reads as text anyway. Logging the skip leaves a
// breadcrumb if a user expected one of these to import.
func (s *importedSkill) addFile(path, content string) error {
if isLikelyBinaryFilePath(path) {
slog.Info("skill import: skipping binary file", "path", path, "size", len(content))
return nil
}
if len(s.files) >= maxImportFileCount {
return fmt.Errorf("%w: import bundle exceeds %d file limit", errImportCapExceeded, maxImportFileCount)
}
if s.bundleSize+len(content) > maxImportTotalSize {
return fmt.Errorf("%w: import bundle exceeds %d byte limit", errImportCapExceeded, maxImportTotalSize)
}
s.bundleSize += len(content)
s.files = append(s.files, importedFile{path: path, content: content})
return nil
}
// isLikelyBinaryFilePath reports whether the file's extension indicates a
// non-text payload. Conservative blacklist — extensions not on the list
// are assumed text and pass through. `sanitizeNullBytes` (called at PG
// insert time) is the second-line defence against any text file that
// turns out to have stray invalid-UTF-8 bytes.
func isLikelyBinaryFilePath(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case
// images
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".ico", ".heic",
// fonts
".ttf", ".otf", ".woff", ".woff2", ".eot",
// archives
".zip", ".gz", ".tar", ".bz2", ".7z", ".rar",
// documents (binary office)
".pdf", ".docx", ".xlsx", ".pptx", ".doc", ".xls", ".ppt",
// media
".mp3", ".mp4", ".wav", ".avi", ".mov", ".webm", ".m4a", ".flac",
// compiled / executable
".exe", ".dll", ".so", ".dylib", ".class", ".jar", ".wasm",
// db / cache
".db", ".sqlite", ".sqlite3", ".pyc":
return true
}
return false
}
// --- ClawHub types ---
var clawHubAPIBase = "https://clawhub.ai/api/v1"
const clawHubSearchStatsLimit = 10
type clawhubSearchResponse struct {
Results []clawhubSearchResult `json:"results"`
}
type clawhubSearchResult struct {
Slug string `json:"slug"`
DisplayName string `json:"displayName"`
Summary string `json:"summary"`
OwnerHandle string `json:"ownerHandle"`
}
type clawhubSkillStats struct {
InstallsAllTime int64 `json:"installsAllTime"`
InstallsCurrent int64 `json:"installsCurrent"`
}
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"`
Stats clawhubSkillStats `json:"stats"`
}
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"
URL string `json:"url"`
DownloadURL string `json:"download_url"`
}
type githubRepoInfo struct {
DefaultBranch string `json:"default_branch"`
}
type githubTreeResponse struct {
Tree []githubTreeEntry `json:"tree"`
Truncated bool `json:"truncated"`
}
type githubTreeEntry struct {
Path string `json:"path"`
Type string `json:"type"` // "blob" or "tree"
}
// fetchGitHubDefaultBranch returns the default branch of a GitHub repository.
// Falls back to "main" if the API call fails.
func fetchGitHubDefaultBranch(httpClient *http.Client, owner, repo string) string {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s",
url.PathEscape(owner), url.PathEscape(repo))
resp, err := doGitHubAPIGet(httpClient, apiURL)
if err != nil || resp.StatusCode != http.StatusOK {
if resp != nil {
resp.Body.Close()
}
return "main"
}
defer resp.Body.Close()
var info githubRepoInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil || info.DefaultBranch == "" {
return "main"
}
return info.DefaultBranch
}
// --- URL detection ---
// importSource identifies where a URL points.
type importSource int
const (
sourceClawHub importSource = iota
sourceSkillsSh
sourceGitHub
)
// 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
case host == "github.com" || host == "www.github.com":
return sourceGitHub, 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, github.com)", 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 searchClawHubSkills(httpClient *http.Client, query string) ([]SkillSearchCandidateResponse, error) {
searchURL := clawHubAPIBase + "/search?q=" + url.QueryEscape(query)
resp, err := httpClient.Get(searchURL)
if err != nil {
return nil, fmt.Errorf("failed to reach ClawHub: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ClawHub search returned status %d", resp.StatusCode)
}
var searchResp clawhubSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to parse ClawHub search response")
}
candidates := make([]SkillSearchCandidateResponse, 0, len(searchResp.Results))
for i, result := range searchResp.Results {
if result.Slug == "" {
continue
}
candidate := SkillSearchCandidateResponse{
Name: result.DisplayName,
URL: buildClawHubSkillURL(result.OwnerHandle, result.Slug),
Source: "clawhub.ai",
Description: result.Summary,
}
if candidate.Name == "" {
candidate.Name = result.Slug
}
if i < clawHubSearchStatsLimit {
if count, ok := fetchClawHubInstallCount(httpClient, result.Slug); ok {
candidate.InstallCount = &count
}
}
candidates = append(candidates, candidate)
}
return candidates, nil
}
func buildClawHubSkillURL(ownerHandle, slug string) string {
if ownerHandle == "" {
return "https://clawhub.ai/" + url.PathEscape(slug)
}
return "https://clawhub.ai/" + url.PathEscape(ownerHandle) + "/" + url.PathEscape(slug)
}
func fetchClawHubInstallCount(httpClient *http.Client, slug string) (int64, bool) {
detailURL := clawHubAPIBase + "/skills/" + url.PathEscape(slug)
resp, err := httpClient.Get(detailURL)
if err != nil {
slog.Warn("clawhub search: failed to fetch skill details", "slug", slug, "error", err)
return 0, false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
slog.Warn("clawhub search: skill details returned non-200", "slug", slug, "status", resp.StatusCode)
return 0, false
}
var detail clawhubGetSkillResponse
if err := json.NewDecoder(resp.Body).Decode(&detail); err != nil {
slog.Warn("clawhub search: failed to parse skill details", "slug", slug, "error", err)
return 0, false
}
if detail.Skill.Stats.InstallsAllTime > 0 {
return detail.Skill.Stats.InstallsAllTime, true
}
return detail.Skill.Stats.InstallsCurrent, true
}
func fetchFromClawHub(httpClient *http.Client, rawURL string) (*importedSkill, error) {
slug, err := parseClawHubSlug(rawURL)
if err != nil {
return nil, err
}
apiBase := clawHubAPIBase
// 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,
origin: map[string]any{
"type": "clawhub",
"source_url": rawURL,
"slug": slug,
},
}
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 {
// Cap violations must abort: silently dropping a file would
// produce an incomplete bundle that looks valid. SKILL.md is
// load-bearing, so any failure on it is fatal too.
if isCapError(err) || fp == "SKILL.md" {
return nil, fmt.Errorf("clawhub import: %s: %w", fp, err)
}
slog.Warn("clawhub import: file download failed", "path", fp, "error", err)
continue
}
if fp == "SKILL.md" {
result.content = string(body)
continue
}
if err := result.addFile(fp, string(body)); err != nil {
return nil, err
}
}
if result.content == "" {
return nil, fmt.Errorf("clawhub import: SKILL.md is empty or missing for %s", slug)
}
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)
// .claude/skills/{name}/SKILL.md (Claude Code native discovery)
// plugin/skills/{name}/SKILL.md (e.g. microsoft repos)
// {name}/SKILL.md (e.g. anthropics/skills layout)
// SKILL.md (single-skill repo: the repo is the skill)
defaultBranch := fetchGitHubDefaultBranch(httpClient, owner, repo)
rawPrefix := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s",
url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(defaultBranch))
candidatePaths := []string{
"skills/" + skillName,
".claude/skills/" + skillName,
"plugin/skills/" + skillName,
skillName,
}
var skillMdBody []byte
var skillDir string
for _, dir := range candidatePaths {
body, err := fetchRawFile(httpClient, buildRawGitHubURL(rawPrefix, dir+"/SKILL.md"))
if err == nil {
skillMdBody = body
skillDir = dir
break
}
}
// Single-skill repos place SKILL.md at the repository root. Try it as a
// fast path before the tree-listing fallback to avoid a recursive tree
// API call for a common case. Verify the frontmatter name matches so a
// stray root SKILL.md in a multi-skill repo can't get picked up for an
// unrelated skill URL.
if skillMdBody == nil {
body, err := fetchRawFile(httpClient, buildRawGitHubURL(rawPrefix, "SKILL.md"))
if err == nil {
if name, _ := skillpkg.ParseSkillFrontmatter(string(body)); name == skillName {
skillMdBody = body
skillDir = ""
}
}
}
if skillMdBody == nil {
skillDir, skillMdBody, err = resolveGitHubSkillDirByName(httpClient, owner, repo, defaultBranch, rawPrefix, skillName)
if err != nil {
return nil, err
}
}
// Parse name and description from YAML frontmatter
name, description := skillpkg.ParseSkillFrontmatter(string(skillMdBody))
if name == "" {
name = skillName
}
result := &importedSkill{
name: name,
description: description,
content: string(skillMdBody),
origin: map[string]any{
"type": "skills_sh",
"source_url": rawURL,
"owner": owner,
"repo": repo,
"skill": skillName,
},
}
// 2. List supporting files via GitHub API
apiURL := buildGitHubContentsURL(owner, repo, skillDir, defaultBranch)
dirResp, err := doGitHubAPIGet(httpClient, 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 {
slog.Warn("github import: failed to decode top-level directory listing", "url", apiURL, "error", err)
return result, nil
}
// 3. Recursively collect files (excluding SKILL.md and LICENSE)
var allFiles []githubContentEntry
slog.Info("github import: collecting supporting files", "skill", skillName, "top_level_entries", len(entries))
collectGitHubFiles(httpClient, entries, &allFiles, apiURL)
slog.Info("github import: collected supporting files", "skill", skillName, "files", len(allFiles))
// 4. Download each file
basePath := ""
if skillDir != "" {
basePath = skillDir + "/"
}
for _, entry := range allFiles {
if entry.DownloadURL == "" {
continue
}
body, err := fetchRawFile(httpClient, entry.DownloadURL)
if err != nil {
if isCapError(err) {
return nil, fmt.Errorf("github import: %s: %w", entry.Path, err)
}
slog.Warn("github 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)
if err := result.addFile(relPath, string(body)); err != nil {
return nil, err
}
}
return result, nil
}
func resolveGitHubSkillDirByName(httpClient *http.Client, owner, repo, defaultBranch, rawPrefix, skillName string) (string, []byte, error) {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/trees/%s?recursive=1",
url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(defaultBranch))
resp, err := doGitHubAPIGet(httpClient, apiURL)
if err != nil {
return "", nil, fmt.Errorf("failed to inspect repository %s/%s for skill %s: %w", owner, repo, skillName, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", nil, fmt.Errorf("failed to inspect repository %s/%s for skill %s: HTTP %d", owner, repo, skillName, resp.StatusCode)
}
var tree githubTreeResponse
if err := json.NewDecoder(resp.Body).Decode(&tree); err != nil {
return "", nil, fmt.Errorf("failed to inspect repository %s/%s for skill %s: %w", owner, repo, skillName, err)
}
skillPaths := extractSkillMdPaths(tree.Tree)
preferred, remaining := partitionSkillMdPaths(skillName, skillPaths)
if dir, body, ok := findMatchingSkillDirByFrontmatter(httpClient, rawPrefix, skillName, preferred); ok {
return dir, body, nil
}
if !tree.Truncated {
if dir, body, ok := findMatchingSkillDirByFrontmatter(httpClient, rawPrefix, skillName, remaining); ok {
return dir, body, nil
}
return "", nil, skillMdNotFoundError(owner, repo, skillName)
}
slog.Warn("github import: repository tree listing truncated", "owner", owner, "repo", repo, "branch", defaultBranch)
if dir, body, ok := findSkillDirFromConventionalPrefixes(httpClient, owner, repo, defaultBranch, rawPrefix, skillName); ok {
return dir, body, nil
}
return "", nil, fmt.Errorf("repository %s/%s tree is too large to scan exhaustively for skill %s", owner, repo, skillName)
}
// 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 := entry.URL
if subURL == "" {
parsed, err := url.Parse(parentURL)
if err != nil {
slog.Warn("github import: invalid parent directory url", "url", parentURL, "error", err)
continue
}
parsed.Path = strings.TrimSuffix(parsed.Path, "/") + "/" + entry.Name
subURL = parsed.String()
}
subResp, err := doGitHubAPIGet(httpClient, subURL)
if err != nil || subResp.StatusCode != http.StatusOK {
attrs := []any{"url", subURL}
if subResp != nil {
attrs = append(attrs, "status", subResp.StatusCode)
subResp.Body.Close()
}
if err != nil {
attrs = append(attrs, "error", err)
}
slog.Warn("github import: failed to list subdirectory", attrs...)
continue
}
var subEntries []githubContentEntry
if err := json.NewDecoder(subResp.Body).Decode(&subEntries); err != nil {
subResp.Body.Close()
slog.Warn("github import: failed to decode subdirectory listing", "url", subURL, "error", err)
continue
}
subResp.Body.Close()
collectGitHubFiles(httpClient, subEntries, out, subURL)
}
}
}
func findSkillDirFromConventionalPrefixes(httpClient *http.Client, owner, repo, defaultBranch, rawPrefix, skillName string) (string, []byte, bool) {
prefixes := []string{"skills", ".claude/skills", "plugin/skills"}
var skillPaths []string
for _, prefix := range prefixes {
paths, err := listGitHubSkillMdPaths(httpClient, owner, repo, prefix, defaultBranch)
if err != nil {
slog.Warn("github import: failed to list conventional skill prefix", "prefix", prefix, "error", err)
continue
}
skillPaths = append(skillPaths, paths...)
}
preferred, remaining := partitionSkillMdPaths(skillName, skillPaths)
if dir, body, ok := findMatchingSkillDirByFrontmatter(httpClient, rawPrefix, skillName, preferred); ok {
return dir, body, true
}
return findMatchingSkillDirByFrontmatter(httpClient, rawPrefix, skillName, remaining)
}
func listGitHubSkillMdPaths(httpClient *http.Client, owner, repo, repoPath, ref string) ([]string, error) {
apiURL := buildGitHubContentsURL(owner, repo, repoPath, ref)
resp, err := doGitHubAPIGet(httpClient, apiURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
var entries []githubContentEntry
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
return nil, err
}
var paths []string
collectGitHubSkillMdPaths(httpClient, entries, &paths, apiURL)
return paths, nil
}
func collectGitHubSkillMdPaths(httpClient *http.Client, entries []githubContentEntry, out *[]string, parentURL string) {
for _, entry := range entries {
lower := strings.ToLower(entry.Name)
if entry.Type == "file" {
if lower == "skill.md" {
*out = append(*out, entry.Path)
}
continue
}
if entry.Type != "dir" {
continue
}
subURL := entry.URL
if subURL == "" {
parsed, err := url.Parse(parentURL)
if err != nil {
slog.Warn("github import: invalid parent directory url", "url", parentURL, "error", err)
continue
}
parsed.Path = strings.TrimSuffix(parsed.Path, "/") + "/" + entry.Name
subURL = parsed.String()
}
subResp, err := doGitHubAPIGet(httpClient, subURL)
if err != nil || subResp.StatusCode != http.StatusOK {
attrs := []any{"url", subURL}
if subResp != nil {
attrs = append(attrs, "status", subResp.StatusCode)
subResp.Body.Close()
}
if err != nil {
attrs = append(attrs, "error", err)
}
slog.Warn("github import: failed to list skill metadata subdirectory", attrs...)
continue
}
var subEntries []githubContentEntry
if err := json.NewDecoder(subResp.Body).Decode(&subEntries); err != nil {
subResp.Body.Close()
slog.Warn("github import: failed to decode skill metadata subdirectory", "url", subURL, "error", err)
continue
}
subResp.Body.Close()
collectGitHubSkillMdPaths(httpClient, subEntries, out, subURL)
}
}
func extractSkillMdPaths(entries []githubTreeEntry) []string {
paths := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.Type != "blob" || (!strings.HasSuffix(entry.Path, "/SKILL.md") && entry.Path != "SKILL.md") {
continue
}
paths = append(paths, entry.Path)
}
return paths
}
func partitionSkillMdPaths(skillName string, skillPaths []string) (preferred []string, remaining []string) {
for _, skillPath := range skillPaths {
if isLikelySkillPathMatch(skillName, skillPath) {
preferred = append(preferred, skillPath)
continue
}
remaining = append(remaining, skillPath)
}
return preferred, remaining
}
func findMatchingSkillDirByFrontmatter(httpClient *http.Client, rawPrefix, skillName string, skillPaths []string) (string, []byte, bool) {
for _, skillPath := range skillPaths {
body, err := fetchRawFile(httpClient, buildRawGitHubURL(rawPrefix, skillPath))
if err != nil {
slog.Warn("github import: fallback SKILL.md fetch failed", "path", skillPath, "error", err)
continue
}
name, _ := skillpkg.ParseSkillFrontmatter(string(body))
if name == skillName {
return skillDirFromSkillFilePath(skillPath), body, true
}
}
return "", nil, false
}
func isLikelySkillPathMatch(skillName, skillPath string) bool {
dir := strings.ToLower(skillDirFromSkillFilePath(skillPath))
base := strings.ToLower(filepath.Base(dir))
for _, hint := range skillNameHints(skillName) {
if strings.Contains(dir, hint) || strings.Contains(base, hint) || strings.Contains(hint, base) {
return true
}
}
return false
}
func skillNameHints(skillName string) []string {
skillName = strings.ToLower(skillName)
parts := strings.Split(skillName, "-")
seen := map[string]struct{}{}
var hints []string
addHint := func(value string) {
value = strings.TrimSpace(value)
if len(value) < 3 {
return
}
if _, ok := seen[value]; ok {
return
}
seen[value] = struct{}{}
hints = append(hints, value)
}
addHint(skillName)
for i := 1; i < len(parts); i++ {
addHint(strings.Join(parts[i:], "-"))
}
for _, part := range parts {
addHint(part)
}
return hints
}
// --- GitHub import ---
// errGitHubAPIBlocked signals that an api.github.com probe was rejected for
// auth/rate-limit reasons (401/403/429) rather than because the resource
// genuinely does not exist. Resolvers treat this as "indeterminate" and may
// fall back to the optimistic URL split rather than aborting the import.
var errGitHubAPIBlocked = errors.New("github API blocked (rate limit or auth)")
// doGitHubAPIGet performs a GET against an api.github.com URL, attaching the
// GITHUB_TOKEN bearer header when the env var is set. Unauthenticated GitHub
// API requests are capped at 60/hour per IP, which is trivially exhausted on
// shared self-hosted servers and surfaces to users as 403 errors during
// skill imports. Setting GITHUB_TOKEN raises the limit to 5000/hour.
func doGitHubAPIGet(httpClient *http.Client, apiURL string) (*http.Response, error) {
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return nil, err
}
addGitHubAuthHeader(req)
return httpClient.Do(req)
}
func addGitHubAuthHeader(req *http.Request) {
if req == nil {
return
}
if token := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
}
// githubSpec captures the parsed components of a github.com URL pointing at a
// skill (or single-skill repository).
type githubSpec struct {
owner string
repo string
ref string // empty → caller resolves the default branch
skillDir string // relative directory within the repo, "" for the repository root
// refSegments holds the raw path segments after /tree/ or /blob/ that
// jointly encode (ref, skillDir). GitHub's web URLs do not delimit the
// boundary between branch/tag name and in-repo path, so when a ref
// contains '/' (e.g. "release/v2") segments[0] alone is not the ref.
// fetchFromGitHub uses resolveGitHubRefAndPath to walk these segments
// and ask the API which prefix is a real branch/tag/commit. When this
// slice is empty, ref/skillDir above are authoritative (root URL).
refSegments []string
// kind is "tree" or "blob"; "" for root URLs. blob requires the last
// segment to be SKILL.md, which is already stripped from refSegments.
kind string
}
// parseGitHubURL extracts the owner, repo, and the raw post-/tree|/blob
// segments from a github.com URL. Supported forms:
//
// github.com/{owner}/{repo} → root, default branch
// github.com/{owner}/{repo}/tree/{ref}/{path...} → ref / skill dir
// github.com/{owner}/{repo}/blob/{ref}/{path.../SKILL.md} → ref / skill dir
//
// A simple-ref shortcut (segments[0] is the ref, the rest is the path) is
// stored in spec.ref/spec.skillDir; refSegments is also populated so that
// fetchFromGitHub can disambiguate refs containing '/' against the API.
func parseGitHubURL(raw string) (githubSpec, error) {
parsed, err := url.Parse(raw)
if err != nil {
return githubSpec{}, fmt.Errorf("invalid URL: %w", err)
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return githubSpec{}, fmt.Errorf("expected URL format: github.com/{owner}/{repo}[/tree/{ref}/{path}], got: %s", parsed.Path)
}
spec := githubSpec{owner: parts[0], repo: strings.TrimSuffix(parts[1], ".git")}
if len(parts) == 2 {
return spec, nil
}
kind := parts[2]
if kind != "tree" && kind != "blob" {
return githubSpec{}, fmt.Errorf("unsupported URL form: github.com/%s/%s/%s/... (use /tree/{ref}/... or /blob/{ref}/.../SKILL.md)", spec.owner, spec.repo, kind)
}
if len(parts) < 4 || parts[3] == "" {
return githubSpec{}, fmt.Errorf("missing ref after /%s/", kind)
}
spec.kind = kind
rest := parts[3:]
if kind == "blob" {
if !strings.EqualFold(rest[len(rest)-1], "SKILL.md") {
return githubSpec{}, fmt.Errorf("blob URL must point to a SKILL.md file")
}
rest = rest[:len(rest)-1]
if len(rest) == 0 {
return githubSpec{}, fmt.Errorf("missing ref after /blob/")
}
}
// Decode URL-escaped segments (e.g. spaces) so paths match the repo's
// real on-disk layout. Re-escaping happens in buildRawGitHubURL.
decoded := make([]string, len(rest))
for i, p := range rest {
d, err := url.PathUnescape(p)
if err != nil {
return githubSpec{}, fmt.Errorf("invalid path segment %q: %w", p, err)
}
if d == "" {
return githubSpec{}, fmt.Errorf("empty path segment in URL")
}
decoded[i] = d
}
spec.refSegments = decoded
// Optimistic split: assume the simple case where the ref is one segment.
// fetchFromGitHub will re-resolve via the API and overwrite both fields
// when the optimistic guess does not validate (e.g. release/v2 refs).
spec.ref = decoded[0]
if len(decoded) > 1 {
spec.skillDir = strings.Join(decoded[1:], "/")
}
return spec, nil
}
// resolveGitHubRefAndPath walks the parsed refSegments and asks the GitHub
// commits API which prefix corresponds to a real branch, tag, or commit.
// This is what makes refs containing '/' (e.g. "release/v2") work correctly:
// the URL github.com/o/r/tree/release/v2/skills/foo is ambiguous between
// (ref=release, path=v2/skills/foo) and (ref=release/v2, path=skills/foo),
// so we probe /repos/{o}/{r}/commits/{candidate} from longest to shortest
// and accept the first one the server confirms exists.
//
// On success spec.ref and spec.skillDir are overwritten with the resolved
// pair. On failure (no candidate resolves) a single error is returned that
// names every candidate that was tried.
func resolveGitHubRefAndPath(httpClient *http.Client, spec *githubSpec) error {
if len(spec.refSegments) == 0 {
return nil
}
// Try longest prefix first so that release/v2 wins over release.
tried := make([]string, 0, len(spec.refSegments))
blocked := false
for n := len(spec.refSegments); n >= 1; n-- {
candidate := strings.Join(spec.refSegments[:n], "/")
tried = append(tried, candidate)
ok, err := githubRefExists(httpClient, spec.owner, spec.repo, candidate)
if errors.Is(err, errGitHubAPIBlocked) {
// 401/403/429 means we can't tell whether the ref exists. Keep
// trying the remaining (shorter) candidates so we don't punish
// the common single-segment-ref case for one bad probe.
blocked = true
continue
}
if err != nil {
// Network / transport errors should not be silently treated as
// "ref does not exist" — surface them so the caller can retry.
return fmt.Errorf("validating ref %q: %w", candidate, err)
}
if ok {
spec.ref = candidate
if n == len(spec.refSegments) {
spec.skillDir = ""
} else {
spec.skillDir = strings.Join(spec.refSegments[n:], "/")
}
return nil
}
}
if blocked {
// Every probe was either a confirmed 404 or rate-limited and we never
// got a confirmation. Fall back to the optimistic single-segment
// split that parseGitHubURL populated. If that's wrong, the
// subsequent raw-file fetch will surface a clearer "SKILL.md not
// found" error than failing the whole import on a 403.
slog.Warn("github import: ref resolution blocked by GitHub API (rate limit or auth); falling back to optimistic single-segment ref. Set GITHUB_TOKEN to enable disambiguation of slash-bearing refs.",
"owner", spec.owner, "repo", spec.repo, "tried", tried)
return nil
}
return fmt.Errorf("could not resolve ref in github.com/%s/%s URL — tried: %s. Make sure the branch, tag, or commit exists and that the URL is the canonical /tree/{ref}/{path} or /blob/{ref}/{path}/SKILL.md form",
spec.owner, spec.repo, strings.Join(tried, ", "))
}
// githubRefExists returns true when GitHub recognizes ref as a branch, tag,
// or commit SHA on owner/repo. It uses the commits endpoint because that
// single call accepts all three ref kinds (unlike /branches or /tags which
// only match one). 404 means the ref does not exist; any other non-200
// status is treated as an error so the caller can distinguish "missing"
// from "API down".
func githubRefExists(httpClient *http.Client, owner, repo, ref string) (bool, error) {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/%s",
url.PathEscape(owner), url.PathEscape(repo), escapeRefPath(ref))
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return false, err
}
// Per GitHub docs: Accept: application/vnd.github.v3.sha returns just
// the SHA when the ref resolves, which is the cheapest possible probe.
req.Header.Set("Accept", "application/vnd.github.v3.sha")
addGitHubAuthHeader(req)
resp, err := httpClient.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound, http.StatusUnprocessableEntity:
return false, nil
case http.StatusUnauthorized, http.StatusForbidden, http.StatusTooManyRequests:
return false, errGitHubAPIBlocked
default:
return false, fmt.Errorf("github API returned status %d for ref %q", resp.StatusCode, ref)
}
}
func fetchFromGitHub(httpClient *http.Client, rawURL string) (*importedSkill, error) {
spec, err := parseGitHubURL(rawURL)
if err != nil {
return nil, err
}
if len(spec.refSegments) > 0 {
// Disambiguate slash-bearing refs (release/v2 etc.) against the API
// before issuing any raw or contents requests.
if err := resolveGitHubRefAndPath(httpClient, &spec); err != nil {
return nil, err
}
}
if spec.ref == "" {
spec.ref = fetchGitHubDefaultBranch(httpClient, spec.owner, spec.repo)
}
rawPrefix := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s",
url.PathEscape(spec.owner), url.PathEscape(spec.repo), escapeRefPath(spec.ref))
skillMdPath := "SKILL.md"
if spec.skillDir != "" {
skillMdPath = spec.skillDir + "/SKILL.md"
}
skillMdBody, err := fetchRawFile(httpClient, buildRawGitHubURL(rawPrefix, skillMdPath))
if err != nil {
if spec.skillDir == "" {
return nil, fmt.Errorf("SKILL.md not found at the root of %s/%s@%s. For multi-skill repositories, point to a specific directory using github.com/%s/%s/tree/%s/<skill-dir>",
spec.owner, spec.repo, spec.ref, spec.owner, spec.repo, spec.ref)
}
return nil, fmt.Errorf("SKILL.md not found at %s in %s/%s@%s: %w",
skillMdPath, spec.owner, spec.repo, spec.ref, err)
}
name, description := skillpkg.ParseSkillFrontmatter(string(skillMdBody))
if name == "" {
if spec.skillDir != "" {
name = filepath.Base(spec.skillDir)
} else {
name = spec.repo
}
}
result := &importedSkill{
name: name,
description: description,
content: string(skillMdBody),
origin: map[string]any{
"type": "github",
"source_url": rawURL,
"owner": spec.owner,
"repo": spec.repo,
"ref": spec.ref,
"path": spec.skillDir,
},
}
apiURL := buildGitHubContentsURL(spec.owner, spec.repo, spec.skillDir, spec.ref)
dirResp, err := doGitHubAPIGet(httpClient, apiURL)
if err != nil || dirResp.StatusCode != http.StatusOK {
// Cannot list the directory — return what we have (SKILL.md only).
// Keep this lenient: a private rate-limited request shouldn't fail
// an import that has already produced a valid SKILL.md.
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 {
slog.Warn("github import: failed to decode top-level directory listing", "url", apiURL, "error", err)
return result, nil
}
var allFiles []githubContentEntry
collectGitHubFiles(httpClient, entries, &allFiles, apiURL)
basePath := ""
if spec.skillDir != "" {
basePath = spec.skillDir + "/"
}
for _, entry := range allFiles {
if entry.DownloadURL == "" {
continue
}
body, err := fetchRawFile(httpClient, entry.DownloadURL)
if err != nil {
if isCapError(err) {
return nil, fmt.Errorf("github import: %s: %w", entry.Path, err)
}
slog.Warn("github import: file download failed", "path", entry.Path, "error", err)
continue
}
relPath := strings.TrimPrefix(entry.Path, basePath)
if err := result.addFile(relPath, string(body)); err != nil {
return nil, err
}
}
return result, nil
}
// --- Shared helpers ---
// fetchRawFile downloads a URL and returns the body bytes. Returns an error
// if the response exceeds maxImportFileSize so we never silently truncate a
// half-downloaded skill file into the workspace.
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)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxImportFileSize+1))
if err != nil {
return nil, err
}
if len(body) > maxImportFileSize {
return nil, fmt.Errorf("%w: file exceeds %d byte limit", errImportCapExceeded, maxImportFileSize)
}
return body, nil
}
// escapeRefPath percent-encodes each segment of a git ref individually so
// that slash-bearing refs like "release/v2" are sent to GitHub as
// "release/v2" (path separators preserved) rather than "release%2Fv2"
// (which GitHub does not accept on the commits / raw endpoints).
func escapeRefPath(ref string) string {
parts := strings.Split(ref, "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
return strings.Join(parts, "/")
}
func buildRawGitHubURL(rawPrefix, repoPath string) string {
parts := strings.Split(strings.Trim(repoPath, "/"), "/")
escaped := make([]string, 0, len(parts))
for _, part := range parts {
if part == "" {
continue
}
escaped = append(escaped, url.PathEscape(part))
}
if len(escaped) == 0 {
return rawPrefix
}
return rawPrefix + "/" + strings.Join(escaped, "/")
}
func buildGitHubContentsURL(owner, repo, repoPath, ref string) string {
base := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents",
url.PathEscape(owner), url.PathEscape(repo))
if repoPath == "" {
return base + "?ref=" + url.QueryEscape(ref)
}
return base + "/" + strings.TrimPrefix(buildRawGitHubURL("", repoPath), "/") + "?ref=" + url.QueryEscape(ref)
}
func skillDirFromSkillFilePath(path string) string {
if path == "SKILL.md" {
return ""
}
return strings.TrimSuffix(path, "/SKILL.md")
}
func skillMdNotFoundError(owner, repo, skillName string) error {
return fmt.Errorf("SKILL.md not found in repository %s/%s for skill %s", owner, repo, skillName)
}
func skillImportConflictReason() string {
return "a skill with this name already exists; use --on-conflict overwrite to replace it or --on-conflict rename to import a copy"
}
func (h *Handler) createImportedSkillWithName(ctx context.Context, workspaceID, creatorID pgtype.UUID, name string, imported *importedSkill, config map[string]any, files []CreateSkillFileRequest) (SkillWithFilesResponse, error) {
return h.createSkillWithFiles(ctx, skillCreateInput{
WorkspaceID: workspaceID,
CreatorID: creatorID,
Name: name,
Description: imported.description,
Content: imported.content,
Config: config,
Files: files,
})
}
func (h *Handler) createRenamedImportedSkill(ctx context.Context, workspaceID, creatorID pgtype.UUID, baseName string, imported *importedSkill, config map[string]any, files []CreateSkillFileRequest) (SkillWithFilesResponse, error) {
for suffix := 2; suffix < maxImportRenameAttempts+2; suffix++ {
candidate := fmt.Sprintf("%s-%d", baseName, suffix)
resp, err := h.createImportedSkillWithName(ctx, workspaceID, creatorID, candidate, imported, config, files)
if err == nil {
return resp, nil
}
if !isUniqueViolation(err) {
return SkillWithFilesResponse{}, err
}
}
return SkillWithFilesResponse{}, fmt.Errorf("failed to find an available renamed skill name after %d attempts", maxImportRenameAttempts)
}
func skillImportOverwriteFailure(err error) (int, string) {
switch {
case errors.Is(err, errSkillOverwriteNotFound):
return http.StatusConflict, "target skill no longer exists"
case errors.Is(err, errSkillOverwriteForbidden):
return http.StatusForbidden, "only the skill creator can overwrite this skill"
case errors.Is(err, errSkillOverwriteNameMismatch):
return http.StatusConflict, "target skill name no longer matches the imported skill"
default:
return http.StatusInternalServerError, "failed to overwrite skill: " + err.Error()
}
}
func (h *Handler) resolveImportSkillConflict(w http.ResponseWriter, r *http.Request, strategy string, workspaceID string, workspaceUUID, creatorUUID pgtype.UUID, creatorID string, name string, imported *importedSkill, config map[string]any, files []CreateSkillFileRequest, existing db.Skill) {
existingInfo := existingSkillIdentity(existing, creatorID)
switch strategy {
case importOnConflictSkip:
writeJSON(w, http.StatusOK, SkillImportResult{
Status: "skipped",
Reason: "a skill with this name already exists",
ExistingSkill: &existingInfo,
})
case importOnConflictOverwrite:
if !canOverwriteSkillByLocalImport(creatorID, existing) {
writeJSON(w, http.StatusForbidden, SkillImportResult{
Status: "failed",
Reason: "only the skill creator can overwrite this skill",
ExistingSkill: &existingInfo,
})
return
}
resp, err := h.overwriteSkillWithFiles(r.Context(), skillOverwriteInput{
WorkspaceID: workspaceUUID,
TargetSkillID: existing.ID,
UserID: creatorID,
ExpectedName: name,
Description: imported.description,
Content: imported.content,
Config: config,
Files: files,
})
if err != nil {
status, reason := skillImportOverwriteFailure(err)
writeJSON(w, status, SkillImportResult{
Status: "failed",
Reason: reason,
ExistingSkill: &existingInfo,
})
return
}
actorType, actorID := h.resolveActor(r, creatorID, workspaceID)
h.publish(protocol.EventSkillUpdated, workspaceID, actorType, actorID, map[string]any{"skill": resp})
writeJSON(w, http.StatusOK, SkillImportResult{Status: "updated", Skill: &resp})
case importOnConflictRename:
resp, err := h.createRenamedImportedSkill(r.Context(), workspaceUUID, creatorUUID, name, imported, config, files)
if err != nil {
writeJSON(w, http.StatusInternalServerError, SkillImportResult{
Status: "failed",
Reason: "failed to create renamed skill: " + err.Error(),
ExistingSkill: &existingInfo,
})
return
}
actorType, actorID := h.resolveActor(r, creatorID, workspaceID)
h.publish(protocol.EventSkillCreated, workspaceID, actorType, actorID, map[string]any{"skill": resp})
writeJSON(w, http.StatusCreated, SkillImportResult{
Status: "created",
Reason: "renamed to avoid an existing skill",
Skill: &resp,
ExistingSkill: &existingInfo,
})
default:
writeJSON(w, http.StatusConflict, SkillImportResult{
Status: "conflict",
Reason: skillImportConflictReason(),
ExistingSkill: &existingInfo,
})
}
}
// --- Import handler ---
func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
creatorID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
if !ok {
return
}
creatorUUID := parseUUID(creatorID)
var req ImportSkillRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if !validImportOnConflict(req.OnConflict) {
writeError(w, http.StatusBadRequest, "on_conflict must be one of: fail, overwrite, rename, skip")
return
}
structuredResult := req.OnConflict != ""
strategy := req.OnConflict
if strategy == "" {
strategy = importOnConflictFail
}
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)
case sourceGitHub:
imported, err = fetchFromGitHub(httpClient, normalized)
}
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
files := make([]CreateSkillFileRequest, 0, len(imported.files))
for _, f := range imported.files {
if !validateFilePath(f.path) {
continue
}
files = append(files, CreateSkillFileRequest{
Path: f.path,
Content: f.content,
})
}
// Persist provenance into skill.config.origin so list/detail UI can show
// "Imported from GitHub / ClawHub / Skills.sh" and link back to the source.
config := map[string]any{}
if imported.origin != nil {
config["origin"] = imported.origin
}
name := sanitizeNullBytes(imported.name)
if structuredResult {
if existing, found, lerr := h.lookupSkillByName(r.Context(), workspaceUUID, name); lerr != nil {
writeJSON(w, http.StatusInternalServerError, SkillImportResult{
Status: "failed",
Reason: "failed to check for existing skill: " + lerr.Error(),
})
return
} else if found {
h.resolveImportSkillConflict(w, r, strategy, workspaceID, workspaceUUID, creatorUUID, creatorID, name, imported, config, files, existing)
return
}
}
resp, err := h.createImportedSkillWithName(r.Context(), workspaceUUID, creatorUUID, name, imported, config, files)
if err != nil {
if isUniqueViolation(err) {
if structuredResult {
if existing, found, lerr := h.lookupSkillByName(r.Context(), workspaceUUID, name); lerr == nil && found {
h.resolveImportSkillConflict(w, r, strategy, workspaceID, workspaceUUID, creatorUUID, creatorID, name, imported, config, files, existing)
return
}
}
if existing, found, findErr := h.existingSkillIdentityByName(r.Context(), workspaceUUID, name); findErr == nil && found {
writeSkillImportDuplicateConflict(w, existing)
} else {
writeError(w, http.StatusConflict, "a skill with this name already exists")
}
return
}
writeError(w, http.StatusInternalServerError, "failed to create skill: "+err.Error())
return
}
actorType, actorID := h.resolveActor(r, creatorID, workspaceID)
h.publish(protocol.EventSkillCreated, workspaceID, actorType, actorID, map[string]any{"skill": resp})
if structuredResult {
writeJSON(w, http.StatusCreated, SkillImportResult{Status: "created", Skill: &resp})
return
}
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 !h.canManageSkill(w, r, skill) {
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
}
if skillpkg.IsReservedContentPath(req.Path) {
writeError(w, http.StatusBadRequest, "SKILL.md is reserved for the primary skill content")
return
}
sf, err := h.Queries.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{
SkillID: skill.ID,
Path: sanitizeNullBytes(req.Path),
Content: sanitizeNullBytes(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 !h.canManageSkill(w, r, skill) {
return
}
fileID := chi.URLParam(r, "fileId")
fileUUID, ok := parseUUIDOrBadRequest(w, fileID, "file id")
if !ok {
return
}
// Verify the file belongs to the parent skill we just authorized — guards
// against deleting a file owned by a different skill via the URL param.
file, err := h.Queries.GetSkillFile(r.Context(), fileUUID)
if err != nil || uuidToString(file.SkillID) != uuidToString(skill.ID) {
writeError(w, http.StatusNotFound, "skill file not found")
return
}
if err := h.Queries.DeleteSkillFile(r.Context(), file.ID); 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.ListAgentSkillSummaries(r.Context(), agent.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list agent skills")
return
}
resp := make([]SkillSummaryResponse, len(skills))
for i, s := range skills {
resp[i] = skillSummaryToResponse(
s.ID, s.WorkspaceID, s.Name, s.Description, s.Config,
s.CreatedBy, s.CreatedAt, s.UpdatedAt,
)
}
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 !h.canManageAgent(w, r, agent) {
return
}
var req SetAgentSkillsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
skillUUIDs, ok := parseUUIDSliceOrBadRequest(w, req.SkillIDs, "skill_ids")
if !ok {
return
}
if !h.validateAgentSkillIDsInWorkspace(w, r, agent, skillUUIDs) {
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 skillUUIDs {
if err := qtx.AddAgentSkill(r.Context(), db.AddAgentSkillParams{
AgentID: agent.ID,
SkillID: 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
}
h.writeUpdatedAgentSkills(w, r, agent)
}
func (h *Handler) AddAgentSkills(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
agent, ok := h.loadAgentForUser(w, r, id)
if !ok {
return
}
if !h.canManageAgent(w, r, agent) {
return
}
var req AddAgentSkillsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
skillUUIDs, ok := parseUUIDSliceOrBadRequest(w, req.SkillIDs, "skill_ids")
if !ok {
return
}
if !h.validateAgentSkillIDsInWorkspace(w, r, agent, skillUUIDs) {
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)
for _, skillID := range skillUUIDs {
if err := qtx.AddAgentSkill(r.Context(), db.AddAgentSkillParams{
AgentID: agent.ID,
SkillID: 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
}
h.writeUpdatedAgentSkills(w, r, agent)
}
func (h *Handler) validateAgentSkillIDsInWorkspace(w http.ResponseWriter, r *http.Request, agent db.Agent, skillUUIDs []pgtype.UUID) bool {
seen := map[string]struct{}{}
for _, skillID := range skillUUIDs {
key := uuidToString(skillID)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if _, err := h.Queries.GetSkillInWorkspace(r.Context(), db.GetSkillInWorkspaceParams{
ID: skillID,
WorkspaceID: agent.WorkspaceID,
}); err != nil {
writeError(w, http.StatusNotFound, "skill not found")
return false
}
}
return true
}
func (h *Handler) writeUpdatedAgentSkills(w http.ResponseWriter, r *http.Request, agent db.Agent) {
skills, err := h.Queries.ListAgentSkillSummaries(r.Context(), agent.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list agent skills")
return
}
resp := make([]SkillSummaryResponse, len(skills))
for i, s := range skills {
resp[i] = skillSummaryToResponse(
s.ID, s.WorkspaceID, s.Name, s.Description, s.Config,
s.CreatedBy, s.CreatedAt, s.UpdatedAt,
)
}
actorType, actorID := h.resolveActor(r, requestUserID(r), uuidToString(agent.WorkspaceID))
h.publish(protocol.EventAgentStatus, uuidToString(agent.WorkspaceID), actorType, actorID, map[string]any{"agent_id": uuidToString(agent.ID), "skills": resp})
writeJSON(w, http.StatusOK, resp)
}