Merge pull request #143 from multica-ai/forrestchang/import-skill
feat(skills): add skill import from ClawHub and Skills.sh
This commit is contained in:
commit
8a1067e2aa
4 changed files with 602 additions and 33 deletions
|
|
@ -11,6 +11,7 @@ import {
|
|||
FolderOpen,
|
||||
AlertCircle,
|
||||
X,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import type { Skill, CreateSkillRequest, UpdateSkillRequest } from "@/shared/types";
|
||||
import {
|
||||
|
|
@ -30,6 +31,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
|
|
@ -42,22 +44,47 @@ import { useWSEvent } from "@/features/realtime";
|
|||
function CreateSkillDialog({
|
||||
onClose,
|
||||
onCreate,
|
||||
onImport,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onCreate: (data: CreateSkillRequest) => Promise<void>;
|
||||
onImport: (url: string) => Promise<void>;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"create" | "import">("create");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [importUrl, setImportUrl] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [importError, setImportError] = useState("");
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const detectedSource = (() => {
|
||||
const url = importUrl.trim().toLowerCase();
|
||||
if (url.includes("clawhub.ai")) return "clawhub" as const;
|
||||
if (url.includes("skills.sh")) return "skills.sh" as const;
|
||||
return null;
|
||||
})();
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) return;
|
||||
setCreating(true);
|
||||
setLoading(true);
|
||||
try {
|
||||
await onCreate({ name: name.trim(), description: description.trim() });
|
||||
onClose();
|
||||
} catch {
|
||||
setCreating(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!importUrl.trim()) return;
|
||||
setLoading(true);
|
||||
setImportError("");
|
||||
try {
|
||||
await onImport(importUrl.trim());
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setImportError(err instanceof Error ? err.message : "Import failed");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -65,42 +92,94 @@ function CreateSkillDialog({
|
|||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Skill</DialogTitle>
|
||||
<DialogTitle>Add Skill</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a reusable skill that can be assigned to agents.
|
||||
Create a new skill or import from ClawHub / Skills.sh.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Code Review, Bug Triage"
|
||||
className="mt-1"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Description</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of what this skill does"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "create" | "import")}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="create" className="flex-1">
|
||||
<Plus className="mr-1.5 h-3 w-3" />
|
||||
Create
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="import" className="flex-1">
|
||||
<Download className="mr-1.5 h-3 w-3" />
|
||||
Import
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="create" className="space-y-4 mt-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Code Review, Bug Triage"
|
||||
className="mt-1"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Description</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of what this skill does"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="import" className="space-y-4 mt-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Skill URL</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={importUrl}
|
||||
onChange={(e) => { setImportUrl(e.target.value); setImportError(""); }}
|
||||
placeholder="https://clawhub.ai/owner/skill-name"
|
||||
className="mt-1"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleImport()}
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
{detectedSource ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-2 py-0.5 text-xs font-medium">
|
||||
{detectedSource === "clawhub" ? "ClawHub" : "Skills.sh"}
|
||||
</span>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Supports <span className="font-medium">clawhub.ai</span> and <span className="font-medium">skills.sh</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{importError && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
{importError}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={creating || !name.trim()}>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
{tab === "create" ? (
|
||||
<Button onClick={handleCreate} disabled={loading || !name.trim()}>
|
||||
{loading ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleImport} disabled={loading || !importUrl.trim()}>
|
||||
<Download className="mr-1.5 h-3 w-3" />
|
||||
{loading ? "Importing..." : "Import"}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
@ -453,6 +532,12 @@ export default function SkillsPage() {
|
|||
setSelectedId(skill.id);
|
||||
};
|
||||
|
||||
const handleImport = async (url: string) => {
|
||||
const skill = await api.importSkill({ url });
|
||||
upsertSkill(skill);
|
||||
setSelectedId(skill.id);
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string, data: UpdateSkillRequest) => {
|
||||
const updated = await api.updateSkill(id, data);
|
||||
upsertSkill(updated);
|
||||
|
|
@ -561,6 +646,7 @@ export default function SkillsPage() {
|
|||
<CreateSkillDialog
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreate={handleCreate}
|
||||
onImport={handleImport}
|
||||
/>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
|
|
|
|||
|
|
@ -372,6 +372,13 @@ export class ApiClient {
|
|||
await this.fetch(`/api/skills/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async importSkill(data: { url: string }): Promise<Skill> {
|
||||
return this.fetch("/api/skills/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async listAgentSkills(agentId: string): Promise<Skill[]> {
|
||||
return this.fetch(`/api/agents/${agentId}/skills`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
|||
r.Route("/api/skills", func(r chi.Router) {
|
||||
r.Get("/", h.ListSkills)
|
||||
r.Post("/", h.CreateSkill)
|
||||
r.Post("/import", h.ImportSkill)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", h.GetSkill)
|
||||
r.Put("/", h.UpdateSkill)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@ 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"
|
||||
|
|
@ -383,6 +388,476 @@ func (h *Handler) DeleteSkill(w http.ResponseWriter, r *http.Request) {
|
|||
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("skill:created", 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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue